Ad Conversion Tracking (Reddit Pixel + CAPI)
CardNexus mirrors ad-platform conversions both client-side (the browser pixel) and server-side (the platform’s Conversions API). The two halves share a conversion_id so the ad platform can deduplicate them — the pixel catches what runs in the browser, CAPI catches what the browser misses (ad blockers, ITP, server-only events).
Reddit is the only configured channel today. The backend is built as an umbrella (@repo/ad-conversions) so additional channels (Meta, TikTok, …) slot in without touching the call sites.
This is ad-platform attribution (paid acquisition). The send path is deliberately separate from Affiliate & Creator Tracking (Impact.com) — different domain, different payout and regional rules. But both are stored on one user.attribution object keyed by channel (attribution.impact for affiliate, attribution.reddit for ad pixels) and are mutually exclusive: recording a new paid touch from any channel clears every other channel, so only the latest paid touch is ever credited (last-touch wins). This avoids double-counted stats and paying two platforms for one conversion.
Conversion Events
Two events are mirrored, each fired from both the browser pixel and the Conversions API:
| Event | Reddit tracking_type | Browser fires | Server (CAPI) fires | conversion_id |
|---|---|---|---|---|
| SignUp | SignUp | On onboarding completion (reddit_sign_up GTM event) | updateClerkUserMetadata handler, once per user | user._id |
| Purchase | Purchase | On checkout success (reddit_purchase GTM event) | transaction.completed subscriber, once per checkout | transactionNumber (e.g. TX-9F4K2D8M) |
Purchase is keyed on the transaction, not the order. A checkout with three sellers produces three Orders but one Transaction and one purchase event — matching the single browser pixel fire. The browser and server both send conversion_id = transactionNumber, so Reddit collapses the pixel ↔ CAPI pair into one conversion.
Attribution Capture
Reddit’s pixel drops an rdt_cid first-party cookie when a user lands from a Reddit ad. We persist that click ID against the user so later server-side conversions can carry it.
RedditAttributionCaptureis rendered once in the authenticated app layout. It readsrdt_cid, callsuser.setRedditAttribution, and renders nothing. It mirrorsuseAffiliateAttribution.- A
sentRefguard means the call fires at most once per page load; it resets only if the request fails, leaving the capture open for retry. setRedditAttributionwritesattribution.reddit.{clickId, capturedAt}last-touch-wins — each new click overwrites the previous one (referral windows are rolling) and$unsets every other paid-attribution channel (affiliate included), so only the latest touch is credited.
Where attribution is stored
| Location | Purpose | Mutable? |
|---|---|---|
user.attribution.reddit.{clickId, capturedAt, signupConversionSentAt} | Current attribution for the user, alongside attribution.impact. Only one channel is ever populated. Drives the SignUp event and is snapshotted onto orders at checkout. | Yes — overwritten on each new click; cleared when another channel wins |
order.adAttribution.reddit.{clickId, capturedAt} | Snapshot from the buyer at order creation. Drives the Purchase event. | No — set once at creation |
OrderOrchestratorService reads user.attribution.reddit at order creation and snapshots it onto every order in the transaction (as order.adAttribution.reddit) — but only if capturedAt is within the 30-day referral window. Stale clicks never enter the conversion pipeline.
SignUp Conversion
The fire-once guard lives in AdConversionsService, not the Reddit service:
signupConversionSentAtis set only when the send succeeds (CAPI returnedtrue). A failed send leaves it unset so the next onboarding-complete call retries.- The
$setis guarded bysignupConversionSentAt: { $exists: false }, making the claim atomic — concurrent calls can’t both flip it. - The browser
reddit_sign_uppixel fires independently inonboarding-acquisition-ui.tsxwith the sameconversion_id = user._id.
The same handler also fires the affiliate signup conversion (Impact). They are independent code branches, but the underlying attribution is mutually exclusive — a new paid touch clears the others — so in practice at most one channel is populated when signup completes.
Purchase Conversion
- The subscriber listens on
transaction.completed(not per-orderorder.created), so exactly one event fires per checkout. RedditConversionsServicepulls theclick_idfrom whichever order carries the snapshot (all orders in a transaction share the buyer’s snapshot). No snapshot → the buyer didn’t come from Reddit → skip.valueistotalAmountCents / 100(Reddit wants major units),item_countis summed across all order line quantities, and the buyer’s email / IP / user-agent come from theUserdocument.- The browser
reddit_purchasepixel fires incheckout-success-ui.tsxwithconversion_id = transactionNumber.
PII Hashing & Match Signals
Reddit matches conversions to ad clicks using hashed identifiers. capi.events.ts assembles the user block; capi.hash.ts does the hashing.
| Field | Sent as | Notes |
|---|---|---|
email | SHA-256 hex of trim().toLowerCase() | Omitted entirely if absent |
external_id | SHA-256 hex of the user ID | Stable cross-event identifier |
ip_address | Raw | Reddit hashes server-side |
user_agent | Raw | — |
click_id | Raw rdt_cid | Top-level on the event, not in user |
Hashing uses Web Crypto (crypto.subtle.digest), not node:crypto, so the package stays platform-neutral. Empty values are dropped rather than hashed into empty digests, and a user block with no fields is omitted.
Reddit requires at least one match signal (click ID or a hashed identifier) per event. The SignUp guard enforces email presence before sending; Purchase always has external_id (the buyer’s ID) plus the order’s click_id.
The CAPI Client
RedditCAPIClient is a thin wrapper over POST https://ads-api.reddit.com/api/v2.0/conversions/events/{pixelId}:
- Bearer auth with
REDDIT_CAPI_ACCESS_TOKEN. - 5-second timeout via
AbortController. - Fire-and-forget — a non-2xx response logs a warning and returns
false; a thrown request logs an error and returnsfalse. Conversions never block or fail the user-facing flow. track()returns abooleanso the SignUp fire-once guard can distinguish success from failure.
If REDDIT_CAPI_PIXEL_ID or REDDIT_CAPI_ACCESS_TOKEN is unset, RedditConversionsService builds no client and every method no-ops. This is the normal state in local dev and CI — no secrets, no calls, no errors.
The Browser Pixel (GTM)
The Reddit Pixel itself is loaded and configured in Google Tag Manager, not in app code. The app only pushes dataLayer events; GTM tags translate them into Reddit pixel calls.
| App code | GTM dataLayer event | Payload |
|---|---|---|
onboarding-acquisition-ui.tsx | reddit_sign_up | { conversion_id: user._id } |
checkout-success-ui.tsx | reddit_purchase | { conversion_id: transactionNumber } |
<GoogleTagManager /> is only mounted in production (document.tsx), so the pixel does not fire in preview/dev. Both event pushes are useRef-guarded to fire at most once per mount.
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
REDDIT_CAPI_PIXEL_ID | For CAPI | Reddit pixel/advertiser ID; forms the CAPI endpoint path |
REDDIT_CAPI_ACCESS_TOKEN | For CAPI | Conversions API access token (Bearer auth) |
Missing either → server-side CAPI silently disabled. The browser pixel is independent and configured in GTM.
Adding a New Ad Channel
The umbrella is structured so a new channel (Meta, TikTok, …) is additive. The call sites — the onboarding handler and the purchase subscriber — never change.
Add the channel to the attribution schema
In packages/core/db/src/models/user.model.ts, add the channel under attribution (it reuses adChannelAttributionSchema) and to PAID_ATTRIBUTION_CHANNELS in user.service.ts so a new touch clears it. Add it under order.adAttribution too if the channel needs an order-time snapshot. Rebuild @repo/db.
Build the channel service
Add the vendor wire layer as a sibling SDK package — packages/integrations/capi-<channel>/ (mirror @repo/capi-reddit: wire client, event builders, payload types, PII hashing, zero CardNexus deps). Then add packages/backend/features/ad-conversions/src/<channel>/ with a ConversionsService exposing reportSignUp / reportPurchase that imports the SDK. Mirror the Reddit service: build the client from env (returning null when unset so it no-ops), hash PII via @repo/capi-<channel>, and keep sends fire-and-forget.
Fan out from the umbrella
In ad-conversions.service.ts, inject the new service and add a try<Channel> branch in reportSignUp (and a call in reportPurchase). Each channel owns its own skip conditions; the umbrella owns the shared signupConversionSentAt guard.
Capture the click ID
Add a frontend capture hook modeled on useRedditAttribution, persist via setAdAttribution(userId, "<channel>", clickId), and snapshot it onto orders in OrderOrchestratorService alongside the Reddit branch (respecting the 30-day referral window).
Wire the browser pixel
Push the channel’s GTM dataLayer events from the onboarding and checkout-success components, using the same conversion_id the server sends (user._id for signup, transactionNumber for purchase) so the platform can dedupe.