# affset API reference

The HTTP API behind the affset ad server. This document is generated from the same source as the https://affset.com/docs page.

Every authenticated endpoint takes two headers:

- `Authorization: Bearer <api-key>` — a tenant API key (issued by magic-link login).
- `X-Namespace: <namespace>` — the tenant the key belongs to.

The ad-serving endpoints are public and take neither header — the namespace is
resolved from the zone or campaign in the URL. Base URL: https://api.affset.com

Keys carry `permissions` (roles decide what a key sees; permissions decide whether it can write):

- `read` — GET endpoints — list and read resources, stats, and conversions.
- `write` — POST, PUT, PATCH and DELETE — create, update, delete, rotate, revoke.

There is no OAuth flow on the REST API itself. The hosted MCP server
(https://mcp.affset.com/mcp) is the OAuth-protected surface over the same API; each
grant is backed by an API key with exactly the permissions its scope implies.
Scopes are declared in its RFC 9728 metadata (https://mcp.affset.com/.well-known/oauth-protected-resource)
and RFC 8414 metadata (https://oauth.affset.com/.well-known/oauth-authorization-server):

- `read` → key permissions `read`. Read-only tool set — every tool that writes is stripped from the session.
- `full` → key permissions `read`, `write`. Every tool, backed by a read+write key. Only offered to roles that can write.

The same reference is available as an OpenAPI 3.0 document at https://affset.com/openapi.json
for function-calling tools and client generators.

## Roles

| Role | Access |
| --- | --- |
| `owner` | Full access to everything. The only role that can permanently delete the tenant. |
| `manager` | Same day-to-day access as owner — campaigns, zones, team, payouts, targeting. Can’t delete the tenant. |
| `advertiser` | Manages their own campaigns and can read zones. Sees campaign spend, but not publisher payout, media cost, or ROI. |
| `advertiser_manager` | Manages campaigns for assigned advertisers and can add advertisers to their own team. Uses the same financial redaction as advertiser. |
| `publisher` | Manages their own zones and has no campaign access. Sees payout, media cost, and ROI, but not advertiser spend. |
| `publisher_manager` | Manages zones for assigned publishers and can add publishers to their own team. Uses the same financial redaction as publisher. |

## Errors

Errors are JSON: `{ "error": "human-readable message" }`. A few responses attach extra machine-readable fields on top of that shape — notably plan-limit responses, below. The public ad-serving endpoints answer in plain text instead.

| Status | Meaning |
| --- | --- |
| `400` | Bad Request — missing header, parameter, or body, or an invalid value. |
| `401` | Unauthorized — invalid or expired key, or X-Namespace doesn't match it. |
| `402` | Payment Required — you’re at a plan limit. See below. |
| `403` | Forbidden — your role or permissions don't allow this. Also used to mask ownership on writes. |
| `404` | Not Found — the resource doesn't exist, or isn't visible to your role. |
| `409` | Conflict — something with the same identity already exists. |
| `422` | Unprocessable Entity — the request is well-formed but cannot be processed (e.g. a CSV export that exceeds the row cap). |
| `500` | Internal Server Error — something went wrong on our end. |

**Plan limits (402)** are returned when an action would exceed your plan — activating a campaign, creating a zone, inviting a team member, adding a machine API key, or setting a custom API domain:

```json
{
  "error": "Plan limit reached for campaigns",
  "code": "PLAN_LIMIT_REACHED",
  "dimension": "campaigns",
  "limit": 10,
  "current": 10,
  "plan_id": "free",
  "min_plan_id": "starter"
}
```

## Verify a key

### GET /api/me

Verify a key and see what it can do

**Example request**

```bash
curl "https://api.affset.com/api/me" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "namespace": "acme-media",
    "user_id": "usr_8f2a1c",
    "email": "buyer@example.com",
    "role": "advertiser",
    "permissions": [
      "read",
      "write"
    ],
    "capabilities": {
      "campaigns": true,
      "zones": true,
      "assign_campaign_user": false,
      "assign_zone_user": false,
      "tenant_management": false
    }
  }
  ```

**Notes**

- The response also includes capabilities, a role-derived summary of what this key can do — handy for building conditional UI without hard-coding the role table above.

## Tenant settings

Branding, timezone, sub-label names, and a couple of serving behaviors for your account.

### GET /api/tenant

Read tenant settings

**Example request**

```bash
curl "https://api.affset.com/api/tenant" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "company": "Acme Media",
    "timezone": "America/New_York",
    "primary_color": "#4F46E5",
    "secondary_color": "#14161C",
    "custom_api_domain": "api.acme-media.com",
    "sub_labels": {
      "sub1": "Zone",
      "sub2": "Creative"
    },
    "redirect_method": "html",
    "email": "owner@acme-media.com",
    "feature_flags": {}
  }
  ```

**Notes**

- There’s also an unauthenticated GET /api/public/tenant (X-Namespace header only, no API key) that returns just company, primary_color and secondary_color — enough to brand a public-facing page.

### PUT /api/tenant

Update tenant settings

**Body**

- `company` (string) — Display name shown in the dashboard and emails.
- `timezone` (string) — IANA timezone, e.g. "America/New_York". Drives Stats date bucketing and campaign date-only schedules.
- `primary_color` (string) — Hex color, e.g. "#4F46E5". Must match #RRGGBB.
- `secondary_color` (string) — Hex color, e.g. "#14161C". Must match #RRGGBB.
- `custom_api_domain` (string) — Domain used in generated /serve and /track/click links instead of the default API host. Setting a non-empty value is plan-gated; clearing it is always free.
- `sub_labels` (object) — Partial update of sub1–sub5 display names — see below. Only the keys you send are touched.
- `redirect_method` ("html" | "3xx") — How the /serve → /track/click hop is delivered — see Ad serving.

**Example request**

```bash
curl -X PUT "https://api.affset.com/api/tenant" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"sub_labels":{"sub1":"Zone","sub3":null},"redirect_method":"3xx"}'
```

**Responses**

- `200`

  ```json
  {
    "company": "Acme Media",
    "timezone": "America/New_York",
    "redirect_method": "3xx",
    "sub_labels": {
      "sub1": "Zone"
    },
    "updated_at": 1753887600000
  }
  ```

**Notes**

- At least one field is required.
- sub_labels is a merge, not a replace — send only the keys you want to change. null or "" clears that slot. Max 40 characters per label; an unrecognized key like sub6 is a 400, not a silent no-op.
- Setting a non-empty custom_api_domain is plan-gated and can return 402; clearing it is always free.

## Stats

Aggregated performance, grouped by one dimension per call.

### GET /api/stats

Grouped traffic and conversion stats

**Query parameters**

- `from` (epoch ms) — Default: start of today, UTC.
- `to` (epoch ms) — Default: now.
- `group_by` (enum) — date (default) | campaign_id | zone_id | country | conversion_type | status | publisher_email | advertiser_email | sub1…sub5.
- `campaign_ids` (comma-separated) — Restrict to these campaigns.
- `zone_ids` (comma-separated) — Restrict to these zones.
- `publisher_manager_email` (string) — Restrict to zones owned by publishers assigned to this manager. Owner/manager may use any manager email; publisher_manager may use only their own.
- `advertiser_email` (string) — Narrow every row to one advertiser’s campaigns, independent of group_by. Owner/manager may use any advertiser email; advertiser_manager only one of their own assigned advertisers.
- `publisher_email` (string) — Narrow every row to one publisher’s zones, independent of group_by. Owner/manager may use any publisher email; publisher_manager only one of their own assigned publishers.
- `sub1…sub5` (string) — A value, a comma list, or an empty string to match rows where that sub is unset.
- `conversion_type` (string) — Filter to specific conversion goal types — a value, a comma list, or an empty string to match conversions recorded without a type. Returns conversion rows only, so impressions, clicks and click-derived cost are zero in this slice.
- `status` (comma-separated) — Conversion lifecycle filter: any comma list of pending, approved, rejected. Like conversion_type, this describes conversion rows only — impressions, clicks and click-derived cost are zero.
- `paid_only` (boolean) — true drops informative conversions (recorded with postback_skipped: non_goal_type and $0 money) from the conversions count — the same switch as on /api/conversions. Default false. Recent (raw) rows only: days already folded into the archive keep informative rows in their count. Money sums are unaffected either way.

**Example request**

```bash
curl "https://api.affset.com/api/stats?group_by=zone_id&campaign_ids=42" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "stats": [
      {
        "zone_id": "b6e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b11",
        "zone_name": "RichAds Push",
        "impressions": 18400,
        "fallbacks": 210,
        "unsold": 40,
        "clicks": 512,
        "conversions": 9,
        "spend": 12.8,
        "payout": 27.5,
        "media_cost": 14.2,
        "roi": 0.9366
      }
    ],
    "period": {
      "from": 1753747200000,
      "to": 1753833600000
    },
    "sub_labels": {
      "sub1": "Zone"
    }
  }
  ```

**Notes**

- group_by=publisher_email needs owner, manager, or publisher_manager (scoped to their assigned publishers); group_by=advertiser_email needs owner, manager, or advertiser_manager (scoped to their assigned advertisers). The row key is the current zone/campaign owner — reassigning a zone or campaign re-attributes its history.
- advertiser_email and publisher_email are also standalone filters (not just group_by values): passed as their own query params they narrow every row to one advertiser/publisher regardless of what group_by is set to, instead of breaking every user out into its own row. Same RBAC as the matching group_by.
- group_by=status breaks conversions down by review state (pending | approved | rejected — the lifecycle behind hold windows and manual review). Like group_by=conversion_type, a status breakdown or filter covers conversion rows only, so impressions, clicks and click-derived cost are zero there.
- from and to are UTC epoch milliseconds. With group_by=date, each returned date label is a calendar date in your tenant’s timezone (set via PUT /api/tenant).
- Two different costs show up here: spend is Affset’s own campaign accounting — CPM campaigns accrue it per click at the campaign’s rate, CPA campaigns accrue it on conversion instead. media_cost is your actual traffic cost — the cost= click-time estimate from /serve or /track/click, except on the unfiltered group_by=date view, where complete days (and the current day-to-date) covered by traffic-source cost sync use the network’s own synced spend instead; partial historical days keep the estimate. The covered zones’ estimate is replaced, never added on top, and the synced_cost field on those rows shows the synced component. roi is computed from payout and media_cost, not spend, since that’s the number a media buyer is optimizing against — and it’s null (not 0) when there’s no cost data for that row.
- Publisher-side roles (publisher, publisher_manager) never see spend. Advertiser-side roles (advertiser, advertiser_manager) never see payout, media_cost, or roi. Same redaction applies to Conversions.

## Campaigns

Owner and manager see every campaign. advertiser and advertiser_manager are scoped to their own or managed advertisers. publisher and publisher_manager get 403 on this entire tree.

### GET /api/campaigns

List campaigns

**Query parameters**

- `status` (enum) — active | paused | archived. Omit for all statuses.
- `limit` (integer) — 1–100, default 20.
- `offset` (integer) — Default 0.
- `sort` (enum) — name | created_at (default) | start_date.
- `order` (enum) — asc | desc (default).

**Example request**

```bash
curl "https://api.affset.com/api/campaigns?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "campaigns": [
      {
        "id": 42,
        "name": "BR Sweepstakes — Push",
        "status": "active",
        "redirect_url": "https://offer.example/lp?s={click_id}",
        "redirect_urls": [
          "https://offer.example/lp?s={click_id}"
        ],
        "payment_model": "cpa",
        "rate": 0,
        "payout_goal_type": null,
        "daily_budget": null,
        "total_budget": null,
        "pacing": "asap",
        "start_date": 1753747200000,
        "end_date": null,
        "user_email": "buyer@example.com",
        "created_at": 1753747200000
      }
    ],
    "pagination": {
      "total": 7,
      "limit": 20,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- There’s no server-side name search — filter the page client-side if you need it.

### GET /api/campaigns/{campaign_id}

Get one campaign

**Example request**

```bash
curl "https://api.affset.com/api/campaigns/42" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "id": 42,
    "name": "BR Sweepstakes — Push",
    "status": "active",
    "redirect_url": "https://offer.example/lp?s={click_id}",
    "redirect_urls": [
      "https://offer.example/lp?s={click_id}",
      "https://offer.example/lp-b?s={click_id}"
    ],
    "payment_model": "cpa",
    "rate": 0,
    "user_email": "buyer@example.com",
    "targeting_rules": [
      {
        "id": 501,
        "targeting_rule_type_id": 1,
        "targeting_method": "whitelist",
        "rule": "BR,MX"
      }
    ]
  }
  ```

**Notes**

- 404 for a campaign that doesn’t exist or isn’t visible to your role — GET never reveals which.

### POST /api/campaigns

Create a campaign

**Body**

- `name` (string, required) — Campaign name.
- `redirect_url` (string, required) — Must be http(s). See Ad serving for the macros it can contain. Either this or redirect_urls is required.
- `redirect_urls` (string[]) — 1–10 http(s) URLs; clicks are split randomly between them (prelander rotation). Wins over redirect_url when both are sent. Repeat a URL to give it a bigger share.
- `user_email` (string) — Required for owner, manager and advertiser_manager — whose advertiser this bills to. Advertisers may omit it (defaults to themselves).
- `payment_model` ("cpm" | "cpa") — Default cpm.
- `rate` (number) — Default 0. Rounded to 2 decimals.
- `payout_goal_type` (string | null) — Only conversions whose pixel type= matches this exactly accrue spend/payout — others record at $0.
- `silent` (integer) — 0 disables silent conversions. A positive N makes every Nth conversion pay $0 and skip the affiliate postback; requires the silent_conversions feature flag.
- `daily_budget / total_budget` (number | null) — 0–999999999.99999.
- `pacing` ("asap" | "even") — Default asap.
- `start_date / end_date` (epoch ms) — Optional.
- `targeting_rules` (array) — Optional shortcut: [{ targeting_rule_type_id, targeting_method, rule }] — same shape as the Targeting sync endpoint. Convenient for geo at creation time; everything else, use Targeting after creating.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/campaigns" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"name":"BR Sweepstakes — Push","redirect_url":"https://offer.example/lp?s={click_id}","user_email":"buyer@example.com","payment_model":"cpa","targeting_rules":[{"targeting_rule_type_id":1,"targeting_method":"whitelist","rule":"BR,MX"}]}'
```

**Responses**

- `201`

  ```json
  {
    "id": 42,
    "name": "BR Sweepstakes — Push",
    "redirect_url": "https://offer.example/lp?s={click_id}",
    "redirect_urls": [
      "https://offer.example/lp?s={click_id}"
    ],
    "status": "paused",
    "payout_goal_type": null,
    "silent": 0,
    "created_at": 1753747200000
  }
  ```

**Notes**

- New campaigns are always created paused — run it with PUT once it’s ready (see Update below).

### PUT /api/campaigns/{campaign_id}

Update a campaign (partial)

**Body**

- `name` (string) — New campaign name.
- `redirect_url` (string) — New http(s) destination; supports the macros listed under Ad serving. Replaces the whole rotation set with this one URL.
- `redirect_urls` (string[]) — 1–10 http(s) URLs; clicks are split randomly between them. Replaces the existing set; wins over redirect_url when both are sent.
- `status` ("active" | "paused" | "archived") — Use this to run/pause/archive a campaign.
- `payment_model` ("cpm" | "cpa") — How campaign spend is calculated.
- `rate` (number) — Non-negative; rounded to 2 decimals.
- `payout_goal_type` (string | null) — Send null or an empty string to clear the goal filter.
- `silent` (integer) — Non-negative silent-conversion cadence; 0 disables it. A positive value requires the silent_conversions feature flag.
- `daily_budget / total_budget` (number | null) — Send null to clear a budget.
- `pacing` ("asap" | "even") — Delivery pacing; even works against the daily budget.
- `start_date / end_date` (epoch ms | null) — Send null to clear a boundary.
- `user_email` (string) — advertiser_email works identically — both write the same field.

**Example request**

```bash
curl -X PUT "https://api.affset.com/api/campaigns/42" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"status":"active"}'
```

**Responses**

- `200`

  ```json
  {
    "id": 42,
    "updated_at": 1753887600000
  }
  ```

**Notes**

- Only the fields you send are changed — this is a partial update despite the PUT verb. An empty/unrecognized body is a 400.
- Setting status to active runs your plan’s active-campaign check and can return 402.
- Response is just { id, updated_at } — re-fetch with GET if you need the full row back.

### DELETE /api/campaigns/{campaign_id}

Delete a campaign

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/campaigns/42" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `204`

**Notes**

- Cascades its targeting rules. Conversions and click history are not deleted with it.

## Zones

Owner, manager, publisher and publisher_manager can create/update zones — publisher and publisher_manager scoped to their own or managed publishers. advertiser and advertiser_manager have read-only access.

### GET /api/zones

List zones

**Query parameters**

- `status` (enum) — active | inactive.
- `limit` (integer) — 1–100, default 20.
- `offset` (integer) — Default 0.
- `sort` (enum) — name | created_at (default) | site_url.
- `order` (enum) — asc | desc (default).

**Example request**

```bash
curl "https://api.affset.com/api/zones?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "zones": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "RichAds Push",
        "status": "active",
        "site_url": null,
        "traffic_back_url": "https://richads.example/fallback",
        "postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}",
        "traffic_source_id": null,
        "traffic_source_name": null,
        "user_email": "publisher@example.com",
        "manager_email": null,
        "created_at": 1753747200000
      }
    ],
    "pagination": {
      "total": 3,
      "limit": 20,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- manager_email is computed, not stored — it’s the publisher_manager who owns that zone’s publisher, if any.

### GET /api/zones/{zone_id}

Get one zone

**Example request**

```bash
curl "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "RichAds Push",
    "status": "active",
    "postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}",
    "traffic_source_id": null,
    "traffic_source_name": null
  }
  ```

### POST /api/zones

Create a zone

**Body**

- `name` (string, required) — Zone name.
- `site_url` (string) — Must be http(s) if present.
- `traffic_back_url` (string) — Where /serve sends traffic when there’s no eligible campaign. Must be http(s) if present.
- `postback_url` (string) — Affiliate passback — Affset GETs this on conversion. Macros: {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Must be http(s) if present.
- `traffic_source_id` (string) — Optional. Must reference a traffic source in this tenant; otherwise 400. Zone reads then include the joined traffic_source_name — see Traffic Sources.
- `user_email` (string) — Publishers can only create for themselves. Owner/manager/publisher_manager may assign any publisher.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/zones" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"name":"RichAds Push","postback_url":"https://richads.example/pb?click_id={source_click_id}&payout={payout}"}'
```

**Responses**

- `201`

  ```json
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "active",
    "created_at": 1753747200000
  }
  ```

**Notes**

- Zones are always created active.
- Creating a zone counts against the plan’s zone limit and can return 402.
- Affset doesn’t require {source_click_id} (or the legacy {sub_id}) in postback_url, but a postback without it can’t be matched back to a click — you’ll get a warning, not a rejection.
- traffic_back_url is only the /serve fallback when no campaign can deliver — it is not a conversion postback.

### PUT /api/zones/{zone_id}

Update a zone (partial)

**Body**

- `name` (string) — New zone name.
- `status` ("active" | "inactive") — Unlike campaigns, there’s no archived state for zones.
- `site_url / traffic_back_url / postback_url` (string | null) — Send null to clear a URL.
- `traffic_source_id` (string | null) — Link to a traffic source in this tenant, or send null / "" to unlink.

**Example request**

```bash
curl -X PUT "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"postback_url":null}'
```

**Responses**

- `200`

  ```json
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "updated_at": 1753887600000
  }
  ```

### DELETE /api/zones/{zone_id}

Delete a zone

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/zones/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `204`

## Team

Team members and machine API keys share one endpoint, distinguished by type. There’s no separate /api/team.

### GET /api/api-keys

List team members or machine keys

_Access: Owner and manager see the whole tenant. publisher_manager / advertiser_manager see only their own assigned publishers/advertisers._

**Query parameters**

- `type` ("user" | "api-key", required) — user = people (dashboard/API logins). api-key = machine keys with no owning person.

**Example request**

```bash
curl "https://api.affset.com/api/api-keys?type=user" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  [
    {
      "token": "<redacted — the live response contains the bearer token>",
      "namespace": "acme-media",
      "user_id": "usr_8f2a1c",
      "email": "buyer@example.com",
      "role": "advertiser",
      "created_at": 1753747200000,
      "permissions": [
        "read",
        "write"
      ]
    }
  ]
  ```

**Notes**

- ⚠️ Unlike the dashboard’s Team page, this response includes each member’s live bearer token in plaintext. If you’re building a UI, log, or support tool on top of this endpoint, redact token before you display or store it anywhere.
- Returns a bare array — no pagination envelope, unlike every other list endpoint.

### POST /api/api-keys

Invite a team member / issue a machine key

_Access: Owner and manager can create any role. publisher_manager can only create publisher (assigned to themselves). advertiser_manager can only create advertiser._

**Query parameters**

- `type` ("user" | "api-key", required) — Use user for a person with an email, or api-key for a machine credential.

**Body**

- `email` (string) — Required when type=user, ignored for api-key.
- `role` (enum, required) — owner | manager | publisher | advertiser | advertiser_manager | publisher_manager.
- `permissions` (string[]) — Any of read, write. Default ["read","write"].
- `manager_email` (string) — Only valid for type=user when role is publisher or advertiser.
- `expires_at` (epoch ms) — Optional future expiration time.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/api-keys?type=user" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"email":"sarah@offer.com","role":"publisher"}'
```

**Responses**

- `201`

  ```json
  {
    "token": "<new live bearer token>",
    "namespace": "acme-media",
    "email": "sarah@offer.com",
    "role": "publisher",
    "permissions": [
      "read",
      "write"
    ],
    "created_at": 1753747200000
  }
  ```

**Notes**

- The plaintext token is returned here, by rotate, and by the list endpoint to permitted roles. Treat all three responses as secrets and avoid logging them.
- This creates the key directly, like the dashboard’s "Add team member" — it does not send an invite email. Hand the token to the person yourself, over a channel you trust.
- type=user is gated by the seat limit (402) and requires a verified tenant email address (403, code EMAIL_VERIFICATION_REQUIRED). type=api-key uses the separate machine-key plan limit.

### DELETE /api/api-keys

Revoke a team member or key

**Body**

- `token` (string, required) — Bearer token to revoke.
- `action` ("revoke", required) — Must be revoke.

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/api-keys" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"token":"sk_live_...","action":"revoke"}'
```

**Responses**

- `204`

**Notes**

- Sets the key’s expiry to now. For a user key, it also pauses campaigns and deactivates zones owned by that email. History stays intact.

### DELETE /api/api-keys

Permanently remove a team member or key

**Body**

- `token` (string, required) — Already-revoked bearer token to remove.
- `action` ("remove", required) — Must be remove.

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/api-keys" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"token":"sk_live_...","action":"remove"}'
```

**Responses**

- `204`

**Notes**

- Hard-deletes an expired key. For a user key, it also deletes campaigns and zones owned by that email; removing a machine key deletes only the credential. Revoke an active key first.

### PATCH /api/api-keys

Rotate a token

**Body**

- `token` (string, required) — Active bearer token to replace.
- `action` ("rotate", required) — Must be rotate.

**Example request**

```bash
curl -X PATCH "https://api.affset.com/api/api-keys" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"token":"sk_live_...","action":"rotate"}'
```

**Responses**

- `200`

  ```json
  {
    "token": "<new live bearer token>",
    "role": "publisher",
    "permissions": [
      "read",
      "write"
    ]
  }
  ```

**Notes**

- Issues a new token for the same identity and invalidates the old one immediately.

### PATCH /api/api-keys?type=user

Reassign who manages this person

**Body**

- `token` (string, required) — Bearer token belonging to the person being reassigned.
- `manager_email` (string | null) — The publisher_manager/advertiser_manager they report to. null clears it.

**Example request**

```bash
curl -X PATCH "https://api.affset.com/api/api-keys?type=user" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"token":"sk_live_...","manager_email":"manager@acme-media.com"}'
```

**Responses**

- `200`

  ```json
  {
    "user_id": "usr_8f2a1c",
    "manager_email": "manager@acme-media.com"
  }
  ```

### DELETE /api/api-keys

Close the account

_Access: Owner only._

**Body**

- `token` (string, required) — Owner API key or active owner session token.
- `action` ("terminate", required) — Must be terminate.

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/api-keys" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"token":"sk_live_...","action":"terminate"}'
```

