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/:coderedirect)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 group | Auth |
|---|---|
GET /r/:code | OptionalAuth — anonymous allowed, customer session captured on the click row when present |
POST /store/affiliate/applications | required (customer session) |
GET /store/affiliate/applications/me | required |
GET /store/affiliate/config | required |
GET /store/affiliate/me | required (404 when not an approved affiliate) |
GET/POST/DELETE /store/affiliate/links* | required (404 when not an approved affiliate) |
GET /store/affiliate/commissions | required (404 when not an approved affiliate) |
PATCH /store/affiliate/payout-details | required |
GET /store/affiliate/payout-details | required (404 when not an approved affiliate) |
GET /store/affiliate/balance | required |
GET /store/affiliate/payouts | required |
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
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 404 | NOT_FOUND (no affiliate row; unknown/soft-deleted link code; affiliate suspended) |
| 409 | CONFLICT (existing PENDING application; customer is already an affiliate; suspended affiliate operations) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Currency + money
| Field | Unit |
|---|---|
*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.
Cookie
/r/:code sets a signed sc_aff cookie:
| Attribute | Value |
|---|---|
| Name | sc_aff |
| Value | <linkCode>.<expiresAtEpochSeconds> wrapped by @fastify/cookie's HMAC signature |
HttpOnly | true |
SameSite | Lax |
Secure | true in production |
Max-Age | admin.affiliate.cookie_duration_days × 86400 (default 30 days) |
| Domain | env 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:
- Click history —
resolveAttributionForCustomer(customerId)looks up the most recentaffiliate_link_clickrow tied to a logged-in click for this customer. - Cart-cookie fallback — only when step 1 finds nothing,
resolveAttribution({ cartAffiliateLinkCode })re-validates the link code carried oncart.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
customerIdon 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 id — set 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
GET /r/:code — Affiliate-link redirect
Public, unauthenticated. Records a click row and 302s to a destination resolved in this order:
- Typed links (
linkTypeother thanGENERIC) — the target's own storefront page: thetargetIdis resolved to itsslugand built into a URL from the matchingstorefront_urlspath 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. - 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). - The configured storefront base URL (store settings →
storefront_urls.store_url, elseseo.canonical_base_url). - 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
| Name | Notes |
|---|---|
code | Affiliate-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)
| Name | Notes |
|---|---|
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.enabledsetting 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 201 — ApplicationResponse. 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing required fields, bad URLs, terms not accepted, > 10 platforms/links, etc. |
| 400 | BAD_REQUEST | admin.affiliate.enabled is false |
| 409 | CONFLICT | Customer already has a PENDING application |
| 409 | CONFLICT | Customer is already an approved affiliate |
GET /store/affiliate/applications/me — Latest application for the current customer
Response 200 — ApplicationResponse 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 404 — NOT_FOUND when the customer has never applied.
Config
GET /store/affiliate/config — Plugin config for the create-link UI
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 200 — DashboardResponse.
Response 404 — NOT_FOUND when the customer is not an approved affiliate. The error body suggests applying via POST /store/affiliate/applications.
Link management
GET /store/affiliate/links — List my links
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
linkType | GENERIC | PRODUCT | BRAND | VENDOR | CATEGORY | TAG | — | optional 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 201 — LinkResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | linkType is not in admin.affiliate.allowed_link_types |
| 400 | BAD_REQUEST | GENERIC link with non-null targetId, or typed link with null targetId |
| 404 | NOT_FOUND | targetId doesn't reference an existing row of the matching kind |
| 404 | NOT_FOUND | Caller is not an approved affiliate |
| 500 | INTERNAL_SERVER_ERROR | Unable to generate a unique code after 5 attempts (statistically impossible — flag if seen) |
DELETE /store/affiliate/links/:id — Soft-delete one of my links
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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Link doesn't exist or is already soft-deleted |
| 409 | CONFLICT | Link 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
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
status | PENDING | APPROVED | REJECTED | PAID | — | optional filter |
from / to | ISO datetime | — | optional createdAt window |
Response 200 — paginated CommissionResponse[] with metadata: { total, limit, offset, hasMore }. Newest first.
Response 404 — NOT_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 200 — PayoutDetailsResponse. bankAccountNumber is masked to its last 4 characters (e.g. ••••••1234).
Response 404 — NOT_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 404 — NOT_FOUND when caller is not an approved affiliate.
GET /store/affiliate/payouts — Payout history
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
status | DRAFT | PROCESSING | PAID | FAILED | — | optional filter |
Response 200 — paginated PayoutResponse[] with metadata. Newest first.
Response 404 — NOT_FOUND when caller is not an approved affiliate.
Suspension semantics
When an admin suspends an affiliate (see admin docs):
/r/:codereturns 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.suspendedAtandsuspendReasonare 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:
| Endpoint | Behavior when enabled: false |
|---|---|
GET /r/:code | 404, no click row |
POST /store/affiliate/applications | 400 with "Affiliate program is currently disabled" |
| All other reads | Unchanged — 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.
Auth — Storefront Registration
Customer sign-up for the storefront: creates an account and returns an authenticated session in one step. Phone number (E.164) is required.
Back in Stock Module — Storefront
HTTP surface for back-in-stock ("notify me") requests on a sold-out variant. Signed-in shoppers and — when the operator allows it — guests with just an email address can ask to be told when…