Supercommerce API Docs
Admin API

Affiliate Module — Admin

HTTP surface for platform staff managing the affiliate plugin: review applications, manage affiliates (incl. suspend/resume + per-affiliate commission overrides), configure…

HTTP surface for platform staff managing the affiliate plugin: review applications, manage affiliates (incl. suspend/resume + per-affiliate commission overrides), configure per-entity commission overrides (product/brand/vendor/category/tag), and process payouts. Four new RBAC resources gate the surface (affiliateApplication, affiliateProfile, affiliateOverride, affiliatePayout) — granted by default to the built-in superAdmin and admin roles.

Source:

  • api-modules/affiliate/src/controllers/admin-affiliate-application.controller.ts
  • api-modules/affiliate/src/controllers/admin-affiliate.controller.ts
  • api-modules/affiliate/src/controllers/admin-affiliate-override.controller.ts
  • api-modules/affiliate/src/controllers/admin-affiliate-payout.controller.ts

Plugin configuration (default commission rate, repeat-order policy, TDS rate, approval cron, etc.) is not managed here — those are platform settings under group affiliate, served by the generic /admin/settings/admin surface (settings.md). The admin Affiliate section drives the operational surface only.


Conventions

Authentication

All endpoints require a Better-Auth admin session and the matching RBAC permission:

Endpoint groupPermission
GET /admin/affiliate/applications*affiliateApplication: read
POST /admin/affiliate/applications/:id/approveaffiliateApplication: review
POST /admin/affiliate/applications/:id/rejectaffiliateApplication: review
GET /admin/affiliate/affiliates* (incl. /audit)affiliateProfile: read
PATCH /admin/affiliate/affiliates/:idaffiliateProfile: manage
POST /admin/affiliate/affiliates/:id/suspendaffiliateProfile: suspend
POST /admin/affiliate/affiliates/:id/resumeaffiliateProfile: suspend
GET /admin/affiliate/commissionsaffiliateCommission: read
GET /admin/affiliate/commissions/:id/auditaffiliateCommission: read
GET /admin/affiliate/linksaffiliateProfile: read
GET /admin/affiliate/overrides/:kind (picker)affiliateOverride: read
GET /admin/affiliate/overrides/<entity>/:idaffiliateOverride: read
PUT /admin/affiliate/overrides/<entity>/:idaffiliateOverride: manage
DELETE /admin/affiliate/overrides/<entity>/:idaffiliateOverride: manage
GET /admin/affiliate/summaryaffiliateProfile: read
GET /admin/affiliate/payouts*affiliatePayout: read
POST /admin/affiliate/payoutsaffiliatePayout: create
POST /admin/affiliate/payouts/:id/confirmaffiliatePayout: process
POST /admin/affiliate/payouts/:id/mark-paidaffiliatePayout: process

Response envelope

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

Error envelope

statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN (missing permission)
404NOT_FOUND (application, affiliate, payout, override target)
409CONFLICT (status transition not allowed; payout method missing; balance below threshold)
500INTERNAL_SERVER_ERROR

Money + units

FieldUnit
*Subunitsinteger subunits (paise / cents)
commissionRateBps / commissionValue (PERCENTAGE)basis points, 0–10000
commissionValue (FIXED)integer subunits per qty
DatesISO 8601 strings

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;
  reviewedAt: string | null;
  createdAt: string;
  updatedAt: string;
  platforms: Array<{
    platform: "INSTAGRAM" | "YOUTUBE" | "TIKTOK" | "FACEBOOK"
      | "X_TWITTER" | "BLOG" | "NEWSLETTER" | "PODCAST" | "OTHER";
    detailsText: string | null;
  }>;
  socialLinks: Array<{ url: string }>;
};

AffiliateResponse