**Responses**

- `204`

**Notes**

- ⚠️ Deletes the entire tenant — every campaign, zone, team member and history record. Irreversible. This is account closure, not team management.

## Payout rules

Same campaign-scoping as Campaigns above. A campaign can have one global rule and one rule per zone.

### GET /api/campaigns/{campaign_id}/payout_rules

List a campaign’s payout rules

**Example request**

```bash
curl "https://api.affset.com/api/campaigns/42/payout_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "payout_rules": [
      {
        "id": 12,
        "campaign_id": 42,
        "zone_id": null,
        "payout": 2.5,
        "created_at": 1753747200000
      },
      {
        "id": 13,
        "campaign_id": 42,
        "zone_id": "550e8400-e29b-41d4-a716-446655440000",
        "payout": 3,
        "created_at": 1753747200000
      }
    ]
  }
  ```

### POST /api/campaigns/{campaign_id}/payout_rules

Create a payout rule

**Body**

- `payout` (number, required) — 0.00001–9999.99999.
- `zone_id` (string) — Omit for the global (fallback) rule.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/campaigns/42/payout_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"payout":3,"zone_id":"550e8400-e29b-41d4-a716-446655440000"}'
```

**Responses**

- `201`

  ```json
  {
    "id": 13,
    "campaign_id": 42,
    "zone_id": "550e8400-e29b-41d4-a716-446655440000",
    "payout": 3,
    "created_at": 1753747200000
  }
  ```

**Notes**

- Resolution at conversion time: zone-specific rule, then the global rule, then $0.
- 409 if a rule already exists for that exact campaign + zone (or campaign + global) — see "Changing a payout" below.

### DELETE /api/campaigns/{campaign_id}/payout_rules

Delete a payout rule

**Query parameters**

- `zone_id` (string) — Omit to delete the global rule.

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/campaigns/42/payout_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `204`

## Traffic Sources

Owner, manager, advertiser and advertiser_manager can manage traffic sources — same scoping as Campaigns above. publisher and publisher_manager get 403 on this entire tree. The stored api_token is write-only: every read returns has_api_token instead. Zones link to a source via traffic_source_id on POST/PUT /api/zones (see Zones above); zone reads then include the joined traffic_source_name. Sources with an api_token and an exoclick or trafficstars preset get cost sync: an hourly job pulls the network’s own daily spend (re-syncing the last three days), and the unfiltered group_by=date Statistics view prefers those numbers for complete days and the current day-to-date over the cost= click-time estimate — link your zones to the source so the estimate is replaced rather than added on top.

### GET /api/traffic-source-presets

List traffic source presets

**Example request**

```bash
curl "https://api.affset.com/api/traffic-source-presets" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "presets": [
      {
        "id": "exoclick",
        "name": "ExoClick",
        "doc_url": "https://docs.exoclick.com/advertisers/campaigns/macros",
        "tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}&sub2={site_id}&sub3={variation_id}&sub4={campaign_id}&sub5={format}",
        "postback_template": "https://s.magsrv.com/tag.php?goal=[GOAL_ID]&tag={source_click_id}&value={payout}",
        "sub_meanings": {
          "sub1": "Zone id",
          "sub2": "Site id",
          "sub3": "Variation id",
          "sub4": "Campaign id",
          "sub5": "Format"
        },
        "notes": "Replace [GOAL_ID] with the Conversion Goal ID from your ExoClick account…"
      }
    ]
  }
  ```

**Notes**

- Built-in presets: exoclick, trafficstars, propellerads, adsterra, richads. `[BRACKETED]` pieces in a postback_template are account-specific values you fill in after copying it into your own traffic source.
- tracking_template is the query string to append to a zone /serve (or /track/click) URL — your parameter names on the left, the network’s own macros on the right; the network expands them before the request reaches Affset.

### GET /api/traffic-sources

List traffic sources

**Query parameters**

- `status` (enum) — active | archived.
- `limit` (integer) — 1–100, default 50.
- `offset` (integer) — Default 0.
- `sort` (enum) — name | created_at (default).
- `order` (enum) — asc | desc (default).

**Example request**

```bash
curl "https://api.affset.com/api/traffic-sources?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "traffic_sources": [
      {
        "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
        "name": "ExoClick — main",
        "preset": "exoclick",
        "tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}&sub2={site_id}&sub3={variation_id}&sub4={campaign_id}&sub5={format}",
        "postback_template": "https://s.magsrv.com/tag.php?goal=abc123&tag={source_click_id}&value={payout}",
        "has_api_token": false,
        "status": "active",
        "created_at": 1755640000000,
        "updated_at": 1755640000000
      }
    ],
    "pagination": {
      "total": 1,
      "limit": 50,
      "offset": 0,
      "has_more": false
    }
  }
  ```

### POST /api/traffic-sources

Create a traffic source

**Body**

- `name` (string, required) — Unique within the tenant. Max 200 characters.
- `preset` (enum) — Copying a preset (exoclick | trafficstars | propellerads | adsterra | richads) fills tracking_template / postback_template unless you also send your own; the row stays fully editable and remembers its preset.
- `tracking_template` (string) — Raw query string appended after the zone URL’s "?" — must not start with "?"/"&" and no control characters. Max 2000 characters. Network macro syntaxes ({x}, ${X}, ##X##, [X]) pass through byte-exact.
- `postback_template` (string) — The network’s S2S conversion endpoint, using the same macros as a zone postback_url ({payout}, {source_click_id}, {sub1}…{sub5}). Must be http(s) if present. Max 2000 characters.
- `api_token` (string) — The network’s API credential, stored write-only; powers cost sync for the exoclick and trafficstars presets. Max 500 characters, no control characters.
- `status` ("active" | "archived") — Default active.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/traffic-sources" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"name":"ExoClick — main","preset":"exoclick","api_token":"your-exoclick-api-token"}'
```

