# API reference
Source: https://rekomi.com/docs/developers/reference

> Every v1 endpoint with request schema, response JSON example, scope, and notes, verified against the live code.

---
title: API reference
description: Every v1 endpoint with request schema, response JSON example, scope, and notes, verified against the live code.
---

This page covers the core endpoints exposed under `/api/v1/*` for API-key-authenticated callers. Each entry shows the request shape, a realistic response JSON, the scope required, and any plan or role gates. It is not exhaustive: the coupons surface is documented separately in [Coupon code tracking](/docs/developers/coupons), and a handful of auxiliary endpoints (slug checks, per-campaign tracking status, commission summaries, payout ledger and receipts) are not listed here. Responses may also carry additional convenience fields beyond those shown (display names, customer details, avatar URLs, budget and threshold fields); treat the schemas below as the core shape, not a strict ceiling.

**Not building a custom client?** Rekomi exposes the same surface through two no-code / low-code consumers:

- [Zapier](/docs/developers/zapier), 9 triggers, 9 actions, 4 searches. Click-to-connect from the Zapier marketplace. Use this when your goal is to wire Rekomi into Slack, Gmail, HubSpot, Notion, Google Sheets, Typeform, or any other Zapier-supported app.
- [MCP server](/docs/developers/mcp), 61 tools exposed to Claude, Cursor, ChatGPT, and other AI clients via the Model Context Protocol. Use this when you want an AI assistant to operate Rekomi for you.

Both consumers are governed by the same plan gates and audit log as the raw REST surface documented below, they are alternate transports onto the same domain model, not separate APIs.

**Auth header** on every authenticated endpoint:

```http
Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxxx
```

**Scope** column tells you which API-key scope satisfies the endpoint:

- `read`: keys created with scope `read` are accepted (also satisfied by `read_write`)
- `read_write`: only `read_write` keys are accepted

Default inference: `GET/HEAD/OPTIONS` → `read`; `POST/PUT/PATCH/DELETE` → `read_write`. Embed token endpoints always require `read_write`.

