RekomiRekomi
DemoPricingFor creators
DashboardSign inLaunch your program
Launch your program
Rekomi Docs
Rekomi Docs
Welcome to Rekomi
HomeQuickstart for brandsPlans and trialsIntegrationsStripe Connect (sales tracking)Organization settingsTeam managementNotifications

Campaigns and commissions

CampaignsCommission modelsPay per click or lead (CPC & CPL)Coupon-code attributionProducts and per-product ratesCreative assetsRecruiting (sub-affiliates)Tracking and attributionTest your integration

Affiliates

Recruit affiliatesManage affiliatesAI co-pilotApply to the curated networkBooking creators with deals

Money flow

SalesCustomersPayoutsMulti-currencyTax formsReports

Email

Sending domainEmailsBroadcasts
Quickstart for affiliatesYour public pageThe page builderBrand dealsDeals and getting paidMessagesBuild your creator profileBrowse and apply to campaignsApply directly via a brand's public pageAffiliate dashboard tourYour tracking linkProduct & page linksUsing creative assetsEarnings and performanceConnect Stripe for payoutsYour tax formsGetting paidFeesSupported countriesPromote Rekomi and earn
API overviewAuthenticationOAuth 2.0Server-to-server trackingTracking script & window.RekomiTrack leads and signupsNo-code & non-Stripe checkoutsCustom domainConversion currencyCoupon code trackingSub-affiliate recruiting APIWebhooksZapierWhite-label embedMCP serverAPI reference
For developers
Developers

API reference

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, 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, 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, 45 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:

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 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):

// 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.

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).