**Responses**

- `201`

  ```json
  {
    "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
    "status": "active",
    "created_at": 1755640000000
  }
  ```

**Notes**

- 409 if the name already exists in the namespace.

### GET /api/traffic-sources/{traffic_source_id}

Get one traffic source

**Example request**

```bash
curl "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
    "name": "ExoClick — main",
    "preset": "exoclick",
    "tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}",
    "postback_template": "https://s.magsrv.com/tag.php?goal=abc123&tag={source_click_id}&value={payout}",
    "has_api_token": false,
    "status": "active",
    "linked_zones": 3,
    "created_at": 1755640000000,
    "updated_at": 1755640000000
  }
  ```

**Notes**

- Same shape as List, plus linked_zones — how many zones currently reference it.

### PUT /api/traffic-sources/{traffic_source_id}

Update a traffic source (partial)

**Body**

- `name` (string) — New name; must stay unique within the tenant.
- `preset` (enum | null) — exoclick | trafficstars | propellerads | adsterra | richads. null or "" clears it (without touching the templates already on the row).
- `tracking_template` (string) — Replaces the stored template; send "" to clear it.
- `postback_template` (string) — Replaces the stored template; send "" to clear it.
- `api_token` (string | null) — Omit to leave unchanged, null or "" to clear, or a string to replace it.
- `status` ("active" | "archived") — New status.

