Sub-affiliate recruiting API
Endpoints, override conversion shape, webhook payloads, and apply-form semantics for the bounded 2-tier sub-affiliate model.
This is the developer-side surface for sub-affiliate recruiting: the brand-side concept page covers the "what" and "why"; this page covers the wire shapes.
There are five endpoints, one new conversion-row shape, one webhook variation, and two apply-form fields. Everything is bounded to one recruiting level, hard-capped; there is no chain propagation.
Endpoints
All five are mounted under /api/v1/*. The three brand-side endpoints accept the standard Authorization: Bearer rk_live_* API key auth, plus the same JWT path for the in-product dashboard; the two /me/sub-affiliate/* endpoints are affiliate-side and accept the affiliate JWT only, never an API key.
GET /api/v1/programs/{id}/sub-affiliate-config
Scope: read. Returns the current sub-affiliate config for a campaign.
curl -sS https://api.rekomi.com/api/v1/programs/0789abcd-.../sub-affiliate-config \
-H "Authorization: Bearer $REKOMI_API_KEY"Response:
{
"enabled": true,
"percent": 10.0
}enabled: whether the recruiting feature is on for this campaign.percent: override percent on parent commission. Range 0 to 100. Whenenabledis false, this field is still returned (it just doesn't fire on new conversions).
PUT /api/v1/programs/{id}/sub-affiliate-config
Scope: read_write. Role: Owner (on JWT). Plan: included on every plan. Email verification required.
Audit-logged as program.sub_affiliate_config_changed.
curl -sS -X PUT https://api.rekomi.com/api/v1/programs/0789abcd-.../sub-affiliate-config \
-H "Authorization: Bearer $REKOMI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"percent": 10
}'Response (200):
{
"enabled": true,
"percent": 10.0
}Failures:
| HTTP | error | Cause |
|---|---|---|
| 400 | body_required | Missing or empty JSON body |
| 400 | unsupported_value | Percent outside [0, 100] or not a number |
| 403 | email_verification_required | Dashboard caller has not verified their email |
| 403 | (empty) | Caller is not Owner |
| 404 | (empty) | Program not in your org |
GET /api/v1/me/sub-affiliate/link?programId={id}
Scope: read. Affiliate-side. Returns the calling affiliate's personal recruitment link for a campaign. Auth is the affiliate's /a JWT, not a brand API key.
curl -sS "https://api.rekomi.com/api/v1/me/sub-affiliate/link?programId=0789abcd-..." \
-H "Authorization: Bearer $AFFILIATE_JWT"Response:
{
"affiliateId": "01234567-...",
"programId": "0789abcd-...",
"programSlug": "default",
"url": "https://your-brand.example.com/default?recruited_by=v6media"
}The recruited_by token is slug-first: it is the affiliate's account-level ?via= slug when they have one (v6media above), otherwise their per-campaign link slug, and only the raw affiliate id GUID when neither slug exists.
Failures:
| HTTP | error | Cause |
|---|---|---|
| 403 | email_verification_required | Calling affiliate has not verified their email (checked before everything else) |
| 400 | program_id_required | Missing programId query param |
| 400 | sub_affiliate_recruiting_disabled | Campaign has enabled = false |
| 403 | (empty) | Calling affiliate is not Approved on the campaign |
| 404 | (empty) | Campaign not visible to the calling affiliate (not a member) |
| 409 | portal_not_configured | The brand has no public portal host to build the URL on |
GET /api/v1/me/sub-affiliate/recruits
Scope: read. Affiliate-side. Lists the calling affiliate's downline. Cursor-paginated.
curl -sS "https://api.rekomi.com/api/v1/me/sub-affiliate/recruits?take=50" \
-H "Authorization: Bearer $AFFILIATE_JWT"Response:
{
"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
}totalCommissionGeneratedCents: this recruit's own commission income (excludes override rows so it never double-counts when the recruit later recruits someone else).overrideEarnedCents: total override the calling affiliate has earned from this recruit, incurrency.- Pending applicants and rejected applications do not appear in the list; a recruit shows up once the brand approves them (recruits later paused or banned stay listed with their history). Only Approved recruits accrue override income.
GET /api/v1/affiliates/{id}/sub-affiliates
Scope: read. Brand-side drilldown: lets a brand operator inspect any affiliate's downline. Returns a flat array (no pagination wrapper) with slightly different field names than the affiliate-side endpoint.
curl -sS "https://api.rekomi.com/api/v1/affiliates/01234567-.../sub-affiliates" \
-H "Authorization: Bearer $REKOMI_API_KEY"[
{
"id": "fed00b00-...",
"email": "recruit@example.com",
"fullName": "Recruited Person",
"status": "Approved",
"totalCommissionCents": 12400,
"overrideEarnedCents": 1240,
"currency": "USD"
}
]Override conversion row
When affiliate A recruits affiliate B, and B closes a sale, two Conversion rows are written: the parent commission row first, then the override row in a separate idempotent write. If the override write ever fails, it is backfilled automatically the next time the same sale event is delivered, so no override is lost.
- Parent commission row for B. Looks like a normal conversion.
AmountCentsis the sale,CommissionCentsis B's commission,AttributionMethodis whatever path attributed the sale (for examplecouponormetadata_or_customer).ParentAffiliateConversionIdisnull. - Override row for A.
AmountCents = 0,CommissionCents = (B.commission × Program.SubAffiliateCommissionPercent / 100), rounded to the nearest minor unit with banker's rounding (round half to even,MidpointRounding.ToEven).AttributionMethod = "sub_affiliate_override".ParentAffiliateConversionIdpoints at the parent row's id.
Example conversion list query showing both rows:
curl -sS "https://api.rekomi.com/api/v1/conversions?programId=0789abcd-...&take=50" \
-H "Authorization: Bearer $REKOMI_API_KEY"Response (relevant fields only):
{
"items": [
{
"id": "c0nv0parent-...",
"affiliateId": "01234567-B-...",
"programId": "0789abcd-...",
"amountCents": 10000,
"currency": "USD",
"commissionCents": 2000,
"attributionMethod": "coupon",
"parentAffiliateConversionId": null,
"status": "Approved"
},
{
"id": "c0nv0override-...",
"affiliateId": "01234567-A-...",
"programId": "0789abcd-...",
"amountCents": 0,
"currency": "USD",
"commissionCents": 200,
"attributionMethod": "sub_affiliate_override",
"parentAffiliateConversionId": "c0nv0parent-...",
"status": "Approved"
}
],
"nextCursor": null,
"hasMore": false
}Notes:
- Override rows always have
amountCents = 0. The sale amount lives on the parent row; duplicating it on the override would double-count revenue on dashboards. - Override rows always have
commissionCents > 0. If the computed override rounds to zero (very small parent commission × very small percent), the row is not created. parentAffiliateConversionIdis a foreign key to the sameConversiontable. Use it to join override rows back to the sale that generated them.
Refund cascade
Whenever the parent conversion is refunded (partially or fully), through any refund path (payment-processor webhooks, the refund tracking endpoint, or the S2S refund endpoint), Rekomi computes the proportional fraction of the parent commission that was clawed back and applies the same fraction to the override.
- Full refund of the parent: override is fully reversed.
- Partial refund (e.g., 40% of the sale amount): both the parent commission and the override are reduced by 40%.
Both rows fire conversion.refunded webhook events. Consumers tracking lifetime earnings should subtract the commissionReversedCents from their running totals on both rows.
Deny and invalidate cascade
Status changes on the parent conversion cascade to its unpaid override the same way refunds do. The override is only ever adjusted while it is still unpaid; once an override has been paid out it is left untouched.
- Deny. Denying a parent conversion also denies its unpaid override, so a rejected sale never leaves recruiter income behind.
- Invalidate. Fraud-quarantining a parent conversion quarantines its unpaid override as well.
- Validate. Restoring a previously invalidated parent restores the override at its original amount.
Webhook payloads
conversion.created (override row)
Same envelope as the normal conversion.created event. The body distinguishes override rows via attributionMethod:
{
"id": "evt_9f3c1a7b2e4d6f80a1b3c5d7e9f0a2b4",
"type": "conversion.created",
"createdAt": "2026-05-12T03:14:25.000Z",
"organizationId": "b7e2c9a4-3f1d-4e8a-9c2b-6d5f0a1e3c7b",
"data": {
"conversionId": "c0nv0override-...",
"affiliateId": "01234567-A-...",
"programId": "0789abcd-...",
"amountCents": 0,
"currency": "USD",
"commissionCents": 200,
"status": "Pending",
"attributionMethod": "sub_affiliate_override",
"parentAffiliateConversionId": "c0nv0parent-...",
"externalEventId": "in_1abc23DEF456:override:3f9c2a1b8e4d6f70a1b3c5d7e9f0a2b4",
"type": "OneTime"
}
}The override row's externalEventId is the parent sale's external event id with an override suffix that also embeds the recruiter's id as 32 hex characters (:override:<32-hex-recruiter-id>); very long parent ids are replaced by a hash prefix. It is stable across redeliveries of the same sale event, so consumers can use it as an idempotency key when recording override income.
conversion.refunded (override row)
Emitted on every cascade iteration where the override's RefundedAmountCents was incremented. refundedAmountCents and commissionReversedCents are the THIS-event clawback; cumulativeRefundedAmountCents is the lifetime total on the row; isFullRefund is true only on the iteration that crossed the override into fully-refunded state. Carries the parent linkage + attribution-method so consumers can route override-row events separately.
{
"id": "evt_2b4d6f80a1b3c5d7e9f0a2b41c3e5d70",
"type": "conversion.refunded",
"createdAt": "2026-05-15T11:00:00Z",
"organizationId": "b7e2c9a4-3f1d-4e8a-9c2b-6d5f0a1e3c7b",
"data": {
"conversionId": "c0nv0override-...",
"affiliateId": "01234567-A-...",
"programId": "0789abcd-...",
"refundedAmountCents": 200,
"cumulativeRefundedAmountCents": 200,
"commissionReversedCents": 200,
"currency": "USD",
"isFullRefund": true,
"refundedAt": "2026-05-15T11:00:00Z",
"parentAffiliateConversionId": "c0nv0parent-...",
"attributionMethod": "sub_affiliate_override"
}
}Warning for webhook consumers
If your handler filters conversion.created events by amountCents > 0 to drop "zero-revenue" events, you will silently lose all override events. Override rows are real commission obligations with commissionCents > 0 but always amountCents = 0.
Better filters:
- Filter on
status(e.g., only act onApprovedorPaid). - Filter on
commissionCents > 0if you want anything that owes the affiliate money. - Match on
attributionMethodif you want to route override rows to a different downstream pipeline (e.g., a separate accounting bucket for recruiter income vs. direct-sale commission).
Apply form: recruitedBy and recruitedByAffiliateId
The public apply endpoint (POST /api/p/id/{programId}/apply) optionally accepts a recruiter reference that establishes the one-recruiting-level link on the new affiliate row. Two fields exist:
recruitedBy(string): the current field. Accepts the recruiter's affiliate id as a GUID string, their per-campaign link slug, or their account-level?via=slug.recruitedByAffiliateId(GUID): the legacy field. Still accepted for older integrations.
curl -sS https://api.rekomi.com/api/p/id/0789abcd-.../apply \
-H "Content-Type: application/json" \
-d '{
"email": "newaffiliate@example.com",
"fullName": "New Affiliate",
"recruitedBy": "v6media"
}'Rules:
- The reference must resolve to an
Approvedaffiliate in the same campaign being applied to. Unresolvable values and mismatches are silently dropped (the application succeeds, butRecruitedByAffiliateIdstays null). - Self-recruit attempts are silently dropped, not rejected: if the applicant is the same Clerk user as the recruiter, or applies with the same normalized email, the application still succeeds without the recruiter link.
- If the campaign has
enabled = false, recruiter binding is skipped entirely: the application succeeds but no recruiter is stored. - The fields are not discoverable from the public apply schema for SEO reasons; only callers who have a recruiter reference (typically from a tagged URL) should pass them.
In practice you don't construct this by hand. The recruitment URL (returned by GET /api/v1/me/sub-affiliate/link) carries recruited_by={token} as a query param, where the token is the recruiter's account-level ?via= slug when they have one, else their per-campaign link slug, else their affiliate id GUID. The public apply landing page reads that param and forwards it as recruitedBy in the form submission.
MCP tools
For AI-agent integrations, three tools wrap the brand-side endpoints:
| Tool | Maps to |
|---|---|
get_program_sub_affiliate_config | GET /api/v1/programs/{id}/sub-affiliate-config |
set_program_sub_affiliate_config | PUT /api/v1/programs/{id}/sub-affiliate-config |
list_affiliate_sub_affiliates | GET /api/v1/affiliates/{id}/sub-affiliates |
All three accept either rk_live_* (direct API key) or rk_mcp_* (relayed via mcp.rekomi.com). The affiliate-side /me/sub-affiliate/* endpoints are Clerk-JWT-only and intentionally not exposed via MCP (the relay carries brand-org identity, not per-affiliate identity). See MCP for the auth model.
See also
- Sub-affiliate recruiting (brands): concept, math, refund cascade, FAQ.
- Conversion currency: override rows carry the same
currencyfield; the override commission is denominated in the parent sale's currency. - Webhooks: event envelope, signature verification, retry semantics.
- API reference: terse endpoint signatures alongside the rest of
/api/v1/*.