Supercommerce API Docs
Store API

Affiliate Module — Storefront

HTTP surface for the customer-facing affiliate plugin. Customers apply to join, generate trackable referral links, see their commission balance + payout history, and (anonymous…

HTTP surface for the customer-facing affiliate plugin. Customers apply to join, generate trackable referral links, see their commission balance + payout history, and (anonymous visitors) hit /r/:code to be redirected to the storefront while we record the click and set an attribution cookie. The plugin is optional — removing AffiliateModule.forRoot() from the API's app.module.ts unregisters every route below and silences the order-placed listener; cart/order flows continue to work unchanged.

Source:

  • api-modules/affiliate/src/controllers/store-affiliate-redirect.controller.ts (the public /r/:code redirect)
  • api-modules/affiliate/src/services/attribution.service.ts + affiliate-target-resolver.service.ts (redirect resolution order)
  • api-modules/affiliate/src/controllers/store-affiliate.controller.ts (config, dashboard + payout-details)
  • api-modules/affiliate/src/controllers/store-affiliate-application.controller.ts (apply + status)
  • api-modules/affiliate/src/controllers/store-affiliate-link.controller.ts (link CRUD)
  • api-modules/affiliate/src/controllers/store-affiliate-commission.controller.ts (own commission ledger)
  • api-modules/affiliate/src/controllers/store-affiliate-payout.controller.ts (balance + history)

Conventions

Authentication

Endpoint groupAuth
GET /r/:codeOptionalAuth — anonymous allowed, customer session captured on the click row when present
POST /store/affiliate/applicationsrequired (customer session)
GET /store/affiliate/applications/merequired
GET /store/affiliate/configrequired
GET /store/affiliate/merequired (404 when not an approved affiliate)
GET/POST/DELETE /store/affiliate/links*required (404 when not an approved affiliate)
GET /store/affiliate/commissionsrequired (404 when not an approved affiliate)
PATCH /store/affiliate/payout-detailsrequired
GET /store/affiliate/payout-detailsrequired (404 when not an approved affiliate)
GET /store/affiliate/balancerequired
GET /store/affiliate/payoutsrequired

The /r/:code redirect is public on purpose — affiliates share these URLs from social posts and the visitor may not have a session yet. When an authenticated session IS present, customer_id is captured on the click row so the order-placed attribution lookup can find it later.

Response envelope

{
  "data": <payload>,
  "message": "Success",
  "statusCode": 200,
  "metadata": { /* on paginated lists */ }
}

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
404NOT_FOUND (no affiliate row; unknown/soft-deleted link code; affiliate suspended)
409CONFLICT (existing PENDING application; customer is already an affiliate; suspended affiliate operations)
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Currency + money

FieldUnit
*Subunits (any field ending …Subunits)integer subunits (paise / cents)
commissionRateBps / commissionValue (on PERCENTAGE rows)basis points, 0–10000
commissionValue (on FIXED rows)integer subunits per qty
createdAt, updatedAt, paidAt, etc.ISO 8601 strings

The conversion bps × base / 10000 = subunits is the rate math. Storefront only ever sees the precomputed amounts.

/r/:code sets a signed sc_aff cookie:

AttributeValue
Namesc_aff
Value<linkCode>.<expiresAtEpochSeconds> wrapped by @fastify/cookie's HMAC signature
HttpOnlytrue
SameSiteLax
Securetrue in production
Max-Ageadmin.affiliate.cookie_duration_days × 86400 (default 30 days)
Domainenv AFFILIATE_COOKIE_DOMAIN if set, else host-bound

The cookie is now an active fallback attribution source, not just informational. At order placement, CommissionService.processOrderPlaced resolves attribution in two steps:

  1. Click historyresolveAttributionForCustomer(customerId) looks up the most recent affiliate_link_click row tied to a logged-in click for this customer.
  2. Cart-cookie fallback — only when step 1 finds nothing, resolveAttribution({ cartAffiliateLinkCode }) re-validates the link code carried on cart.metadata.affiliateLinkCode.

The cart-side code is captured by @sc/cart's CartService.captureAffiliateAttribution, which decodes the sc_aff cookie on every cart-touching request (cart fetch, line add/update, coupon, redemption, gift, sync, address endpoints) and writes it onto the cart's metadata.affiliateLinkCode — last-touch, overwritten whenever a newer valid cookie shows up. It also survives a guest→customer cart merge (CartMergeService.sync()): the guest cart's code wins unless the customer's cart already carries a strictly newer one.

Click history takes precedence over the cart-cookie fallback whenever both exist — the fallback only fires when there's no logged-in click to attribute to. This closes the common gap of "clicked the link anonymously, logged in and bought later in the same browser" within one cart lifecycle.

Residual limitations:

  • Guest checkout (no customerId on the order) still isn't attributed — both attribution paths require a customer id.
  • A different browser or cleared cookies still loses attribution, same as before — the cookie is the only carrier for the cart-side fallback.

Production note: AFFILIATE_COOKIE_DOMAIN must be set to a shared root domain (e.g. .yourdomain.com) that covers both the storefront and API hosts. If it's left unset, the cookie set by the storefront-origin /r/:code redirect is host-bound and never reaches the API host where cart endpoints run — the fallback then silently never fires.


Domain types

ApplicationResponse

type ApplicationResponse = {
  id: string;
  customerId: string;
  status: "PENDING" | "APPROVED" | "REJECTED";
  websiteUrl: string | null;
  instagramUrl: string;
  additionalInfo: string | null;
  rejectedReason: string | null;
  reviewedBy: string | null;
  reviewer: { id: string; name: string; email: string } | null; // always null on the store application endpoint (reviewer PII withheld)
  reviewedAt: string | null;       // ISO 8601
  createdAt: string;
  updatedAt: string;
  platforms: Array<{
    platform: "INSTAGRAM" | "YOUTUBE" | "TIKTOK" | "FACEBOOK"
      | "X_TWITTER" | "BLOG" | "NEWSLETTER" | "PODCAST" | "OTHER";
    detailsText: string | null;
  }>;
  socialLinks: Array<{ url: string }>;
};

DashboardResponse

type DashboardResponse = {
  id: string;
  customerId: string;
  code: string;                       // 8-char generic referral code
  promotedLandingUrl: string | null;  // where /r/:code redirects when set
  suspendedAt: string | null;
  suspendReason: string | null;
  lifetimeClicks: number;
  lifetimeOrders: number;
  lifetimeRevenueSubunits: number;
  lifetimeCommissionSubunits: number;
  createdAt: string;
};

LinkResponse

type LinkResponse = {
  id: string;
  affiliateId: string;
  linkType: "GENERIC" | "PRODUCT" | "BRAND" | "VENDOR" | "CATEGORY" | "TAG";
  targetId: string | null;            // null iff linkType === "GENERIC"
  code: string;                       // 8-char URL-safe
  title: string | null;
  shareUrl: string;                   // "/r/<code>"pre-computed for the FE
  lifetimeClicks: number;
  lifetimeOrders: number;
  lifetimeRevenueSubunits: number;
  lifetimeCommissionSubunits: number;
  createdAt: string;
  updatedAt: string;
};

CommissionResponse

type CommissionResponse = {
  id: string;
  orderId: string;
  orderNumber: string;
  linkId: string;
  linkCode: string;
  linkTitle: string | null;
  productName: string;
  variantName: string | null;
  sku: string;
  quantity: number;
  commissionSource: string;           // e.g. "GLOBAL"which rate tier fired
  commissionType: "PERCENTAGE" | "FIXED";
  commissionRateBps: number | null;   // set when commissionType === "PERCENTAGE"
  commissionValueSubunits: number | null; // set when commissionType === "FIXED"
  baseAmountSubunits: number;
  commissionAmountSubunits: number;
  status: "PENDING" | "APPROVED" | "REJECTED" | "PAID";
  approvedAt: string | null;
  rejectedAt: string | null;
  rejectedReason: string | null;      // set when status === "REJECTED"
  paidAt: string | null;
  createdAt: string;
};

PayoutResponse

type PayoutResponse = {
  id: string;
  affiliateId: string;
  status: "DRAFT" | "PROCESSING" | "PAID" | "FAILED";
  method: "UPI" | "BANK";
  grossSubunits: number;
  tdsSubunits: number;
  netSubunits: number;                // grossSubunits = tdsSubunits + netSubunits (schema CHECK)
  externalReference: string | null;   // UTR / cheque / wire idset when PAID
  paidAt: string | null;
  createdAt: string;
  updatedAt: string;
};

PayoutDetailsResponse

type PayoutDetailsResponse = {
  payoutMethod: "UPI" | "BANK" | null;
  upiId: string | null;
  bankAccountName: string | null;
  bankAccountNumber: string | null;   // masked to the last 4 characters
  bankIfsc: string | null;
  panNumber: string | null;
  gstin: string | null;
};

Endpoints

Redirect — public

Public, unauthenticated. Records a click row and 302s to a destination resolved in this order:

  1. Typed links (linkType other than GENERIC) — the target's own storefront page: the targetId is resolved to its slug and built into a URL from the matching storefront_urls path template (product_path, brand_path, vendor_path, category_path, product_tag_path). A CATEGORY link goes to that category page, not the affiliate's generic landing page.
  2. The affiliate's promotedLandingUrl, when set (GENERIC links, or a typed link whose target no longer resolves — e.g. soft-deleted since the link was created).
  3. The configured storefront base URL (store settings → storefront_urls.store_url, else seo.canonical_base_url).
  4. A bare /, as an absolute last resort on an unconfigured deployment — never used as the primary fallback, since this route is served directly by the API host and a relative / would otherwise 302 to the API's own root.

When an authenticated session is present, customer_id is captured on the click row.

Path params

NameNotes
codeAffiliate-link code (4–24 chars URL-safe). Both per-affiliate referral codes and per-link generated codes share the namespace.

Query (all optional, copied verbatim onto affiliate_link_click for analytics)

NameNotes
utm_source
utm_medium
utm_campaign
utm_term
utm_content

Response 302 — empty body, Location: header per the resolution order above.

Sets cookie sc_aff per the cookie spec above.

Response 404{ "error": "Link not found" } when:

  • the code does not exist
  • the link has been soft-deleted
  • the affiliate is suspended
  • the plugin's admin.affiliate.enabled setting is false

No click row is written in any 404 case (suspended affiliates do not earn analytics credit).


Application flow

POST /store/affiliate/applications — Submit a new application

Body

{
  "instagramUrl": "https://instagram.com/<handle>",  // required, URL, max 2000
  "websiteUrl": "https://example.com",                // optional, URL, max 2000
  "additionalInfo": "I have a beauty blog with 50k monthly readers.",
  "platforms": [                                      // 1..10 entries
    { "platform": "INSTAGRAM", "detailsText": "100k followers" }
  ],
  "socialLinks": [                                    // 0..10 entries
    { "url": "https://twitter.com/<handle>" }
  ],
  "termsAccepted": true                               // must literal true
}

Response 201ApplicationResponse. Wrapped in the response envelope.

When admin.affiliate.auto_approve_applications is true the application is immediately transitioned to APPROVED and the response's status reflects that; the affiliate row is also created in the same transaction.

Errors

StatusCodeWhen
400VALIDATION_ERRORMissing required fields, bad URLs, terms not accepted, > 10 platforms/links, etc.
400BAD_REQUESTadmin.affiliate.enabled is false
409CONFLICTCustomer already has a PENDING application
409CONFLICTCustomer is already an approved affiliate

GET /store/affiliate/applications/me — Latest application for the current customer

Response 200ApplicationResponse of the latest row (any status). When a customer reapplies after rejection, a fresh row is inserted; this endpoint always returns the most recent.

Response 404NOT_FOUND when the customer has never applied.


Config

Lets the storefront decide which create-link tabs to render without hardcoding the full link-type list.

Response 200

{
  "enabled": true,
  "allowedLinkTypes": ["GENERIC", "PRODUCT", "BRAND", "VENDOR", "CATEGORY", "TAG"]
}

allowedLinkTypes mirrors admin.affiliate.allowed_link_types — restrict it in admin settings to hide the corresponding tabs (a direct POST /store/affiliate/links for a hidden type still 400s server-side).


Dashboard

GET /store/affiliate/me — Affiliate profile + lifetime stats

Drives the affiliate's dashboard home. The four lifetime aggregates are denormalized on the affiliate row and bumped at order placement (and reversed on cancel/return refund); reads are O(1).

Response 200DashboardResponse.

Response 404NOT_FOUND when the customer is not an approved affiliate. The error body suggests applying via POST /store/affiliate/applications.


Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
linkTypeGENERIC | PRODUCT | BRAND | VENDOR | CATEGORY | TAGoptional filter

Response 200 — paginated LinkResponse[] with metadata: { total, limit, offset, hasMore }. Newest first. Excludes soft-deleted links.

POST /store/affiliate/links — Generate a new link

Body

{
  "linkType": "GENERIC" | "PRODUCT" | "BRAND" | "VENDOR" | "CATEGORY" | "TAG",
  "targetId": "<id of the target entity>",   // required for typed links; omit/null for GENERIC
  "title": "Optional internal label"
}

For typed links, targetId must reference an existing row in product / brand / vendor / product_category / tag respectively. The service generates a unique 8-char URL-safe code (alphabet excludes ambiguous 0/O/1/I/l); retries up to 5× on collision. Writes a LINK_CREATE audit row against the affiliate and emits affiliate.link.created after commit (also used by the auto-approve flow, which provisions a default GENERIC link with actorId: null).

Response 201LinkResponse.

Errors

StatusCodeWhen
400BAD_REQUESTlinkType is not in admin.affiliate.allowed_link_types
400BAD_REQUESTGENERIC link with non-null targetId, or typed link with null targetId
404NOT_FOUNDtargetId doesn't reference an existing row of the matching kind
404NOT_FOUNDCaller is not an approved affiliate
500INTERNAL_SERVER_ERRORUnable to generate a unique code after 5 attempts (statistically impossible — flag if seen)

Stamps deleted_at. The partial UNIQUE index on affiliate_link.code WHERE deleted_at IS NULL releases the code for regeneration. Writes a LINK_DELETE audit row and emits affiliate.link.deleted after commit.

Response 204 — empty body.

Errors

StatusCodeWhen
404NOT_FOUNDLink doesn't exist or is already soft-deleted
409CONFLICTLink belongs to a different affiliate

Commission ledger

GET /store/affiliate/commissions — My commission ledger

One row per commissioned order line — the individual detail behind the lifetimeCommissionSubunits aggregate on GET /store/affiliate/me. Always scoped to the caller's own affiliate id; the query cannot reach another affiliate's rows.

Approval is fully automatic — there is no manual approve/reject action, for the affiliate or for admins. A PENDING row becomes APPROVED once its order is delivered and (when admin.affiliate.commission_approval_after_return_window is on, the default) the return window has closed — a daily sweep checks this, not a button anyone clicks. A row becomes REJECTED automatically if the order/sub-order is cancelled or the item is returned/refunded; rejectedReason explains which. APPROVED rows become PAID when bundled into a payout batch (see GET /store/affiliate/payouts).

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
statusPENDING | APPROVED | REJECTED | PAIDoptional filter
from / toISO datetimeoptional createdAt window

Response 200 — paginated CommissionResponse[] with metadata: { total, limit, offset, hasMore }. Newest first.

Response 404NOT_FOUND when the customer is not an approved affiliate.


Payout self-service

PATCH /store/affiliate/payout-details — Update UPI / bank / KYC fields

Body

{
  "payoutMethod": "UPI" | "BANK",
  "upiId": "name@upi",            // required when payoutMethod === "UPI"
  "bankAccountName": "Name",
  "bankAccountNumber": "...",     // required when payoutMethod === "BANK"
  "bankIfsc": "HDFC0001234",      // required when payoutMethod === "BANK"
  "panNumber": "ABCDE1234F",      // optional; impacts TDS — see admin docs
  "gstin": "..."                  // optional
}

Validates the payout-method ↔ details pairing inline. The admin payout flow re-validates before creating a batch. This is a partial update — fields the caller omits are left unchanged; only fields explicitly sent (including an explicit null) are written. Use GET /store/affiliate/payout-details first to prefill the form instead of resubmitting the full set on every save.

Response 200{ "ok": true }.

GET /store/affiliate/payout-details — Saved payout details

Returns the affiliate's currently saved UPI / bank / PAN / GSTIN so the settings form can prefill instead of blind-overwriting on the next PATCH.

Response 200PayoutDetailsResponse. bankAccountNumber is masked to its last 4 characters (e.g. ••••••1234).

Response 404NOT_FOUND when caller is not an approved affiliate.

GET /store/affiliate/balance — Current APPROVED balance

The payable balance — sum of affiliate_commission rows in APPROVED for this affiliate. Returns 0 when nothing is owed.

Response 200

{
  "data": { "approvedBalanceSubunits": 17325 },
  "message": "Success",
  "statusCode": 200
}

Response 404NOT_FOUND when caller is not an approved affiliate.

GET /store/affiliate/payouts — Payout history

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
statusDRAFT | PROCESSING | PAID | FAILEDoptional filter

Response 200 — paginated PayoutResponse[] with metadata. Newest first.

Response 404NOT_FOUND when caller is not an approved affiliate.


Suspension semantics

When an admin suspends an affiliate (see admin docs):

  • /r/:code returns 404 — no redirect, no cookie set, no click row written.
  • Storefront endpoints still return data — the affiliate can still see their dashboard, links list, balance, and payout history. The DashboardResponse.suspendedAt and suspendReason are populated so the UI can render a banner.
  • Existing PENDING commissions do NOT promote to APPROVED during the daily sweep while suspended.
  • Existing APPROVED commissions are not clawed back — they remain in the balance but the affiliate cannot be included in a payout batch until resumed.

The intent is "freeze the relationship" rather than "burn the relationship" — resume is a one-click admin action.


Plugin runtime kill switch

Setting admin.affiliate.enabled = false collapses every store route to a hard 4xx or no-op:

EndpointBehavior when enabled: false
GET /r/:code404, no click row
POST /store/affiliate/applications400 with "Affiliate program is currently disabled"
All other readsUnchanged — affiliates can still read their own state

Removing AffiliateModule.forRoot() from apps/api/src/app.module.ts is the bigger hammer: the routes disappear entirely (Nest doesn't register them) and the @Optional() AFFILIATE_ATTRIBUTION_PORT consumers fall through to no-op.

On this page