**Example request**

```bash
curl -X PUT "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"api_token":null,"status":"active"}'
```

**Responses**

- `200`

  ```json
  {
    "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
    "updated_at": 1755650000000
  }
  ```

**Notes**

- At least one field is required.

### DELETE /api/traffic-sources/{traffic_source_id}

Delete a traffic source

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `204`

**Notes**

- 409 with linked_zones while any zone still references it — unlink those zones or set status to archived instead (keeps history attributable).

### POST /api/traffic-sources/{traffic_source_id}/sync-costs

Pull spend from the network now

**Body**

- `date_from` (string) — Inclusive YYYY-MM-DD start of the window to sync. Optional — omit both dates to sync the hourly job’s own window (today plus the two previous UTC days).
- `date_to` (string) — Inclusive YYYY-MM-DD end. Not in the future; at most 31 days after date_from. Send an explicit range once after adding a token to backfill history.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22/sync-costs" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"date_from":"2026-08-01","date_to":"2026-08-28"}'
```

**Responses**

- `200`

  ```json
  {
    "synced": true,
    "date_from": "2026-08-01",
    "date_to": "2026-08-28",
    "days": 28,
    "rows_synced": 54,
    "rows_skipped": 0,
    "cost_total": 148.52
  }
  ```

**Notes**

- Each synced day is replaced wholesale with what the network reports, so revised numbers never double count. rows_skipped counts malformed or out-of-window rows the network returned (normally 0).
- 422 when the source has no api_token or its preset has no cost adapter (supported: exoclick, trafficstars). 502 with the network’s failure reason when the network refuses — stored costs are left untouched.

### POST /api/traffic-sources/{traffic_source_id}/check-credentials

Verify the stored API token against the network

**Example request**

```bash
curl -X POST "https://api.affset.com/api/traffic-sources/b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22/check-credentials" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "ok": true,
    "error": null
  }
  ```

**Notes**

- One authentication round-trip with the stored api_token — nothing is stored, nothing is revealed. ok: false carries the network’s rejection reason; 422 for presets without a cost adapter.

## Offers

The CPA-network catalog. Owner/manager and advertiser-side roles (advertiser, advertiser_manager) manage offers — same ownership rule as Campaigns above. Publisher-side roles (publisher, publisher_manager) get a stripped, payout-only catalog instead: active public/apply offers, plus any private offer they already hold a link for.

### GET /api/offers

List offers

**Query parameters**

- `status` (enum) — active | paused | archived.
- `limit` (integer) — 1–100, default 50.
- `offset` (integer) — Default 0.

**Example request**

```bash
curl "https://api.affset.com/api/offers?status=active" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "offers": [
      {
        "id": 12,
        "name": "Sweeps SOI — US",
        "description": null,
        "category": null,
        "status": "active",
        "destination_url": "https://landing.example/sweeps?clid={click_id}",
        "preview_url": null,
        "visibility": "public",
        "hold_days": 7,
        "auto_approve": true,
        "daily_conversion_cap": null,
        "monthly_payout_cap_cents": null,
        "per_affiliate_daily_cap": null,
        "allowed_traffic": [
          "push",
          "pop"
        ],
        "terms": null,
        "campaign_id": 345,
        "user_email": "advertiser@yournetwork.com",
        "created_at": 1755640000000,
        "updated_at": 1755640000000,
        "goals": [
          {
            "id": 30,
            "name": "Registration",
            "conversion_type": "reg",
            "payout_cents": 250,
            "revenue_cents": 400,
            "sort": 0
          },
          {
            "id": 31,
            "name": "First deposit",
            "conversion_type": "dep",
            "payout_cents": 2500,
            "revenue_cents": 4000,
            "sort": 1
          }
        ]
      }
    ],
    "pagination": {
      "total": 1,
      "limit": 50,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- Publisher-side entries carry the stripped shape instead: no destination_url, revenue_cents, caps, campaign_id or user_email — plus link_issued (whether the caller already holds a tracking link) and application_status (the caller’s own latest application, or null).
- Only active offers that are public or apply, or a private offer the caller already holds a link for, are listed for publisher-side roles.

### POST /api/offers

Create an offer

**Body**

- `name` (string, required) — Offer name.
- `destination_url` (string, required) — Must be http(s). Accepts the same macros as a campaign redirect_url, e.g. {click_id} — see Ad serving.
- `status` ("active" | "paused" | "archived") — Default paused.
- `visibility` ("public" | "apply" | "private") — public lets any publisher self-issue a link; apply requires an approved application first; private is granted only by an operator or the offer’s advertiser. Default public.
- `goals` (array, required) — 1–20 entries: { name, conversion_type, payout_cents, revenue_cents, sort? }. conversion_type ([A-Za-z0-9_.-]{1,64}) must equal the conversion pixel’s type= parameter and be unique within the offer. revenue_cents (what the advertiser pays) minus payout_cents (what the affiliate earns) is the network margin.
- `user_email` (string) — Whose advertiser this belongs to — required for owner, manager and advertiser_manager, same ownership rule as Campaigns. Advertisers may omit it (defaults to themselves). The materialized campaign inherits the same user.
- `description` (string) — Optional.
- `category` (string) — Optional.
- `preview_url` (string) — Optional. Must be http(s) if present.
- `terms` (string) — Optional.
- `allowed_traffic` (string[]) — Optional free-form traffic type tags, e.g. ["push", "pop"].
- `hold_days` (integer) — 0–90 — how long a conversion sits in review before it’s eligible to pay out. Default 0.
- `auto_approve` (boolean) — Skip manual review and clear the hold automatically once hold_days elapses. Default true.
- `daily_conversion_cap` (integer) — Max conversions/day across the offer — later ones land rejected with status_reason cap_exceeded. Optional, no cap by default.
- `monthly_payout_cap_cents` (integer) — Max total payout cents/month across the offer. Optional.
- `per_affiliate_daily_cap` (integer) — Max conversions/day for a single affiliate. Optional.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/offers" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"name":"Sweeps SOI — US","destination_url":"https://landing.example/sweeps?clid={click_id}","status":"active","visibility":"public","hold_days":7,"user_email":"advertiser@yournetwork.com","goals":[{"name":"Registration","conversion_type":"reg","payout_cents":250,"revenue_cents":400},{"name":"First deposit","conversion_type":"dep","payout_cents":2500,"revenue_cents":4000}]}'
```

**Responses**

- `201`

  ```json
  {
    "id": 12,
    "campaign_id": 345,
    "status": "active",
    "created_at": 1755640000000
  }
  ```

**Notes**

- Each offer materializes to exactly one serving campaign (campaign_id) — serving, targeting, budgets and payout rules are the existing campaign machinery underneath it. Offer-owned campaign fields (name, destination_url, status) must be managed through this endpoint, not through Campaigns directly.
- Activating an offer (status: active) consumes the tenant’s active-campaign plan allowance and can return 402.
- On a conversion, the pixel’s type is matched against the offer’s goals: a match writes spend = revenue_cents/100, payout = payout_cents/100 (a zone-scoped payout rule still overrides the payout for that affiliate); a non-matching or missing type records the conversion at $0/$0 with postback_skipped: non_goal_type.

### GET /api/offers/{offer_id}

Get one offer

**Example request**

```bash
curl "https://api.affset.com/api/offers/12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "id": 12,
    "name": "Sweeps SOI — US",
    "description": null,
    "category": null,
    "status": "active",
    "destination_url": "https://landing.example/sweeps?clid={click_id}",
    "preview_url": null,
    "visibility": "public",
    "hold_days": 7,
    "auto_approve": true,
    "daily_conversion_cap": null,
    "monthly_payout_cap_cents": null,
    "per_affiliate_daily_cap": null,
    "allowed_traffic": [
      "push",
      "pop"
    ],
    "terms": null,
    "campaign_id": 345,
    "user_email": "advertiser@yournetwork.com",
    "created_at": 1755640000000,
    "updated_at": 1755640000000,
    "goals": [
      {
        "id": 30,
        "name": "Registration",
        "conversion_type": "reg",
        "payout_cents": 250,
        "revenue_cents": 400,
        "sort": 0
      },
      {
        "id": 31,
        "name": "First deposit",
        "conversion_type": "dep",
        "payout_cents": 2500,
        "revenue_cents": 4000,
        "sort": 1
      }
    ]
  }
  ```

**Notes**

- Role-shaped as in List. A publisher holding a link for this offer also gets tracking_link and zone_id in the response.

### PUT /api/offers/{offer_id}

Update an offer (partial)

**Body**

- `name` (string) — Propagates to the materialized campaign.
- `destination_url` (string) — Propagates to the materialized campaign’s redirect_url.
- `status` ("active" | "paused" | "archived") — Propagates to the materialized campaign: active serves, paused/archived pause it.
- `visibility` ("public" | "apply" | "private") — See Create an offer.
- `goals` (array) — Replaces the whole list — same shape as Create (name, conversion_type, payout_cents, revenue_cents, sort?). Send every goal you want to keep; ids are ignored and new ids are assigned.
- `user_email` (string) — Reassign the owning advertiser; same rule as Create.
- `description` (string | null) — Send null or "" to clear.
- `category` (string | null) — Send null or "" to clear.
- `preview_url` (string | null) — Must be http(s) if present. Send null or "" to clear.
- `terms` (string | null) — Send null or "" to clear.
- `allowed_traffic` (string[] | null) — Send null or [] to clear.
- `hold_days` (integer) — 0–90.
- `auto_approve` (boolean) — Optional.
- `daily_conversion_cap` (integer | null) — Send null to clear.
- `monthly_payout_cap_cents` (integer | null) — Send null to clear.
- `per_affiliate_daily_cap` (integer | null) — Send null to clear.

**Example request**

```bash
curl -X PUT "https://api.affset.com/api/offers/12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"status":"paused"}'
```

**Responses**

- `200`

  ```json
  {
    "id": 12,
    "updated_at": 1755650000000
  }
  ```

**Notes**

- Only the fields you send are changed — any subset of the create fields.
- Setting status to active runs the tenant’s active-campaign plan check and can return 402.

### DELETE /api/offers/{offer_id}

Delete an offer

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/offers/12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `204`

**Notes**

- 409 with linked_zones while any publisher still holds a link — archive the offer (status: archived) instead if you want to keep their history attributable.
- Deleting detaches and pauses the materialized campaign but keeps it and its event history.

### POST /api/offers/{offer_id}/link

Issue a tracking link

**Body**

- `user_email` (string) — The publisher to issue for. Required for owner, manager, the offer’s advertiser, and publisher_manager (naming their managed publisher). A publisher self-issuing on a public offer omits it.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/offers/12/link" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Responses**

- `201` — or 200 when a link already existed for this (offer, publisher) pair

  ```json
  {
    "offer_id": 12,
    "zone_id": "8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
    "tracking_link": "https://api.affset.com/track/click/345/8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
    "created": true
  }
  ```

**Notes**

- A publisher self-issues on an active public offer; apply/private offers return 403 with code apply_required for a publisher acting alone. Operators (and the offer’s advertiser) can issue for a named publisher via user_email on any visibility — including apply/private, which then becomes visible to that publisher — and this also approves a pending application for them if one exists.
- Issuance creates a dedicated zone per (offer, publisher) pair, named "«offer» — «publisher»" — repeat calls return the same link and reactivate the zone if it had gone inactive. Creating the first link for a new (offer, publisher) pair counts against the zone plan allowance and can return 402; returning or reactivating an existing one does not.
- The dedicated zone is an ordinary zone afterward — set its postback_url, traffic_back_url, or a zone-scoped payout rule same as any other zone.
- The link host honors the tenant’s custom_api_domain.

### POST /api/offers/{offer_id}/apply

Apply for an offer

_Access: Publisher-side roles only. A publisher applies for itself; publisher_manager must name a managed publisher via user_email._

**Body**

- `message` (string) — Optional, max 1000 characters.
- `user_email` (string) — Required for publisher_manager — the managed publisher applying.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/offers/12/apply" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"message":"Push traffic, US, 5k/day"}'
```

