API reference
Every v1 endpoint with request schema, response JSON example, scope, and notes, verified against the live code.
This page enumerates every endpoint exposed under /api/v1/* for API-key-authenticated callers. Each entry shows the exact request shape, a realistic response JSON, the scope required, and any plan or role gates. The schemas are generated from the source records and verified against the live controller code on every release: if a field appears here, it ships; if it doesn't appear, the API does not return it.
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, 39 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_xxxxxxxxxxxxxxxxxxxxxScope column tells you which API-key scope satisfies the endpoint:
read: keys created with scopereadare accepted (also satisfied byread_write)read_write: onlyread_writekeys are accepted
Default inference: GET/HEAD/OPTIONS → read; POST/PATCH/DELETE → read_write. Embed token endpoints override to read_write even on the GET path that doesn't exist yet (defensive).
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 (every v1 list endpoint)
{
"items": [ /* ... */ ],
"nextCursor": "eyJpZCI6IjAxNzg5..." ,
"hasMore": true
}
// Error (every 4xx/5xx)
{ "error": "validation_failed", "message": "perPage must be between 1 and 200", "field": "perPage" }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
}Response: 201 Created with the new ProgramResponse. Fires no webhook (programs are not webhook-bearing today).
Enums (verified against Rekomi.Core.Domain.Enums):
defaultCommissionType:Percentage|FixeddefaultCommissionModel: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 likeCps).attributionModel:FirstTouch|LastTouchnetworkVisibility:Private|Networked|InviteOnlyrecurringMonths: per-sale recurring duration.null/ omitted = lifetime (every renewal);0= one-time (no renewals);N= the first N renewals. OnPATCH, send-1to clear an existing cap back to lifetime.
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": "Public"
}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), cursor, programId, status (Pending | Approved | Rejected | Paused), q (search across email/fullName).
{
"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:
emailis the affiliate's email, visible to you as the brand owner (your own data).notesis 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 omitsapplicationData(the form-builder submission JSON). The dashboard JWT path keepsapplicationDataso the in-product review screen can render it. statusenum:Pending|Approved|Rejected|Paused|Banned.sourceenum:Direct|Public|Invite|Network.formatenum (on eachlinks[]entry):Short|Query|Path|Hash.
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 AffiliateResponse shape, filtered to status=Pending.
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 single-use, time-limited Spaces presigned URL. Do not cache it.
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/v1/affiliates/bulk-action
Scope: read_write. Role: Owner. Up to 200 affiliates per call.
{
"affiliateIds": ["uuid-1", "uuid-2", "..."],
"action": "approve"
}Actions: approve | pause | delete.
Response:
{ "affected": 198, "total": 200, "action": "approve" }If affected < total, the missing rows were not in your org or didn't match the precondition for the action.
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.
GET /api/v1/conversions
Scope: read. List conversions. Cursor-paginated.
Query: take, cursor, affiliateId, programId, status (Pending | Approved | Paid | Denied | Refunded), since (ISO).
{
"items": [
{
"id": "c0nv0001-...",
"affiliateId": "01234567-...",
"programId": "0789abcd-...",
"amountCents": 9900,
"currency": "USD",
"commissionCents": 1980,
"status": "Approved",
"type": "Subscription",
"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:
typeenum:OneTime|Recurring|Bonus|ClickReward|LeadReward.statusenum:Pending|Approved|Paid|Refunded|Denied.attributionMethodis how the sale was attributed (for examplecoupon,metadata_or_customer,cpc_aggregation,sub_affiliate_override, ornullfor S2S). The full value set and how the dashboard groups them is in the webhooks reference.couponCodeis the redeemed code when (and only when) the sale was attributed by a coupon, otherwisenull.- Lead rows (
type=LeadReward, withamountCentsandcommissionCentsof0) are free identified signups, not sales. They tie a customer email to the referral before any payment and carry no commission on their own; the paid sale is recorded as a separateOneTime/Recurringrow later. See Track leads and signups. stripeSubscriptionIdandstripeCustomerIdare the brand's own Stripe identifiers (i.e., yourcus_*/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": "Subscription",
"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_codeattribution_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.
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 (
413over the limit). - Rate limit: the dedicated
importpolicy, 6 requests/min per caller (429over 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
programIdthat is not in your organization returns404 program_not_found. - Revertible: each import persists a
MigrationJob; the returnedjobIdappears 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
}emailis required per row.statusis optional and defaults toapprovedwhen omitted (the affiliate was already live on the prior platform); an unrecognized value falls back topending. 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.notifyAffiliatesrequires 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/affiliateSlugis required (affiliate_email_or_slug_required). amountCentsandcommissionCentsmust be integers in[0, 100000000](cents). Negative or over-cap is rejected (amount_out_of_range/commission_out_of_range).currencymust be a supported ISO-4217 code (unsupported_currency).occurredAtis required (ISO 8601; sets the historical cohort month).typeisOneTime(default) orRecurring(invalid_type).
Money safety (read this):
paid: truerows are recorded as settled history (Paid, locked) and can never be paid again.paid: falserows are recorded as locked history by default. They become a payable balance (Approved, an amount Rekomi will pay out) only when you setconfirmUnpaidPayout: true.confirmUnpaidPayoutdefaults tofalse.- Importing never moves money by itself; payouts remain operator-run and Stripe-Connect-gated.
excludePaid: trueskips 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:Manual|StripeConnect|PayPal|Wise.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
Scope: read_write. Role: Owner. Returns eligible affiliates without committing.
{
"method": "StripeConnect",
"affiliateIds": null,
"periodStart": "2026-04-01",
"periodEnd": "2026-04-30"
}Response:
{
"totalCents": 12450,
"currency": "USD",
"eligibleCount": 8,
"ineligibleCount": 2,
"candidates": [
{
"affiliateId": "01234567-...",
"amountCents": 1980,
"lineItemCount": 1,
"isEligible": true,
"ineligibilityReason": null
},
{
"affiliateId": "ineligible-uuid",
"amountCents": 9900,
"lineItemCount": 5,
"isEligible": false,
"ineligibilityReason": "no_stripe_connect"
}
]
}POST /api/v1/payouts/run
Scope: read_write. Role: Owner. Idempotency-Key supported (recommended for retries).
{
"method": "StripeConnect",
"affiliateIds": ["01234567-..."],
"confirmTotalCents": 1980,
"periodStart": "2026-04-01",
"periodEnd": "2026-04-30",
"notes": "April commission run"
}Headers:
Idempotency-Key: 8f14e45f-ceea-467a-bb2c-d8b8c0afc8d8
Content-Type: application/jsonResponse: 200 with a flat array of PayoutResponse (one element per recipient). Fires payout.created per recipient, then payout.paid (Manual) or payout.paid/payout.failed (Stripe).
confirmTotalCents must equal the server-computed total. If they disagree (e.g., a conversion was finalized between preview and run), the request returns 409; re-preview and retry.
POST /api/v1/payouts/{id}/retry
Scope: read_write. Role: Owner. Retries a Failed payout. Fires the same events as /run on success.
{}Response: 200 with the updated PayoutResponse.
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 7 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,afterJWT dashboard CSV header (full, 11 columns):
id,created_at,action,entity_type,entity_id,actor_type,actor_user_id,ip,user_agent,before,afterCapped 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
}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. totalLeads counts free identified signups (LeadReward lead-only rows) in the window; see Track leads and signups.
GET /api/v1/dashboard/timeseries
Scope: read. Time-series for charts.
Query: metric (required: clicks | conversions | leads | payouts), since, until, granularity (day only today).
metric=leads returns the daily count of free identified signups (LeadReward lead-only rows); 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,5Insights (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 returnsleads(signup count),clickToLeadRate, andleadToSaleRate(both0..1fractions); see Track leads and signups.sale-origin: top referring hosts byconversions+revenueCents, plusattributedConversions/totalConversions/coverage.geography: per-countryclicks,conversions,revenueCents.devices: Mobile / Tablet / Desktop / Bot split ofclicks+conversions.utm:dimensionquery (source|medium|campaign); per-valueclicks,conversions,revenueCents.time-patterns: day-of-week (0=Sun) x hour-of-day (UTC) cells ofclicks+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, aleadsstage is inserted adaptively betweenclicksandconversions(clicks->leads->conversions->approved->paid); brands with no leads in the window never see an empty Lead row.attribution:Coupon/Link/Othersplit ofconversions,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).
// 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, noStartsWithmatching.rotate: whenfalseand a token already exists for this(program, affiliate), the response returns metadata only (alreadyExists: true) and no plaintext token. Whentrue, the existing token is replaced and a fresh plaintext is returned.expiresInHours: default24. 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
tokenquery param - 401:
{}empty body OR{ "error": "token_expired" }if the token'sexpiresAtis 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.
S2S tracking
Plan tier: Starter+. Trialing orgs are always accepted (14-day free trial on any tier). Orgs with SubscriptionStatus = None 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 }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). 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) andexternalEventId(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
}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 Growth brand's own domain, e.g. affiliates.yourbrand.com) 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 via strict-write. 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 (fresh approval, autoApprove=true):
{
"affiliateId": "01234567-...",
"status": "Approved",
"affiliateLinkSlug": "newaff-fit",
"message": "Welcome aboard."
}Response (pending review, default):
{
"affiliateId": "01234567-...",
"status": "Pending",
"affiliateLinkSlug": null,
"message": "Application received."
}Response (duplicate; same email already applied to this program; indistinguishable from a fresh application to prevent enumeration):
{ "ok": true, "message": "application_received" }Failures:
400: validation error:{ "error": "validation_failed", "field": "email", "message": "..." }404: program not found / inactive409:{ "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 POST /api/v1/payouts/run. 60-minute replay window. Same key + same body → original response; same key + different body → 409. |
X-Rekomi-Source: <free-text> | Request | Optional label that shows up in support logs. |
X-RateLimit-Remaining | Response | Per-window quota remaining. |
Retry-After | Response (429) | Seconds to wait before retry. |
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-Event: <type> | Outbound (webhook delivery) | Event type for routing without body parse. |
X-Rekomi-Delivery-Id: <ulid> | 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 only).
{
"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 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: Pro+ on the brand side to enable on a campaign. The affiliate-side endpoints (/me/sub-affiliate/*) are unrestricted once the campaign is enabled.
Bounded 1-tier override: affiliate A recruits B; A earns B.commission × Program.SubAffiliateCommissionPercent / 100 on every sale B closes. Hard cap at 1 tier; no chain propagation. 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: Pro+. Email verification: required. Audit-logged as program.sub_affiliate_config_changed.
{ "enabled": true, "percent": 10 }Failures:
| HTTP | error | Cause |
|---|---|---|
| 400 | unsupported_value | Percent outside [0, 100] |
| 402 | plan_tier_required | Org below Pro and not trialing |
| 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.
{
"affiliateId": "01234567-...",
"programId": "0789abcd-...",
"programSlug": "default",
"url": "https://{handle}.rekomi.com/default?recruited_by=01234567-..."
}Returns 400 sub_affiliate_recruiting_disabled when the campaign has the toggle off, 404 when the campaign is not visible to the calling affiliate.
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 recruitedByAffiliateId field on the body. When supplied and matched to an Approved affiliate in the same org, the new affiliate's RecruitedByAffiliateId is populated. Mismatches are silently ignored; self-recruit is blocked at row creation.
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, billing email, timezone, brand color, logo URL, description, white-label flag, plan tier, subscription status, trial end, payout grace days, and the max grace days allowed at the current tier.
{
"id": "01234567-...",
"name": "Acme",
"slug": "acme",
"billingEmail": "billing@acme.com",
"timezone": "America/Los_Angeles",
"primaryBrandColor": "#3B0764",
"logoUrl": "https://cdn.rekomi.com/...",
"description": "Acme makes the world's best widgets.",
"whiteLabelEnabled": false,
"planTier": "Growth",
"subscriptionStatus": "Active",
"trialEndsAt": null,
"payoutGraceDays": 30,
"payoutGraceDaysMax": 30
}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 Connect 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; false means the user is on manual payouts and needs coupon codes, browser pixel, or S2S to attribute conversions.
{
"planTier": "Growth",
"subscriptionStatus": "Active",
"trialEndsAt": null,
"interval": "Monthly",
"stripeSubscriptionId": "sub_...",
"stripeCustomerLinked": true,
"stripeConnectLinked": true,
"limits": {
"affiliates": { "current": 12, "max": null },
"campaigns": { "current": 2, "max": null },
"partnerPayoutsThisMonthCents": { "current": 285000, "max": 1500000 },
"teamSeats": { "current": 1, "max": 5 }
}
}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 JWT callers (API keys / MCP bypass per UserIdentity.cs:122).
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_failedwithfield: 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. Each accepted invite is fanned out via Hangfire at 1/sec to stay under Resend's rate limit.
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 byprogramId+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 theAffiliaterow (Approved or Pending based onAutoApprove). 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): includedata-rekomi-orgfor the browser-pixel conversion-firing path. RequiresPixelPublicKeyto be provisioned under/dashboard/settings/web-trackingfirst. Returns 409pixel_not_provisionedwhen 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:
| 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 PixelPublicKey |
| 403 | (empty) | RequireApiScope failure |
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 16 canonical defaults (matching Rewardful, Tapfiliate, FirstPromoter, and 13 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", "rfsn", "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:
| 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: 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 38 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.