Affiliate & Creator Tracking (Impact.com)
CardNexus uses Impact.com for affiliate and creator partner tracking. Partners share tracked links that attribute signups and purchases back to them, driving commission payouts.
This page covers the full system: domains, link formats, conversion events, attribution model, deep linking, and the code that wires it all together.
Impact.com Account
| Field | Value |
|---|---|
| Account ID | 6932348 |
| Program | CardNexus - Creator Program |
| Campaign ID | 48046 |
| Credit Policy | Last Click |
| Referral Window | 30 days |
Domains
Two external domains are involved in the tracking flow:
| Domain | Purpose | Infrastructure |
|---|---|---|
go.cardnexus.link | Impact.com tracking domain. Cloudflare-proxied to Impact. Handles click recording, attribution, and redirect to landing page. | DNS proxied to Impact |
af.cardnexus.link | Clean URL shortener for affiliates. Cloudflare Worker that mints an af_id correlation token, injects utm_* + af_id into the landing URL, then 302s to go.cardnexus.link with proper Impact URL structure. | Cloudflare Worker |
Conversion Events
Two events are reported to Impact via their Conversions API:
| Event | ActionTrackerId | When Reported | Amount | CustomerStatus |
|---|---|---|---|---|
| Signup | 68897 | New user creation (when im_ref cookie is present) | None | NEW |
| Purchase | 68898 | Order creation (one per order — a checkout with 3 sellers fires 3 events) | funds.charged (post-coupon, buyer’s currency) | EXISTING |
Both share CampaignId: 48046 and a 30-day referral window pre-filter.
Purchase fires at order creation, not completion. This gives affiliates immediate visibility into conversions. If the order is later cancelled, the conversion is reversed via Impact’s Actions API. Partial refunds update the conversion amount.
Conversion Lifecycle
Purchase conversions follow a lifecycle that mirrors the order:
| Order Event | Impact Action | API Endpoint |
|---|---|---|
| Order created | Create conversion (POST) | POST /Conversions |
| Order cancelled (full) | Reverse conversion (DELETE) | DELETE /Actions with DispositionCode |
| Partial refund processed | Update amount (PUT) | PUT /Actions with new Amount and Reason=ORDER_UPDATE |
Conversions are identified by ActionTrackerId + OrderId — no need to store Impact’s internal ActionId.
Cancellation Disposition Codes
cancelledBy | DispositionCode |
|---|---|
buyer | ITEM_RETURNED |
seller | ORDER_ERROR |
system | ORDER_ERROR |
Refund Logic
When order.refund_processed fires, the subscriber calculates newAmount = funds.charged - funds.refunded:
- If
newAmount <= 0→ reverse the conversion (same as cancellation) - If
newAmount > 0→ update the conversion amount via PUT - If the order is already in a cancelled status → skip (reversal already sent)
Attribution Model
Last-click-wins. Each time a user clicks a new affiliate link, their attribution.impact.clickId is overwritten. The most recent click gets credit for future conversions.
Attribution is stored in two places:
| Location | Purpose | Mutable? |
|---|---|---|
user.attribution.impact.{clickId, capturedAt, afId?} | Current attribution for the user. Used for signup conversion and snapshotted onto orders at checkout. | Yes — overwritten on each new affiliate click |
order.affiliateAttribution.{clickId, capturedAt, afId?} | Snapshot from buyer at order creation. Used for purchase conversion. | No — set once, never changed |
af_id — CardNexus-Side Correlation Token
af_id is a UUID minted by the af.cardnexus.link Worker on every inbound click. It is a property of an impact attribution record, not a separate attribution source. It lives alongside clickId inside attribution.impact.
Invariant: if afId is present, clickId is always present. The inverse is not true — direct Impact partner links (go.cardnexus.link/c/... shared outside our Worker) carry only clickId. A bare af_id with no im_ref is treated as a broken upstream flow and silently dropped.
Why it exists. clickId (Impact’s im_ref) is minted by Impact after our Worker 302s into their tracking domain. We can’t join our own Cloudflare Logpush rows to app-side conversions without crossing the Impact API boundary. af_id is a key we control end-to-end — from the Worker’s log row in cardnexus-datawarehouse.affiliate_logs.af_clicks all the way to user.attribution.impact.afId and order.affiliateAttribution.afId.
Example BigQuery join:
SELECT c.af_id, c.mp_id, c.event_ts, u._id AS user_id
FROM `cardnexus-datawarehouse.affiliate_logs.af_clicks` c
JOIN `<users-mirror>` u ON u.attribution.impact.afId = c.af_idCapture flow:
- Worker mints
af_idand injects it into the landing URL alongsideutm_*(see the Clean URL Shortener section for params). - Next.js middleware reads
?af_id=…and sets a first-party cookie (af_id, 30 days, alongsideim_ref). - On authenticated requests,
orpcSSRforwards the cookie asX-Affiliate-Af-Idheader alongsideX-Affiliate-Click-Id. - Backend signup middleware writes both to
user.attribution.impactwhenisNewUser. The frontend hook does the same for returning users viauser.setAffiliateAttribution. OrderOrchestratorsnapshotsafIdontoorder.affiliateAttributionat order creation, next toclickId.
No backfill: pre-existing users and orders without afId are fine. The field is forward-only and only populated for traffic routed through af.cardnexus.link.
Link Formats
Standard Impact Tracking Link
Partners get their tracking link from Impact’s dashboard:
https://go.cardnexus.link/c/{mpId}/{adId}/48046{mpId}— partner’s media partner ID (from Impact){adId}— ad/creative ID (from Impact)48046— CardNexus campaign ID (constant)
Impact records the click and redirects to the configured landing page with im_ref appended.
Deep Links (Product-Specific)
To link to a specific product, partners use the u parameter to override the landing URL:
https://go.cardnexus.link/c/{mpId}/{adId}/48046?u=https%3A%2F%2Fcardnexus.com%2Fgo%3Ftcg%3D631412%26q%3DCharizard%2BBase%2BSetImpact redirects to:
https://cardnexus.com/go?tcg=631412&q=Charizard+Base+Set&im_ref=<click-id>Clean URL Shortener (af.cardnexus.link)
af.cardnexus.link provides clean, readable URLs for affiliates. The Cloudflare Worker:
-
Mints
af_id = crypto.randomUUID()per click and logs the request to BigQuery via Logpush (cardnexus-datawarehouse.affiliate_logs.af_clicks). -
Builds the landing URL
https://cardnexus.com/go?…with injected tracking params:Param Value utm_sourceafutm_mediumaffiliateutm_campaign{mpId}utm_content{afId}utm_term{shape}(marketplace/search/general)af_id{afId} -
302s to
go.cardnexus.linkwith that URL encoded as Impact’su=param. -
Impact 302s back to
https://cardnexus.com/go?…&utm_*=…&af_id=…&im_ref=<click-id>— so the landing URL carries both ouraf_idand Impact’sim_ref.
See specs/impact-affiliate/brief-cloudflare.md for the Worker implementation brief and specs/impact-affiliate/brief-af-id.md for the app-side af_id persistence design.
Product deep-link (with CardNexus or marketplace ID):
af.cardnexus.link/{mpId}/cn/{productId}
af.cardnexus.link/{mpId}/cn/{productId}/{product name}
af.cardnexus.link/{mpId}/tcg/{productId}
af.cardnexus.link/{mpId}/tcg/{productId}/{product name}
af.cardnexus.link/{mpId}/cm/{productId}
af.cardnexus.link/{mpId}/cm/{productId}/{product name}Search link:
af.cardnexus.link/{mpId}/{search terms}General link (explore page):
af.cardnexus.link/{mpId}Examples:
af.cardnexus.link/1111/cn/50212af.cardnexus.link/1111/cn/50212/Charizard%20Base%20Setaf.cardnexus.link/1111/tcg/12345af.cardnexus.link/1111/tcg/12345/Charizard%20Base%20Setaf.cardnexus.link/1111/cm/67890/Black%20Lotusaf.cardnexus.link/1111/Charizard%20Base%20Set
| Segment | Description |
|---|---|
{mpId} | Partner’s media partner ID (from Impact) |
cn / tcg / cm | ID source: cn = CardNexus public product id, tcg = TCGPlayer, cm = Cardmarket |
{productId} | CardNexus public numeric id (cn) or the product’s external marketplace ID (tcg/cm) |
{product name} | Optional — product name used as search fallback |
Product names and search terms must be URI-encoded. Spaces should be encoded as %20 (e.g. Charizard%20Base%20Set). Special characters like &, ?, #, / must also be percent-encoded.
/go Route — Query Parameters
The /go route on the app receives the redirect from Impact (after click tracking) and resolves external product IDs to internal product pages.
| Param | Description | Example |
|---|---|---|
cn | CardNexus public numeric product id (products.public_id) | 50212 |
tcg | TCGPlayer external product ID | 631412 |
cm | Cardmarket external product ID | 67890 |
q | Product name (fallback search query) | Charizard+Base+Set |
game | Game slug (optional, disambiguates search) | pokemon |
im_ref | Impact click ID (captured by middleware as cookie) | abc123 |
af_id | CardNexus Worker correlation token (captured by middleware as cookie) | a7f3-… |
utm_source, utm_medium, utm_campaign, utm_content, utm_term | Standard UTM params injected by the Worker | utm_source=af |
Resolution priority: cn public-id lookup → tcg ID lookup → cm ID lookup → search by q → /explore homepage.
The route is excluded from i18n locale prefixing. It reads the NEXT_LOCALE cookie to determine the correct locale prefix for the redirect target.
UTM + tracking param forwarding. /go copies utm_*, af_id, and im_ref onto the destination URL on every redirect branch. Without this, Amplitude’s auto-capture (which reads window.location.search at SDK init on the landing page) would see an empty search and attribute all affiliate traffic as “direct”. The cookie capture in middleware is independent of this forwarding — both mechanisms run.
End-to-End Flows
Signup Conversion
Purchase Conversion (with Cancellation & Refund)
Deep Link Resolution
Last-Click-Wins (Returning Users)
Mobile (AppsFlyer)
On mobile, the web’s cookie-based capture isn’t available, so we go through an MMP (Mobile Measurement Partner): AppsFlyer. AppsFlyer attributes installs and re-engagement on iOS and Android, then we relay the Impact clickId to the same backend conversion path the web uses.
Link Wrapping
Creator links pass through Impact first, then AppsFlyer’s OneLink:
Creator's Impact tracking link → go.cardnexus.link → cardnexus.onelink.me
(clickid = Impact clickId)
│
â–Ľ
App Store / Play Store
│
â–Ľ
App opens → SDK
fires conversion dataThe Impact clickId is carried into AppsFlyer as the OneLink clickid parameter (the device reads clickid first, falling back to af_sub1 only for non-Impact test links). AppsFlyer’s SDK then surfaces it back through onInstallConversionData (cold install) or onAppOpenAttribution (re-engagement) on the device.
Capture and Persistence
Two channels carry the attribution after sign-in, mirroring the web flow:
- Header forwarding. While
pendingAttributionis non-empty, the mobile oRPC client attachesX-Affiliate-Click-Id(andX-Affiliate-Af-Idif present) to every authed request. The first such request lets the backend signup-conversion middleware credit Impact synchronously for new users (the same path web’s SSR uses). setAffiliateAttribution. A hook watches Clerk’sisSignedInand flushes the pending attribution viaorpc.user.setAffiliateAttribution, persisting it touser.attribution.impactso later purchase conversions snapshot correctly.
The MMKV buffer applies a 30-day TTL on read (matching the web cookie window). Failures are retried on subsequent renders — last-click-wins still applies.
setCustomerUserId
Once /me resolves we call appsFlyer.setCustomerUserId(userId) — the internal user ID, not the Clerk ID, since backend linkage keys on the internal ID. This lets future server-to-server events (purchase, refund) tie back to our user record in AppsFlyer’s dashboard.
Deferred Deep Linking — Out of Scope
The MVP intentionally does not route AppsFlyer installs to specific in-app destinations. New installs land on the default home screen. The native deep-link configuration (linking config, ios.associatedDomains, android.intentFilters for applinks:cardnexus.onelink.me) and the navigationRef plumbing are owned by a separate mobile branch. Once that lands, parsing deep_link_value from the AppsFlyer callback and routing the user is a small follow-up.
Environment
| Variable | Required | Purpose |
|---|---|---|
EXPO_PUBLIC_APPSFLYER_DEV_KEY | Yes | Dev key from the AppsFlyer dashboard. Used to initialise the SDK. |
EXPO_PUBLIC_APPSFLYER_IOS_APP_ID | Yes (iOS) | Numeric Apple App Store ID (no id prefix). Required for iOS attribution. |
ATT permission copy lives in app.config.ts under ios.infoPlist.NSUserTrackingUsageDescription and the expo-tracking-transparency plugin config.
Attribution Windows
| Window | Duration | Where | Purpose |
|---|---|---|---|
Cookie (im_ref) | 30 days | Browser (first-party cookie) | Captures click ID from landing URL, survives until signup or next visit |
Cookie (af_id) | 30 days | Browser (first-party cookie) | Captures CardNexus Worker correlation token, mirrors im_ref lifecycle |
Referral window (referralWindowDays) | 30 days | Backend (ConversionTrackingService) | Pre-filter — skip obviously-expired conversions before calling Impact |
| Impact’s commission window | Configurable | Impact.com dashboard | Authoritative source of truth for whether a conversion earns commission |
Our referralWindowDays is a conservative pre-filter. Impact’s own window is what ultimately determines commission eligibility.
Impact Dashboard Configuration
Payout Setup (Purchase Event)
The Purchase event (68898) payout is configured in Impact’s dashboard as a percentage of the order sale amount. The Amount we send is funds.charged — what the buyer actually paid (post-coupon, in their checkout currency). Impact normalizes currencies on their side.
To give partners a share of your commission:
- If your platform commission is ~5-8%, and you want to share 50%, set the payout percentage to ~2.5-4%
- Use Payout Groups for per-partner rates
Action Locking & Payout Scheduling
These are configured per-event-type in the Impact dashboard and control when conversions lock and when payouts are processed.
Important: Since Purchase now fires at order creation (not completion), ensure the Impact action locking window is long enough to account for possible cancellations and refund adjustments.
SQS Message Format
Messages are published to the affiliate-conversions queue as a discriminated union on campaignEventId:
// Signup
{ campaignEventId: "signup", userId, clickId, capturedAt }
// Purchase
{ campaignEventId: "purchase", userId, clickId, capturedAt, orderId, totalAmountCents, currency }
// Reversal (cancellation)
{ campaignEventId: "reversal", orderId, cancelledBy }
// Amount Update (partial refund)
{ campaignEventId: "amount_update", orderId, newAmountCents, currency }Queue config: standard queue, 1 concurrent consumer, 3 retries before DLQ, 4-day retention.
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
IMPACT_ACCOUNT_SID | Yes | Impact.com advertiser account ID (for API auth) |
IMPACT_AUTH_TOKEN | Yes | Impact.com API token (Basic auth with account SID) |
If missing, conversions are silently skipped with a warning log.
How to Add a New Conversion Event
Add the event to the enum
In packages/backend/features/affiliate-tracking/src/campaigns/campaign-event.ts:
export enum CampaignEvent {
Signup = "signup",
Purchase = "purchase",
MyNewEvent = "my_new_event", // new
}Add config with campaignId and actionTrackerId
In packages/backend/features/affiliate-tracking/src/campaigns/config.ts. Get the actionTrackerId from the Impact.com dashboard (Event Types settings):
[CampaignEvent.MyNewEvent]: {
campaignId: 48046,
actionTrackerId: 99999, // from Impact dashboard
referralWindowDays: 30,
},Add payload type to the payload map
In packages/backend/features/affiliate-tracking/src/campaigns/payload-map.ts:
[CampaignEvent.MyNewEvent]: { orderId: string; totalAmountCents: number; currency: string }Add the SQS message schema
In packages/backend/queue/src/dtos/affiliate-conversion.dto.ts, add a Zod schema and include it in the discriminated union:
export const myNewEventSchema = z.object({
campaignEventId: z.literal("my_new_event"),
...baseFields,
orderId: z.string(),
totalAmountCents: z.number().int().positive(),
currency: z.string(),
})
// Add to the union:
export const affiliateConversionMessageSchema = z.discriminatedUnion(
"campaignEventId",
[signupConversionSchema, purchaseConversionSchema, myNewEventSchema],
)Add a consumer branch
In packages/backend/features/affiliate-tracking/src/conversion-tracking.consumer.ts:
case "my_new_event":
await this.conversionTrackingService.report(
CampaignEvent.MyNewEvent, userInfo,
{ orderId: message.orderId, totalAmountCents: message.totalAmountCents, currency: message.currency },
)
breakWire the publisher
Publish to the "affiliate-conversions" queue from wherever the conversion happens. For events that should only fire on completion (like purchases), use an EventBus subscriber pattern — see PurchaseConversionSubscriber for the example.