# Rekomi Public API - machine-readable manifest This file is the canonical, single-source spec of every endpoint exposed under https://api.rekomi.com/api/v1/* for programmatic callers. It is regenerated on every API change. The human-friendly view of the same surface lives at https://rekomi.com/docs/developers/reference; if the two ever disagree, this file is the source of truth. ## Base URL and transport Production base URL is https://api.rekomi.com. Most endpoints described below live under the prefix /api/v1. Public-API surfaces that intentionally live outside /api/v1 are: /api/tracking/s2s (HMAC-signed conversion ingest), /api/p (unauthenticated storefront), /api/organizations/me (read-only org profile), /api/billing/state (read-only billing state), and /api/affiliate-invites (brand-to-affiliate invitation flow). Transport is HTTPS only; HTTP requests are redirected. Content type is application/json on every request body and every response body except CSV exports which return text/csv. UTF-8 only. ## Authentication Every authenticated endpoint accepts a Bearer token in the Authorization header. The token format is rk_live_ followed by ~40 base62 characters. Generate keys in the dashboard at /dashboard/settings/api-keys. Authorization: Bearer rk_live_xxxxxxxxxxxxxxxxxxxxx A separate webhook-signing secret is provisioned alongside each key with the prefix rks_ followed by ~40 base62 characters. The rks_ secret is NOT used for inbound auth; it is used only for verifying the HMAC signature on outbound webhook deliveries that Rekomi POSTs to your endpoint, and for signing S2S conversion postbacks. The brand dashboard at app.rekomi.com authenticates using Clerk session JWTs, not rk_live_* keys. Every controller below is wired to accept either auth class via the [BearerOrJwt] attribute. For programmatic integration always use the rk_live_* path; the JWT path is meant for the in-product UI only. ## Scopes Each API key carries a scope claim, either read or read_write. Read keys can call GET endpoints only. Read_write keys can call every endpoint. The scope filter looks at the HTTP method: GET, HEAD, OPTIONS satisfy read; POST, PATCH, PUT, DELETE require read_write. Mint a read-only key for SIEM exports and reporting consumers; mint a read_write key for write integrations. ## Plan gate Direct rk_live_* access to the public REST API requires the calling brand to be on the Starter plan or higher. The audit log API export additionally requires Pro, and the embed-token endpoints require Growth. The in-app dashboard at app.rekomi.com bypasses plan gates (Clerk JWT auth path); the gate fires only on rk_live_* calls. The gate checks tier rank, not subscription status: a trial at a qualifying tier passes (a Starter trial can call Starter+ endpoints), but a trial never grants a tier above the one chosen at signup. Plan gate denial responds with HTTP 402 and the body {"error":"plan_tier_required","required":"Starter","current":"Free","trialing":false,"message":"..."}. The MCP relay at https://mcp.rekomi.com forwards calls to the same /api/v1 endpoints using rk_mcp_* tokens. The MCP path is open to every plan tier including Trial (it bypasses the tier gate). If an LLM agent needs access regardless of the calling brand's plan, prefer the MCP path; if the agent needs to issue writes on behalf of a brand below Starter (Free), the brand must upgrade to Starter or higher, or use the MCP relay which has its own per-token scopes. ## Error envelope Every 4xx and 5xx response on /api/v1 routes uses the same JSON shape: {"error":"","message":"","field":""} Stable error codes you can branch on are: unauthorized (401, missing/invalid token), forbidden (403, scope mismatch or RBAC failure), plan_tier_required (402, plan gate), not_found (404, resource missing or out of scope), validation_failed (400/422, body or query did not validate), idempotency_conflict (409, same key with different payload), rate_limited (429, quota exhausted), server_error (5xx). The message field is for human display, do not parse it. Branch on error only. ## Pagination List endpoints come in two flavors. The default is cursor pagination, returning {"items":[...],"nextCursor":"","hasMore":true|false}. To fetch the next page pass the opaque nextCursor value back as the cursor query parameter. When hasMore is false you have reached the end. The default page size is 100 with a clamp at 200. Pass take=N to override within that range. Cursor encodes (id, createdAt) as base64; treat it as opaque and do not parse. The exception is /api/v1/audit-log which uses offset pagination because SIEM consumers want total counts. It returns {"page":1,"pageSize":50,"total":1234,"items":[...]} where page defaults to 1 and pageSize defaults to 50 with a clamp at 100. ## Rate limits The authenticated policy applies to every /api/v1 endpoint by default. It is a token bucket of 600 tokens per minute per Clerk user ID (JWT path) or per API key ID (rk_live_* path). Token replenishment is 600/min. Excess requests get HTTP 429 with the error envelope. The strict-write policy applies to /api/p/{slug}/apply, /api/migrations/rewardful, and /api/ai/*; it is a fixed window of 10 requests per minute. The import policy applies to the bulk import endpoints (/api/v1/affiliates/import, /api/v1/conversions/import, /api/v1/conversions/import/preview); it is a fixed window of 6 requests per minute per caller. The anonymous-tracking policy on /api/v1/track/click and /api/p/{slug} is 60/min per IP. The webhook policy on /api/webhooks/clerk, /api/webhooks/stripe, /api/tracking/s2s is 1500/min per source IP (permissive because Stripe / Clerk push from a small set of egress IPs and treat 429 as a delivery failure). When you hit a limit the response carries a Retry-After header in seconds; back off and retry. ## Idempotency POST /api/v1/payouts/run accepts an Idempotency-Key request header (UUID v4 recommended). Within a 60-minute window the same key with the same payload returns the prior response and sets Idempotency-Replayed: true. The same key with a different payload returns HTTP 409 idempotency_conflict. Other write endpoints do not currently honor the header; for non-payout writes, use natural keys (externalEventId on S2S conversions, the (programId, email) pair on affiliate creates) to make calls safe to retry. ## Endpoints ### Programs GET /api/v1/programs - list affiliate programs for the active org. Scope read. Plan Starter+. Query params: take (default 50, max 200), cursor (opaque from prior nextCursor). Returns CursorPaged. GET /api/v1/programs/{id} - single program by UUID. Scope read. Plan Starter+. Returns a ProgramResponse object. 404 if not in the calling org. POST /api/v1/programs - create a new program. Scope read_write. Plan Starter+. Role Manager (JWT only; API keys bypass role). Body: {name,slug,description?,termsUrl?,defaultCommissionType,defaultCommissionValue,defaultCommissionModel,cookieWindowDays,recurringMonths?,recurringDelayInvoices,attributionModel,autoApprove}. 201 Created with ProgramResponse. PATCH /api/v1/programs/{id} - partial update. Scope read_write. Plan Starter+. Role Manager. Body is a subset of CreateProgramRequest plus networkVisibility, customDomain, isPubliclyDiscoverable. Setting customDomain to a new value resets customDomainVerified to false and triggers async DNS verification. DELETE /api/v1/programs/{id} - soft-delete (sets archivedAt). Scope read_write. Plan Starter+. Role Owner. 204 No Content. GET /api/v1/programs/{id}/tracking-status - diagnostic. Scope read. Plan Starter+. Returns {verified,lastClickAt,lastConversionAt,totalClicks30d,totalConversions30d}. GET /api/v1/programs/{id}/recent-events - recent click + conversion stream for the program. Scope read. Query: limit (default 20, max 100). Returns {items:[{type,id,occurredAt,affiliateEmail,affiliateSlug,amountCents,currency,commissionCents,status,source}]}. POST /api/v1/programs/{id}/custom-domain/verify - re-run DNS verification for the brand's custom portal subdomain. Scope read_write. Plan Pro+. Role Owner. Body {}. Returns {verified,domain,resolvedTarget,errorReason}. POST /api/v1/programs/{id}/tracking/test - JWT-only synthetic conversion for the in-app install wizard. Not part of the rk_live_* surface; mentioned here for completeness. ProgramResponse shape: {id,name,slug,description,termsUrl,isActive,defaultCommissionType,defaultCommissionValue,defaultCommissionModel,cookieWindowDays,recurringMonths,recurringDelayInvoices,attributionModel,autoApprove,customDomain,customDomainVerified,networkVisibility,isPubliclyDiscoverable,createdAt,updatedAt,archivedAt}. Enums on the Program surface: defaultCommissionType is Percentage|Fixed (Fixed is shown as "Flat fee" in the UI). defaultCommissionModel is Cps (per sale, flat fee or recurring %)|Cpc (per click)|Cpl (per lead); legacy values Cpa|RevShare|Hybrid are still accepted for existing campaigns but not offered for new ones (they behave like Cps; RevShare requires Percentage). attributionModel is FirstTouch|LastTouch. networkVisibility is Private|Networked|InviteOnly. isPubliclyDiscoverable is a boolean controlling SEO indexing of the public landing page. recurringMonths controls 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 the cap back to lifetime. ### Affiliates GET /api/v1/affiliates - list affiliates. Scope read. Plan Starter+. Query: take, cursor, programId (filter), status (Pending|Approved|Rejected|Paused|Banned), q (search across email + fullName). Returns CursorPaged. GET /api/v1/affiliates/{id} - single affiliate. Scope read. Plan Starter+. 404 if not in the calling org. GET /api/v1/affiliates/applications - pending applications only. Scope read. Plan Starter+. Returns a flat array of AffiliateResponse. GET /api/v1/affiliates/{id}/tax-form - most recent W-9 or W-8BEN metadata for the affiliate. Scope read. Role Owner. Returns {id,affiliateId,type,submittedAt,signedUrl,expiresInSeconds:300}. The signedUrl is a 5-minute presigned Spaces URL; do not cache it. Every read is audit-logged. POST /api/v1/affiliates - admin-path create (skips the application form). Scope read_write. Plan Starter+. Body {programId,email,fullName?,status?,customCommissionType?,customCommissionValue?,customAlias?}. 201 Created with AffiliateResponse. Fires webhook affiliate.created; if status=Approved also fires affiliate.approved. PATCH /api/v1/affiliates/{id} - update. Scope read_write. Plan Starter+. Body subset of {fullName,status,customCommissionType,customCommissionValue,notes,tags}. POST /api/v1/affiliates/{id}/approve - approve a pending application. Scope read_write. Plan Starter+. Body {}. Fires affiliate.approved. POST /api/v1/affiliates/{id}/reject - reject a pending application. Scope read_write. Plan Starter+. Body {reason?,notify?:true}. Default behavior emails the applicant; set notify=false to suppress for spam/abuse. POST /api/v1/affiliates/bulk-action - bulk action on up to 200 affiliates. Scope read_write. Plan Starter+. Role Owner. Body {affiliateIds:[uuid,...],action:"approve"|"pause"|"delete"}. Returns {affected,total,action}. AffiliateResponse shape for API-key callers: {id,programId,email,fullName,status,customCommissionType,customCommissionValue,tags,notes,source,appliedAt,approvedAt,createdAt,links:[AffiliateLinkResponse]}. The applicationData field is omitted on the API-key path; it is included on the JWT dashboard path so the in-product application review screen can render the raw form-builder JSON. The response also intentionally omits clerkUserId, stripeConnectAccountId, paypalEmail, taxFormStatus, kycStatus - those are server-side only. AffiliateLinkResponse shape: {id,affiliateId,slug,customAlias,isDefault,format,createdAt}. Enums on the Affiliate surface: status is Pending|Approved|Rejected|Paused|Banned. source is Direct|Public|Invite|Network (where Public means the affiliate filled out the public application form on /p/{slug}, Direct means admin-created, Invite means accepted a brand invite, Network means came via the Rekomi affiliate network). format on each link is Short|Query|Path|Hash. ### Conversions GET /api/v1/conversions - list conversions. Scope read. Plan Starter+. Query: affiliateId, programId, status (Pending|Approved|Paid|Refunded|Denied), since (ISO8601), take, cursor. Returns CursorPaged. GET /api/v1/conversions/{id} - single conversion including the subscription event timeline. Scope read. Plan Starter+. Returns ConversionResponse plus events:[{id,eventType,amountCents,commissionCents,occurredAt}]. GET /api/v1/conversions/export.csv - CSV export with the same filters plus until. Scope read. Plan Starter+. Capped at 50,000 rows. CSV header: id,affiliate_id,program_id,amount_cents,currency,commission_cents,status,type,external_event_id,created_at,refunded_at. Stripe IDs are intentionally omitted from CSV; use the JSON endpoint if you need them. Every cell starting with =, +, -, @, tab, or carriage return is prefixed with a single quote (OWASP CSV-injection guard). Every export is audit-logged. ConversionResponse shape: {id,affiliateId,programId,amountCents,currency,commissionCents,status,type,stripeSubscriptionId,stripeCustomerId,externalEventId,createdAt,refundedAt}. stripeSubscriptionId and stripeCustomerId are the brand's own cus_* / sub_* identifiers (the brand's Stripe data), not the affiliate's - affiliates do not have Stripe identifiers on this surface. Use these to join Rekomi conversions back to your Stripe data. Enums on the Conversion surface: status is Pending|Approved|Paid|Refunded|Denied. type is OneTime|Recurring|Bonus|ClickReward|LeadReward. ### Import (bulk migration) Generic, platform-agnostic bulk import. You POST canonical JSON rows (not a vendor CSV); the dashboard migrate flow keeps its per-vendor CSV parsing. Both surfaces run the same audited services, so the money and identity rules are identical. Shared safeguards on all three endpoints: scope read_write; plan Starter+ (API-key callers); role Manager + verified email (JWT only); at most 1000 rows per request (400 row_cap_exceeded); ~4MB body (413); the import rate-limit policy (6/min/caller, 429); one import per org at a time (409 migration_already_in_progress); a cross-org programId returns 404 program_not_found; each import persists a revertible MigrationJob whose jobId appears in import history. POST /api/v1/affiliates/import: bulk-import an affiliate roster. Scope read_write. Plan Starter+. Body {programId, affiliates:[{email, fullName?, status?, paypalEmail?, slug?}], includeStatuses?:[string], notifyAffiliates?:bool, notificationMessageHtml?}. email is required per row; status defaults to approved when omitted (approved|pending|rejected|paused|banned; unrecognized -> pending). slug preserves the affiliate's existing referral slug (falls back to a fresh slug if taken). includeStatuses is an allowlist (rows outside it are skipped status_excluded). Dedupes by email within the campaign AND the batch (idempotent: existing emails come back skipped, never duplicated). notifyAffiliates additionally requires Growth+ active subscription, else the import still succeeds but no emails send. Returns {jobId, status, kind, total, imported, skipped, errored, reasons} where reasons tallies skip/error causes (already_exists, status_excluded, missing_email, ...). POST /api/v1/conversions/import/preview: dry-run of a conversion import; writes nothing. Scope read_write. Plan Starter+. Body {programId, conversions:[...]} (same row shape as the import). Returns {rowsTotal, wouldImport, wouldSkip, wouldError, skipReasons, paidBalance:[{currency,commissionCents,count}], unpaidBalance:[{currency,commissionCents,count}]}. unpaidBalance is exactly how much commission per currency would become payable if you then import with confirmUnpaidPayout=true. Always preview before importing. POST /api/v1/conversions/import: import historical conversions/commissions. Scope read_write. Plan Starter+. Body {programId, conversions:[{affiliateEmail?, affiliateSlug?, customerEmail?, customerName?, amountCents, commissionCents, currency, occurredAt, paid, type?, stripeCustomerId?, stripeSubscriptionId?, externalId?}], confirmUnpaidPayout?:false, excludePaid?:false}. Per-row validation (any failure returns 400 invalid_rows with {index,error} per offending row, before any write): at least one of affiliateEmail/affiliateSlug required; amountCents and commissionCents are integers in [0, 100000000] cents; currency is a supported ISO-4217 code; occurredAt is ISO 8601 (sets the cohort month); type is OneTime (default) or Recurring. MONEY SAFETY: paid=true rows record as settled history (Paid, locked) and can never be paid again; paid=false rows record as locked history by DEFAULT and become a payable balance (Approved) ONLY when confirmUnpaidPayout=true (defaults false). Importing never moves money; payouts stay operator-run and Stripe-Connect-gated. excludePaid=true skips already-paid rows. Idempotent (re-import skips existing rows via a deterministic external id, never double-pays). Returns {jobId, status, kind, total, imported, skipped, errored, unpaidConfirmedCents, confirmUnpaidPayout, excludePaid, reasons}. unpaidConfirmedCents is the exact commission total this import made payable (0 unless confirmUnpaidPayout=true). ### Payouts GET /api/v1/payouts - list payout batches. Scope read. Plan Starter+. Query: status, affiliateId, since, take, cursor. Returns CursorPaged. GET /api/v1/payouts/{id} - single payout including lineItems. Scope read. Plan Starter+. POST /api/v1/payouts/preview - eligible-affiliate dry-run, returns totals + per-affiliate breakdown without committing. Scope read_write. Plan Starter+. Role Owner. Body {method:"StripeConnect"|"Manual"|"PayPal"|"Wise",affiliateIds?:[uuid,...]|null,periodStart?,periodEnd?,programId?,currency?}. Returns {totalCents,currency,eligibleCount,ineligibleCount,candidates:[{affiliateId,amountCents,lineItemCount,isEligible,ineligibilityReason}]}. POST /api/v1/payouts/run - execute a payout batch. Scope read_write. Plan Starter+. Role Owner. Idempotency-Key header supported (60-minute window). Body {method,affiliateIds:[uuid,...],confirmTotalCents,periodStart?,periodEnd?,programId?,since?,currency?,notes?,externalTransactionId?}. confirmTotalCents must equal the server-computed total or the call returns 409 confirm_total_mismatch (re-preview and retry). Returns 200 with a flat array of PayoutResponse. Fires payout.created per recipient, then payout.paid (Manual) or payout.paid / payout.failed (StripeConnect/PayPal/Wise). POST /api/v1/payouts/{id}/retry - retry a Failed payout. Scope read_write. Plan Starter+. Role Owner. Body {}. Returns the updated PayoutResponse. Same webhooks as /run. PayoutResponse shape: {id,affiliateId,amountCents,currency,method,status,stripeTransferId,externalTransactionId,notes,failureReason,periodStart,periodEnd,createdAt,paidAt,failedAt,lineItems:[{id,conversionId,amountCents}]}. Enums on the Payout surface: method is Manual|StripeConnect|PayPal|Wise. status is Pending|Processing|Paid|Failed. ### Dashboard metrics GET /api/v1/dashboard/summary - headline KPIs for the window. Scope read. Plan Starter+. Query: since (ISO; default 30 days ago). Returns {totalClicks,totalConversions,totalEarningsCents,conversionRate,activeAffiliates,pendingPayoutsCents}. conversionRate is a percent value (e.g. 4.72 = 4.72%) rounded to 4 decimals; equals 0 when totalClicks is 0. Refunded and Denied conversions are excluded from totalConversions and totalEarningsCents. GET /api/v1/dashboard/timeseries - time-series for charting. Scope read. Plan Starter+. Query: metric (required: clicks|conversions|payouts), since, until, granularity (day; this is the only supported value today). Returns {metric,granularity,since,until,points:[{bucket:"YYYY-MM-DD",count:n}]}. For metric=payouts, count is the daily sum of amountCents (despite the name); for clicks and conversions, count is a literal count. GET /api/v1/dashboard/leaderboard - top affiliates. Scope read. Plan Starter+. Query: metric (required: clicks|conversions|earnings|epc), limit (default 10, max 50), since, programId (optional: one campaign). Returns a flat array of {affiliateId,fullName,email,value}. value is the per-affiliate conversion count (conversions), click count (clicks), home-currency commissionCents sum of Approved + Paid (earnings), or earnings-per-click in cents (epc: FX-normalized commission / clicks; only affiliates with at least one click qualify). GET /api/v1/dashboard/traffic-sources - click breakdown by referrer host. Scope read. Plan Starter+. Query: since, programId (optional: one campaign), affiliateId (optional: one affiliate). Returns a flat array of {source,count,percentage}, top 20 sources. Empty or unparseable referrers bucket as (direct). GET /api/v1/dashboard/export.csv - CSV export of timeseries. Scope read. Plan Starter+. Query: metric, since, until. Header: date,. GET /api/v1/dashboard/insights/{module} - brand analytics breakdowns (alias /api/metrics/insights/{module}). Scope read. Plan Starter+. Modules: overview, sale-origin, geography, devices, utm (dimension=source|medium|campaign), time-patterns, time-to-convert, funnel, attribution, recurring, customers. Every module accepts since, until, programId, affiliateId, and format=csv (CSV-injection guarded). Money is FX-normalized to home currency; overview also returns fxUnconvertibleCents (foreign revenue with no rate at the row date, excluded from totals). Sale/geo/device of SALES are coverage-aware (only click-attributed conversions carry a click dimension): sale-origin returns a numeric coverage fraction; geography/devices bucket unattributed sales under (unknown). customers (new vs returning, org-level) returns counts + revenueCents only, never customer identity. GET /api/me/insights/{module} - affiliate self-scoped, PII-free analytics. Modules: overview, sale-origin, geography, devices, links, time-patterns, utm (dimension=subid|source|medium|campaign), channels. Accepts affiliateId (focus one membership), since, until, format=csv. Returns only the caller's own counts/commission plus hosts, countries, device classes, link slugs, sub-ids, time buckets. Raw-USD money. A guessed affiliateId for another affiliate cannot widen scope. ### Audit log GET /api/v1/audit-log - paginated audit entries for the active org, newest first. Scope read. Plan Pro+. Query: action, entityType, actorUserId (ignored on API-key path; the field is also stripped from the response so a probe cannot enumerate), ip (ignored on API-key path same reason), since, until, page (default 1), pageSize (default 50, max 100). Returns {page,pageSize,total,items:[AuditEntryResponse]}. GET /api/v1/audit-log/export.csv - CSV export, same filters. Scope read. Plan Pro+. Capped at 10,000 rows. API-key CSV header (8 columns): id,created_at,action,entity_type,entity_id,actor_type,before,after. JWT dashboard CSV header (11 columns): id,created_at,action,entity_type,entity_id,actor_type,actor_user_id,ip,user_agent,before,after. Cells with leading =, +, -, @, tab, carriage return are single-quote-prefixed. AuditEntryResponse (API-key, redacted): {id,action,entityType,entityId,actorType,actorUserId:null,ip:null,userAgent:null,before,after,createdAt}. The before and after fields are JSON-as-string snapshots of the entity before and after the action. actorType classifies who initiated the action and is exposed to all callers because it is a category, not PII; values are user (Clerk-authed brand operator), api_key (rk_live_*), mcp (rk_mcp_* relay), system (background job), admin (super-admin portal). ### Embed (white-label iframe) POST /api/v1/embed/tokens - mint a per-affiliate iframe token. Scope read_write. Plan Growth+. Body {affiliateId,allowedOrigins:"https://yourapp.com,https://app.yourapp.com",rotate?:false,expiresInHours?:24}. allowedOrigins is a comma-separated list of full-URL origins (scheme + host + port). expiresInHours is clamped to [1, 720] (30 days). When rotate=false and a token already exists for this (program, affiliate), the response returns metadata only ({id,allowedOrigins,expiresAt,alreadyExists:true}) and no plaintext token. When rotate=true the existing token is replaced and a fresh plaintext is returned. The plaintext token is returned exactly once - Rekomi only stores SHA-256(token) and cannot recover the plaintext after the response is delivered. Response (fresh / rotated): {token,allowedOrigins,expiresAt}. POST /api/v1/embed/tokens/{id}/revoke - revoke a token. Scope read_write. Plan Growth+. Body {}. Returns {ok:true}. GET /api/embed/public/dashboard?token=... - unauthenticated iframe endpoint, gated by token + Origin header. Used directly by the embedded iframe in a browser; not callable server-to-server because the Origin header is mandatory. Returns {affiliateName,programName,earnedCents,paidCents,pendingCents,brandColor,brandName,recentConversions:[{id,amountCents,commissionCents,status,createdAt}]}. Failure codes: 400 missing token; 401 token invalid or expired (body {error:"token_expired"} on expiry); 403 with one of no_allowed_origins_configured / origin_required / origin_invalid / origin_not_allowed. ### Public storefront (unauthenticated) GET /api/p/{slug} - public program info for the affiliate-facing landing page. No auth; rate-limited per IP via anonymous-tracking. Returns {id,organizationName,name,slug,description,termsUrl,customAliasSupported}. POST /api/p/{slug}/apply - submit an affiliate application. No auth; rate-limited via strict-write. Body {email,fullName?,website?,audience?,whyInterested?,customAlias?}. Fires affiliate.created; if the program has autoApprove=true, also fires affiliate.approved. Returns {affiliateId,status:"Approved"|"Pending",affiliateLinkSlug,message} on success or {ok:true,message:"application_received"} on duplicate (indistinguishable from a fresh application to prevent enumeration). Failures: 400 validation_failed, 404 not_found, 409 with body {error:"alias_already_taken"} or {error:"program_at_capacity"}. ## Organization profile (read-only, outside /api/v1) GET /api/organizations/me reads the calling org's full profile. Scope read. Plan Growth+ for rk_live_* callers; MCP-relayed traffic bypasses the plan gate per the standard pattern. Returns {id,name,slug,billingEmail,timezone,primaryBrandColor,logoUrl,description,whiteLabelEnabled,planTier,subscriptionStatus,trialEndsAt,payoutGraceDays,payoutGraceDaysMax}. Writes on this controller (PATCH /api/organizations/me, POST /api/organizations/me/logo, PATCH /api/organizations/me/whitelabel, PATCH /api/organizations/me/payout-grace-days) remain JWT-only with Owner role. Programmatic mutation of org profile is intentionally not exposed via bearer auth. ## Billing state (read-only, outside /api/v1) GET /api/billing/state reads plan tier, subscription status, trial end, Stripe Connect linkage, and a plan-limits snapshot. Scope read. Plan Growth+ for rk_live_* callers; MCP-relayed traffic bypasses. Returns {planTier,subscriptionStatus,trialEndsAt,interval,stripeSubscriptionId,stripeCustomerLinked,stripeConnectLinked,limits:{...}}. The stripeConnectLinked boolean is the canonical signal for choosing an install path: true means conversions flow automatically via the org's Stripe Connect webhook; false means the org is on manual payouts and needs coupon codes, browser pixel, or S2S to attribute conversions. Other endpoints on this controller (GET /api/billing/invoices, GET /api/billing/payment-methods, POST /api/billing/checkout, POST /api/billing/portal, POST /api/billing/validate-promotion-code) remain JWT-only with Owner role. Stripe session creation and payment-method PII are intentionally not exposed via bearer auth. ## Affiliate invites (outside /api/v1) Brand-to-affiliate invitation flow at /api/affiliate-invites. Single-recipient invites are available on every plan including Trial; bulk invites require Starter+ with SubscriptionStatus = Active. POST /api/affiliate-invites creates one invitation. Scope read_write. Role Manager+. Email verification required for JWT callers (bypassed for api_key per UserIdentity.cs:122). Body {programId,email,fullName?,slug?,personalNoteHtml?,customCommissionType?,customCommissionValue?,autoApprove?}. The optional `slug` preserves the affiliate's existing slug from a prior platform; returns 409 slug_already_taken on collision, 400 slug_reserved on reserved-word match. The personalNoteHtml is server-sanitized via RichTextSanitizer (Ganss.Xss parser-driven). Strict tag allowlist: p, br, strong, b, em, i, u, ul, ol, li, a. The only allowed attribute is href on a-tags, https-only. Merge tags supported: {{affiliate.firstName}}, {{program.commission}}, {{brand.name}}. Returns {invite,acceptUrl} where acceptUrl is shown once. Failures: 400 validation_failed with field, 409 affiliate_already_exists, 409 pending_invites_limit_reached (limit 500). POST /api/affiliate-invites/bulk creates up to 50 invitations in one call. Accepts either `recipients[]` (preferred: per-row {email, slug?, fullName?}) or legacy `emails[]`. Recipients wins when both supplied. Response includes `slugConflicts[]` per row when a requested slug was rejected (taken, reserved, charset-invalid, or duplicated within batch); those rows still get an invite with a fresh random slug. ## Install snippet renderer GET /api/v1/r/install-snippet renders the canonical install