type AffiliateResponse = {
  id: string;
  customerId: string;
  code: string;                         // unique 8-char referral code
  promotedLandingUrl: string | null;

  suspendedAt: string | null;
  suspendedBy: string | null;
  suspendedByUser: { id: string; name: string; email: string } | null;
  suspendReason: string | null;

  // Payout identity (any subset may be null until first payout)
  payoutMethod: "UPI" | "BANK" | null;
  upiId: string | null;
  bankAccountName: string | null;
  bankAccountNumber: string | null;
  bankIfsc: string | null;
  panNumber: string | null;
  gstin: string | null;

  // Per-affiliate commission override — sits at the top of the
  // precedence chain. null fields = inherit from chain.
  commissionEnabled: boolean | null;
  commissionType: "PERCENTAGE" | "FIXED" | null;
  commissionValue: number | null;

  // Lifetime aggregates, denormalized + bumped at order placement
  lifetimeClicks: number;
  lifetimeOrders: number;
  lifetimeRevenueSubunits: number;
  lifetimeCommissionSubunits: number;

  createdAt: string;
  updatedAt: string;
};

OverrideResponse

type OverrideResponse = {
  targetId: string;
  enabled: boolean | null;
  commissionType: "PERCENTAGE" | "FIXED" | null;
  commissionValue: number | null;
};

All three fields are nullable per the inheritance semantics. An all-null response is returned by GET when no override row exists for the target — i.e. "this entity inherits everything from the chain."

AuditResponse

type AuditResponse = {
  id: string;
  affiliateId: string;
  /** Known values:
   *    AFFILIATE_CREATED            on approve / auto-approve
   *    AFFILIATE_PROFILE_UPDATE     PATCH endpoint + storefront payout-details PATCH
   *    AFFILIATE_SUSPEND
   *    AFFILIATE_RESUME
   *    COMMISSION_SKIP_SELF_REFERRAL  order placed by affiliate themselves
   *    COMMISSION_SKIP_REPEAT_POLICY  order blocked by repeat-order policy
   *    LINK_CREATE                  a tracking link was generated for this affiliate
   *    LINK_DELETE                  a tracking link was soft-deleted
   *    PAYOUT_CONFIRM               a payout batch moved DRAFT → PROCESSING
   *    PAYOUT_MARK_PAID             a payout batch moved to PAID
   */
  action: string;
  actorId: string | null;     // admin user.id, or null for system actions
  actor: { id: string; name: string; email: string } | null;
  before: Record<string, unknown> | null;
  after: Record<string, unknown> | null;
  reason: string | null;
  createdAt: string;
};

PayoutResponse

type PayoutResponse = {
  id: string;
  affiliateId: string;
  affiliateName?: string | null;  // admin reads only (joined from customer); absent on storefront
  affiliateCode?: string | null;  // admin reads only
  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 on PAID
  paidAt: string | null;
  createdAt: string;
  updatedAt: string;
};

EligibleAffiliateResponse

type EligibleAffiliateResponse = {
  affiliateId: string;
  customerId: string;
  affiliateName: string;
  affiliateCode: string;
  payoutMethod: "UPI" | "BANK" | null;  // so the UI can flag missing payout details
  upiId: string | null;
  eligibleSubunits: number;     // sum of APPROVED commission_amount_subunits
  commissionRowCount: number;
};

CommissionResponse

type CommissionResponse = {
  id: string;
  affiliateId: string;
  affiliateName: string;
  affiliateCode: string;
  orderId: string;
  orderNumber: string;
  customerId: string;
  linkId: string;
  linkCode: string;
  linkTitle: string | null;
  productName: string;
  variantName: string | null;
  sku: string;
  quantity: number;
  commissionSource: "AFFILIATE" | "PRODUCT" | "VENDOR" | "BRAND" | "CATEGORY" | "TAG" | "GLOBAL";
  commissionType: "PERCENTAGE" | "FIXED";
  commissionRateBps: number | null;       // basis points when PERCENTAGE
  commissionValueSubunits: number | null;  // subunits/unit when FIXED
  baseAmountSubunits: number;
  commissionAmountSubunits: number;
  status: "PENDING" | "APPROVED" | "REJECTED" | "PAID";
  approvedAt: string | null;
  rejectedAt: string | null;
  rejectedReason: string | null;
  paidAt: string | null;
  createdAt: string;
};

AdminLinkResponse

type AdminLinkResponse = {
  id: string;
  affiliateId: string;
  affiliateName: string;
  affiliateCode: string;
  linkType: "GENERIC" | "PRODUCT" | "BRAND" | "VENDOR" | "CATEGORY" | "TAG";
  targetId: string | null;
  code: string;
  title: string | null;
  shareUrl: string;             // `/r/:code`
  lifetimeClicks: number;
  lifetimeOrders: number;
  lifetimeRevenueSubunits: number;
  lifetimeCommissionSubunits: number;
  createdAt: string;
  updatedAt: string;
};