**Responses**

- `201`

  ```json
  {
    "id": 81,
    "offer_id": 12,
    "user_email": "publisher@example.com",
    "status": "pending",
    "created_at": 1787356800000
  }
  ```

**Notes**

- Only offers with visibility: "apply" accept applications. A public offer returns 409 with code public_offer — issue a link directly instead. A private or inactive offer reads as 404, same as any offer the caller can’t see.
- Only one pending application may exist per offer and publisher — re-applying after a rejection or revocation creates a new history row.

### GET /api/offer-applications

List offer applications

_Access: Owner/manager see the tenant queue. advertiser-side roles see applications for offers they own or manage. publisher sees their own; publisher_manager sees their managed publishers’._

**Query parameters**

- `status` (enum) — pending | approved | rejected | revoked.
- `offer_id` (integer) — Restrict to one offer.
- `limit` (integer) — 1–100, default 50.
- `offset` (integer) — Default 0.

**Example request**

```bash
curl "https://api.affset.com/api/offer-applications?status=pending&offer_id=12" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "applications": [
      {
        "id": 81,
        "offer_id": 12,
        "offer_name": "Sweeps SOI — US",
        "user_email": "publisher@example.com",
        "status": "pending",
        "message": "Push traffic, US, 5k/day",
        "reviewed_by": null,
        "reviewed_at": null,
        "created_at": 1787356800000
      }
    ],
    "pagination": {
      "total": 1,
      "limit": 50,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- Publisher-facing responses omit reviewed_by — they see the outcome and when, never who decided it.

### PATCH /api/offer-applications/{application_id}

Approve, reject or revoke an application

_Access: Owner, manager, and advertiser-side roles that own or manage the offer it targets. Publisher-side roles get 403._

**Body**

- `status` ("approved" | "rejected" | "revoked", required) — Allowed transitions: pending → approved | rejected, and approved → revoked.

**Example request**

```bash
curl -X PATCH "https://api.affset.com/api/offer-applications/81" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"status":"approved"}'
```

**Responses**

- `200`

  ```json
  {
    "id": 81,
    "offer_id": 12,
    "user_email": "publisher@example.com",
    "status": "approved",
    "zone_id": "8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
    "tracking_link": "https://api.affset.com/track/click/345/8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
    "reviewed_at": 1787360000000
  }
  ```

**Notes**

- Approval atomically creates or reactivates the dedicated zone and returns zone_id/tracking_link; creating a new zone counts against the plan’s zone limit and can return 402.
- Revocation atomically pauses that zone without deleting stats history. Re-applying afterward creates a new application history row.
- 409 when the offer is no longer active/apply, or the application isn’t in the expected starting status for that transition (pending for approved/rejected, approved for revoked).

## Payouts

Money out for the network: publisher balance, ledger history, and payout requests. Publishers act only for themselves; publisher_manager acts for a managed publisher (user_email required); owner/manager act for any publisher (user_email required) and are the only roles that can decide requests or post manual adjustments. Advertiser-side roles get 403 on this entire tree — affiliate earnings are the network’s ledger, never the advertiser’s. Amounts are integer cents throughout.

### GET /api/payouts/balance

Get a publisher’s balance

**Query parameters**

- `user_email` (string) — Required for owner, manager and publisher_manager — the publisher to look up. A publisher omits it (defaults to themselves).

**Example request**

```bash
curl "https://api.affset.com/api/payouts/balance?user_email=publisher%40example.com" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "user_email": "publisher@example.com",
    "balance_cents": 128500,
    "locked_cents": 5000,
    "available_cents": 123500,
    "payout_min_cents": 5000
  }
  ```

**Notes**

- locked_cents is the total of that publisher’s currently open (requested or approved) payout requests; available_cents is what a new request can draw against.

### GET /api/payouts/balances

List every publisher’s balance

_Access: Owner and manager only._

**Query parameters**

- `limit` (integer) — 1–100, default 50.
- `offset` (integer) — Default 0.

**Example request**

```bash
curl "https://api.affset.com/api/payouts/balances" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "balances": [
      {
        "user_email": "publisher@example.com",
        "balance_cents": 128500,
        "locked_cents": 5000,
        "available_cents": 123500
      }
    ],
    "pagination": {
      "total": 1,
      "limit": 50,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- A paginated overview across every publisher with ledger activity — not the full user list.

### GET /api/payouts/ledger

Get a publisher’s ledger

**Query parameters**

- `user_email` (string) — Required for owner, manager and publisher_manager — the publisher to read. A publisher omits it (defaults to themselves).
- `limit` (integer) — 1–100, default 50.
- `offset` (integer) — Default 0.

**Example request**

```bash
curl "https://api.affset.com/api/payouts/ledger" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "user_email": "publisher@example.com",
    "entries": [
      {
        "id": 501,
        "amount_cents": 3000,
        "kind": "conversion_credit",
        "conversion_id": "7291834650192837",
        "payout_request_id": null,
        "settled": false,
        "reversed": false,
        "note": null,
        "created_by": null,
        "created_at": 1787356800000
      },
      {
        "id": 502,
        "amount_cents": -2500,
        "kind": "adjustment",
        "conversion_id": null,
        "payout_request_id": null,
        "settled": false,
        "reversed": false,
        "note": "chargeback on conversion 991",
        "created_by": "owner@acme-media.com",
        "created_at": 1787360000000
      }
    ],
    "pagination": {
      "total": 2,
      "limit": 50,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- kind is conversion_credit | conversion_reversal | payout | adjustment. settled means the entry has been folded into a paid payout request; reversed means a later reversal cancelled it — the entry itself stays for the audit trail either way.
- Publisher-facing responses omit created_by.

### POST /api/payouts/adjustments

Post a manual ledger adjustment

_Access: Owner and manager only._

**Body**

- `user_email` (string, required) — The publisher whose balance this adjusts.
- `amount_cents` (integer, required) — Non-zero, ±$1,000,000 cap. Negative debits the balance, positive credits it.
- `note` (string, required) — Required — an adjustment must say why.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/payouts/adjustments" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"user_email":"publisher@example.com","amount_cents":-2500,"note":"chargeback on conversion 991"}'
```

**Responses**

- `201`

  ```json
  {
    "id": 502,
    "user_email": "publisher@example.com",
    "amount_cents": -2500,
    "kind": "adjustment",
    "created_at": 1787360000000,
    "balance_cents": 126000,
    "locked_cents": 5000,
    "available_cents": 121000,
    "payout_min_cents": 5000
  }
  ```

**Notes**

- Takes effect immediately — the response includes the publisher’s new balance.

### GET /api/payout-requests

List payout requests

**Query parameters**

- `status` (enum) — requested | approved | paid | rejected.
- `user_email` (string) — Narrow to one publisher’s requests. Always scoped to what the caller can already see — owner/manager may use any publisher, publisher_manager only a managed one.
- `format` ("csv") — Owner/manager only. Returns a CSV export instead of JSON, capped at 10,000 rows — narrow status/user_email above that.
- `limit` (integer) — 1–100, default 50. Ignored when format=csv.
- `offset` (integer) — Default 0. Ignored when format=csv.

**Example request**

```bash
curl "https://api.affset.com/api/payout-requests?status=requested" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "payout_requests": [
      {
        "id": 91,
        "user_email": "publisher@example.com",
        "amount_cents": 50000,
        "status": "requested",
        "method": "USDT TRC-20 T…",
        "note": "monthly cashout",
        "created_by": "publisher@example.com",
        "decided_by": null,
        "decided_at": null,
        "paid_at": null,
        "created_at": 1787356800000
      }
    ],
    "pagination": {
      "total": 1,
      "limit": 50,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- Publishers and publisher_manager see only their own (or managed) requests.
- 422 (CSV only) if the filtered result exceeds 10,000 rows — narrow the filter and retry.

### POST /api/payout-requests

Create a payout request

_Access: publisher (for themselves), publisher_manager (user_email required — a managed publisher), owner and manager (user_email required — any publisher)._

**Body**

- `amount_cents` (integer, required) — Positive, up to $1,000,000.
- `method` (string, required) — Where the payout should go, e.g. "USDT TRC-20 T…".
- `note` (string) — Optional.
- `user_email` (string) — Required for owner, manager and publisher_manager — the publisher this request is for. A publisher omits it (defaults to themselves).

**Example request**

```bash
curl -X POST "https://api.affset.com/api/payout-requests" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"amount_cents":50000,"method":"USDT TRC-20 T…","note":"monthly cashout"}'
```

**Responses**

- `201`

  ```json
  {
    "id": 91,
    "user_email": "publisher@example.com",
    "amount_cents": 50000,
    "status": "requested",
    "method": "USDT TRC-20 T…",
    "note": "monthly cashout",
    "decided_at": null,
    "paid_at": null,
    "created_at": 1787356800000
  }
  ```

**Notes**

- A publisher-side request must clear the tenant’s payout_min_cents (default 5000, see Tenant settings) — an operator opening one on a publisher’s behalf may close out any balance regardless of the minimum.
- Only one open (requested or approved) request may exist per publisher — a second attempt returns 409 with code open_request_exists. An amount exceeding the publisher’s available balance returns 409 with code insufficient_balance.
- The 201 response omits created_by/decided_by for publisher-side callers.

### PATCH /api/payout-requests/{payout_request_id}

Decide a payout request

_Access: Owner and manager only._

**Body**

- `status` ("approved" | "rejected" | "paid", required) — Allowed transitions: requested → approved | rejected, and approved → paid | rejected.
- `note` (string) — Optional.

**Example request**

```bash
curl -X PATCH "https://api.affset.com/api/payout-requests/91" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"status":"paid","note":"sent 2026-08-24"}'
```

**Responses**

- `200`

  ```json
  {
    "id": 91,
    "user_email": "publisher@example.com",
    "amount_cents": 50000,
    "status": "paid",
    "method": "USDT TRC-20 T…",
    "note": "sent 2026-08-24",
    "created_by": "publisher@example.com",
    "decided_by": "owner@acme-media.com",
    "decided_at": 1787360000000,
    "paid_at": 1787360000000,
    "created_at": 1787356800000
  }
  ```

**Notes**

- Marking a request paid re-checks that the publisher’s balance still covers it — 409 with code insufficient_balance if not (e.g. a chargeback landed since approval) — and settles the corresponding ledger entries.
- 404 for an unknown id; 409 when the request isn’t in the expected starting status (requested for approved, requested or approved for rejected, approved for paid).

### POST /api/payout-requests/bulk

Bulk-decide payout requests

_Access: Owner and manager only._

**Body**

- `ids` (array, required) — 1–100 positive integer payout request ids.
- `status` ("approved" | "rejected" | "paid", required) — Same statuses as Decide a payout request, applied to every id.
- `note` (string) — Optional; applied to every id decided.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/payout-requests/bulk" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '{"ids":[91,92,93],"status":"approved"}'
```

