Skip to Content
BackendAffiliate Conversion Tracking

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

PackagePatternKey Files
@repo/affiliate-trackingBackend feature — HTTP client + campaign configpackages/backend/features/affiliate-tracking/src/
@repo/affiliate-tracking-frontendFrontend package — cookie capture hook + componentpackages/frontend/affiliate-tracking/src/
@repo/queueSQS queue with DLQ (discriminated union messages)packages/backend/queue/src/dtos/affiliate-conversion.dto.ts
@repo/userDomain — attribution storage + signup SQS publishpackages/backend/domains/user/src/user.service.ts
@repo/apiHandler — attribution capture endpointpackages/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

EventActionTrackerIdWhen reported
signup68897New 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 API

Last-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:

WindowDurationWherePurpose
Cookie (im_ref)30 daysBrowser (first-party cookie)Captures the click ID from the landing URL so it survives until signup or next visit
Referral window (referralWindowDays)30 daysOur backend (ConversionTrackingService)Pre-filter to avoid sending obviously-expired conversions to Impact
Impact’s commission windowVariesImpact.com dashboardThe 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.
  • updateAffiliateAttribution uses dot notation ("attribution.impact.clickId") to preserve other fields.

API

MethodPurpose
user.setAffiliateAttributionStore 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 — reads im_ref from URL, sets cookie; when user is logged in and cookie is set, calls user.setAffiliateAttribution and clears cookie.
  • Component: AffiliateCapture — renders nothing; just runs the hook. Mounted in BusinessProviders next to UTMCapture.
  • SSR forwarding: orpcSSR() in @repo/trpc reads im_ref cookie and forwards as X-Affiliate-Click-Id header.
  • Middleware: setAffiliateTrackingCookie() in Next.js middleware captures im_ref query 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(/* ... */) break

Wire 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.

Code Locations

ComponentLocation
User model (attribution shape)packages/core/db/src/mongo/user.model.ts
Contract (capture)packages/core/api-dtos/src/lib/user/set-affiliate-attribution.ts
Handler (capture)packages/backend/api/src/lib/orpc/handlers/user/set-affiliate-attribution.handler.ts
Set attribution (user domain)packages/backend/domains/user/src/user.service.ts
Signup publish (user domain)packages/backend/domains/user/src/user.service.ts
SQS queue configpackages/backend/queue/src/config.ts
SQS message DTOpackages/backend/queue/src/dtos/affiliate-conversion.dto.ts
Conversion tracking servicepackages/backend/features/affiliate-tracking/src/
SQS consumerpackages/backend/features/affiliate-tracking/src/conversion-tracking.consumer.ts
Consumer registrationapps/backend/src/handlers/registry.ts
Cookie→header forwardingpackages/shared/trpc/src/server/orpc-server.ts
Middleware (header read)packages/backend/api/src/lib/orpc/middleware.ts
Frontend hookpackages/frontend/affiliate-tracking/src/use-affiliate-attribution.ts
Frontend componentpackages/frontend/affiliate-tracking/src/affiliate-capture.tsx
Next.js middleware (cookie set)apps/frontend/middleware.ts
Last updated on