CommissionAuditResponse

type CommissionAuditResponse = {
  id: string;
  commissionId: string;
  action: string;               // e.g. approval-sweep, rejection, payout inclusion
  source: "system" | "admin";
  actorId: string | null;
  actor: { id: string; name: string; email: string } | null;
  before: Record<string, unknown> | null;
  after: Record<string, unknown> | null;
  reason: string | null;
  createdAt: string;
};

Same shape/pattern as the affiliate-row AuditResponse above, but scoped by commissionId and carrying source so the UI can distinguish a cron-driven transition from an admin action.

SummaryResponse

type SummaryResponse = {
  activeAffiliateCount: number;
  totalAffiliateCount: number;
  lifetimeClicks: number;
  lifetimeOrders: number;
  lifetimeRevenueSubunits: number;
  lifetimeCommissionSubunits: number;
  payoutNetSubunitsByStatus: {
    DRAFT: number;
    PROCESSING: number;
    PAID: number;
    FAILED: number;
  };
};

Endpoints

Base path: /admin/affiliate.

Applications

GET /admin/affiliate/applications — Paginated application list

Permission: affiliateApplication: read.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
statusPENDING | APPROVED | REJECTEDoptional filter

Response 200 — paginated ApplicationResponse[] with metadata: { total, limit, offset, hasMore }. Newest first. Each row carries its platforms[] and socialLinks[] collections inline.

GET /admin/affiliate/applications/:id — Application detail

Permission: affiliateApplication: read.

Response 200ApplicationResponse. Response 404NOT_FOUND when the id is unknown.

POST /admin/affiliate/applications/:id/approve — Approve a PENDING application

Permission: affiliateApplication: review.

In a single transaction: flips the application status to APPROVED, writes an audit row, and creates the affiliate row with a freshly generated unique 8-char code. Fires affiliate.application.approved after commit (drives the customer email).

Response 200ApplicationResponse (now status: "APPROVED").

Errors

StatusCodeWhen
404NOT_FOUNDUnknown application id
409CONFLICTApplication is not in PENDING
409CONFLICTCustomer already has an affiliate row (defense-in-depth — the submit endpoint blocks this too)

POST /admin/affiliate/applications/:id/reject — Reject a PENDING application

Permission: affiliateApplication: review.

Body

{ "reason": "Doesn't meet the audience-size requirement." }

reason is required (1–1000 chars), surfaced verbatim in the customer email and persisted on affiliate_application.rejected_reason + the audit row.

Response 200ApplicationResponse (now status: "REJECTED", rejectedReason set). Errors: same shape as approve.


Affiliates

GET /admin/affiliate/affiliates — Paginated affiliate list

Permission: affiliateProfile: read.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
stateactive | suspendedoptional filter (omit for all)

Response 200 — paginated AffiliateResponse[]. Newest first.

GET /admin/affiliate/affiliates/:id — Affiliate detail

Permission: affiliateProfile: read.

Response 200AffiliateResponse. Response 404NOT_FOUND.

PATCH /admin/affiliate/affiliates/:id — Update affiliate profile

Permission: affiliateProfile: manage.