{
  "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).

POST /api/v1/programs

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

Request:

{
  "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). 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
  • recurringMonths: per-sale recurring duration. null / omitted = lifetime (every renewal); 0 = one-time (no renewals); N = the first N renewals. On PATCH, send -1 to clear an existing cap back to lifetime.
  • budgetCents: CPC/CPL only. Total spend cap in minor units of the org home currency; once non-refunded commission reaches it, new clicks/leads stop earning. Omit for uncapped, which is not recommended for CPC/CPL. Check spend with GET /api/v1/programs/{id}/budget-status.
  • referralDestinationUrl: CPC only. The URL a validated click redirects to. minPayoutCents: per-campaign minimum payout threshold (minor units).
  • leadBountyCents: Sale models only (Cps/Cpa/RevShare/Hybrid). Optional flat bounty in minor units, paid for each qualified lead captured through the campaign's Rekomi-hosted lead form, on top of the sale commission. Positive sets it (clamped 10..100000, i.e. $0.10..$1,000), 0 clears it, omit = unchanged on PATCH. Ignored (nulled) for Cpc/Cpl.

PATCH /api/v1/programs/{id}

Scope: read_write. Role: Manager.

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

{
  "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.

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.

{
  "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/{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: Owner. Returns a 5-minute signed URL to download the affiliate's W-9 / W-8BEN PDF. Audit-logged on every read.

{
  "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:

{
  "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.

{
  "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.

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

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

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.

{ "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.

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), since (ISO). Use status=Invalidated to pull the CPC/CPL fraud-quarantine review queue (traffic held out of payment as invalid).

{
  "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"
    }
  ],
  "nextCursor": null,
  "hasMore": false
}

Notes:

  • type enum: OneTime | Recurring | Bonus | ClickReward | LeadReward.
  • status enum: Pending | Approved | Paid | Refunded | Denied | Invalidated. Invalidated = fraud-quarantined CPC/CPL traffic held out of payment; validate it (release) or invalidate a Pending row from the Sales view 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. 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.
  • 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.
  • 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.

{
  "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",
  "events": [
    { "id": "evt-uuid-1", "eventType": "initial", "amountCents": 9900, "commissionCents": 1980, "occurredAt": "2026-05-10T16:00:00Z" },
    { "id": "evt-uuid-2", "eventType": "renewal", "amountCents": 9900, "commissionCents": 1980, "occurredAt": "2026-06-10T16:00:00Z" }
  ]
}

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):

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

attribution_method and coupon_code are the last two columns (appended, so existing column positions are unchanged). coupon_code is populated only for coupon-attributed rows. 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:

{
  "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 (max 1,000,000,000, a typo guard). 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 (max 100,000,000).
  • 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.

{
  "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".

CPC/CPL fraud review and budget

CPC and CPL campaigns, 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 CPC and CPL 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 CPC/CPL campaign's 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.

GET /api/v1/fraud-events

Scope: read. Lists fraud signals Rekomi flagged on CPC/CPL 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.

POST /api/v1/lead (paid CPL lead)

Limited preview (returns 404 until enabled for your account). Records a billable lead on a CPL campaign. 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.

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:

{
  "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.

{
  "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.

{
  "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:

{
  "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, 100000000] (cents). 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.

{
  "jobId": "1234abcd-...",
  "status": "Completed",
  "kind": "Conversions",
  "total": 1,
  "imported": 1,
  "skipped": 0,
  "errored": 0,
  "unpaidConfirmedCents": 0,
  "confirmUnpaidPayout": false,
  "excludePaid": false,
  "reasons": {}
}

unpaidConfirmedCents is the exact commission total this import made payable (the sum of the Approved conversions it created). It is 0 whenever confirmUnpaidPayout is false.

Payouts

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

GET /api/v1/payouts

Scope: read. List payout batches. Cursor-paginated.

{
  "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 },
        { "id": "li-uuid-2", "conversionId": "c0nv0002-...", "amountCents": 7820 }
      ]
    }
  ],
  "nextCursor": null,
  "hasMore": false
}

Enums:

  • method on stored payouts is StripeConnect or PayPal (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.

GET /api/v1/payouts/{id}

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

POST /api/v1/payouts/preview

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

Request body (all fields optional):

{
  "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
  • 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)

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.

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.

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).

// 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):

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

JWT dashboard CSV header (full, 11 columns):

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).

{
  "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.

GET /api/v1/dashboard/timeseries

Scope: read. Time-series for charts.

Query: metric (required: clicks | conversions | commissions | leads | payouts), since, until, granularity (day only today).

metric=leads returns the daily count of all lead rows (free identified signups plus per-lead campaign leads); see Track leads and signups.

{
  "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 metric=payouts, the count field is the daily sum of amountCents (despite the name). For clicks and conversions, 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).

[
  { "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 referrer host. Top 20 sources.

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

[
  { "source": "youtube.com", "count": 540, "percentage": 32.5 },
  { "source": "twitter.com", "count": 312, "percentage": 18.8 },
  { "source": "(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, plus since / until).

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. 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.
  • 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, Gumroad, Lemon Squeezy, Polar, Creem, Dodo (single-product orders), and Braintree subscriptions; Paddle, Chargebee, and Recurly aren't covered yet. revenueCents is the pre-discount line subtotal, not the discounted amount actually paid.
// 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.

{
  "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):

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

Response (token already exists, rotate=false):

{
  "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.

{
  "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.

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:

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:

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

Response (fresh):

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

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

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

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

See 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:

{
  "affiliateSlug": "jane-recommends",
  "email": "user@example.com",
  "name": "Jane Doe",
  "externalEventId": "signup_abc123"
}
  • affiliateSlug (required): the affiliate to credit.
  • email (required): the customer email a later sale is matched against.
  • name (optional) and externalEventId (optional, used for de-duplication so retries are safe).

See Track leads and signups 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.

{
  "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.

{ "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:

{
  "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.

{
  "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

HeaderDirectionPurpose
Authorization: Bearer rk_live_*RequestAPI-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>RequestSupported 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: trueResponseSet 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).

{
  "homeCurrency": "USD",
  "changedAt": null,
  "supportedCurrencies": ["AED", "AUD", "BRL", "CAD", "CHF", "CNY", "CZK", "DKK", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PLN", "RON", "SAR", "SEK", "SGD", "THB", "TRY", "USD", "ZAR"],
  "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:

{ "homeCurrency": "EUR" }

Response:

{ "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. Ingestion details: Conversion currency.

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. Full developer guide with webhook payloads: Sub-affiliate API.

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

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

{
  "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.

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

Failures:

HTTPerrorCause
400unsupported_valuePercent outside [0, 100]
403email_not_verifiedOwner 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.

{
  "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.

{
  "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).

[
  {
    "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.

{
  "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": 30,
  "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.

{
  "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:

{
  "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:

{
  "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:

{
  "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):

{
  "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:

{
  "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), and optional pixel public key already embedded. Used by the dashboard's snippet generators and 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.

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.

Response:

{
  "snippet": "<!-- Rekomi tracking -->\n<script async src=\"https://api.rekomi.com/api/v1/r/loader.js\" data-program-id=\"01234567-...\" 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"
}

Failures:

HTTPerrorCause
404no_program_foundNo matching program in the calling org (or no active programs exist yet)
409pixel_not_provisionedwithPixel=true but the org has no pixel public key
403forbiddenAPI 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:

{ "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.

The hosted MCP server exposes the equivalent record_lead tool (affiliateSlug, email, optional name, campaignId); see MCP.

Attribution params (drop-in migration)

These endpoints control which query params the Rekomi loader recognizes for affiliate attribution. The 19 canonical defaults (matching Rewardful, 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.

{
  "defaults": ["via", "ref", "fpr", "aff", "afmc", "rfsn", "sca_ref", "bg_ref", "a_aid", "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.

{
  "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:

{ "custom": "myparam,brand_ref" }

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

Failures:

HTTPerrorCause
400attribution_params_too_longBody exceeds 256 chars
400too_many_attribution_paramsMore than 10 entries
400invalid_attribution_paramAn 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: generate and manage keys.
  • Webhooks: receive events, verify signatures, handle retries.
  • S2S tracking: non-Stripe conversion ingest.
  • Track leads and signups: capture free signups and email-match a later sale.
  • Embed: host the affiliate dashboard on your domain.
  • MCP server: AI-assistant integration with 45 tools including an install assistant.
  • Zapier: no-code automations across 8,000+ apps. 9 triggers, 9 actions, 4 searches.
  • Conversion currency: ISO 4217 allowlist, error shape, FX behavior.

MCP server

Connect AI assistants (Claude, Cursor, Continue, Zed, ChatGPT, Windsurf) to your Rekomi account through Model Context Protocol.

On this page

ProgramsGET /api/v1/programsGET /api/v1/programs/{id}POST /api/v1/programsPATCH /api/v1/programs/{id}DELETE /api/v1/programs/{id}AffiliatesGET /api/v1/affiliatesGET /api/v1/affiliates/{id}GET /api/v1/affiliates/applicationsGET /api/v1/affiliates/{id}/tax-formPOST /api/v1/affiliatesPATCH /api/v1/affiliates/{id}POST /api/v1/affiliates/{id}/approvePOST /api/v1/affiliates/{id}/rejectPOST /api/affiliates/bulk/approve and POST /api/affiliates/bulk/pauseConversionsGET /api/v1/conversionsGET /api/v1/conversions/{id}GET /api/v1/conversions/export.csvPOST /api/v1/conversionsCPC/CPL fraud review and budgetPOST /api/v1/conversions/{id}/validatePOST /api/v1/conversions/{id}/invalidateGET /api/v1/programs/{id}/budget-statusGET /api/v1/fraud-eventsPOST /api/v1/lead (paid CPL lead)Import (bulk migration)POST /api/v1/affiliates/importPOST /api/v1/conversions/import/previewPOST /api/v1/conversions/importPayoutsGET /api/v1/payoutsGET /api/v1/payouts/{id}POST /api/v1/payouts/previewPOST /api/v1/payouts/runPOST /api/v1/payouts/{id}/retryAudit logGET /api/v1/audit-logGET /api/v1/audit-log/export.csvDashboard metricsGET /api/v1/dashboard/summaryGET /api/v1/dashboard/timeseriesGET /api/v1/dashboard/leaderboardGET /api/v1/dashboard/traffic-sourcesGET /api/v1/dashboard/export.csvInsights (analytics)Embed (white-label iframe)POST /api/v1/embed/tokensPOST /api/v1/embed/tokens/{id}/revokeGET /api/embed/public/dashboard?token=...S2S trackingPOST /api/tracking/s2sPOST /api/tracking/leadPublic storefront (unauthenticated)GET /api/p/by-handle/{handle}/{campaign?}GET /api/p/by-domain?host={host}POST /api/p/id/{programId}/applyHeaders referenceSettingsGET /api/v1/settings/home-currencyPUT /api/v1/settings/home-currencySub-affiliate recruitingGET /api/v1/programs/{id}/sub-affiliate-configPUT /api/v1/programs/{id}/sub-affiliate-configGET /api/v1/me/sub-affiliate/link?programId={id}GET /api/v1/me/sub-affiliate/recruitsGET /api/v1/affiliates/{id}/sub-affiliatesApply-form additionOrganization profileGET /api/organizations/meBilling stateGET /api/billing/stateAffiliate invitesPOST /api/affiliate-invitesPOST /api/affiliate-invites/bulkLifecycleInstall snippet rendererGET /api/v1/r/install-snippetLead capture (browser)POST /api/v1/r/lead/signupAttribution params (drop-in migration)GET /api/v1/settings/attribution-params/defaultsGET /api/v1/settings/attribution-paramsPATCH /api/v1/settings/attribution-paramsWhere to go next