Skip to Content
BackendAd Conversion Tracking (Reddit Pixel + CAPI)

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:

EventReddit tracking_typeBrowser firesServer (CAPI) firesconversion_id
SignUpSignUpOn onboarding completion (reddit_sign_up GTM event)updateClerkUserMetadata handler, once per useruser._id
PurchasePurchaseOn checkout success (reddit_purchase GTM event)transaction.completed subscriber, once per checkouttransactionNumber (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.

  • RedditAttributionCapture is rendered once in the authenticated app layout. It reads rdt_cid, calls user.setRedditAttribution, and renders nothing. It mirrors useAffiliateAttribution.
  • A sentRef guard means the call fires at most once per page load; it resets only if the request fails, leaving the capture open for retry.
  • setRedditAttribution writes attribution.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

LocationPurposeMutable?
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:

  • signupConversionSentAt is set only when the send succeeds (CAPI returned true). A failed send leaves it unset so the next onboarding-complete call retries.
  • The $set is guarded by signupConversionSentAt: { $exists: false }, making the claim atomic — concurrent calls can’t both flip it.
  • The browser reddit_sign_up pixel fires independently in onboarding-acquisition-ui.tsx with the same conversion_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-order order.created), so exactly one event fires per checkout.
  • RedditConversionsService pulls the click_id from 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.
  • value is totalAmountCents / 100 (Reddit wants major units), item_count is summed across all order line quantities, and the buyer’s email / IP / user-agent come from the User document.
  • The browser reddit_purchase pixel fires in checkout-success-ui.tsx with conversion_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.

FieldSent asNotes
emailSHA-256 hex of trim().toLowerCase()Omitted entirely if absent
external_idSHA-256 hex of the user IDStable cross-event identifier
ip_addressRawReddit hashes server-side
user_agentRaw—
click_idRaw rdt_cidTop-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 returns false. Conversions never block or fail the user-facing flow.
  • track() returns a boolean so 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 codeGTM dataLayer eventPayload
onboarding-acquisition-ui.tsxreddit_sign_up{ conversion_id: user._id }
checkout-success-ui.tsxreddit_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

VariableRequiredPurpose
REDDIT_CAPI_PIXEL_IDFor CAPIReddit pixel/advertiser ID; forms the CAPI endpoint path
REDDIT_CAPI_ACCESS_TOKENFor CAPIConversions 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.

Code Locations

ComponentLocation
AdConversionsService (umbrella + signup guard)packages/backend/features/ad-conversions/src/ad-conversions.service.ts
Purchase subscriber (transaction.completed)packages/backend/features/ad-conversions/src/purchase.subscriber.ts
RedditConversionsServicepackages/backend/features/ad-conversions/src/reddit/conversions.service.ts
Reddit CAPI clientpackages/integrations/capi-reddit/src/capi.client.ts
Event builderspackages/integrations/capi-reddit/src/capi.events.ts
PII hashingpackages/integrations/capi-reddit/src/capi.hash.ts
CAPI payload typespackages/integrations/capi-reddit/src/capi.types.ts
SignUp trigger (onboarding handler)packages/backend/api/src/lib/orpc/handlers/user/update-clerk-user-metadata.handler.ts
setRedditAttribution contractpackages/core/api-dtos/src/lib/user/set-reddit-attribution.ts
setRedditAttribution handlerpackages/backend/api/src/lib/orpc/handlers/user/set-reddit-attribution.handler.ts
setAdAttribution (user service)packages/backend/domains/user/src/user.service.ts
Order snapshotpackages/backend/features/orders/src/order-orchestrator.service.ts
Frontend capture hookpackages/integrations/analytics/src/client/reddit-attribution-capture.tsx
Browser SignUp pixelapps/frontend/app/[locale]/(fullscreen)/onboarding/acquisition/onboarding-acquisition-ui.tsx
Browser Purchase pixelapps/frontend/app/[locale]/(default)/checkout/success/checkout-success-ui.tsx
User model (attribution)packages/core/db/src/models/user.model.ts
Order model (adAttribution)packages/core/db/src/models/marketplace/order.model.ts
Subscriber registrationapps/backend/src/bootstrap/event-subscribers.ts
Last updated on