Mutable fields (all optional, all nullable — omit = leave unchanged, null = clear). Each request that changes at least one value writes an AFFILIATE_PROFILE_UPDATE audit row with a diff of the changed columns only, and emits affiliate.profile.updated (captured into ClickHouse audit_events via @sc/audit's wildcard domain-event listener, same as every event below).

{
  "promotedLandingUrl": "https://shop.example.com",
  "commissionEnabled": true,
  "commissionType": "PERCENTAGE",
  "commissionValue": 1500,       // 15% in basis points
  "payoutMethod": "UPI",
  "upiId": "alice@upi",
  "bankAccountName": null,
  "bankAccountNumber": null,
  "bankIfsc": null,
  "panNumber": "ABCDE1234F",
  "gstin": null
}

Validation

  • commissionType + commissionValue must be both set or both null (partial overrides aren't allowed — the column pair travels together)
  • When commissionType === "PERCENTAGE", commissionValue must be 0..10000
  • Pairing for payoutMethod ↔ details is checked at payout-creation time, not here

Response 200AffiliateResponse (updated).

POST /admin/affiliate/affiliates/:id/suspend — Suspend an affiliate

Permission: affiliateProfile: suspend.

Body

{ "reason": "Detected fraudulent traffic." }

In one transaction: sets suspendedAt, suspendedBy, suspendReason; writes AFFILIATE_SUSPEND audit row. Emits affiliate.profile.suspended after commit.

Effects:

  • /r/:code for any of this affiliate's links returns 404 with no click recorded
  • Daily approval sweep skips this affiliate's PENDING commissions
  • Payout creation blocks (createForAffiliate errors with CONFLICT)
  • Storefront dashboard still works — affiliate sees the suspended banner with reason

Response 200AffiliateResponse.

POST /admin/affiliate/affiliates/:id/resume — Resume a suspended affiliate

Permission: affiliateProfile: suspend.

Clears suspendedAt/By/Reason. Writes AFFILIATE_RESUME audit row with the prior suspended_at / reason captured in before. Emits affiliate.profile.resumed after commit.

Response 200AffiliateResponse.

GET /admin/affiliate/affiliates/:id/audit — Audit log for one affiliate

Permission: affiliateProfile: read.

Every mutation to the affiliate row (creation, profile updates, suspend, resume) plus commission-skip forensic rows (self-referral, repeat policy) appear here.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50

Response 200 — paginated AuditResponse[]. Newest first.


Commissions

GET /admin/affiliate/commissions — Paginated commission ledger

Permission: affiliateCommission: read.

One row per commissioned order line, enriched with the affiliate, order and line snapshot. Newest first.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
affiliateIdstringfilter to one affiliate
statusenumPENDING | APPROVED | REJECTED | PAID
fromISO datetimecreated-at lower bound
toISO datetimecreated-at upper bound

Response 200 — paginated CommissionResponse[].

GET /admin/affiliate/commissions/:id/audit — Audit log for one commission row

Permission: affiliateCommission: read.

Every transition on this specific commission row — approval-sweep promotion, rejection (cancel/return), and payout-batch inclusion (PAID) — with actor and before/after diff. Same shape/pattern as GET /admin/affiliate/affiliates/:id/audit, but scoped by commissionId instead of affiliateId.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50

Response 200 — paginated CommissionAuditResponse[]. Newest first. Response 404NOT_FOUND when the commission id is unknown.


Permission: affiliateProfile: read.

Every non-deleted tracking link, with its owning affiliate's name + code and lifetime metrics. Newest first.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
affiliateIdstringfilter to one affiliate
linkTypeenumGENERIC | PRODUCT | BRAND | VENDOR | CATEGORY | TAG

Response 200 — paginated AdminLinkResponse[].


Overrides

Five parallel resources, one per entity dimension. Shape is identical; only the target lookup table differs.

GET /admin/affiliate/overrides/:kind — Paginated target picker

Permission: affiliateOverride: read.

Lists entities of kind (product | brand | vendor | category | tag) with each one's current override inline, so the admin can browse and edit without knowing ids. Products are searchable by title or variant SKU; other kinds by title/name and slug.

Query

NameTypeDefaultConstraints
pageint1>= 1
limitint201..50
searchstringmatches name/title (and variant SKU for products)

Response 200 — paginated OverrideTargetResponse[]:

type OverrideTargetResponse = {
  targetId: string;
  label: string;             // product title / brand|category|tag title / vendor name
  sublabel: string | null;   // slug
  enabled: boolean | null;
  commissionType: "PERCENTAGE" | "FIXED" | null;
  commissionValue: number | null;  // basis points (PERCENTAGE) or subunits (FIXED)
};

GET /admin/affiliate/overrides/<entity>/:targetId

Permission: affiliateOverride: read.

Where <entity>product | brand | vendor | category | tag and :targetId is the id in the corresponding catalog table.

Response 200OverrideResponse. All-null fields ({ enabled: null, commissionType: null, commissionValue: null }) indicate "no override row for this target — inherits the rest of the chain."

PUT /admin/affiliate/overrides/<entity>/:targetId — Upsert override

Permission: affiliateOverride: manage.

BodyOverrideResponse shape (without targetId):

{
  "enabled": true,                // or false / null (inherit)
  "commissionType": "PERCENTAGE", // or "FIXED" / null
  "commissionValue": 1000         // 10% in bps; or null
}

Validation

  • commissionType + commissionValue must be both set or both null
  • PERCENTAGE: commissionValue ∈ 0..10000
  • Schema CHECKs (<table>_commission_value_range) enforce the same on the DB side

Response 200OverrideResponse (upserted).

DELETE /admin/affiliate/overrides/<entity>/:targetId — Remove override

Permission: affiliateOverride: manage.

Removes the override row. The entity then inherits from the rest of the precedence chain at calc time.

Response 204 — empty body.

Override audit trail: every PUT/DELETE above writes a row (kind, targetId, action UPSERT/DELETE, actor, before/after) to a dedicated override-audit table, separate from the affiliate-row and commission-row audit trails, and emits affiliate.override.upserted / affiliate.override.deleted after commit. There is currently no read endpoint exposed for the Postgres override-audit table — write-only for now, the same gap the commission-audit endpoint above just closed for commissions — but both events are captured into ClickHouse audit_events regardless, so kind/targetId/actorId/before/after are queryable there today.


Summary

GET /admin/affiliate/summary — Program-wide dashboard totals

Permission: affiliateProfile: read.

Grouped-SQL totals for the admin dashboard — computed server-side rather than paginating rows and reducing client-side, which is what the previous dashboard implementation did (and silently undercounted past the first page of 50 rows).

Response 200SummaryResponse: active/total affiliate counts, lifetime clicks/orders/revenue/commission across the whole program, and payout net-subunits totals grouped by status (DRAFT/PROCESSING/PAID/FAILED).


Payouts

Lifecycle: DRAFTPROCESSINGPAID.

  1. DRAFTPOST /admin/affiliate/payouts builds the batch. The included commissions flip APPROVEDPAID immediately at this point, not when the payout itself is later confirmed/disbursed — a commission is "spoken for" the moment it's batched.
  2. PROCESSINGPOST /admin/affiliate/payouts/:id/confirm gives the admin a review checkpoint before disbursing (e.g. double-checking the export before wiring funds).
  3. PAIDPOST /admin/affiliate/payouts/:id/mark-paid records the external reference. It accepts DRAFT or PROCESSING as the starting state, so PROCESSING is optional — the common case skips straight from DRAFT to PAID.

FAILED is reserved for a future disbursement-provider integration and isn't reachable from any endpoint today.

GET /admin/affiliate/payouts/eligible — Affiliates ready for payout

Permission: affiliatePayout: read.

Returns affiliates whose APPROVED commission balance ≥ admin.affiliate.min_payout_subunits. Sorted by balance descending so high-value affiliates surface first. Suspended affiliates are excluded.

Response 200EligibleAffiliateResponse[] (not paginated).

GET /admin/affiliate/payouts — Paginated payout list

Permission: affiliatePayout: read.

Query

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

Response 200 — paginated PayoutResponse[]. Newest first.

GET /admin/affiliate/payouts/:id — Payout detail

Permission: affiliatePayout: read.

Response 200PayoutResponse. Response 404NOT_FOUND.

POST /admin/affiliate/payouts — Build a batch

Permission: affiliatePayout: create.

Creates one DRAFT payout per affiliateId. Per-affiliate failures don't abort the batch — the response carries succeeded[] and errors[].

Body

{
  "affiliateIds": ["aff_1", "aff_2"],   // 1..500 entries
  "actualTdsSubunits": 850              // optional — overrides the auto-computed TDS
}

actualTdsSubunits overrides step 3's auto-computed TDS with an admin-supplied exact figure — but it's only valid for a single-affiliate batch (affiliateIds.length === 1). Supplying it alongside more than one affiliateId rejects every entry with a per-affiliate error ("actualTdsSubunits override requires a single-affiliate batch"), rather than failing the whole request.

For each affiliate the service:

  1. Validates the affiliate exists, is not suspended, and has matching payout method + identity fields (UPI requires upiId; BANK requires bankAccountNumber + bankIfsc).
  2. Sums APPROVED commission amounts. Rejects with CONFLICT if balance < min_payout_subunits.
  3. Computes TDS via admin.affiliate.tds_rate_bps (default 0 = no deduction). India:
    • 500 = 5% with PAN (sec 194H)
    • 2000 = 20% without PAN
    • v1 uses a single flat tenant rate; pan-based derivation is a v1.5 follow-up
  4. In one transaction: inserts affiliate_payout (status: DRAFT) + affiliate_payout_item rows linking each commission + flips those commissions to PAID with payoutItemId set.

Response 201

{
  "data": {
    "succeeded": [
      { "id": "...", "affiliateId": "aff_1", "status": "DRAFT", "method": "UPI",
        "grossSubunits": 17325, "tdsSubunits": 0, "netSubunits": 17325, ... }
    ],
    "errors": [
      { "affiliateId": "aff_2", "error": "Affiliate has not set a payout method..." }
    ]
  },
  "message": "Success",
  "statusCode": 201
}

Per-affiliate error messages

Message fragmentCause
Affiliate not foundUnknown id
Cannot payout to a suspended affiliateaffiliate.suspendedAt IS NOT NULL
has not set a payout methodpayoutMethod IS NULL
upi_id is emptyUPI method but upiId is null/empty
bank details are incompleteBANK method but bank fields missing
has no APPROVED commissionsBalance is zero
is below min_payoutBalance < min_payout_subunits

POST /admin/affiliate/payouts/:id/confirm — Confirm a DRAFT batch

Permission: affiliatePayout: process (same permission as mark-paid).

Transitions DRAFTPROCESSING — a review checkpoint before disbursing. No body. Writes a PAYOUT_CONFIRM audit row against the affiliate and emits affiliate.payout.confirmed after commit.

Response 200PayoutResponse (status now PROCESSING).

Errors

StatusCodeWhen
404NOT_FOUNDUnknown payout id
409CONFLICTPayout is not in DRAFT

POST /admin/affiliate/payouts/:id/mark-paid — Record disbursement

Permission: affiliatePayout: process.

Transitions DRAFT or PROCESSING → PAID. Records the external reference (UTR / cheque / wire id) returned by the bank/PSP. Emits affiliate.payout.processed after commit — the customer receives the breakdown email.

Body

{ "externalReference": "UTR-2026-05-16-XXXXXX" }

externalReference is required (1–200 chars). Trim is automatic.

Response 200PayoutResponse (status now PAID).

Errors

StatusCodeWhen
400BAD_REQUESTEmpty externalReference
404NOT_FOUNDUnknown payout id
409CONFLICTPayout is not in DRAFT or PROCESSING

Plugin configuration (admin settings)

The full plugin is gated by admin.affiliate.enabled. Every other knob lives in the same affiliate settings group, served by /admin/settings/admin. Summary of the keys this admin section depends on:

KeyDefaultPurpose
enabledfalseMaster kill switch
auto_approve_applicationsfalseWhen true, submit → APPROVED in one step
default_commission_rate_bps500 (5%)Bottom of the precedence chain
default_commission_typePERCENTAGECompanion to the default rate
min_payout_subunits300000 (₹3,000)Eligibility threshold for batch creation
cookie_duration_days30Click attribution window
repeat_order_policyFIRST_ONLYFIRST_ONLY / FIRST_PER_LINK / ALL_WITHIN_WINDOW
repeat_order_window_days30Used by ALL_WITHIN_WINDOW
tds_rate_bps0Flat TDS deducted from every payout
commission_approval_after_return_windowtrueGate sweep on return-window-closed in addition to delivered
approval_cron0 3 * * *Daily approval sweep schedule
allowed_link_typesall six (GENERIC, PRODUCT, BRAND, VENDOR, CATEGORY, TAG)Link types affiliates may generate. Enforced in AffiliateLinkService.generate() (400 on a disallowed type) and read by the storefront via GET /store/affiliate/config to decide which create-link tabs to show.
auto_generate_link_on_approvaltrueWhether a GENERIC link is auto-provisioned the moment an affiliate is approved. When false, the affiliate generates their first link manually.

Commission lifecycle (for context)

The admin surface exposes the start- and end-of-lifecycle transitions; the middle (PENDING → APPROVED) is driven by the daily cron and isn't a manual operation here.

  1. PENDING — created by the order.placed listener for each eligible order_line. Affiliate's lifetime stats are bumped at this point. Emits affiliate.commission.created (one event per order, summarizing line count + total) after commit; if attribution existed but a guard blocked creation (self-referral, or the repeat-order policy), emits affiliate.commission.skipped with reason: "SELF_REFERRAL" | "REPEAT_POLICY" instead — and writes an audit row either way.
  2. APPROVED — the daily approval sweep promotes PENDING rows whose sub-order is delivered and the return window has closed. No admin endpoint. Emits affiliate.commission.approved.
  3. REJECTED — the cancel/return event listener flips PENDING/APPROVED → REJECTED on order.cancelled, order.vendor.cancelled, or return.refunded; reverses lifetime stats. PAID commissions are NOT auto-rejected on returns — that's a v2 clawback workstream. Emits affiliate.commission.rejected.
  4. PAID — flipped the moment an admin batches the commission into a payout (POST /admin/affiliate/payouts, while the payout itself is still DRAFT) — not when the payout is later confirmed or marked disbursed.

The affiliate_audit + affiliate_commission_audit tables capture every transition with actor + before/after snapshot. GET /admin/affiliate/affiliates/:id/audit covers the affiliate-row side (incl. suspend/resume, profile updates, link create/delete, and payout confirm/mark-paid); GET /admin/affiliate/commissions/:id/audit covers the commission-row side (approval-sweep, rejection, and payout-inclusion transitions).

Every affiliate domain event lands in ClickHouse. @sc/audit's onAny listener captures every EventEmitter2.emit() call app-wide with no registration step — so every event named above (affiliate.application.*, affiliate.commission.*, affiliate.payout.*, affiliate.profile.*, affiliate.link.*, affiliate.override.*) is queryable in ClickHouse audit_events (GET /admin/audit, audit:view permission) in addition to whichever Postgres audit table also captures it. This is a deliberate belt-and-suspenders setup: the Postgres tables are the source of truth for the module's own admin UI ("show me this affiliate's/commission's history"); ClickHouse is for cross-cutting analytics and long-retention forensics across every module, not just this one.

On this page

ConventionsAuthenticationResponse envelopeError envelopeMoney + unitsDomain typesApplicationResponseAffiliateResponseOverrideResponseAuditResponsePayoutResponseEligibleAffiliateResponseCommissionResponseAdminLinkResponseCommissionAuditResponseSummaryResponseEndpointsApplicationsGET /admin/affiliate/applications — Paginated application listGET /admin/affiliate/applications/:id — Application detailPOST /admin/affiliate/applications/:id/approve — Approve a PENDING applicationPOST /admin/affiliate/applications/:id/reject — Reject a PENDING applicationAffiliatesGET /admin/affiliate/affiliates — Paginated affiliate listGET /admin/affiliate/affiliates/:id — Affiliate detailPATCH /admin/affiliate/affiliates/:id — Update affiliate profilePOST /admin/affiliate/affiliates/:id/suspend — Suspend an affiliatePOST /admin/affiliate/affiliates/:id/resume — Resume a suspended affiliateGET /admin/affiliate/affiliates/:id/audit — Audit log for one affiliateCommissionsGET /admin/affiliate/commissions — Paginated commission ledgerGET /admin/affiliate/commissions/:id/audit — Audit log for one commission rowLinksGET /admin/affiliate/links — Paginated links across all affiliatesOverridesGET /admin/affiliate/overrides/:kind — Paginated target pickerGET /admin/affiliate/overrides/<entity>/:targetIdPUT /admin/affiliate/overrides/<entity>/:targetId — Upsert overrideDELETE /admin/affiliate/overrides/<entity>/:targetId — Remove overrideSummaryGET /admin/affiliate/summary — Program-wide dashboard totalsPayoutsGET /admin/affiliate/payouts/eligible — Affiliates ready for payoutGET /admin/affiliate/payouts — Paginated payout listGET /admin/affiliate/payouts/:id — Payout detailPOST /admin/affiliate/payouts — Build a batchPOST /admin/affiliate/payouts/:id/confirm — Confirm a DRAFT batchPOST /admin/affiliate/payouts/:id/mark-paid — Record disbursementPlugin configuration (admin settings)Commission lifecycle (for context)