**Responses**

- `200`

  ```json
  {
    "status": "approved",
    "applied": 2,
    "results": [
      {
        "id": 91,
        "ok": true,
        "user_email": "publisher@example.com",
        "amount_cents": 50000,
        "status": "approved",
        "decided_by": "owner@acme-media.com",
        "decided_at": 1787360000000
      },
      {
        "id": 92,
        "ok": false,
        "error": "Cannot transition a 'paid' request to 'approved'"
      }
    ]
  }
  ```

**Notes**

- Each id is decided independently — one bad row (already-decided, insufficient balance) never blocks the rest of the batch. applied counts only the ones that actually changed.
- Duplicate ids in the same call are decided once.

## Targeting

Same campaign-scoping as Campaigns above. Enforced on /serve only — never on a direct /track/click link.

### GET /api/targeting-rule-types

Catalog of targeting rule types

**Example request**

```bash
curl "https://api.affset.com/api/targeting-rule-types" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "targeting_rule_types": [
      {
        "id": 1,
        "name": "geo",
        "description": "Comma-separated ISO-3166 alpha-2 country codes, matched against the request’s country."
      },
      {
        "id": 2,
        "name": "device_type",
        "description": "desktop, mobile or tablet, comma-separated."
      },
      {
        "id": 3,
        "name": "capping",
        "description": "Stored only — not evaluated by /serve."
      },
      {
        "id": 4,
        "name": "zone_id",
        "description": "Comma-separated zone IDs. This is how zone blacklists/whitelists work."
      },
      {
        "id": 5,
        "name": "os",
        "description": "Comma-separated OS names, exact match, e.g. Android, iOS, Windows."
      },
      {
        "id": 6,
        "name": "browser",
        "description": "Comma-separated browser names, exact match, e.g. Chrome, Safari."
      },
      {
        "id": 7,
        "name": "weekdays",
        "description": "Stored only — not evaluated by /serve."
      },
      {
        "id": 8,
        "name": "hours",
        "description": "Stored only — not evaluated by /serve."
      },
      {
        "id": 9,
        "name": "unique_users",
        "description": "A single \"visits/hours\" value, e.g. 1/24 — frequency capping."
      }
    ]
  }
  ```

