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/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 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/me | required (404 when not an approved affiliate) |
GET/POST/DELETE /store/affiliate/links* | required (404 when not an approved affiliate) |
PATCH /store/affiliate/payout-details | required |
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 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 id — set when PAID
paidAt: string | null;
createdAt: string;
updatedAt: string;
};Endpoints
Redirect — public
GET /r/:code — Affiliate-link redirect
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
| 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 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.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.
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.
Response 201 — LinkResponse.
Errors
| Status | Code | When |
|---|---|---|
| 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.
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 |
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 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.
Vendor Module
HTTP surface for vendor onboarding (a user applies to become a vendor; an admin reviews + approves/rejects) and vendor directory reads (admin lists all approved vendors with team…
Banner Module — Storefront
HTTP surface for storefront banner reads. The customer-facing app fetches active, platform-targeted promotional banners attached to a catalog entity (category / brand / tag /…