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/controllers/store-affiliate.controller.ts (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-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/merequired (404 when not an approved affiliate)
GET/POST/DELETE /store/affiliate/links*required (404 when not an approved affiliate)
PATCH /store/affiliate/payout-detailsrequired
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 informational only — server-side attribution at order placement uses the most recent affiliate_link_click row matching customer_id within the cookie window. Anonymous clicks (no session at click time) currently do not bind to the customer at register/login; that's a known limitation.


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;
  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;
};

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;
};

Endpoints

Redirect — public

Public, unauthenticated. Records a click row and 302s to the affiliate's promotedLandingUrl (or /). 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 to affiliate.promoted_landing_url (when set) or /.

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.


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.

Response 201LinkResponse.

Errors

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

Response 204 — empty body.

Errors

StatusCodeWhen
404NOT_FOUNDLink doesn't exist or is already soft-deleted
409CONFLICTLink belongs to a different 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.

Response 200{ "ok": true }.

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