**Notes**

- ⚠️ capping, weekdays and hours are accepted and stored, but /serve never evaluates them — writing them has no effect on delivery. Use unique_users for frequency capping instead.
- geo, os and browser are matched exactly and case-sensitively. An unmatched whitelist value silently stops delivery rather than erroring anywhere.

### GET /api/campaigns/{campaign_id}/targeting_rules

List a campaign’s targeting rules

**Example request**

```bash
curl "https://api.affset.com/api/campaigns/42/targeting_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "targeting_rules": [
      {
        "id": 501,
        "targeting_rule_type_id": 1,
        "targeting_method": "whitelist",
        "rule": "BR,MX"
      }
    ]
  }
  ```

### POST /api/campaigns/{campaign_id}/targeting_rules

Replace a campaign’s targeting rules

**Body**

- `(array body)` (array, required) — targeting_method is "whitelist" or "blacklist".

**Example request**

```bash
curl -X POST "https://api.affset.com/api/campaigns/42/targeting_rules" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE" \
  -H "Content-Type: application/json" \
  -d '[{"id":501,"targeting_rule_type_id":1,"targeting_method":"whitelist","rule":"BR,MX"},{"targeting_rule_type_id":4,"targeting_method":"blacklist","rule":"550e8400-e29b-41d4-a716-446655440000"}]'
```

**Responses**

- `200`

  ```json
  [
    {
      "id": 501,
      "targeting_rule_type_id": 1,
      "targeting_method": "whitelist",
      "rule": "BR,MX"
    },
    {
      "id": 502,
      "targeting_rule_type_id": 4,
      "targeting_method": "blacklist",
      "rule": "550e8400-e29b-41d4-a716-446655440000"
    }
  ]
  ```

**Notes**

- ⚠️ This replaces the whole set for the campaign. Any existing rule whose id is left out of the body gets deleted. Always GET the current list first, then send it back with your change folded in — see the recipes below.

## Conversions

The conversion audit trail — individual records, not aggregates.

### GET /api/conversions

List conversions

**Query parameters**

- `limit` (integer) — 1–100, default 20.
- `offset` (integer) — Default 0.
- `sort` (enum) — created_at (default) | ad_event_id | click_id.
- `order` (enum) — asc | desc (default).

**Example request**

```bash
curl "https://api.affset.com/api/conversions?limit=5" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `200`

  ```json
  {
    "conversions": [
      {
        "ad_event_id": "7291834650192837",
        "click_id": "7291834612345678",
        "payload": "{\"type\":\"deposit\",\"value\":\"49\"}",
        "spend": 0,
        "payout": 3,
        "source_click_id": "abc123",
        "sub1": "richads",
        "sub2": null,
        "sub3": null,
        "sub4": null,
        "sub5": null,
        "created_at": 1753747200000
      }
    ],
    "pagination": {
      "total": 3,
      "limit": 5,
      "offset": 0,
      "has_more": false
    }
  }
  ```

**Notes**

- ad_event_id and click_id are returned as strings — they’re 64-bit IDs that don’t fit exactly in a JS number.
- No campaign, zone or date filters — just pagination and sort. This is the record-level audit trail; use Stats for aggregated, filterable reporting.
- payload is a JSON-encoded string of the conversion pixel’s query parameters (except click_id), and may also include postback status fields. Anyone who can fire a pixel controls those values — treat them as untrusted data if you feed them into anything automated.
- Same role-based redaction as Stats: publisher-side roles don’t see spend, advertiser-side roles don’t see payout.

### DELETE /api/conversions/{ad_event_id}

Delete a conversion

_Access: Owner only._

**Example request**

```bash
curl -X DELETE "https://api.affset.com/api/conversions/7291834650192837" \
  -H "Authorization: Bearer $AFFSET_API_KEY" \
  -H "X-Namespace: $AFFSET_NAMESPACE"
```

**Responses**

- `204`

**Notes**

- Deletes the conversion row and its matching conversion event. The originating click remains in the event history.

## Sign-in

Public — no Authorization or X-Namespace header. The passwordless dashboard sign-in behind {namespace}.affset.com/login and the generic app.affset.com/login. Every response uses Cache-Control: no-store. In user-facing copy a namespace is called a workspace; the API keeps namespace.

### POST /api/public/auth/find-workspaces

Email one sign-in link per workspace the address belongs to

_Public — no `Authorization` or `X-Namespace` header._

**Body**

- `email` (string, required) — The address to look up. Nothing else — the workspaces are listed only in the email.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/public/auth/find-workspaces" \
  -H "Content-Type: application/json" \
  -d '{"email":"owner@my-company.com"}'
```

**Responses**

- `200` — always the same body — invalid, unknown, 1 or many workspaces

  ```json
  {
    "ok": true,
    "message": "If the address belongs to any workspace, an email with your sign-in links is on its way."
  }
  ```
- `429` — per-IP bucket (5 / 15 min), with Retry-After

**Notes**

- Enumeration-safe by construction: the lookup and the send happen after the response is returned, so neither the body nor the timing reveals whether the address belongs anywhere.
- One workspace → the same email request-link sends. Several → one email, one namespace-bound link per workspace (max 20; the email says when there are more). None → an email saying no workspace matched.
- Each link redeems through verify-link in its own workspace only. Throttled per address to one email per minute (silently).

### POST /api/public/auth/request-link

Email a single-use sign-in link for one workspace

_Public — no `Authorization` or `X-Namespace` header._

**Body**

- `email` (string, required) — Member address in that workspace.
- `namespace` (string, required) — The workspace — the first label of {namespace}.affset.com.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/public/auth/request-link" \
  -H "Content-Type: application/json" \
  -d '{"email":"owner@my-company.com","namespace":"my-company"}'
```

**Responses**

- `200` — always the same body — valid, unknown or throttled address

  ```json
  {
    "ok": true,
    "message": "If the address matches an account, a sign-in link has been sent."
  }
  ```
- `429` — per-IP bucket (10 / 15 min), with Retry-After

**Notes**

- Links work once and expire after 15 minutes. At most one link per address and workspace per minute; repeats inside the window are dropped silently.

### POST /api/public/auth/verify-link

Redeem a sign-in link for a 30-day browser session

_Public — no `Authorization` or `X-Namespace` header._

**Body**

- `token` (string, required) — The token from the emailed link’s ?token= parameter.

**Example request**

```bash
curl -X POST "https://api.affset.com/api/public/auth/verify-link" \
  -H "Content-Type: application/json" \
  -d '{"token":"token-from-the-login-link"}'