**Plan tier** required per resource family (see [Overview → Plan requirements](/docs/developers/overview#plan-requirements) for the full table):

- **Starter+**: S2S tracking, Programs, Affiliates, Conversions, Payouts, Dashboard metrics, Customer webhooks, Zapier
- **Growth+**: Embed iframe, AI Co-Pilot
- **Pro+**: Audit log API export

Plan gates fire as `402 plan_tier_required` with `required` + `current` + `trialing` in the response body. The in-app dashboard at app.rekomi.com bypasses plan gates (JWT auth path).

**Common response envelopes** (defined in [Overview](/docs/developers/overview)):

```json
// Cursor-paginated list (the core resource lists: programs, affiliates, conversions, payouts)
{
  "items": [ /* ... */ ],
  "nextCursor": "eyJpZCI6IjAxNzg5..." ,
  "hasMore": true
}

// Business-rule error
{ "error": "plan_tier_required", "message": "Feature requires a higher plan tier" }
```

Request-validation failures return a standard problem details object (`{ type, title, status, errors }`) instead of the business-rule envelope, and 401s and many 404s carry no body; see [Overview → Errors](/docs/developers/overview#errors).

## Programs

**Plan tier:** Starter+ for API-key callers. JWT dashboard is unrestricted.

`AffiliateProgram` resources. Brands typically have 1-3 programs (e.g., one default + one with a special promo rate).

### `GET /api/v1/programs`

**Scope:** `read`. List programs. Cursor-paginated.

Query params: `take` (default 50, max 200), `cursor` (opaque from previous `nextCursor`).

```json
{
  "items": [
    {
      "id": "0789abcd-...",
      "name": "Default program",
      "slug": "default",
      "description": "Earn 20% recurring on every annual plan.",
      "termsUrl": "https://yourbrand.com/affiliate-terms",
      "isActive": true,
      "defaultCommissionType": "Percentage",
      "defaultCommissionValue": 20.0,
      "defaultCommissionModel": "Cps",
      "cookieWindowDays": 60,
      "recurringMonths": 12,
      "recurringDelayInvoices": 0,
      "attributionModel": "LastTouch",
      "autoApprove": false,
      "customDomain": null,
      "customDomainVerified": false,
      "networkVisibility": "Private",
      "isPubliclyDiscoverable": true,
      "createdAt": "2026-05-01T12:00:00Z",
      "updatedAt": "2026-05-12T08:30:00Z",
      "archivedAt": null
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
```

`isPubliclyDiscoverable` controls SEO visibility of the public `{handle}.rekomi.com/{campaign}` landing page. When `false`, the page emits `<meta name="robots" content="noindex">` and is excluded from the sitemap; flip to `false` for invite-only or sensitive-rate programs.

### `GET /api/v1/programs/{id}`

**Scope:** `read`. Returns a single `ProgramResponse` (same shape as a list item above).

### `GET /api/v1/programs/stats`

**Scope:** `read`. The campaigns-list rollup: an array with one entry per campaign (`programId` on each), carrying `affiliatesCount`, `clicks30d`, `conversions30d` (sales, leads and paid clicks together), `sales30d`, `leads30d`, `paidClicks30d`, `pendingCommissionCents`, `availableCommissionCents`, `settlingCommissionCents`, `owedEligibleInHomeCents`, and `settlingInHomeCents`. The money figures are minor units of the org home currency, each conversion valued at the stored reference rate on its own date. Rows whose sale currency has no usable rate are held out of the figure rather than counted at face value and reported per currency, in native minor units, in the additive maps `pendingUnconvertibleByCurrency`, `availableUnconvertibleByCurrency`, and `settlingUnconvertibleByCurrency` (each `null` when everything converted); those maps are the authoritative record of what was held. A campaign with no activity yet still gets an entry with zeros.

### `POST /api/v1/programs`

**Scope:** `read_write`. **Role required:** `Manager` or higher (on JWT; API keys are Owner-equivalent and pass).

Request:

```json
{
  "name": "Holiday promo program",
  "slug": "holiday-2026",
  "description": "Limited-time 30% commission for Q4.",
  "termsUrl": "https://yourbrand.com/affiliate-terms",
  "defaultCommissionType": "Percentage",
  "defaultCommissionValue": 30.0,
  "defaultCommissionModel": "Cps",
  "cookieWindowDays": 30,
  "recurringMonths": null,
  "recurringDelayInvoices": 0,
  "attributionModel": "LastTouch",
  "autoApprove": false,
  "budgetCents": 500000,
  "referralDestinationUrl": "https://yourbrand.com/landing",
  "minPayoutCents": 2500
}
```

Response: `201 Created` with the new `ProgramResponse`. Fires no webhook (programs are not webhook-bearing today).

Enums:
- `defaultCommissionType`: `Percentage` | `Fixed`
- `defaultCommissionModel`: `Cps` (per sale, flat fee or recurring %) | `Cpc` (per click) | `Cpl` (per lead). Names the campaign's **main payout leg**; `defaultCommissionType` / `defaultCommissionValue` carry its rate and partner-specific rates override it. Add the other legs with `leadBountyCents`, `clickRewardCents` and `saleCommissionType` / `saleCommissionValue` (any combination; see below). Legacy values still accepted for existing campaigns but not offered for new ones: `Cpa`, `RevShare`, `Hybrid` (all behave like `Cps`).
- `attributionModel`: `FirstTouch` | `LastTouch`
- `networkVisibility`: `Private` | `Networked` | `InviteOnly`. `Networked` lists the campaign in the Rekomi creator network after review and needs an active paid plan and a funding source on the organization; when either is missing, a request that turns listing on is saved as `Private` and returned that way with no error, so read `networkVisibility` back from the response. A campaign that is already `Networked` keeps that value; creators stop seeing it while the plan or the funding source is missing, and it shows again on its own once both are back.
- `recurringMonths`: per-sale recurring duration. `null` / omitted = lifetime (every renewal); `0` = one-time (no renewals); `N` = every payment in each customer's first N calendar months, counted from their first payment (a monthly plan pays N times, a yearly plan pays once when N is 12); the window is per customer, keyed on the billing platform's customer id, so a resubscribe under a new subscription id stays inside it. On `PATCH`, send `-1` to clear an existing cap back to lifetime.
- `budgetCents`: **campaigns that pay per click or per lead** (any primary). Cap on click + lead reward spend in minor units of the org home currency; once non-refunded click and lead rewards reach it, new clicks/leads stop earning. Sale commissions never count against it. Omit for uncapped, which is **not recommended** when paying per click or per lead. The cap can be at most USD 100,000,000 equivalent, valued against your home currency at today's stored reference rate; a larger value is rejected with `400 budget_out_of_range` (the body names `homeCurrency`), never clamped. Check spend with `GET /api/v1/programs/{id}/budget-status`.
- `referralDestinationUrl`: the website affiliates promote (every campaign); a validated `rekomi.link` click redirects there with the affiliate's code. `minPayoutCents`: per-campaign minimum payout threshold (minor units).
- `hostedCheckoutDoorEnabled` (bool, default false), `hostedCheckoutDoorMode` (`redirect` | `email`, default `redirect`), `hostedCheckoutThankYouUrl` (http(s) URL or `""` to clear): the checkout door for platforms where no script can run (beehiiv paid subscriptions). When enabled, share links are `https://rekomi.link/{code}?p={campaignId}` (an optional `&to=/path` deep-links inside the campaign site) and the platform's post-purchase redirect should be `https://rekomi.link/thanks?p={campaignId}&session_id={CHECKOUT_SESSION_ID}`. Sales credited this way carry `attributionMethod: "checkout_door"`. Invalid mode: `400 invalid_hosted_checkout_door_mode`; invalid URL: `400 invalid_hosted_checkout_thank_you_url`.
- `leadBountyCents`: **any primary except `Cpl`** (a `Cpl` campaign's per-lead rate is `defaultCommissionValue`). The per-lead leg: a flat amount in minor units, paid for each qualified lead captured through the campaign's Rekomi-hosted lead form, on top of whatever else the campaign pays. Needs a Starter plan or above. The value is minor units of the org home currency (cents for a USD home, whole yen for a JPY home) and must sit between USD 0.10 and USD 1,000 equivalent, valued at today's stored reference rate for that currency; anything outside that band is rejected with `400 lead_bounty_out_of_range` (the body names `homeCurrency`), never clamped. Positive sets it, `0` clears it, omit = unchanged on `PATCH`. Ignored (nulled) on a `Cpl` primary.
- `clickRewardCents`: **any primary except `Cpc`** (a `Cpc` campaign's per-click rate is `defaultCommissionValue`). The per-click leg: a flat amount in minor units of the org home currency, paid for each validated click (fraud-scored, de-duplicated, capped, under `budgetCents`), USD 0.01 to USD 1,000 equivalent at today's stored rate, else `400 click_reward_out_of_range`, never clamped. Positive sets it, `0` clears it, omit = unchanged on `PATCH`; ignored (nulled) on a `Cpc` primary. Turning it on makes the campaign **invite-only** (auto-approve, network listing and public discoverability are forced off) and needs a Starter plan or above.
- `saleCommissionType` / `saleCommissionValue`: **`Cpc` / `Cpl` primaries only.** The per-sale leg on a click or lead campaign: `Percentage` (0 to 100) or `Fixed` (major units of the currency each sale arrives in, up to 1,000,000), sent together. Renewals follow `recurringMonths` / `recurringDelayInvoices` exactly like a sale campaign. On `PATCH`, `saleCommissionValue: 0` clears the pair. Nulled on sale primaries (their sale rate is the default rate). Partner-specific rates never apply to it (they override the primary per-unit rate).
- `defaultCommissionValue` bounds: `Percentage` is 0 to 100. A `Fixed` value on `Cpc`/`Cpl` is the per-click or per-lead fee in major units of the org home currency and can be at most USD 1,000 equivalent (`400 unit_fee_out_of_range`); a `Fixed` value on a sale model pays that many units of each sale's own currency and can be at most USD 1,000,000 equivalent (`400 fixed_commission_out_of_range`). Both ceilings are valued at today's stored reference rate and rejected rather than clamped.

### `PATCH /api/v1/programs/{id}`

**Scope:** `read_write`. **Role:** `Manager`.

All fields optional; only the keys you send are updated.

```json
{
  "name": "Holiday promo (extended)",
  "isActive": true,
  "defaultCommissionValue": 35.0,
  "autoApprove": true,
  "customDomain": "go.yourbrand.com",
  "networkVisibility": "Networked"
}
```

Response: 200 with the updated `ProgramResponse`. `customDomainVerified` resets to `false` if `customDomain` changes; verify via the dashboard.

### `DELETE /api/v1/programs/{id}`

**Scope:** `read_write`. **Role:** `Owner`. Soft-deletes (sets `archivedAt`). Returns `204 No Content`.

### `GET /api/v1/programs/{id}/tracking-status`

**Scope:** `read`. Install verification: whether the campaign has ever recorded a click or conversion. Returns `{ verified, lastClickAt, lastConversionAt, totalClicks30d, totalConversions30d }`.

### `GET /api/v1/programs/{id}/recent-events`

**Scope:** `read`. Most recent clicks and conversions for the campaign, interleaved. Query: `limit` (default 20, max 100).

### `GET /api/v1/programs/check-slug`
**Scope:** `read`. Pre-flight slug availability before you create a campaign. Query: `slug`. Returns `{ "available": true, "slug": "spring-launch" }`, or `{ "available": false, "slug": "spring-launch", "suggestion": "spring-launch-2" }` with the first free `-2` to `-10` variant. `400 slug_required` or `400 invalid_slug_format` (4 to 64 lowercase letters, digits and hyphens, starting and ending alphanumeric).

### `PATCH /api/v1/programs/{id}/slug`
**Scope:** `read_write`. **Role:** `Manager`. Change a campaign's slug, the path segment of its signup page. The previous slug is kept as a redirect alias, so old links 308-redirect to the new one. Body: `{ "slug": "new-slug" }`. Returns `{ "slug": "new-slug", "previousSlug": "old-slug" }`. Errors: `400 invalid_slug_format`, `400 slug_reserved`, `409` when the slug is taken by another live campaign or is a retired alias of a different campaign.

### `GET /api/v1/programs/{id}/tracking-status/lookup`
**Scope:** `read`. What Rekomi saw for one buyer, for support on a hosted checkout. Query: `email`. Plus-addressed variants of one inbox count as the same person. Returns the lead (if any), the attributed sale (if any), the last purchases the connected gateway delivered for that email, and a one-word `verdict`: `attributed`, `attributed_by_platform`, `lead_and_purchase_but_no_sale`, `lead_only`, `purchase_without_lead` or `nothing_seen`.

### `POST /api/v1/programs/{id}/custom-domain/verify`
**Scope:** `read_write`. **Role:** `Admin`. **Plan tier:** any paid plan, trials included. Re-runs DNS verification for the campaign's custom tracking domain and provisions the certificate on success. Returns `{ "verified": true, "domain": "go.yourbrand.com", "resolvedTarget": "cname.rekomi.com", "errorReason": null }`. Rate-limited.

### `POST /api/v1/programs/{id}/test-event`
**Scope:** `read_write`. **Role:** `Manager`. Fires a synthetic click plus a $10.00 conversion for the campaign's seeded test affiliate through the real pipeline, including the `conversion.created` webhook, so you can test an integration end to end. Test rows show in the conversions list and campaign activity but never count toward payouts or dashboard totals. Returns `{ "clickId": "...", "conversionId": "...", "commissionCents": 100 }`.

## Affiliates

**Plan tier:** Starter+ for API-key callers. JWT dashboard is unrestricted.

### `GET /api/v1/affiliates`

**Scope:** `read`. List affiliates. Cursor-paginated.

Query: `take` (default 50, max 200), `skip`, `cursor`, `programId`. There is no status or text-search filter on this list; pending applications live at `GET /api/v1/affiliates/applications`.

```json
{
  "items": [
    {
      "id": "01234567-...",
      "programId": "0789abcd-...",
      "email": "jane@creator.com",
      "fullName": "Jane Doe",
      "status": "Approved",
      "customCommissionType": null,
      "customCommissionValue": null,
      "tags": ["youtube", "tier-1"],
      "notes": "Top performer Q1 2026",
      "source": "Public",
      "appliedAt": "2026-04-15T10:00:00Z",
      "approvedAt": "2026-04-16T11:30:00Z",
      "createdAt": "2026-04-15T10:00:00Z",
      "links": [
        {
          "id": "abcdef12-...",
          "affiliateId": "01234567-...",
          "slug": "jane-recommends",
          "customAlias": null,
          "isDefault": true,
          "format": "Short",
          "createdAt": "2026-04-16T11:30:00Z"
        }
      ]
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
```

Notes:
- `email` is the affiliate's email, visible to you as the brand owner (your own data).
- `notes` is brand-side internal commentary. Do not re-share with the affiliate.
- The response intentionally **omits** `ClerkUserId`, `StripeConnectAccountId`, `PaypalEmail`, `TaxFormStatus`, `KycStatus`. For API-key callers, the response also omits `applicationData` (the form-builder submission JSON). The dashboard JWT path keeps `applicationData` so the in-product review screen can render it.
- `status` enum: `Pending` | `Approved` | `Rejected` | `Paused` | `Banned`.
- `source` enum: `Direct` | `Public` | `Invite` | `Network`.
- `format` enum (on each `links[]` entry): `Short` | `Query` | `Path` | `Hash`.
- The default link (`isDefault: true`) is the affiliate's main account slug. On sale-model campaigns, affiliates can hold up to 20 links per campaign (including their main link), managed from their dashboard.

### `GET /api/v1/affiliates/stats`
**Scope:** `read`. One row per affiliate with the numbers the dashboard's affiliate reports show: clicks (`visitors`), leads, sales (`conversions`), commission buckets and revenue, all money in minor units normalized to your home currency. Lifetime figures by default.
Query: `programId` (optional: one campaign), `since` and `until` (optional ISO timestamps; `since` inclusive, `until` exclusive).
```json
{
  "currency": "USD",
  "items": [
    {
      "affiliateId": "01234567-...",
      "visitors": 43,
      "leads": 2,
      "conversions": 5,
      "unpaidCommissionCents": 12000,
      "dueNowCommissionCents": 8000,
      "paidCommissionCents": 45000,
      "processingCommissionCents": 0,
      "failedPayoutCommissionCents": 0,
      "grossRevenueCents": 310000,
      "netRevenueCents": 298000
    }
  ]
}
```
For one affiliate's day-by-day shape use `GET /api/v1/dashboard/timeseries` with `affiliateId`.

### `GET /api/v1/affiliates/{id}`

**Scope:** `read`. Single `AffiliateResponse`. Returns 404 if the affiliate isn't in your org.

### `GET /api/v1/affiliates/applications`

**Scope:** `read`. List pending applications awaiting approval. Same per-affiliate shape as the main list, filtered to `status=Pending`. Returns a **flat array** paged with `take`/`skip` (no cursor envelope).

### `GET /api/v1/affiliates/{id}/tax-form`

**Scope:** `read`. **Role:** `Admin`. Returns a 5-minute signed URL to download the affiliate's W-9 / W-8BEN PDF. Audit-logged on every read.

```json
{
  "id": "tx-form-uuid",
  "affiliateId": "01234567-...",
  "type": "W9",
  "submittedAt": "2026-04-20T14:00:00Z",
  "signedUrl": "https://nyc3.digitaloceanspaces.com/rekomi-tax/...sig=...&exp=...",
  "expiresInSeconds": 300
}
```

The `signedUrl` is a time-limited (5 minutes) presigned URL; it is not single-use, so do not log or share it, and do not cache it. When the form is held by the payout provider rather than stored by Rekomi, the endpoint returns `{ "heldByStripe": true }` instead of the shape above.

### `POST /api/v1/affiliates`

**Scope:** `read_write`. Admin-path affiliate creation (skips the application form). The new affiliate's default tracking link is auto-generated.

Request:

```json
{
  "programId": "0789abcd-...",
  "email": "newaffiliate@example.com",
  "fullName": "New Affiliate",
  "status": "Approved",
  "customCommissionType": null,
  "customCommissionValue": null,
  "customAlias": null
}
```

Response: 201 with the new `AffiliateResponse`. Fires `affiliate.created`. If `status=Approved`, also fires `affiliate.approved`.

### `PATCH /api/v1/affiliates/{id}`

**Scope:** `read_write`. Update affiliate fields. All optional.

```json
{
  "fullName": "Jane Doe (updated)",
  "status": "Approved",
  "customCommissionType": "Percentage",
  "customCommissionValue": 25.0,
  "notes": "Promoted to tier-1 on 2026-05-12.",
  "tags": ["youtube", "tier-1", "vip"]
}
```

Response: 200 with the updated `AffiliateResponse`.

### `POST /api/v1/affiliates/{id}/approve`

**Scope:** `read_write`. Approves a pending application. Fires `affiliate.approved`.

Request body: `{}` (empty). Response: 200 with the updated `AffiliateResponse`.

### `POST /api/v1/affiliates/{id}/reject`

**Scope:** `read_write`. Rejects a pending application.

```json
{ "reason": "Off-topic audience" }
```

Response: 200 with the updated `AffiliateResponse` (status now `Rejected`).

### `POST /api/v1/affiliates/bulk-action`

**Scope:** `read_write`. **Role:** `Admin` (JWT) with a verified email; API-key callers pass the role gate by design. Bulk `approve`, `pause`, or `delete` up to **200** affiliates in one call. Idempotent (re-approving an approved affiliate is a no-op), audit-logged per action.

```json
{ "affiliateIds": ["uuid-1", "uuid-2"], "action": "approve" }
```

### `POST /api/affiliates/bulk/approve` and `POST /api/affiliates/bulk/pause`

Bulk approve or pause affiliates. These are **dashboard-session endpoints** (Clerk JWT only; there is no API-key bearer alias). **Role:** `Manager` with a verified email. Up to **500** ids per call.

```json
{ "ids": ["uuid-1", "uuid-2", "..."] }
```

Response reports how many rows were affected; ids not in your org or not matching the action's precondition are skipped.

### `GET /api/v1/affiliates/export.csv`
**Scope:** `read`. The full roster as CSV, one row per affiliate (org-wide, or one campaign with `programId`), with the identity columns the dashboard shows plus the stats columns of `GET /api/v1/affiliates/stats` (`clicks`, `leads`, `sales`, the commission buckets, gross and net revenue, `currency`). Optional `since` and `until` window the stats columns. Streams up to 50,000 rows. Rate-limited like `/stats`.

### `GET /api/v1/affiliates/{id}/commission-summary`
**Scope:** `read`. One affiliate's money, bucketed per currency, using the same rules a payout run uses, so `dueNowCents` is exactly what the next run would pay.
```json
{
  "primaryCurrency": "USD",
  "graceDays": 30,
  "hasStripeConnect": true,
  "payoutReady": true,
  "rail": "StripeConnect",
  "blockedReason": null,
  "byCurrency": [
    { "currency": "USD", "dueNowCents": 8000, "pendingInGraceCents": 4000, "paidCents": 45000, "processingCents": 0, "refundedCents": 1200, "totalCommissionCents": 58200, "grossRevenueCents": 310000, "netRevenueCents": 298000, "dueConversionCount": 3 }
  ],
  "referrals": { "visitors": 43, "leads": 2, "conversions": 5 }
}
```

### `GET /api/v1/affiliates/{id}/commissions`
**Scope:** `read`. Paginated commission rows for one affiliate and one bucket. Query: `bucket` (`due` | `pending` | `paid` | `refunded`), `currency` (optional), `page` (default 1), `pageSize` (default 25). Only payable rows are listed; a $0 lead or click marker never appears. Customer identity is included because this is the brand side.
```json
{
  "items": [
    { "conversionId": "c0nv0001-...", "programId": "0789abcd-...", "programName": "Spring launch", "amountCents": 9900, "commissionCents": 1980, "netCommissionCents": 1980, "refundedAmountCents": 0, "currency": "USD", "type": "Sale", "payoutStatus": "Paid", "payoutId": "payout-uuid", "customerName": "Sam Lee", "customerEmail": "sam@example.com", "customerAvatarUrl": null, "createdAt": "2026-04-16T11:30:00Z" }
  ],
  "page": 1, "pageSize": 25, "totalCount": 12, "currency": "USD",
  "counts": { "due": 3, "pending": 2, "paid": 6, "refunded": 1 }
}
```

### `GET /api/v1/affiliates/{id}/crm-sync`
**Scope:** `read`. One row per connected CRM integration with the latest sync job for this affiliate.
```json
[
  { "integrationId": "...", "slug": "klaviyo", "displayName": "Klaviyo", "integrationEnabled": true, "defaultListName": "Voltra Affiliates", "jobId": "...", "status": "Succeeded", "attempts": 1, "lastAttemptAt": "2026-09-12T12:19:00Z", "succeededAt": "2026-09-12T12:19:02Z", "error": null }
]
```
`status` is `NeverSynced` when no job exists yet, otherwise the job state (`Pending`, `Succeeded`, `Failed`, `Skipped`); `error` carries the failure or skip reason.

### `POST /api/v1/affiliates/{id}/crm-resync`
**Scope:** `read_write`. **Plan tier:** paid plans only (trials get `402`). Drops the affiliate's prior sync jobs and re-enqueues a fresh sync to every connected CRM. Returns `{ "deletedPriorJobs": 2 }`. Rate-limited.

### `POST /api/v1/affiliates/{id}/remind-payout-setup`
**Scope:** `read_write`. **Role:** `Admin`. Emails an affiliate who has earned commission but has no payout account yet, asking them to connect one. Once per affiliate per 24 hours. Returns `{ "sent": true, "email": "jane@creator.com" }`; `409 already_connected` when they already have one; `422 no_email`; `429` inside the cooldown.

### `GET /api/v1/affiliates/{id}/claim-reminder/context`
**Scope:** `read`. For imported affiliates who have not claimed their Rekomi account yet. Returns what a reminder email can merge in: the person's campaigns, accrued earnings per currency, referral count, the resolved `{{claim.campaigns}}` and `{{claim.earnings}}` sample values, and the cooldown state.
```json
{
  "email": "jane@creator.com", "firstName": "Jane",
  "campaigns": [ { "programId": "0789abcd-...", "name": "Spring launch" } ],
  "earnings": [ { "currency": "USD", "totalCommissionCents": 12000 } ],
  "referralCount": 7,
  "resolvedTokens": { "campaigns": "Spring launch", "earnings": "$120.00" },
  "cooldown": { "canSend": true, "retryAfterHours": 0, "sendsRemaining": 3 }
}
```
`409 already_joined` when the person already has an account, `409 not_eligible` for pending, rejected or banned rows.

### `POST /api/v1/affiliates/{id}/claim-reminder`
**Scope:** `read_write`. Sends the claim reminder to one person. Body: `{ "subject": "...", "bodyHtml": "..." }`. The body is sanitized server-side, merge tokens resolve at send time and the claim link is appended by Rekomi. Returns `{ "sent": true, "email": "..." }`. Errors: `409 already_joined` or `not_eligible`, `422 unsafe_subject`, `empty_body` or `no_email`, `429 cooldown` (24 hours per person, with `Retry-After`) or `resend_limit_reached`. Rate-limited.

### `POST /api/v1/affiliates/claim-reminder/bulk`
**Scope:** `read_write`. **Plan tier:** paid plans only. Body: `{ "ids": ["..."], "subject": "...", "bodyHtml": "..." }`, up to 200 ids. Dedupes by email, skips people who already joined or are on cooldown, and queues one send per person at about one per second. Returns aggregate counts only: `{ "queued": 18, "skippedJoined": 1, "skippedCooldown": 0, "skippedIneligible": 1 }`.

## Conversions

**Plan tier:** Starter+ for API-key callers (read + write programmatic access). S2S conversion creation has its own gate at Starter+. JWT dashboard is unrestricted.

These endpoints are the commission tracking behind every affiliate's ledger: they're how a conversion becomes a payable commission.

### `GET /api/v1/conversions`

**Scope:** `read`. List conversions. Cursor-paginated.

Query: `take` (max 200; note this list returns **100** rows when `take` is omitted, unlike the 50-row default on other lists), `cursor`, `affiliateId`, `programId`, `status` (`Pending` | `Approved` | `Paid` | `Denied` | `Refunded` | `Invalidated`), `kind` (`sale` | `lead` | `click`; the same split as the Sales, Leads and Clicks tabs on the Conversions page), `since` (ISO), `until` (ISO, exclusive upper bound on `createdAt`), `stripeCustomerId` (the customer id captured on the conversion; for an S2S postback this is the `customerId` you sent) and `customerEmail` (case-insensitive). Use `status=Invalidated` to pull the click/lead (CPC/CPL) fraud-quarantine review queue (traffic held out of payment as invalid).

```json
{
  "items": [
    {
      "id": "c0nv0001-...",
      "affiliateId": "01234567-...",
      "programId": "0789abcd-...",
      "amountCents": 9900,
      "currency": "USD",
      "commissionCents": 1980,
      "status": "Approved",
      "type": "Recurring",
      "stripeSubscriptionId": "sub_1ABC...",
      "stripeCustomerId": "cus_1ABC...",
      "externalEventId": "in_1XYZ...",
      "createdAt": "2026-05-10T16:00:00Z",
      "refundedAt": null,
      "attributionMethod": "coupon",
      "couponCode": "SARAH20",
      "subId": null
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
```

Notes:
- `type` enum: `OneTime` | `Recurring` | `Bonus` | `ClickReward` | `LeadReward`.
- `status` enum: `Pending` | `Approved` | `Paid` | `Refunded` | `Denied` | `Invalidated`. `Invalidated` = fraud-quarantined click/lead traffic held out of payment; validate it (release) or invalidate a Pending row from the Conversions page or the API (see below).
- `attributionMethod` is how the sale was attributed (for example `coupon`, `metadata_or_customer`, `cpc_aggregation`, `sub_affiliate_override`, or `null` for S2S). The full value set and how the dashboard groups them is in the [webhooks reference](/docs/developers/webhooks). `couponCode` is the redeemed code when (and only when) the sale was attributed by a coupon, otherwise `null`.
- **Lead rows** use `type` = `LeadReward` with `amountCents` of `0`. A free identified signup (attribution `lead_signup`) also has `commissionCents` of `0` and ties a customer email to the referral before any payment; a lead on a per-lead (CPL) campaign (attribution `lead_form`) carries the campaign's fixed per-lead fee in `commissionCents`. Neither is a sale; a later paid sale is recorded as a separate `OneTime` / `Recurring` row. The reliable zero-commission discriminator is the `lead_signup` attribution, not the `LeadReward` type. See [Track leads and signups](/docs/developers/lead-tracking).
- `payability` says whether a row carries money: `payable` (a sale, or a lead or click the campaign pays for; it follows the commission lifecycle above), `not_paid_on_campaign` (a lead or click recorded on a campaign that does not pay for that type; kept for attribution and fraud review, never a commission) or `attribution_only` (a $0 lead or click marker on a campaign that does pay for that leg; the paid lead or click is a separate row). The list and detail responses both carry it.
- `stripeSubscriptionId` and `stripeCustomerId` are the brand's own Stripe identifiers (i.e., your `cus_*` / `sub_*`). They are not the affiliate's Stripe identifiers (affiliates don't have one). Use them to join Rekomi conversions back to your Stripe data.
- `subId` is the channel tag the customer's visit carried (`?sub=yt` on the affiliate's link), or `null` when untagged. Filter the list with `subId=yt`, or `subId=untagged` for rows with none. See [Sub-IDs](/docs/developers/pixel-tracking#sub-ids).
- For a deep-dive on a single conversion including subscription event history, hit `GET /api/v1/conversions/{id}`.

### `GET /api/v1/conversions/{id}`

**Scope:** `read`. Includes subscription event timeline.

```json
{
  "id": "c0nv0001-...",
  "affiliateId": "01234567-...",
  "programId": "0789abcd-...",
  "amountCents": 9900,
  "currency": "USD",
  "commissionCents": 1980,
  "status": "Approved",
  "type": "Recurring",
  "stripeSubscriptionId": "sub_1ABC...",
  "stripeCustomerId": "cus_1ABC...",
  "externalEventId": "in_1XYZ...",
  "createdAt": "2026-05-10T16:00:00Z",
  "paidAt": null,
  "refundedAt": null,
  "refundedAmountCents": 2000,
  "attributionMethod": "coupon",
  "couponCode": "SARAH20",
  "events": [
    { "id": "evt-uuid-1", "eventType": "Charged", "amountCents": 9900, "commissionCents": 1980, "occurredAt": "2026-05-10T16:00:00Z" },
    { "id": "evt-uuid-2", "eventType": "Refunded", "amountCents": -2000, "commissionCents": -400, "occurredAt": "2026-06-02T09:12:03Z" }
  ]
}
```

`refundedAmountCents` is the cumulative refunded total in minor units of the conversion's currency; `refundedAt` is set only once that total covers the full amount. Every refund also appears in `events` as an entry whose `eventType` is `Refunded`, with a negative `amountCents` for that refund and its `occurredAt`, so a refund whose HTTP response was lost can be confirmed here before it is retried. `eventType` values are `Created`, `Charged`, `Upgraded`, `Downgraded`, `Paused`, `Cancelled`, `Reactivated` and `Refunded`.

### `GET /api/v1/conversions/export.csv`

**Scope:** `read`. CSV export. Same filters as the list endpoint plus `until`. Capped at 50,000 rows per call. Audit-logged.

Header row (verified against code):

```csv
id,affiliate_id,program_id,amount_cents,currency,commission_cents,status,type,external_event_id,created_at,refunded_at,attribution_method,coupon_code,sub_id
```

`attribution_method`, `coupon_code` and `sub_id` are the last three columns (appended, so existing column positions are unchanged). `coupon_code` is populated only for coupon-attributed rows; `sub_id` is the channel tag, empty when untagged. Stripe IDs are intentionally omitted from CSV; use the JSON endpoint if you need them.

### `POST /api/v1/conversions`

**Scope:** `read_write`. **Role required:** `Manager` or higher with a verified email (on JWT; API keys are Owner-equivalent and pass). Rate-limited under the strict-write policy.

Manually record a sale that happened outside your connected billing, or mint a standalone bonus commission for one affiliate. The row runs through the same pipeline as every other conversion (attribution method `manual`), so the campaign's commission engine, sub-affiliate overrides, the `conversion.created` webhook, and the commission-earned notification all apply. The commission becomes payable through the normal grace and threshold flow. This creates payable money; it is audit-logged as `conversion.manual_created`.

Request:

```json
{
  "programId": "0789abcd-...",
  "affiliateId": "01234567-...",
  "kind": "sale",
  "amountCents": 9900,
  "currency": "USD",
  "commissionCents": null,
  "customerEmail": "buyer@example.com",
  "occurredAt": "2026-05-01T12:00:00Z",
  "note": "Invoice #1042, paid by bank transfer"
}
```

- `kind`: `sale` (default) | `bonus`.
- `amountCents`: **sale only**, required and > 0, in minor units of `currency`. Upper bound 1,000,000,000,000 minor units, the same currency-blind sanity bound every ingest path uses (beyond any plausible single sale). A `bonus` must omit it (`bonus_has_no_amount`).
- `currency`: optional; defaults to your org home currency. Must be on the supported allowlist (`unsupported_currency`).
- `commissionCents`: for a `sale`, an optional override of the engine-computed commission, capped at **5x `amountCents`** (`commission_exceeds_cap`). For a `bonus`, required and > 0, in minor units of `currency`, with the same 1,000,000,000,000 minor-unit upper bound.
- `customerEmail`: optional; ties the sale to a customer.
- `occurredAt`: optional backdate (ISO 8601). Never in the future, at most 5 years back. A backdated sale that is already past the refund-hold window becomes payable immediately.
- `note`: optional, trimmed to 1,000 characters; echoed back in the response envelope.

The affiliate must belong to the selected program and be `Approved` (`affiliate_not_in_program`, 409 `affiliate_not_approved`). The seeded Test Affiliate is refused (`test_affiliate_not_allowed`); use the one-click test event instead. A suspended campaign returns 409 `program_suspended`. There is no built-in dedup on this endpoint (each call mints a fresh internal event id), so a blind retry creates a second conversion; send an `Idempotency-Key` header on any request you might retry.

Response: `201 Created`.

```json
{
  "conversion": {
    "id": "c0nv0002-...",
    "affiliateId": "01234567-...",
    "programId": "0789abcd-...",
    "amountCents": 9900,
    "currency": "USD",
    "commissionCents": 1980,
    "status": "Pending",
    "type": "OneTime",
    "attributionMethod": "manual",
    "createdAt": "2026-05-01T12:00:00Z"
  },
  "note": "Invoice #1042, paid by bank transfer"
}
```

A `bonus` comes back with `amountCents: 0` and `type: "Bonus"`.

### `GET /api/v1/conversions/kind-counts`
**Scope:** `read`. All-time row counts per kind for the organization, invalidated rows excluded: `{ "sales": 120, "leads": 34, "clicks": 0 }`. The dashboard uses it to decide whether the Leads and Clicks tabs render at all.

## Click and lead fraud review and budget (CPC/CPL)

Campaigns that pay per click or per lead (CPC/CPL), including the paid lead endpoint, fraud review queue, and budget status, are in **limited preview**. The paid lead endpoint returns `404` until the preview is enabled for your account. These endpoints operate the click and lead fraud workflow and spend caps. The write actions require the **Manager** role.

### `POST /api/v1/conversions/{id}/validate`

**Role:** Manager. Returns a fraud-quarantined (`Invalidated`) conversion to `Pending` so it re-enters the normal approval flow, i.e. clears a false positive. Fires `conversion.validated`.

### `POST /api/v1/conversions/{id}/invalidate`

**Role:** Manager. Quarantines a `Pending` or `Approved` conversion you judge fraudulent, setting it `Invalidated` so it is not paid. Fires `conversion.invalidated`.

### `GET /api/v1/programs/{id}/budget-status`

**Scope:** `read`. The campaign's click + lead spend-cap status: total budget, spent (non-refunded commission), and remaining, in minor units of the org home currency. Unlimited when the campaign has no budget set. Spend that could not be valued in the home currency (a sale currency with no usable exchange rate for the row's date) is held out of `spentCents` rather than counted at face value, and reported in `fxUnconvertibleByCurrency`, a map of currency code to native minor units; that map is the authoritative figure. The older scalar `fxUnconvertibleCents` stays on the wire for compatibility, but it is a sum of native minor units across currencies, a presence signal rather than a money amount: when it is non-zero, read the map.

### `GET /api/v1/fraud-events`

**Scope:** `read`. Lists fraud signals Rekomi flagged on paid clicks and leads (high-risk IPs, bots, self-leads, disposable emails, per-IP-cap hits, abusive affiliates). These were already dropped, with no payout. Query: `programId`, `status` (`open` default; also `resolved`, `dismissed`, or `all`).

- `PATCH /api/v1/fraud-events/{id}` (Manager) with `{ "action": "resolve" | "dismiss" }` reviews a signal.
- `POST /api/v1/fraud-events/ban-affiliate/{affiliateId}` (Manager) bans a partner: stops all their future clicks, leads, and conversions from earning.

### `GET /api/v1/fraud-events/summary`

**Scope:** `read`. **Role:** Manager. Aggregate fraud stats for one campaign that pays per click or per lead. Query: `programId` (required; 400 `program_id_required` without it), `days` (default 30, clamped 1-365). Returns `{ windowDays, blockedClicks, blockedClicksByReason, heldClicks, quarantinedLeads, estimatedNotBilledCents, currency, fraudStrictness }`.

### `GET /api/v1/uncredited-conversions`

**Scope:** `read`. Sales that arrived attributed to an affiliate who could not earn at that moment (not `Approved` at sale time: pending, paused, rejected, or banned). Query: `status` (`open` default | `dismissed` | `all`), `affiliateId`. Returns up to 500 rows of `{ id, affiliateId, affiliateEmail, affiliateName, programId, amountCents, currency, reason, affiliateStatusAtDrop, status, occurredAt }`. Companion endpoints:

- `GET /api/v1/uncredited-conversions/summary` returns `{ openCount, byCurrency: [{ currency, amountCents, count }] }`.
- `PATCH /api/v1/uncredited-conversions/{id}/dismiss` (Manager) marks a row reviewed.

If a row was a mistake (an affiliate paused by accident, say), fix the affiliate's status and credit them with a [manual conversion](#post-apiv1conversions).

### `POST /api/v1/lead` (paid lead on a campaign that pays per lead, CPL)

**Limited preview** (returns `404` until enabled for your account). Records a **billable** lead on a campaign that pays per lead. Body: `{ slug, programId?, email, name?, token }`, where `token` is a **required Cloudflare Turnstile** token. The lead passes email-format, disposable-domain, self-lead, per-campaign dedup, daily-cap, budget, and IPQS fraud checks. A high-risk lead is quarantined (`Invalidated`, opaque `200`); a budget-full campaign returns `409 campaign_budget_reached`; if fraud scoring is unreachable the call returns `503` (retry) and records nothing. This is distinct from the zero-commission signup lead (`/api/v1/lead/signup`); see [Track leads and signups](/docs/developers/lead-tracking).

## Import (bulk migration)

**Plan tier:** Starter+ for API-key callers. JWT dashboard is unrestricted. **Scope:** `read_write`.

Generic, platform-agnostic bulk import. Unlike the dashboard migrate flow (which parses a specific vendor CSV), you post **canonical JSON rows** here, so any source works once you map it into the Rekomi shape. Both surfaces converge on the same audited services: there is exactly one path that creates affiliates and one that records conversions.

Shared safeguards for every endpoint below:

- **Row cap:** at most **1,000 rows per request** (`row_cap_exceeded`, 400). Page larger imports.
- **Body size:** ~4 MB JSON (`413` over the limit).
- **Rate limit:** the dedicated `import` policy, **6 requests/min** per caller (`429` over the limit).
- **Concurrency:** one import per org at a time. A second concurrent import returns `409 migration_already_in_progress` (shared with the dashboard importer).
- **Tenant isolation:** a `programId` that is not in your organization returns `404 program_not_found`.
- **Revertible:** each import persists a `MigrationJob`; the returned `jobId` appears in import history and can be undone from the dashboard.

### `POST /api/v1/affiliates/import`

**Scope:** `read_write`. Bulk-import an affiliate roster. Dedupes by email within the campaign AND within the batch (re-running is idempotent: existing emails come back as `skipped`, never duplicated). Each row gets a preserved or freshly-generated tracking slug.

Request:

```json
{
  "programId": "0789abcd-...",
  "affiliates": [
    { "email": "alex@example.com", "fullName": "Alex Smith", "status": "approved", "paypalEmail": "alex@example.com", "slug": "alex" },
    { "email": "jordan@example.com", "fullName": "Jordan Lee" }
  ],
  "includeStatuses": ["approved", "pending"],
  "notifyAffiliates": false,
  "notificationMessageHtml": null
}
```

- `email` is required per row. `status` is optional and **defaults to `approved` when omitted** (the affiliate was already live on the prior platform); an unrecognized value falls back to `pending`. Accepted: `approved` | `pending` | `rejected` | `paused` | `banned`.
- `slug` (optional) preserves the affiliate's existing referral slug so their old links keep working; if it is taken or invalid, a fresh slug is generated.
- `includeStatuses` (optional) is an allowlist; rows whose mapped status is not in the set are skipped (`status_excluded`). Omit to import every row.
- `notifyAffiliates` requires **Growth+** with an active subscription; on a lower tier the import still succeeds but no emails are sent.

Response: 200.

```json
{
  "jobId": "1234abcd-...",
  "status": "Completed",
  "kind": "Affiliates",
  "total": 2,
  "imported": 2,
  "skipped": 0,
  "errored": 0,
  "reasons": {}
}
```

`reasons` tallies why rows did not import (e.g. `already_exists`, `status_excluded`, `missing_email`).

### `POST /api/v1/conversions/import/preview`

**Scope:** `read_write`. **Dry-run** of a conversion import: classifies and resolves affiliates and tallies money but **writes nothing**. Always call this before the real import so you can show the unpaid balance before authorizing payment. Same row shape and validation as the import endpoint.

Request: `{ "programId": "...", "conversions": [ ... ], "excludePaid": false }` (same row shape as the import). Pass the same `excludePaid` you intend to use on the import: when `true`, paid rows are reported as skipped (`excluded_paid`) and excluded from `wouldImport` / `paidBalance`, so the preview matches exactly what the import will write.

Response: 200.

```json
{
  "rowsTotal": 2,
  "wouldImport": 2,
  "wouldSkip": 0,
  "wouldError": 0,
  "skipReasons": {},
  "paidBalance": [{ "currency": "USD", "commissionCents": 2500, "count": 1 }],
  "unpaidBalance": [{ "currency": "USD", "commissionCents": 5000, "count": 1 }]
}
```

`unpaidBalance` is exactly how much commission per currency would become **payable** if you then import with `confirmUnpaidPayout=true`.

### `POST /api/v1/conversions/import`

**Scope:** `read_write`. Import historical conversions/commissions so migrated affiliates keep their earnings and stats (and, with Stripe IDs, future renewals re-credit them). Idempotent (re-running skips already-imported rows; never double-pays).

Request:

```json
{
  "programId": "0789abcd-...",
  "conversions": [
    {
      "affiliateEmail": "alex@example.com",
      "affiliateSlug": null,
      "customerEmail": "buyer@example.com",
      "customerName": "Buyer One",
      "amountCents": 10000,
      "commissionCents": 2500,
      "currency": "USD",
      "occurredAt": "2025-01-15T00:00:00Z",
      "paid": true,
      "type": "OneTime",
      "stripeCustomerId": "cus_ABC",
      "stripeSubscriptionId": "sub_XYZ",
      "externalId": "src-row-1"
    }
  ],
  "confirmUnpaidPayout": false,
  "excludePaid": false
}
```

Per-row validation (any failure returns `400 invalid_rows` with `{ index, error }` per offending row, before anything is written):

- At least one of `affiliateEmail` / `affiliateSlug` is required (`affiliate_email_or_slug_required`).
- `amountCents` and `commissionCents` must be integers in `[0, 1000000000000]` (minor units of `currency`; a currency-blind sanity bound beyond any plausible single sale). Negative or over-cap is rejected (`amount_out_of_range` / `commission_out_of_range`).
- `currency` must be a supported ISO-4217 code (`unsupported_currency`).
- `occurredAt` is required (ISO 8601; sets the historical cohort month).
- `type` is `OneTime` (default) or `Recurring` (`invalid_type`).

**Money safety (read this):**

- `paid: true` rows are recorded as settled history (`Paid`, locked) and **can never be paid again**.
- `paid: false` rows are recorded as **locked history by default**. They become a **payable** balance (`Approved`, an amount Rekomi will pay out) **only when you set `confirmUnpaidPayout: true`**. `confirmUnpaidPayout` defaults to `false`.
- Importing never moves money by itself; payouts remain operator-run and Stripe-Connect-gated.
- `excludePaid: true` skips already-paid rows entirely (import only open balances).

Response: 200.

```json
{
  "jobId": "1234abcd-...",
  "status": "Completed",
  "kind": "Conversions",
  "total": 1,
  "imported": 1,
  "skipped": 0,
  "errored": 0,
  "unpaidConfirmed": [],
  "unpaidConfirmedCurrency": null,
  "unpaidConfirmedCents": 0,
  "confirmUnpaidPayout": false,
  "excludePaid": false,
  "reasons": {}
}
```

`unpaidConfirmed` is the exact commission this import made payable (the `Approved` conversions it created), listed per currency as `{ currency, commissionCents, count }` records in the same shape as the preview's `unpaidBalance`. A batch that confirmed JPY 1,000 and USD 10.00 returns `[{ "currency": "JPY", "commissionCents": 1000, "count": 1 }, { "currency": "USD", "commissionCents": 1000, "count": 1 }]`. It is empty whenever `confirmUnpaidPayout` is `false`.

`unpaidConfirmedCurrency` is that currency's code when exactly one currency became payable, otherwise `null`.

`unpaidConfirmedCents` is kept for older integrations. It is a raw sum of native minor units across every currency in the batch, so read it as a money amount only when `unpaidConfirmedCurrency` is set. When `unpaidConfirmedCurrency` is `null` it only signals that something became payable; the amounts are in `unpaidConfirmed`.

## Payouts

**Plan tier:** Starter+ for API-key callers. JWT dashboard is unrestricted.

### `GET /api/v1/payouts`

**Scope:** `read`. List payouts, one row per affiliate per run. Cursor-paginated.

```json
{
  "items": [
    {
      "id": "payout-uuid",
      "affiliateId": "01234567-...",
      "amountCents": 9800,
      "currency": "USD",
      "method": "StripeConnect",
      "status": "Paid",
      "stripeTransferId": "tr_1ABC...",
      "externalTransactionId": null,
      "notes": null,
      "failureReason": null,
      "periodStart": "2026-04-01",
      "periodEnd": "2026-04-30",
      "createdAt": "2026-05-01T09:00:00Z",
      "paidAt": "2026-05-01T09:00:05Z",
      "failedAt": null,
      "lineItems": [
        { "id": "li-uuid-1", "conversionId": "c0nv0001-...", "amountCents": 1980, "currency": "USD", "nativeAmountCents": 1980, "nativeCurrency": "USD", "fxRate": 1, "fxRateDate": "2026-05-01", "reversedAmountCents": 0, "reversedAt": null },
        { "id": "li-uuid-2", "conversionId": "c0nv0002-...", "amountCents": 7820, "currency": "USD", "nativeAmountCents": 7200, "nativeCurrency": "EUR", "fxRate": 0.9207, "fxRateDate": "2026-04-30", "reversedAmountCents": 0, "reversedAt": null }
      ]
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
```

Enums:
- `method` on stored payouts is `StripeConnect`, `PayPal`, or `GlobalPayouts` (`Manual` appears only on historical rows). Requests may send `StripeConnect` or `Auto` (pay each affiliate on their resolved rail); `Wise` is not supported.
- `status`: `Pending` | `Processing` | `Paid` | `Failed`.

Each payout row is one affiliate's payout from one run, and `lineItems` lists the commissions inside it:

- `amountCents` + `currency`: what the line paid (USD on consolidated runs; a legacy single-currency run carries that run's currency).
- `nativeAmountCents` + `nativeCurrency`: the commission as it was earned.
- `fxRate`: the stored USD-base reference rate the line converted at, expressed as native major units per 1 USD (the exchange-rate value for `nativeCurrency` on `fxRateDate`; USD lines carry `1`). `usd = native / fxRate` reproduces the line, and the rate reads as "0.9207 EUR per USD", never as USD per native unit.
- `fxRateDate`: the date the rate was published (the run date on USD lines).
- `reversedAmountCents` + `reversedAt`: a clawback applied after payment (`0` / `null` while the line is intact).

The four FX fields are `null` on payouts created before line-level FX was recorded.

### `GET /api/v1/payouts/{id}`

**Scope:** `read`. Single `PayoutResponse` (same shape as a list item).

<Callout title="Funding is dashboard-only">
Payout funding (linking a funding bank on any rail, enabling bank-transfer funding, running, retrying, or reversing payouts) is deliberately not exposed to API keys or OAuth tokens: money movement requires an interactive Owner or Admin dashboard session. The API surface is read plus preview only. See [Payouts: funding your payouts](/docs/brands/payouts#funding-your-payouts).

`GET /api/v1/payouts/funding/status` is readable with an API key. It returns the funding mode (`debit` or `balance`), the bank-transfer balance in cents when the mode is `balance`, the verification state, `payoutsAllowed` with `payoutsBlockedReason` and `payoutsBlockedMessage` (the sentence the dashboard shows), and `unverifiedRules` (holding days, the rolling cap, the cap used and remaining in the current window). `bankTransferInstructions` is always `null` for API-key and OAuth callers and `canManageFunding` is `false`: the dedicated deposit account numbers are shown only to an interactive Owner or Admin session in the dashboard.
</Callout>

### `POST /api/v1/payouts/preview`

**Role:** `Manager` or higher. Computes eligible balances without committing anything. Safe to call repeatedly.

Request body (all fields optional):

```json
{
  "programId": null,
  "since": null,
  "affiliateId": null
}
```

- `programId`: limit the preview to one campaign.
- `since`: only consider conversions on or after this ISO 8601 timestamp.
- `affiliateId`: preview a single affiliate.

The response reports the eligible totals and per-affiliate candidates:

- `totalEligibleCents`, `candidateCount`, `candidatesWithStripeConnectCount`
- `graceDays` and `homeCurrency`
- `eligibleByCurrency`: per-currency eligible totals over every candidate, in each currency's native minor units
- `unconvertibleByCurrency`: native minor units per currency that could not be valued in the home currency (no usable stored rate); `totalEligibleInHomeCurrencyCents` leaves them out
- `nativeTotalsForRun`: per-currency native eligible sums over the candidates that are ready to pay. This is the exact map the dashboard confirms when it runs the payout, and its key set is the set of currencies the run covers
- `fxUnavailableCurrencies`: currencies withheld from the run because no fresh stored reference rate exists; their balances keep accruing and become payable when rates resume
- `usdEstimateAtRunRateCents`: a display-only USD estimate of everything payable at today's stored rates (payouts settle in USD; the final figure locks when the run executes)
- `fxRunRates`: map of currency code to `{ rate, asOf }`, the stored USD-base reference rate each previewed currency would convert at, as native major units per 1 USD (USD is `1`), absent for the currencies in `fxUnavailableCurrencies`. A bucket's USD value is `round(nativeCents / minorUnitsPerMajor(native) / rate * 100)`
- fee fields describing the platform take on the previewed run
- `candidates[]`: one entry per affiliate with `affiliateId`, `affiliateEmail`, `currency`, `eligibleCents`, `eligibleConversionIds`, `payoutReady`, and `blockedReason` (why an affiliate cannot be paid yet, for example a missing payout account)

A run mints one USD payout per affiliate from the confirmed currencies, converting each commission at the stored run-date rate and stamping the native amount, the rate, and the rate date on every line item (the `lineItems` fields above).

### `POST /api/v1/payouts/run`

**Dashboard session only.** Running a payout cannot be triggered with an API key or OAuth token; every API-key caller receives `403 interactive_session_required`. Only a signed-in Owner dashboard session can start a payout run.

For reference, the dashboard call accepts `confirmTotalCents` (required safety check), plus optional `affiliateIds`, `programId`, `since`, `currency` (default `"USD"`), `method`, `externalTransactionId`, `notes`, and `conversionIds`. `confirmTotalCents` must equal the server-computed total; if they disagree (for example, a conversion was finalized between preview and run), the request returns `409 { "error": "confirm_total_mismatch" }`; re-preview and retry. Each run fires `payout.created` per recipient, then `payout.paid` or `payout.failed`.

**Trust and Safety (2026-09-05).** Four more `409` codes can come back from a run: `payouts_require_active_subscription` (the org is on a trial or has no active paid plan), `unverified_prefund_only` (an unverified org tried to run against a bank-debit mandate; fund by bank transfer or request verification), `unverified_cap_exceeded` (the run would take an unverified org past its rolling cap; the message carries the figures) and `funding_frozen` (payout funding is paused after a bank dispute on a funding charge; the message says to contact support@rekomi.com). A fifth, `high_risk_unverified`, comes back from a run and from the funding setup endpoints when an unverified org's fraud score is at or above the threshold Rekomi's super admins set (60 by default): funding and payouts wait until a person at Rekomi approves the account for funding or verifies it, and the funding status carries `verificationRequired: true` alongside the code. A sixth, `identity_required`, comes back from a run and from the funding endpoints when Rekomi requires an identity check (a fraud score at or above the threshold Rekomi's super admins set for it, or a reviewer's requirement on the brand) until the brand passes Stripe's government ID and selfie check or a super admin clears it; it is checked ahead of `high_risk_unverified`, and the funding status carries `identityRequired: true` alongside the code. A run can also succeed with every row carrying `needsManualReview: true`: the run is held for Rekomi's review before any funding charge, nothing is charged and nothing is paid until it is approved, and the rows move to the normal funding flow (or to `Failed`) when the review is decided. The run's receipt shows `reviewStatus` and, for unverified orgs, `holdUntil`: the settled funding waits out the holding period before affiliates are paid. See [Verification and payout reviews](/docs/brands/payouts#verification-and-payout-reviews).

### `POST /api/v1/payouts/{id}/retry`

**Dashboard session only.** Retries a `Failed` payout. Like `/run`, this endpoint rejects every API-key and OAuth caller with `403 interactive_session_required`; only a signed-in Owner dashboard session can retry. Fires the same events as `/run` on success.

### `GET /api/v1/payouts/automatic/preview`
**Scope:** `read`. Dry run of the automatic-payout schedule: what the next run would pay, who rolls forward under the minimum, and who has money waiting but no connected account.
```json
{
  "enabled": true, "cadence": "Weekly", "minPayoutCents": 2000,
  "lastAutomaticPayoutAt": "2026-09-22T14:00:00Z", "nextRunAtUtc": "2026-09-29T14:00:00Z",
  "wouldPay": { "affiliateCount": 6, "totalCents": 41800, "byCurrency": { "USD": 41800 } },
  "belowMinimum": { "affiliateCount": 2, "totalCents": 1500, "byCurrency": { "USD": 1500 } },
  "notConnected": { "affiliateCount": 1, "totalCents": 9000, "byCurrency": { "USD": 9000 } }
}
```

### `GET /api/v1/payouts/ledger`
**Scope:** `read`. The platform ledger for your organization: brand charges, affiliate payouts and reversals, newest first. Query: `take`, `skip`.
```json
[
  { "id": "...", "payoutId": "...", "affiliateId": "...", "direction": "Debit", "type": "BrandFunding", "amountCents": 10094, "currency": "USD", "rail": "StripeAch", "railReferenceId": "py_...", "occurredAt": "2026-09-01T09:00:00Z", "reconciliationStatus": "Settled" }
]
```

### `GET /api/v1/payouts/receipts`
**Scope:** `read`. One receipt per funded payout run. Query: `take`, `skip`.
```json
[
  { "id": "...", "receiptNumber": "R-2026-0031", "currency": "USD", "grossCents": 98000, "platformFeeCents": 2940, "affiliateFeeCents": 1125, "passthroughCents": 0, "netToAffiliatesCents": 96875, "brandTotalCents": 100940, "payoutCount": 5, "billingCase": "BrandFunded", "brandFundingChargeId": "py_...", "brandFundingStatus": "Succeeded", "periodStart": "2026-08-01", "periodEnd": "2026-08-31", "createdAt": "2026-09-01T09:00:00Z", "settledAt": "2026-09-03T00:00:00Z", "reversedAt": null }
]
```
`GET /api/v1/payouts/receipts/{id}` returns the same item under `receipt` plus `organizationName` and `affiliates`, one row per payout (`payoutId`, `affiliateId`, `affiliateName`, `affiliateEmail`, `grossCents`, `platformFeeCents`, `affiliateFeeCents`, `netCents`, `currency`, `status`, `reversed`, and `fxLines` for consolidated multi-currency payouts). `GET /api/v1/payouts/receipts/{id}/receipt.html` renders the receipt as HTML and `GET /api/v1/payouts/receipts/{id}/receipt.csv` downloads the rows. `404 receipt_not_found` otherwise.

### Dashboard-only payout actions
`POST /api/v1/payouts/funding/setup`, `POST /api/v1/payouts/funding/bank-transfer/enable`, `POST /api/v1/payouts/funding/bank-transfer/disable`, `POST /api/v1/payouts/verification-request`, `POST /api/v1/payouts/{id}/reverse` and `POST /api/v1/payouts/{id}/reverse-lines` move money or change how it is funded, so they require a signed-in dashboard session. An API key gets `403` on them.

## Audit log

**Plan tier:** Pro+ for API-key callers (SIEM / compliance export). JWT Owner dashboard is unrestricted and gets the full schema. API-key callers also get a redacted response (operator IP / UserAgent / internal UserId fields are nulled in JSON; CSV header drops to 8 columns).

### `GET /api/v1/audit-log`

**Scope:** `read`. **Role:** `Owner` (JWT only; API keys bypass role checks, but the response is redacted; see below).

Query: `action`, `entityType`, `since` (ISO), `until` (ISO), `page` (default 1), `pageSize` (default 50, max 100).

API-key callers get a **redacted** payload with operator PII (IPs, internal UserIds, UserAgents) stripped. JWT dashboard callers get the full payload for in-product forensics. The `actorUserId` and `ip` query filters are also disabled for API-key callers (matching the redaction so they can't infer values).

```json
// API-key response (redacted)
{
  "page": 1,
  "pageSize": 50,
  "total": 1234,
  "items": [
    {
      "id": "audit-uuid",
      "action": "program.created",
      "entityType": "program",
      "entityId": "0789abcd-...",
      "actorType": "user",
      "actorUserId": null,
      "ip": null,
      "userAgent": null,
      "before": null,
      "after": "{\"id\":\"0789abcd-...\",\"name\":\"Default program\",\"...\":...}",
      "createdAt": "2026-05-01T12:00:00Z"
    }
  ]
}
```

`actorType` classifies who or what initiated the action, values are `user` (Clerk-authed brand operator), `api_key` (programmatic call via `rk_live_*`), `mcp` (MCP-relayed call via `rk_mcp_*`), `system` (background job / Hangfire), `admin` (super-admin portal action). It is exposed to API-key callers because it is a category, not PII.

### `GET /api/v1/audit-log/export.csv`

**Scope:** `read`. **Role:** `Owner` (same redaction as the JSON endpoint).

API-key CSV header (redacted, 8 columns):

```csv
id,created_at,action,entity_type,entity_id,actor_type,before,after
```

JWT dashboard CSV header (full, 11 columns):

```csv
id,created_at,action,entity_type,entity_id,actor_type,actor_user_id,ip,user_agent,before,after
```

Capped at 10,000 rows. Audit-log entries are append-only; no editing or deletion via the API.

## Dashboard metrics

**Plan tier:** Starter+ for API-key callers. JWT dashboard is unrestricted.

Brand-side aggregate metrics. **Role:** `Manager` for JWT; API keys bypass and read.

### `GET /api/v1/dashboard/summary`

**Scope:** `read`. Returns headline KPIs for the active window (`since` query param defaults to 30 days ago).

```json
{
  "totalClicks": 1842,
  "totalConversions": 87,
  "totalLeads": 134,
  "totalEarningsCents": 1734500,
  "conversionRate": 4.7232,
  "activeAffiliates": 23,
  "pendingPayoutsCents": 412000,
  "homeCurrency": "USD",
  "earningsUnconvertibleByCurrency": [],
  "pendingPayoutsUnconvertibleByCurrency": []
}
```

`conversionRate` is the percent value (e.g. `4.72` = 4.72%) rounded to 4 decimals. `Refunded` and `Denied` conversions are excluded from `totalConversions` and `totalEarningsCents`. Money fields are normalized to your `homeCurrency`; any amounts we could not convert (no FX rate for that currency) are reported per currency in `earningsUnconvertibleByCurrency` and `pendingPayoutsUnconvertibleByCurrency` rather than silently folded into the totals. `totalLeads` counts all lead rows in the window (free identified signups plus per-lead campaign leads); see [Track leads and signups](/docs/developers/lead-tracking).

### `GET /api/v1/dashboard/timeseries`

**Scope:** `read`. Time-series for charts.

Query: `metric` (required: `clicks` | `conversions` | `commissions` | `leads` | `payouts` | `refunds` | `revenue`), `since`, `until`, `granularity` (`day` only today; windows over 400 days bucket monthly), `affiliateId` (optional: scope the series to one affiliate, which is what the Activity view on an affiliate's detail page reads; works for every metric except `payouts`), `programId` (optional: scope to one campaign). Sum the `count` values for a window total, for example one affiliate's click count.

`metric=leads` returns the daily count of all lead rows (free identified signups plus per-lead campaign leads); see [Track leads and signups](/docs/developers/lead-tracking).

```json
{
  "metric": "conversions",
  "granularity": "day",
  "since": "2026-04-14T00:00:00Z",
  "until": "2026-05-14T00:00:00Z",
  "points": [
    { "bucket": "2026-04-14", "count": 3 },
    { "bucket": "2026-04-15", "count": 5 },
    { "bucket": "2026-04-16", "count": 0 }
  ]
}
```

For money metrics (`payouts`, `commissions`, `refunds`, `revenue`) the `count` field is the daily FX-normalized sum in minor units (despite the name); `revenue` is gross sale amounts, `commissions` is affiliate earnings. For `clicks`, `conversions`, and `leads`, `count` is a literal count.

### `GET /api/v1/dashboard/leaderboard`

**Scope:** `read`. Top affiliates by metric.

Query: `metric` (`clicks` | `conversions` | `earnings` | `epc`), `limit` (default 10, max 50), `since`, `programId` (optional: rank only one campaign's affiliates).

```json
[
  { "affiliateId": "01234567-...", "fullName": "Jane Doe", "email": "jane@creator.com", "value": 12 },
  { "affiliateId": "abcdef00-...", "fullName": "Joe Smith", "email": "joe@example.com", "value": 9 }
]
```

`value` is the per-affiliate conversion count (`metric=conversions`), click count (`clicks`), home-currency `commissionCents` sum (`earnings`), or earnings-per-click in cents (`epc`, FX-normalized commission divided by clicks; only affiliates with at least one click qualify). `email` is the affiliate's email (your own data).

### `GET /api/v1/dashboard/traffic-sources`

**Scope:** `read`. Click breakdown by source, channel-classified. Top 20 sources; each row carries a `channel`: `paid` (gclid/msclkid/ttclid or ad referrers; note fbclid alone is classified social, since Facebook stamps it on organic shares too), `search`, `social`, `email`, `ai` (ChatGPT/Perplexity/Claude referrers), `signin` (auth providers), `onsite` (the brand's own site), `portal` (Rekomi-hosted signup pages), `bot`, `referral`, or `direct`.

Query: `since`, `until`, `programId` (optional: one campaign), `affiliateId` (optional: one affiliate).

```json
[
  { "source": "youtube.com", "channel": "social", "count": 540, "percentage": 32.5 },
  { "source": "Google Ads", "channel": "paid", "count": 312, "percentage": 18.8 },
  { "source": "(direct)", "channel": "direct", "count": 280, "percentage": 16.9 }
]
```

Empty or unparseable referrers bucket as `(direct)`.

### `GET /api/v1/dashboard/export.csv`

**Scope:** `read`. CSV export of timeseries (`metric=clicks|conversions|payouts` only, plus `since` / `until`).

```csv
date,conversions
2026-04-14,3
2026-04-15,5
```

## Insights (analytics)

**Plan tier:** Starter+ for API-key callers. JWT dashboard is unrestricted. **Role:** `Manager` for JWT; API keys bypass and read. **Scope:** `read`.

Deeper brand-side breakdowns under `GET /api/v1/dashboard/insights/{module}` (alias of `/api/metrics/insights/{module}`). Every module accepts `since`, `until`, `programId` (one campaign), `affiliateId` (one affiliate), and `format=csv` to download the same rows as a CSV (hardened against spreadsheet formula injection). All money is FX-normalized to your home currency; rows whose currency has no usable stored rate for their date are held out and reported per currency (see `overview` below). Built only from data Rekomi already records, so views that attribute a **sale** to a click dimension are **coverage-aware**: only conversions with an originating click carry that dimension. `sale-origin` reports a numeric `coverage` fraction (attributed vs total conversions); `geography` and `devices` bucket sales without a click dimension under `(unknown)` rather than implying full attribution.

Modules:

- `overview`: `clicks`, `conversions`, `conversionRate`, `epcCents`, `aovCents`, `grossRevenueCents`, `commissionCents`, `refundedConversions`, `refundRate`, `recurringConversions`, `recurringSharePct`, `homeCurrency`. It also returns `leads` (signup count), `clickToLeadRate`, and `leadToSaleRate` (both `0..1` fractions); see [Track leads and signups](/docs/developers/lead-tracking). Money that could not be valued in the home currency (a sale currency with no usable stored rate for the row's date) is held out of `grossRevenueCents`, `aovCents`, `commissionCents`, and `epcCents` rather than counted at face value, and reported per currency in native minor units: `fxUnconvertibleByCurrency` for the revenue rows and `fxUnconvertibleCommissionByCurrency` for the commission rows (each `null` when everything converted). Those maps are the authoritative figures. The older scalar `fxUnconvertibleCents` stays on the wire for compatibility, but it is a sum of native minor units across currencies, a presence signal rather than a money amount: when it is non-zero, read the maps. The CSV download carries the same three as `fx_unconvertible_cents`, `fx_unconvertible_by_currency`, and `fx_unconvertible_commission_by_currency` (each map written as space-separated `CODE:minorUnits` pairs).
- `sale-origin`: top referring hosts by `conversions` + `revenueCents`, plus `attributedConversions` / `totalConversions` / `coverage`.
- `geography`: per-country `clicks`, `conversions`, `revenueCents`.
- `devices`: Mobile / Tablet / Desktop / Bot split of `clicks` + `conversions`.
- `utm`: `dimension` query (`source` | `medium` | `campaign`); per-value `clicks`, `conversions`, `revenueCents`.
- `time-patterns`: day-of-week (0=Sun) x hour-of-day (UTC) cells of `clicks` + `conversions`.
- `time-to-convert`: buckets `<1h` / `1-24h` / `1-7d` / `>7d`.
- `funnel`: `clicks` -> `conversions` -> `approved` -> `paid`. Each step is `{ step, count }`; the dashboard shows each step's share of the prior step (computed client-side, not returned as an API field). When lead data exists for the window, a `leads` stage is inserted adaptively between `clicks` and `conversions` (`clicks` -> `leads` -> `conversions` -> `approved` -> `paid`); brands with no leads in the window never see an empty Lead row.
- `attribution`: `Coupon` / `Link` / `Other` split of `conversions`, `revenueCents`, `commissionCents`.
- `recurring`: one-time vs recurring mix, `mrrByMonth` (from subscription charges), `churnedSubscriptions`.
- `customers`: new vs returning (repeat Stripe customer) counts + `revenueCents` (no customer identity returned).
- `top-products`: best-selling products by referred revenue. Coverage covers Shopify, Stripe, Paddle, and Chargebee (line items), Gumroad, Lemon Squeezy, Polar, Creem, Dodo (single-product orders), Mollie (when the payment carries order lines), and Braintree subscriptions; Recurly isn't covered yet. `revenueCents` is the pre-discount line subtotal, not the discounted amount actually paid.

```json
// GET /api/v1/dashboard/insights/sale-origin
{
  "sources": [{ "source": "youtube.com", "conversions": 12, "revenueCents": 184000 }],
  "attributedConversions": 41,
  "totalConversions": 58,
  "coverage": 0.7069,
  "homeCurrency": "USD"
}
```

Affiliates have a self-scoped, **PII-free** mirror under `GET /api/me/insights/{module}` (`overview`, `sale-origin`, `geography`, `devices`, `links`, `time-patterns`, `utm`, `channels`), each also supporting `affiliateId` (to focus one membership) and `format=csv`. These return only the caller's own counts/commission plus hosts, countries, device classes, link slugs, sub-ids and time buckets, never customer identity.

## Embed (white-label iframe)

**Plan tier:** Growth+ (applies to both JWT and API-key auth because embed is a white-label feature, not just API access). Returns 402 `{ "error": "plan_tier_required", required: "Growth", current: "Starter" }` on lower tiers.

### `POST /api/v1/embed/tokens`

**Scope:** `read_write`. Mint a per-affiliate iframe token.

```json
{
  "affiliateId": "01234567-...",
  "allowedOrigins": "https://yourapp.com,https://app.yourapp.com",
  "rotate": false,
  "expiresInHours": 24
}
```

- `allowedOrigins`: comma-separated full-URL origins (scheme + host + port). Strict equality on the iframe public endpoint; no wildcards, no `StartsWith` matching.
- `rotate`: when `false` and a token already exists for this `(program, affiliate)`, the response returns metadata only (`alreadyExists: true`) and no plaintext token. When `true`, the existing token is replaced and a fresh plaintext is returned.
- `expiresInHours`: default `24`. Clamped to `[1, 720]` (30 days max).

Response (new token or rotated):

```json
{
  "token": "ZGVtbzAxYWJjZGVm...",
  "allowedOrigins": "https://yourapp.com,https://app.yourapp.com",
  "expiresAt": "2026-05-15T12:00:00Z"
}
```

Response (token already exists, `rotate=false`):

```json
{
  "id": "embed-token-uuid",
  "allowedOrigins": "https://yourapp.com,https://app.yourapp.com",
  "expiresAt": "2026-05-15T12:00:00Z",
  "alreadyExists": true
}
```

The plaintext `token` is returned **exactly once**. Persist it in your application server immediately; Rekomi only stores its SHA-256 hash and cannot recover the plaintext.

### `POST /api/v1/embed/tokens/{id}/revoke`

**Scope:** `read_write`. Revokes an outstanding token.

Body: `{}`. Response: `{ "ok": true }`.

### `GET /api/embed/public/dashboard?token=...`

**Unauthenticated**, but token-and-Origin gated. Used directly by the iframe in the browser. Returns affiliate earnings + brand theming.

```json
{
  "affiliateName": "Jane Doe",
  "programName": "Default program",
  "earnedCents": 12450,
  "paidCents": 9800,
  "pendingCents": 2650,
  "brandColor": "#0E7C7B",
  "brandName": "Your Brand",
  "recentConversions": [
    {
      "id": "c0nv0001-...",
      "amountCents": 9900,
      "commissionCents": 1980,
      "status": "Approved",
      "createdAt": "2026-05-10T16:00:00Z"
    }
  ]
}
```

Failures:
- 400: missing `token` query param
- 401: `{}` empty body OR `{ "error": "token_expired" }` if the token's `expiresAt` is in the past
- 403: `{ "error": "no_allowed_origins_configured" }` | `{ "error": "origin_required" }` | `{ "error": "origin_invalid" }` | `{ "error": "origin_not_allowed" }`

The Origin check requires the request to send an `Origin` header AND for that origin to match (scheme + host + port) one entry in `allowedOrigins`. Server-to-server callers without an Origin header are rejected.

`earnedCents`, `paidCents`, and `pendingCents` are computed over the affiliate's 50 most recent conversions, not lifetime history.

### `GET /api/v1/embed/dashboard`
Unauthenticated. The data the white-label iframe renders, read with the per-affiliate token from `POST /api/v1/embed/tokens`: query `token`. Checked against the token's allowed origins. Returns `affiliateName`, `programName`, `earnedCents`, `paidCents`, `pendingCents`, `brandColor`, `brandName` and `recentConversions` (up to 20 rows of `id`, `amountCents`, `commissionCents`, `status`, `createdAt`).

## S2S tracking

**Plan tier:** Starter+. Trialing orgs are always accepted (14-day free trial on any tier). Orgs below Starter tier that are not in trial return 402.

### `POST /api/tracking/s2s`

Separate from the bearer-only `/api/v1/*` namespace because S2S adds an HMAC integrity check on top of the bearer.

Headers:

```http
X-Rekomi-Api-Key: rk_live_xxxx
X-Rekomi-Signature: t=1715366423,sig=8f4e2c5b...
Content-Type: application/json
```

(`Authorization: Bearer rk_live_xxxx` also works in place of `X-Rekomi-Api-Key`.)

Request body:

```json
{
  "externalEventId": "purchase_abc123",
  "affiliateSlug": "jane-recommends",
  "amountCents": 9900,
  "currency": "USD",
  "customerId": "cus_external_xyz"
}
```

Response (fresh):

```json
{ "ok": true, "conversionId": "c0nv0001-..." }
```

Response (duplicate, replay-safe; the same `externalEventId` was already recorded for this org):

```json
{ "ok": true, "deduped": true }
```

Mutual exclusivity: while a native processor connection is active (Stripe Connect, Paddle, Braintree, Lemon Squeezy, the Shopify, Wix, and BigCommerce apps, the WooCommerce plugin, Chargebee, Polar, Recurly, Gumroad, Creem, Dodo, or Mollie), the endpoint returns `409` with the matching error code (`stripe_connect_active`, `paddle_connect_active`, `braintree_connect_active`, `lemonsqueezy_connect_active`, `shopify_connect_active`, `wix_connect_active`, `bigcommerce_connect_active`, `wordpress_connect_active`, `chargebee_connect_active`, `polar_connect_active`, `recurly_connect_active`, `gumroad_connect_active`, `creem_connect_active`, `dodo_connect_active`, `mollie_connect_active`) so the same sale is never counted twice.

See [S2S tracking](/docs/developers/s2s-tracking) for full signature computation, replay protection, and language examples.

### `POST /api/tracking/lead`

Record a **lead** (a free identified signup, an email tied to the referral) rather than a sale. Same auth as `/api/tracking/s2s` (Bearer API key or `X-Rekomi-Api-Key`, plus the `X-Rekomi-Signature` HMAC), and the same `409 *_connect_active` mutual-exclusivity conflicts while a native processor connection is active. A lead mints no commission on its own; a later sale with the same email is credited to the referring affiliate via the email-match fallback even if the click cookie is gone.

Request body:

```json
{
  "affiliateSlug": "jane-recommends",
  "email": "user@example.com",
  "name": "Jane Doe",
  "externalEventId": "signup_abc123"
}
```

- `affiliateSlug` (required): the affiliate to credit. Matched case-insensitively, so `SERKAN` and `serkan` credit the same affiliate.
- `email` (required): the customer email a later sale is matched against.
- `name` (optional) and `externalEventId` (optional, used for de-duplication so retries are safe).
- `subId` (optional): the channel tag the signup carried; a later sale for the same email inherits it.

See [Track leads and signups](/docs/developers/lead-tracking) for the browser `Rekomi.convert()` equivalent, the automatic Stripe / Paddle path, and how email-match attribution works.

## Public storefront (unauthenticated)

These do not require an API key; they're rate-limited per IP.

### `GET /api/p/by-handle/{handle}/{campaign?}`

Public program info for the affiliate-facing landing page, org-scoped. Anonymous; rate-limited per IP. The `{handle}` is the org's branded subdomain (the `{handle}` in `{handle}.rekomi.com`); the resolver finds the org from the handle, then the campaign within it by slug. Omit `{campaign}` to fetch the org's primary campaign (the one served at the bare `{handle}.rekomi.com` root).

This replaces the former global-slug lookup `GET /api/p/{slug}`, which was removed because campaign slugs are unique only per org, not globally.

```json
{
  "id": "0789abcd-...",
  "organizationName": "Your Brand",
  "name": "Default program",
  "slug": "default",
  "description": "Earn 20% recurring on every annual plan.",
  "termsUrl": "https://yourbrand.com/affiliate-terms",
  "customAliasSupported": true
}
```

The full response carries more than shown above: it also includes `organizationDescription`, the commission settings (`defaultCommissionType`, `defaultCommissionValue`, `defaultCommissionModel`), `cookieWindowDays`, `recurringMonths`, `attributionModel`, `autoApprove`, `isPubliclyDiscoverable`, `landingPageConfig`, and the brand's logo and favicon URLs, everything the public landing page needs to render.

A campaign slug that was retired (renamed) returns `200 { "redirect": true, "campaign": "<current-slug>" }` so the caller can follow to the live slug. Returns `404 Not Found` when the handle does not resolve to an org, or for inactive or archived campaigns. No org GUID is exposed beyond the program `id`.

### `GET /api/p/by-domain?host={host}`

Resolve a custom domain (a brand's own domain, e.g. `affiliates.yourbrand.com`; available on every plan) to its org handle and primary campaign slug. Anonymous; rate-limited per IP.

```json
{ "handle": "yourbrand", "slug": "default" }
```

Returns `404 Not Found` when the host is not a verified custom domain for any org.

### `POST /api/p/id/{programId}/apply`

Submit an affiliate application. The path is keyed on the program's GUID (`{programId}`), not its slug, because campaign slugs are unique only per org, not globally. Resolve the GUID first via `GET /api/p/by-handle/{handle}/{campaign?}` (the `id` field). Anonymous; rate-limited to 5 requests/min per IP. Fires `affiliate.created`; if the program has `autoApprove=true`, also fires `affiliate.approved`.

Request:

```json
{
  "email": "newaffiliate@example.com",
  "fullName": "New Affiliate",
  "website": "https://creator.example.com",
  "audience": "200k YouTube subscribers in the fitness niche",
  "whyInterested": "Already an evangelist for the product",
  "customAlias": "newaff-fit"
}
```

All fields except `email` are optional. `customAlias` must match `[a-z0-9-]{3,64}`.

Response: the endpoint returns one uniform, anonymized shape to prevent applicant enumeration. It never returns the real affiliate id or link slug; `affiliateId` is always the all-zero GUID and `affiliateLinkSlug` is always `null`. A duplicate application (same email already applied to this program) returns the exact same shape as a fresh application.

```json
{
  "affiliateId": "00000000-0000-0000-0000-000000000000",
  "status": "Pending",
  "affiliateLinkSlug": null,
  "accountExists": false,
  "email": "newaffiliate@example.com",
  "message": "Application received."
}
```

- `accountExists`: whether the applicant already has a Rekomi affiliate account for this email (used by the public form to route them to sign-in).
- `email`: the applicant's own email echoed back.

Failures:
- `400`: request-validation failure; returns a standard problem details object (`{ type, title, status, errors }`), not the business-rule envelope
- `404`: program not found / inactive
- `409`: `{ "error": "alias_already_taken" }` or `{ "error": "program_at_capacity", "message": "..." }`

## Headers reference

| Header | Direction | Purpose |
|---|---|---|
| `Authorization: Bearer rk_live_*` | Request | API-key bearer (every authenticated endpoint). |
| `X-Rekomi-Api-Key: rk_live_*` | Request (S2S only) | Alternative to `Authorization` on `/api/tracking/s2s`. |
| `X-Rekomi-Signature: t=<unix>,sig=<hex>` | Request (S2S only) | HMAC over `<unix>.<body>` using your `rks_*` signing secret. |
| `Idempotency-Key: <uuid>` | Request | Supported on every `POST` and `PATCH` you might retry (imports, invites, affiliate creation). 60-minute replay window. Same key + same body → original response; same key + different body → 409. |
| `Idempotency-Replayed: true` | Response | Set when a response is replayed from a prior idempotent call. |
| `X-Rekomi-Signature: t=<unix>,v1=<hex>` | Outbound (webhook delivery) | HMAC signature on outbound webhook payloads. Use `v1=` for webhook verification (vs `sig=` for inbound S2S; separate code paths). |
| `X-Rekomi-Timestamp: <unix>` | Outbound (webhook delivery) | Unix-seconds timestamp the signature is computed over. |
| `X-Rekomi-Event: <type>` | Outbound (webhook delivery) | Event type for routing without body parse. |
| `X-Rekomi-Delivery-Id: <uuid>` | Outbound (webhook delivery) | De-dup key for retries. |

## Settings

### `GET /api/v1/settings/home-currency`

Returns the org's current HomeCurrency, last change timestamp, the supported ISO 4217 allowlist, and whether the caller's plan permits changing it (Pro plan or higher).

```json
{
  "homeCurrency": "USD",
  "changedAt": null,
  "supportedCurrencies": [
    "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM",
    "BBD", "BDT", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BWP",
    "BYN", "BZD", "CAD", "CDF", "CHF", "CLP", "CNY", "COP", "CRC", "CVE",
    "CZK", "DJF", "DKK", "DOP", "DZD", "EGP", "ETB", "EUR", "FJD", "FKP",
    "GBP", "GEL", "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HTG",
    "HUF", "IDR", "ILS", "INR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS",
    "KHR", "KMF", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD",
    "LSL", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MUR", "MVR",
    "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD",
    "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON",
    "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", "SEK", "SGD", "SHP", "SLE",
    "SOS", "SRD", "SZL", "THB", "TJS", "TND", "TOP", "TRY", "TTD", "TWD",
    "TZS", "UAH", "UGX", "USD", "UYU", "UZS", "VND", "VUV", "WST", "XAF",
    "XCD", "XCG", "XOF", "XPF", "YER", "ZAR", "ZMW"
  ],
  "canChange": true
}
```

### `PUT /api/v1/settings/home-currency`

Change the HomeCurrency. **Pro plan or higher required.** Mutation is idempotent on identical before/after; audit-logged as `org.home_currency_changed`. Historical conversions retain their native currency; only on-read dashboard normalization recomputes against the new home currency.

Body:

```json
{ "homeCurrency": "EUR" }
```

Response:

```json
{ "homeCurrency": "EUR", "changed": true, "changedAt": "2026-05-18T05:42:00Z" }
```

Failures:
- `400 unsupported_currency`: not on the allowlist.
- `402 plan_tier_required`: caller is on Starter or Growth.
- `403`: caller is not Owner or has unverified email.

Full conceptual guide: [Multi-currency for brands](/docs/brands/multi-currency). Ingestion details: [Conversion currency](/docs/developers/conversion-currency).

## CRM integrations

**Plan tier:** Starter+ for API-key callers. **Scope:** `read`.

### `GET /api/v1/integrations/crm`
Connected CRM and email-platform integrations only; a provider with no connection is simply absent. One row per integration, sorted by `slug`: `provider`, `slug`, `enabled`, `syncCustomers`, `salesAttributionEnabled`, `salesAttributionStatus`, `salesAttributionCheckedAt`, `salesAttributionCredited` and `webhookRegisteredAt`. Per-affiliate sync state lives on `GET /api/v1/affiliates/{id}/crm-sync`.

## Sub-affiliate recruiting

**Plan tier:** included on every plan to enable on a campaign. The affiliate-side endpoints (`/me/sub-affiliate/*`) are unrestricted once the campaign is enabled.

Bounded 2-tier override: affiliate A recruits B; A earns `B.commission × Program.SubAffiliateCommissionPercent / 100` on every sale B closes. One recruiting level, hard-capped; no chain propagation, never multi-level. Override rows have `amountCents = 0`, `commissionCents > 0`, `attributionMethod = "sub_affiliate_override"`, and `parentAffiliateConversionId` pointing at the parent sale row. Full concept page: [Sub-affiliate recruiting](/docs/brands/sub-affiliate-recruiting). Full developer guide with webhook payloads: [Sub-affiliate API](/docs/developers/sub-affiliate).

### `GET /api/v1/programs/{id}/sub-affiliate-config`

**Scope:** `read`. Returns the current per-campaign setting.

```json
{
  "enabled": true,
  "percent": 10.0
}
```

### `PUT /api/v1/programs/{id}/sub-affiliate-config`

**Scope:** `read_write`. **Role:** `Owner`. **Plan:** included on every plan. **Email verification:** required. Audit-logged as `program.sub_affiliate_config_changed`.

```json
{ "enabled": true, "percent": 10 }
```

Failures:

| HTTP | `error` | Cause |
|---|---|---|
| 400 | `unsupported_value` | Percent outside `[0, 100]` |
| 403 | `email_not_verified` | Owner has not verified email |
| 403 | (empty) | Caller is not Owner |
| 404 | (empty) | Program not in your org |

### `GET /api/v1/me/sub-affiliate/link?programId={id}`

**Scope:** Clerk JWT only (affiliate-side). Returns the calling affiliate's personal recruitment URL.

```json
{
  "affiliateId": "01234567-...",
  "programId": "0789abcd-...",
  "programSlug": "default",
  "url": "https://{handle}.rekomi.com/default?recruited_by=v6media"
}
```

The `recruited_by` token is slug-first: the affiliate's account-level `?via=` slug when they have one, else their per-campaign link slug, else the affiliate id GUID. Returns `400 program_id_required` without the query param, `400 sub_affiliate_recruiting_disabled` when the campaign has the toggle off, `403` when the calling affiliate is not `Approved`, `404` when the campaign is not visible to the calling affiliate, `409 portal_not_configured` when the brand has no public portal host.

### `GET /api/v1/me/sub-affiliate/recruits`

**Scope:** Clerk JWT only (affiliate-side). Calling affiliate's downline. Cursor-paginated.

```json
{
  "items": [
    {
      "affiliateId": "fed00b00-...",
      "email": "recruit@example.com",
      "fullName": "Recruited Person",
      "status": "Approved",
      "totalCommissionGeneratedCents": 12400,
      "overrideEarnedCents": 1240,
      "currency": "USD",
      "joinedAt": "2026-05-12T10:00:00Z"
    }
  ],
  "nextCursor": null
}
```

### `GET /api/v1/affiliates/{id}/sub-affiliates`

**Scope:** `read`. Brand-side drilldown into any affiliate's downline. Returns a **flat array** (no pagination wrapper).

```json
[
  {
    "id": "fed00b00-...",
    "email": "recruit@example.com",
    "fullName": "Recruited Person",
    "status": "Approved",
    "totalCommissionCents": 12400,
    "overrideEarnedCents": 1240,
    "currency": "USD"
  }
]
```

### Apply-form addition

`POST /api/p/id/{programId}/apply` accepts an optional recruiter reference on the body: `recruitedBy` (string: affiliate id GUID, per-campaign link slug, or account-level `?via=` slug) or the legacy `recruitedByAffiliateId` (GUID). When the reference resolves to an Approved affiliate in the same campaign, the new affiliate's `RecruitedByAffiliateId` is populated. Unresolvable values are silently ignored; self-recruit (same Clerk user or same email) is silently dropped while the application still succeeds; no recruiter is bound while the campaign's recruiting toggle is off.

## Organization profile

These endpoints sit at their canonical `/api/organizations/me` path (no `/v1` alias).

### `GET /api/organizations/me`

**Scope:** `read`. Returns the calling org's full profile: name, slug, branded subdomain and its change counter/cap, billing email, timezone, brand color, logo and favicon URLs, description, white-label flag, plan tier, subscription status, trial end, payout grace days (and the max allowed at the current tier), and the automatic-payout settings.

```json
{
  "id": "01234567-...",
  "name": "Acme",
  "slug": "acme",
  "subdomain": "acme",
  "subdomainChangeCount": 0,
  "subdomainChangesMax": 2,
  "billingEmail": "billing@acme.com",
  "timezone": "America/Los_Angeles",
  "primaryBrandColor": "#3B0764",
  "logoUrl": "https://cdn.rekomi.com/...",
  "faviconUrl": null,
  "description": "Acme makes the world's best widgets.",
  "whiteLabelEnabled": false,
  "planTier": "Growth",
  "subscriptionStatus": "Active",
  "trialEndsAt": null,
  "payoutGraceDays": 30,
  "payoutGraceDaysMax": 90,
  "automaticPayoutEnabled": false,
  "automaticPayoutCadence": "Manual",
  "minPayoutCents": 0,
  "lastAutomaticPayoutAt": null
}
```

The response also carries the brand's industry category and the attribution-param catalog fields used by the dashboard UI.

Writes (`PATCH /api/organizations/me`, `POST /api/organizations/me/logo`, etc.) remain JWT-only with Owner role. API keys and MCP tokens cannot mutate the profile.

## Billing state

### `GET /api/billing/state`

**Scope:** `read`. Returns the org's plan tier, subscription status, trial end, Stripe linkage status, and a plan-limits snapshot. This is the canonical endpoint to call before recommending an install path: `stripeConnectLinked = true` means the org's checkout webhooks flow to Rekomi automatically (the field name is kept for contract stability; the link is the Rekomi app for Stripe); `false` means the org has not linked Stripe for conversion attribution and needs coupon codes, browser pixel, or S2S to attribute conversions.

```json
{
  "planTier": "Growth",
  "subscriptionStatus": "Active",
  "billingProvider": "Stripe",
  "trialEndsAt": null,
  "interval": "Monthly",
  "stripeSubscriptionId": "sub_...",
  "stripeCustomerLinked": true,
  "stripeConnectLinked": true,
  "couponsEnabled": true,
  "limits": {
    "affiliates": { "current": 12, "max": null },
    "campaigns": { "current": 2, "max": null },
    "partnerPayoutsThisMonthCents": { "current": 285000, "max": 1500000 },
    "teamSeats": { "current": 1, "max": 5 }
  },
  "stripeConnectedVia": "StripeApp"
}
```

`billingProvider` is how the org is billed (for example `Stripe` or `Shopify`). `couponsEnabled` is whether the coupons feature is usable. `stripeConnectedVia` is `StripeApp` when the org's Stripe account is linked through the Rekomi app, or `null` when Stripe is not linked; it always agrees with `stripeConnectLinked`.

Stripe Checkout, Portal, and payment-method endpoints remain JWT-only. API keys and MCP tokens cannot open new Checkout sessions or modify billing.

## Affiliate invites

Brand-to-affiliate invite flow. Lives at `/api/affiliate-invites` (no `/v1` alias). Bulk endpoint requires Starter+ paid plan; single-invite is available on every plan including Trial.

### `POST /api/affiliate-invites`

**Scope:** `read_write`. **Role:** `Manager+`. **Email verification:** required for dashboard (JWT) callers; API keys and MCP tokens are exempt.

Creates one invitation. Returns the invite row plus a one-time accept URL.

Body:

```json
{
  "programId": "01234567-...",
  "email": "alex@example.com",
  "fullName": "Alex Example",
  "slug": "alex",
  "personalNoteHtml": "<p>Hi Alex, would love to have you on our affiliate program.</p>",
  "customCommissionType": "Percentage",
  "customCommissionValue": 30,
  "autoApprove": true
}
```

The optional `slug` field preserves the affiliate's existing slug from a prior platform (drop-in migration use case). When supplied, the accept flow assigns it as the new `AffiliateLink.Slug` if it still passes shape validation (3-64 chars, `[a-z0-9-]`, no leading/trailing hyphen) AND is still globally unique. Otherwise the accept flow falls back to a random slug and surfaces a warning. On the affiliate's first sign-in this becomes their account-level referral slug (the one shared across every campaign and used as `?via={slug}`); if they already have a Rekomi affiliate account, their existing account slug is kept instead. Server returns 409 `slug_already_taken` if the slug is already in use at invite-create time; 400 `slug_reserved` if it matches the reserved-word list (`api`, `admin`, etc.); 400 with `field: "slug"` on charset/length validation failure.

The `personalNoteHtml` is sanitized via `RichTextSanitizer` (Ganss.Xss parser-driven) to a strict allowlist: `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `ul`, `ol`, `li`, `a`. The only allowed attribute is `href` on `<a>`, restricted to `https://` URLs. Everything else is stripped. Merge tags supported: `{{affiliate.firstName}}`, `{{program.commission}}`, `{{brand.name}}`. Tokens render after sanitization, so token values cannot smuggle markup.

Response:

```json
{
  "invite": {
    "id": "...",
    "programId": "...",
    "programName": "...",
    "email": "alex@example.com",
    "state": "Pending",
    "expiresAt": "2026-05-26T...",
    "...": "..."
  },
  "acceptUrl": "https://app.rekomi.com/invites/affiliate/<one-time-token>"
}
```

Failures:
- `400 validation_failed` with `field`: missing/invalid email, missing programId.
- `409 affiliate_already_exists`: an affiliate with this email already exists in the program.
- `409 pending_invites_limit_reached`: org has 500 pending invites.

### `POST /api/affiliate-invites/bulk`

**Scope:** `read_write`. **Role:** `Manager+`. **Email verification:** required for JWT callers. **Plan:** Starter+ with `SubscriptionStatus = Active` (Trial users receive 402 `paid_plan_required` by design; bulk email abuse path does not get an MCP free pass).

Up to 50 emails per call. Duplicates within the batch and emails already attached to existing affiliates are skipped silently. Accepted invites are emailed at a steady, throttled rate.

Two body shapes are accepted. The newer `recipients` shape is preferred for migration use cases since it lets you preserve per-affiliate slugs; the legacy `emails` shape stays available for back-compat.

Preferred shape:

```json
{
  "programId": "01234567-...",
  "recipients": [
    { "email": "alex@example.com", "slug": "alex", "fullName": "Alex Example" },
    { "email": "ben@example.com", "slug": "ben-r" },
    { "email": "carol@example.com" }
  ],
  "personalNoteHtml": "<p>We're migrating from Tapfiliate. Same commission, same campaign.</p>",
  "autoApprove": true
}
```

Legacy shape (no per-row slug):

```json
{
  "programId": "01234567-...",
  "emails": ["alex@example.com", "ben@example.com", "carol@example.com"],
  "personalNoteHtml": "<p>...</p>",
  "autoApprove": true
}
```

When both `recipients` and `emails` are supplied, `recipients` wins.

Response:

```json
{
  "created": 3,
  "skipped": 0,
  "invites": [ /* InviteResponse[] */ ],
  "slugConflicts": [
    { "email": "ben@example.com", "requestedSlug": "alex", "reason": "duplicate_within_batch" }
  ]
}
```

`slugConflicts` reports per-row outcomes when a requested slug was rejected (taken globally, reserved, charset-invalid, or duplicated within the same batch). Those rows still get an invite; they fall back to a fresh random slug at accept-time. Use the array to surface "these 3 affiliates didn't get their preferred slug" feedback in your UI.

Failures:
- `400 validation_failed`: empty / oversized batch, invalid programId.
- `402 paid_plan_required`: org on Trial or below Starter.
- `409 pending_invites_limit_reached`: org has 500 pending invites.

### Lifecycle

- `GET /api/affiliate-invites` (JWT-only): list invites filtered by `programId` + `state` (Pending / Accepted / Expired / Revoked).
- `POST /api/affiliate-invites/{id}/resend` (JWT-only): rotate the token + resend.
- `DELETE /api/affiliate-invites/{id}` (JWT-only): revoke a pending invite.
- `GET /api/affiliate-invites/by-token/{token}` (anonymous, rate-limited): preview the brand + program for an unaccepted invite.
- `POST /api/affiliate-invites/by-token/{token}/accept` (anonymous, rate-limited): accept the invite. Creates the `Affiliate` row (Approved or Pending based on `AutoApprove`). Idempotent: re-accepting returns the original affiliate id.

## Install snippet renderer

### `GET /api/v1/r/install-snippet`

**Scope:** `read`. Server-renders the canonical `<script>` install snippet with the calling org's actual program id, custom attribution params (when configured under Settings > Attribution), email capture, and optional pixel public key already embedded. Used by the MCP `get_install_snippet` tool so an AI agent can hand a brand the exact text to paste without composing HTML attributes itself; the dashboard's install card renders the same tag from its own templates.

Optional query params:

- `programId` (Guid): which program/campaign to embed. Defaults to the org's newest active program.
- `withPixel` (bool): include `data-rekomi-org` for the browser-pixel conversion-firing path. Requires a pixel public key to be provisioned under `/dashboard/setup/install` first. Returns 409 `pixel_not_provisioned` when true and no key exists.
- `emailCapture` (bool): whether the tag carries `data-rkmi-email-capture="auto"`. Omitted or `true` = on, the default for every tag Rekomi hands out; `false` renders the bare tag. With it on, Rekomi records an email a referred visitor types into any email or text field on the pages where the tag runs (signup, login, checkout and other forms) so the sale can be credited even when the checkout passes no referral. Nothing is recorded without a referral. The brand can also remove the attribute from a pasted tag to turn it off; explicit `Rekomi.convert()` calls still work.

Response:

```json
{
  "snippet": "<!-- Rekomi tracking -->\n<script async src=\"https://api.rekomi.com/api/v1/r/loader.js\" data-program-id=\"01234567-...\" data-rkmi-email-capture=\"auto\" data-rkmi-params=\"my_aff,brand_ref\"></script>\n<noscript>\n  <img src=\"https://api.rekomi.com/api/v1/r/c.gif\" width=\"1\" height=\"1\" alt=\"\" />\n</noscript>",
  "programId": "01234567-...",
  "programSlug": "default",
  "withPixel": false,
  "hasCustomAttributionParams": true,
  "customAttributionParams": "my_aff,brand_ref",
  "scriptHost": "https://api.rekomi.com",
  "usingTrackingDomain": false,
  "emailCapture": true
}
```

`emailCapture` in the response says whether the rendered tag carries the attribute, so a caller can print the line above next to the tag without parsing the HTML. `scriptHost` is the host the tag loads from (the brand's verified first-party tracking domain when one is active, otherwise the API host) and `usingTrackingDomain` says which.

Failures:

| HTTP | `error` | Cause |
|---|---|---|
| 404 | `no_program_found` | No matching program in the calling org (or no active programs exist yet) |
| 409 | `pixel_not_provisioned` | `withPixel=true` but the org has no pixel public key |
| 403 | `forbidden` | API key lacks the required scope; the body carries a message naming the scope |

XSS-safe by construction: every field embedded in the snippet is server-derived (program id is a Guid, custom params are revalidated through `AttributionParamsService.ParseCustom` against `[a-z0-9_-]{2,32}`, pixel public key comes from DB). There is no caller-controlled input that lands in the HTML output.

## Lead capture (browser)

### `POST /api/v1/r/lead/signup`

The public browser endpoint behind `Rekomi.convert()` / `Rekomi.identify()`. Records a **lead** (a free identified signup) against the current referral, posted by the tracking loader via `sendBeacon` / `fetch` keepalive. Public-tracking CORS, rate-limited per IP. To avoid signup enumeration it always returns an opaque `200` regardless of whether a lead was recorded.

Body:

```json
{ "slug": "jane-recommends", "email": "user@example.com", "name": "Jane Doe", "programId": "0789abcd-..." }
```

`slug` (the resolved referral slug) is required; `programId` scopes the lead to one campaign; `name` is optional. The Rekomi loader (`Rekomi.convert`) resolves the referral from the visitor's Rekomi cookie and sends the resolved `slug` (and `programId`) in the body for you. You do not call this raw endpoint directly; use the loader's `Rekomi.convert({ email })`. For Stripe and Paddle, leads are captured automatically with no call at all. See [Track leads and signups](/docs/developers/lead-tracking).

The hosted MCP server exposes the equivalent `record_lead` tool (`affiliateSlug`, `email`, optional `name`, `campaignId`); see [MCP](/docs/developers/mcp).

## Attribution params (drop-in migration)

These endpoints control which query params the Rekomi loader recognizes for affiliate attribution. The 21 canonical defaults (matching Rewardful, Affonso, Tapfiliate, FirstPromoter, UpPromote, GoAffPro, BixGrow, and other major affiliate platforms) are always recognized; brands can add up to 10 custom params for niche platforms.

### `GET /api/v1/settings/attribution-params/defaults`

Public, unauthenticated. Returns the canonical defaults list plus the validation envelope (max custom entries, max total chars, per-param length bounds). Used by the dashboard UI and the marketing site to render the chip grid without hardcoding the catalog.

```json
{
  "defaults": ["via", "ref", "fpr", "aff", "afmc", "rfsn", "sca_ref", "bg_ref", "a_aid", "atp", "affonso", "awc", "sscid", "cjevent", "irclickid", "partner_key", "affid", "affiliate_id", "affcode", "idev", "mbsy"],
  "maxCustomEntries": 10,
  "maxTotalChars": 256,
  "minParamLength": 2,
  "maxParamLength": 32
}
```

Rate-limited via the `anonymous-tracking` policy.

### `GET /api/v1/settings/attribution-params`

**Scope:** `read`. Returns the calling org's current config plus the merged effective priority order.

```json
{
  "custom": "myparam,brand_ref",
  "defaults": ["via", "ref", "fpr", "aff", "..."],
  "effectiveOrder": ["myparam", "brand_ref", "via", "ref", "..."],
  "maxCustomEntries": 10,
  "maxTotalChars": 256
}
```

`custom` is null when the brand has not configured any custom params; in that case `effectiveOrder` equals `defaults`. Custom entries are deduplicated against defaults at read time so the same param never appears twice in `effectiveOrder`.

### `PATCH /api/v1/settings/attribution-params`

**Scope:** `read_write`. **Role:** `Owner` (bypassed for `api_key` auth scheme per standard role-attribute behavior). **Email verification:** required for JWT callers (bypassed for `api_key`). Audit-logged as `org.attribution_params_changed` with before / after.

Body:

```json
{ "custom": "myparam,brand_ref" }
```

Pass `null` or `""` to clear the override and revert to defaults-only.

Failures:

| HTTP | `error` | Cause |
|---|---|---|
| 400 | `attribution_params_too_long` | Body exceeds 256 chars |
| 400 | `too_many_attribution_params` | More than 10 entries |
| 400 | `invalid_attribution_param` | An entry does not match `[a-z0-9_-]{2,32}` (the response includes the rejected entries in an `invalid` array) |
| 403 | (empty) | Not Owner role and not API key |
| 404 | (empty) | Org not found |

Each successful change fires a single audit log row capturing both the prior value and the new value. The loader picks up the new config on the next page load on the brand's site (no cache delay beyond the loader response's 1-hour `Cache-Control`).

## Where to go next

- [Authentication](/docs/developers/authentication): generate and manage keys.
- [Webhooks](/docs/developers/webhooks): receive events, verify signatures, handle retries.
- [S2S tracking](/docs/developers/s2s-tracking): non-Stripe conversion ingest.
- [Track leads and signups](/docs/developers/lead-tracking): capture free signups and email-match a later sale.
- [Embed](/docs/developers/embed): host the affiliate dashboard on your domain.
- [MCP server](/docs/developers/mcp): AI-assistant integration with 61 tools including an install assistant.
- [Zapier](/docs/developers/zapier): no-code automations across 8,000+ apps. 9 triggers, 9 actions, 4 searches.
- [Conversion currency](/docs/developers/conversion-currency): ISO 4217 allowlist, error shape, FX behavior.
