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/overrides/<entity>/:idaffiliateOverride: read
PUT /admin/affiliate/overrides/<entity>/:idaffiliateOverride: manage
DELETE /admin/affiliate/overrides/<entity>/:idaffiliateOverride: manage
GET /admin/affiliate/payouts*affiliatePayout: read
POST /admin/affiliate/payoutsaffiliatePayout: create
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;
  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;
  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
   */
  action: string;
  actorId: string | null;     // admin user.id, or null for system actions
  before: Record<string, unknown> | null;
  after: Record<string, unknown> | null;
  reason: 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 on PAID
  paidAt: string | null;
  createdAt: string;
  updatedAt: string;
};

EligibleAffiliateResponse

type EligibleAffiliateResponse = {
  affiliateId: string;
  customerId: string;
  eligibleSubunits: number;     // sum of APPROVED commission_amount_subunits
  commissionRowCount: 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.

{
  "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.

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.

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.


Overrides

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

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.


Payouts

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

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/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

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.
  2. APPROVED — the daily approval sweep promotes PENDING rows whose sub-order is delivered and the return window has closed. No admin endpoint.
  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.
  4. PAID — admin batch creation flips APPROVED → PAID via this surface.

The affiliate_audit + affiliate_commission_audit tables capture every transition with actor + before/after snapshot. The admin audit endpoint above covers the affiliate-row side; commission-side audit reads are deliberately not surfaced via a separate endpoint (commission rows are listable directly when that endpoint lands).

On this page