```

**Responses**

- `200`

  ```json
  {
    "token": "browser-session-token",
    "namespace": "my-company",
    "expires_at": 1721347200000
  }
  ```
- `400` — invalid, already used or expired — {error, code}
- `429` — per-IP bucket (30 / 15 min)

**Notes**

- The session token is used exactly like an API key: Authorization: Bearer <token> plus X-Namespace. It is bound to the workspace the link was issued for.
- Redeeming the tenant owner’s link also marks the owner email verified.

## Ad serving

Public — no Authorization or X-Namespace header. Namespace is resolved from the zone/campaign in the URL. On error these return plain text, not JSON.

### GET /serve/{zone_id}

Entry point you give a traffic source

_Public — no `Authorization` or `X-Namespace` header._

**Query parameters**

- `source_click_id` (string) — Your source’s click id. Legacy alias: sub_id.
- `sub1…sub5` (string) — Passed through to the click and any conversions on it.
- `cost` (decimal) — Media cost for this impression — see the note below.

**Example request**

```bash
curl "https://api.affset.com/serve/550e8400-e29b-41d4-a716-446655440000"
```

**Responses**

- `302` — or 200 with an HTML redirect, per redirect_method

**Notes**

- Picks one eligible campaign from the zone’s active, targeting-matched campaigns and redirects to /track/click for it.
- No eligible campaign → redirects to the zone’s traffic_back_url if set, otherwise a plain-text 404.
- source_click_id and sub1–sub5 carry forward to /track/click. cost is recorded on the /serve impression and deliberately not forwarded, so it is counted once; other query parameters are dropped.
- Geo/device/OS/browser targeting is enforced here. It is not enforced on a direct /track/click link.

### GET /track/click/{campaign_id}/{zone_id}

Direct tracking link for one campaign

_Public — no `Authorization` or `X-Namespace` header._

**Query parameters**

- `source_click_id` (string) — Legacy alias: sub_id.
- `sub1…sub5` (string) — Stored on the click and attributed to later conversions.
- `cost` (decimal) — Only send this here or on /serve for a given stream, never both — sending it to both double-counts.

**Example request**

```bash
curl "https://api.affset.com/track/click/42/550e8400-e29b-41d4-a716-446655440000"
```

**Responses**

- `302` — to the campaign’s redirect_url, macros expanded

**Notes**

- Records the click and, for CPM campaigns, computes spend as rate/1000 — CPA campaigns accrue spend on conversion instead.
- Not geo/targeting gated — only /serve enforces targeting.
- Macros in redirect_url: {click_id}, {zone_id}, {source_click_id} (alias {aff_sub_id}), {sub1}…{sub5}. Values are percent-encoded on substitution. A macro with nothing to fill stays as literal text.

### GET /px/{click_id}

Conversion pixel (into Affset)

_Public — no `Authorization` or `X-Namespace` header._

**Query parameters**

- `click_id` (string) — Required when not in the path. Affset click id from the offer redirect’s {click_id}.
- `type` (string) — Goal label. When the campaign has payout_goal_type set, only an exact type= match accrues spend/payout; others still record at $0.
- `sub1…sub5` (string) — Fill empty slots from the click only — cannot overwrite values already stored on the click.
- `(other params)` (string) — Stored in the conversion payload (except click_id). source_click_id here cannot rewrite the click’s token used for the affiliate postback.

**Example request**

```bash
curl "https://api.affset.com/px/7291834612345678?type=deposit"
```

**Responses**

- `200` — 1×1 GIF by default, or {"status":"ok"} for a JSON-flavored request

**Notes**

- Send Accept: application/json for a JSON response. Other callers receive the 1×1 GIF used by browser pixels.
- After a non-silent conversion, Affset GETs the zone’s postback_url (affiliate passback) with macros {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Skipped when silent or when the zone has no postback_url.
- Payout comes from campaign payout rules (zone-specific → global → $0), not from a query param on /px.

## Guides

_Assistant-oriented detail that complements the endpoint reference. The https://affset.com/docs UI stays shorter; prefer these guides when reviewing postback templates or attribution macros._

### Attribution, conversion pixel & affiliate postbacks

Use this section when a customer asks whether a postback / passback link is
correct, or pastes a traffic-source template to adapt. The endpoint reference
above lists the HTTP surface; this is the worked attribution model.

### Who fires what

1. **Traffic source → Affset** — `GET /serve/{zone_id}` or `GET /track/click/{campaign_id}/{zone_id}` with the network's click token and optional `sub1`…`sub5` / `cost`.
2. **Affset → offer** — 302 to the campaign's `redirect_url` with Affset macros expanded (`{click_id}`, …).
3. **Offer / advertiser → Affset** — `GET /px/{click_id}` (or `/px?click_id=`) records the conversion.
4. **Affset → traffic source** — Affset expands the zone's `postback_url` and GETs it (affiliate **passback** / S2S postback out).

Do not confuse **`postback_url`** (conversion reported back to the source) with **`traffic_back_url`** (fallback redirect when `/serve` has no eligible campaign — not a conversion postback).

### Inbound click query params (`/serve`, `/track/click`)

| Param | Role |
| --- | --- |
| `source_click_id` | Upstream network click token. Legacy alias: `sub_id`. Stored on the click and later echoed on the zone postback. |
| `sub1`…`sub5` | Analytical breakdown dims; carried to the click and any conversions. |
| `cost` | Media cost. Send on **either** `/serve` **or** `/track/click` for a stream — never both (double-counts). Plain unsigned decimal only. |

Other query params on `/serve` are dropped (not forwarded to `/track/click`).

### Campaign `redirect_url` macros (offer link)

Affset substitutes these when redirecting the visitor to the offer:

| Macro | Value |
| --- | --- |
| `{click_id}` | Affset click id — put this in the offer URL so the advertiser can fire `/px/{click_id}`. |
| `{zone_id}` | Zone id. |
| `{source_click_id}` | Upstream token from the click. Legacy alias: `{aff_sub_id}`. |
| `{sub1}`…`{sub5}` | Subs from the click. |

Caller-supplied values (`source_click_id`, subs) are percent-encoded. A macro with nothing to fill stays as literal `{…}` text (visible misconfig), it does not become empty.

### Conversion pixel `/px` (into Affset)

- Path: `/px/{click_id}` or query `/px?click_id=`.
- `type` — conversion goal label. When the campaign has `payout_goal_type` set, only pixels whose `type=` matches exactly accrue spend/payout; non-matching events still record at $0.
- `sub1`…`sub5` on the pixel **fill empty slots only** — they cannot overwrite values already stored on the click.
- `source_click_id` on the pixel **cannot rewrite** the click's token (otherwise a forged pixel could re-point the source postback). It may still appear in the stored payload JSON if sent.
- Every other query param (except `click_id`) is stored in the conversion `payload` for audit.
- Payout paid to the publisher comes from campaign **payout rules** (zone-specific → global → $0), not from a pixel query param.
- Silent conversions (campaign `silent = N`) force payout $0 and **skip** the affiliate postback (`postback_skipped=silent_conversion` in payload). Missing zone `postback_url` → `postback_skipped=missing_postback_url`.

Example inbound conversion:

```
https://api.affset.com/px/7291834612345678?type=deposit&order_id=ord_9
```

### Zone `postback_url` macros (affiliate passback out)

Fired by Affset after a non-silent conversion when the zone has a `postback_url`:

| Macro | Value |
| --- | --- |
| `{payout}` | Payout amount for this conversion (from payout rules / goal gating). |
| `{source_click_id}` | Upstream network click token from the original click. Legacy alias: `{sub_id}`. |
| `{sub1}`…`{sub5}` | Subs attributed on the click (pixel-only fills included). |

There is **no** `{click_id}` macro on postbacks — Affset's internal click id is not what the traffic source usually needs. If a template uses a placeholder for "your click id", map it to `{source_click_id}`.

Example correct zone postback:

```
https://network.example/postback?clickid={source_click_id}&payout={payout}&status=1
```

Affset does not require `{source_click_id}` / `{sub_id}` in the URL, but without it the source cannot match the conversion to their click (warning on create, not a hard reject).

### How to review a customer's postback template

When someone pastes a network's "postback URL" or "S2S passback":

1. Decide which side the URL is for.
   - **Tracking / campaign URL they paste into the network** → Affset `/serve/…` or `/track/click/…`. Replace the network's click-id macro with how **they** emit it (e.g. `source_click_id={clickid}` if the network expands `{clickid}` before calling Affset). Optional `&cost={cost}` (their cost macro).
   - **Postback URL they configure in Affset (zone `postback_url`)** → Affset calls the network. Use **Affset** macros (`{source_click_id}`, `{payout}`, …) in the places the network expects values.
2. Map "click id" on the network postback → `{source_click_id}` (or legacy `{sub_id}`). Do **not** use `{click_id}` here.
3. Map payout / payout / revenue / sum → `{payout}`.
4. Map sub / pub_sub / zone creative slots → `{sub1}`…`{sub5}` as needed.
5. Leave static flags the network documents (`status=1`, `event=conversion`) as literal query params.
6. Reject or rewrite clear mistakes: empty payout placeholder, Affset `{click_id}` sent to the network as their click id, `traffic_back_url` used as a conversion postback, or `cost=` on both `/serve` and `/track/click`.

### Quick examples

| Goal | URL |
| --- | --- |
| Zone entry for RichAds-style clickid | `https://api.affset.com/serve/{ZONE}?source_click_id={clickid}&sub1={campaignid}&cost={cost}` |
| Offer URL on the campaign | `https://offer.example/lp?click={click_id}&src={source_click_id}` |
| Advertiser S2S into Affset | `https://api.affset.com/px/{click_id}?type=purchase` |
| Zone postback back to the source | `https://source.example/pb?click_id={source_click_id}&payout={payout}` |
