Documentation

Tracking

Click tracking, code resolution, and conversion attribution from orders.

Tracking is the bridge between a partner's link and a paid order. The flow has two halves: a public click endpoint that records a referrer hit and stores the affiliate code in a cookie or query param, and a server-side conversion step where the Storefront calls AffiliateTrackingService.recordConversion after an order is paid.

Endpoints

Public

GET/affiliate/public/resolve/:codeNo auth
POST/affiliate/public/trackNo auth
POST/affiliate/public/apply-codeNo auth

Staff

POST/affiliate/links/generateJWT
GET/affiliate/referralsJWT
GET/affiliate/referrals/:idJWT
POST/affiliate/referrals/:id/transitionJWT
POST/affiliate/referrals/bulk-transitionJWT

Partner self-service

POST/client/affiliate/me/linkJWT
GET/client/affiliate/me/linkJWT
GET/client/affiliate/me/referralsJWT

Generating a link

A partner can generate their own link, or staff can do it on their behalf.

// Partner — generates a link to a specific product
const { url, code } = await fetch('/client/affiliate/me/link?program=creator', {
  method: 'POST',
  headers: {
    orgid: 'my-org',
    Authorization: `Bearer ${jwt}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ productSlug: 'pro-plan', page: '/pricing' }),
}).then(r => r.json());

// url example: https://shop.example.com/pricing?ref=ALEX-2K9

The ref query param carries the affiliate's code — a short, human-readable handle. Your site can also accept the code through a path segment or a cookie; whatever it sees, it sends to POST /affiliate/public/track.

Resolving a code

When a visitor lands on a page with ?ref=ALEX-2K9, the front-end resolves the code to confirm it's live and to get partner display info:

const res = await fetch(`/affiliate/public/resolve/${code}`, {
  headers: { orgid: 'my-org' },
}).then(r => r.json());

// { valid: true, affiliateName: 'Alex Reyes', programName: 'creator', reward: {...} }
// or { valid: false }

If valid, store the code in a cookie keyed by program.trackingCookieAge (typically 30 days).

Tracking a click

await fetch('/affiliate/public/track', {
  method: 'POST',
  headers: { orgid: 'my-org', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    code: 'ALEX-2K9',
    email: '[email protected]',  // optional, if known
    source: 'link',                // 'link' | 'code' | 'qr'
  }),
});

This writes a pending referral record (or updates an existing one if the same email already clicked). Pending referrals expire after attribution.windowDays if no order lands.

Applying a code at checkout

For checkout flows where the user types a referral code (rather than clicking a link), call /apply-code:

const r = await fetch('/affiliate/public/apply-code', {
  method: 'POST',
  headers: { orgid: 'my-org', 'Content-Type': 'application/json' },
  body: JSON.stringify({ code: 'ALEX-2K9', email: '[email protected]' }),
}).then(r => r.json());

if (r.valid) {
  // r.reward — the customer-side reward to apply (discount, credit)
  // r.affiliateName, r.programName — for the "Referred by ..." UI
}

This combines a resolve and a track in one call.

Conversion attribution

The Storefront automatically calls AffiliateTrackingService.attributeOrder(orgId, orderInfo) after every paid order — you do not call it directly from front-end code. The flow is:

// Inside storefront.service.ts (server-side, after payment success)
await this.affiliateTrackingService.attributeOrder(orgId, {
  affiliateCode: order.data.affiliateCode,    // from cookie / applied code
  orderId: order.sk,
  orderNumber: order.data.orderNumber,
  orderAmount: order.data.totalAmount,
  customerEmail: order.data.customerEmail,
  customerId: order.data.customerId,
  items: order.data.items,
});

The service is idempotent — calling it twice for the same orderId is a no-op. This matters because the Storefront calls it once on checkout creation and again on the payment webhook; only the first call wins.

Initial referral status

Based on program.payout.holdDays:

  • holdDays === 0 and program checks pass → qualified (commission credited immediately)
  • holdDays > 0commission_held (commission booked, waiting out the hold window)
  • Fraud check failure → rejected
  • Customer requested refund → reversed

See Commissions for the full state machine.

Listing referrals

Staff side:

const referrals = await fetch(
  '/affiliate/referrals?status=commission_held&program=creator&page=1&pageSize=50',
  { headers: { orgid: 'my-org', Authorization: `Bearer ${jwt}` } }
).then(r => r.json());

Partner self-service:

const mine = await fetch(
  '/client/affiliate/me/referrals?program=creator&status=converted',
  { headers: { orgid: 'my-org', Authorization: `Bearer ${jwt}` } }
).then(r => r.json());

Manual transitions

Staff can move any referral through the state machine via the unified transition endpoint:

await fetch(`/affiliate/referrals/${id}/transition`, {
  method: 'POST',
  headers: { orgid: 'my-org', Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    action: 'reverse',         // approve | reject | hold | reverse | restore | expire | recalculate | override-amount
    reason: 'Customer refunded',
    actor: '[email protected]',
  }),
});

Bulk transition by ids or filter is at /affiliate/referrals/bulk-transition.

The legacy /referrals/:id/approve and /referrals/:id/reject endpoints still work — they're thin aliases that route through transition. Use transition directly in new code.