Affiliate Conversion Tracking
Affiliate conversion tracking captures partner click IDs (im_ref) from landing URLs and reports conversions to the affiliate provider (currently Impact.com ). Attribution data is optional on the user — only users who land from an affiliate partner link will have it.
The feature spans frontend and backend: cookie capture in Next.js middleware, SSR header forwarding, a client-side hook for returning users, and a backend SQS worker that posts conversions to the provider API.
Overview
| Package | Pattern | Key Files |
|---|---|---|
@repo/affiliate-tracking | Backend feature — HTTP client + campaign config | packages/backend/features/affiliate-tracking/src/ |
@repo/affiliate-tracking-frontend | Frontend package — cookie capture hook + component | packages/frontend/affiliate-tracking/src/ |
@repo/queue | SQS queue with DLQ (discriminated union messages) | packages/backend/queue/src/dtos/affiliate-conversion.dto.ts |
@repo/user | Domain — attribution storage + signup SQS publish | packages/backend/domains/user/src/user.service.ts |
@repo/api | Handler — attribution capture endpoint | packages/backend/api/src/lib/orpc/handlers/user/set-affiliate-attribution.handler.ts |
Optional by design
Many users will never have affiliate attribution (direct traffic, organic, non-partner links). Only users who land with an im_ref query parameter will have attribution.impact set. The backend skips conversion reporting when attribution is missing (no error).
Conversion events
| Event | ActionTrackerId | When reported |
|---|---|---|
signup | 68897 | New user creation via handleNewUser when im_ref cookie is present |
All events share CampaignId: 48046 and a 30-day referral window.
End-to-end flows
Signup conversion (Click → Signup)
Browser lands with ?im_ref=xyz
│
â–Ľ
Next.js middleware: set im_ref cookie (30 days)
│
â–Ľ (user signs up via Clerk)
│
Next.js SSR: orpcSSR() reads im_ref cookie
│
â–Ľ
Forwards as X-Affiliate-Click-Id header → Backend API
│
â–Ľ
Middleware: reads header, passes to ensureUser(clerkId, affiliateClickId)
│
â–Ľ
AuthService.syncUser(): isNewUser? → passes affiliateClickId to handleNewUser
│
â–Ľ
UserService.handleNewUser():
├─ updateAffiliateAttribution(userId, clickId) // dot-notation, last-click-wins
└─ queuePublisher.publish("affiliate-conversions", { campaignEventId: "signup", ... })
│
â–Ľ
SQS: affiliate-conversions queue
│
â–Ľ
ConversionTrackingConsumer.handle()
│
â–Ľ
ConversionTrackingService.report() → POST Impact APILast-click-wins (attribution update for returning users)
User clicks affiliate B link → frontend captures im_ref cookie
│
â–Ľ
useAffiliateAttribution hook detects: user logged in + cookie present
│
â–Ľ
orpc.user.setAffiliateAttribution({ clickId: B })
│
â–Ľ
Backend handler → userService.updateAffiliateAttribution(userId, clickIdB)
│
â–Ľ
MongoDB dot-notation $set: attribution.impact.clickId = B, capturedAt = now
│
â–Ľ
Frontend clears cookie. Future conversions will use clickId B.Attribution windows
There are three distinct “windows” to understand:
| Window | Duration | Where | Purpose |
|---|---|---|---|
Cookie (im_ref) | 30 days | Browser (first-party cookie) | Captures the click ID from the landing URL so it survives until signup or next visit |
Referral window (referralWindowDays) | 30 days | Our backend (ConversionTrackingService) | Pre-filter to avoid sending obviously-expired conversions to Impact |
| Impact’s commission window | Varies | Impact.com dashboard | The authoritative source of truth for whether a conversion earns commission |
Our referralWindowDays is a conservative pre-filter. We check that capturedAt is within 30 days before posting to Impact. Impact’s own window is what ultimately determines commission eligibility — our check just avoids wasting API calls for clearly-expired attributions.
SQS message format
Currently only signup messages are produced:
// packages/backend/queue/src/dtos/affiliate-conversion.dto.ts
{ campaignEventId: "signup", userId, clickId, capturedAt }User model
Attribution is optional on the user document:
// packages/core/db/src/mongo/user.model.ts
attribution?: {
impact?: {
clickId?: string
capturedAt?: Date
}
}- Fields are optional — organic users have no attribution subdoc.
updateAffiliateAttributionuses dot notation ("attribution.impact.clickId") to preserve other fields.
API
| Method | Purpose |
|---|---|
user.setAffiliateAttribution | Store affiliate click ID for the authenticated user. Input: { clickId: string }. Output: void. Protected. Last-click-wins. |
Environment variables
IMPACT_ACCOUNT_SID,IMPACT_AUTH_TOKEN— required for any POST to Impact. If missing, conversions are silently skipped.
Frontend wiring
- Hook:
useAffiliateAttribution— readsim_reffrom URL, sets cookie; when user is logged in and cookie is set, callsuser.setAffiliateAttributionand clears cookie. - Component:
AffiliateCapture— renders nothing; just runs the hook. Mounted inBusinessProvidersnext toUTMCapture. - SSR forwarding:
orpcSSR()in@repo/trpcreadsim_refcookie and forwards asX-Affiliate-Click-Idheader. - Middleware:
setAffiliateTrackingCookie()in Next.js middleware capturesim_refquery parameter and sets a 30-day first-party cookie.
How to add new conversion events
When you need to track a new type of conversion (e.g. a sale or subscription):
Add the event to the enum
In packages/backend/features/affiliate-tracking/src/campaigns/campaign-event.ts, add the new event:
export enum CampaignEvent {
Signup = "signup",
SaleSubscription = "sale_subscription", // new
}Add config with campaignId and actionTrackerId
In packages/backend/features/affiliate-tracking/src/campaigns/config.ts, add the config entry. Get the actionTrackerId from the Impact.com dashboard:
export const campaignConfig: Record<CampaignEvent, CampaignEventConfig> = {
[CampaignEvent.Signup]: { campaignId: 48046, actionTrackerId: 68897 },
[CampaignEvent.SaleSubscription]: { campaignId: 48046, actionTrackerId: 99999 }, // new
}Add payload type to the payload map
In packages/backend/features/affiliate-tracking/src/campaigns/payload-map.ts, define what extra data this event carries:
export interface CampaignEventPayloadMap {
[CampaignEvent.Signup]: Record<string, never>
[CampaignEvent.SaleSubscription]: { orderId: string; totalAmountCents: number; currency: string } // new
}Add the SQS message schema
In packages/backend/queue/src/dtos/affiliate-conversion.dto.ts, add a new Zod schema for the message and include it in the discriminated union:
export const saleSubscriptionSchema = z.object({
campaignEventId: z.literal("sale_subscription"),
userId: z.string(),
clickId: z.string(),
capturedAt: z.string(),
orderId: z.string(),
totalAmountCents: z.number(),
currency: z.string(),
})Add a consumer branch
In packages/backend/features/affiliate-tracking/src/conversion-tracking.consumer.ts, add a case for the new event:
case "sale_subscription":
await this.conversionTrackingService.report(/* ... */)
breakWire the publisher
In the relevant flow (e.g. checkout service), publish a message to the "affiliate-conversions" queue when the conversion happens. The user must have attribution.impact.clickId set.