Smart Router — Conversion Tracking API
← Back to admin

Conversion Tracking API

Report a paid conversion back to Smart Router. Send the click ID we minted on the click-out plus the conversion amount, and we'll record revenue against the original offer, campaign, segment, and source.

v1 · production endpoint
On this page

Endpoint

All three handlers below share the same logic. Pick whichever fits the partner platform you're integrating with.

POST {HOST}/api/conversion
GET {HOST}/api/conversion
GET {HOST}/c/:click_id

Getting the click_id

Every click through Smart Router mints a unique click_id (UUIDv4) and auto-appends it as &click_id= to the outbound destination URL:

// Outbound URL the user lands on
https://your-offer.com/landing?u=abc&sr=src&click_id=8a4f1c3e-…-9b22

If your partner needs the value under a different parameter name (e.g. subid1), drop the {clickId} template variable anywhere in the destination URL when configuring the offer:

// Offer destination template
https://your-offer.com/?subid1={clickId}&sr={sr}
Auto-append is non-clobbering. If you've already placed {clickId} manually, Smart Router won't add a second click_id param. The URL stays clean.

Request fields

Send as JSON body (POST) or query string (GET). Field names are case-insensitive and most accept several aliases.

FieldAliasesTypeRequiredDescription
click_id clickId, cid, subid string yes The ID we minted on click-out. 8–80 chars, [a-zA-Z0-9_-].
amount payout, revenue, value number no Conversion value. Defaults to 0. Negative values are clamped to 0.
currency string no ISO 4217 currency code (e.g. USD). Defaults to USD.
txid transaction_id, order_id string no Your transaction ID. Stored for audit but not used for dedupe.

Examples

POST · JSON body

curl -X POST {HOST}/api/conversion \
  -H 'Content-Type: application/json' \
  -d '{"click_id":"8a4f1c3e-3a91-4d2e-9b22-7f01dd33ee10","amount":12.50,"currency":"USD","txid":"ORD-1001"}'

GET · pixel / postback URL

{HOST}/api/conversion?click_id=8a4f1c3e-3a91-4d2e-9b22-7f01dd33ee10&amount=12.50&currency=USD&txid=ORD-1001

Short path · /c/:click_id

{HOST}/c/8a4f1c3e-3a91-4d2e-9b22-7f01dd33ee10?amount=12.50

Response shape

Always returns 200 OK. JSON body:

{
  "ok":       true,
  "accepted": true,
  "clickId":  "8a4f1c3e-3a91-4d2e-9b22-7f01dd33ee10",
  "amount":   12.5,
  "currency": "USD"
}

accepted is false when one of these is true:

Why always 200? Partner ad-networks aggressively retry on non-2xx responses. We accept the postback and indicate disposition in the JSON body to prevent retry storms during minor issues (network glitches, brief outages, slow Firestore reads).

Dedupe & retries

Conversions are deduplicated by click_id. Once a conversion is recorded for a given click, subsequent postbacks for the same click_id are ignored. The first amount wins.

Your platform may safely retry on transient errors — we'll always return 200, and duplicates are silently dropped at the storage layer.

Heads up: dedupe is on click_id, not on txid. If a single user clicks once but makes two separate purchases (two distinct txids), only the first conversion is recorded against that click. Most partners expect this behavior, but worth noting if you care about lifetime value per click.

How it appears in Reports

After successful postbacks, two new metrics show up in the admin Reports view:

The per-offer, per-campaign, per-segment, and per-source breakdown tables also gain Conv and Revenue columns. Past-day totals come from the nightly rollup; today's totals are computed live.

Type 4: Conversion by user_hash

Separate endpoint family for partners who identify users by a DMP user_hash instead of a click_id. Attribution walks back to the most recent proxy transaction we served that same hash within a 90-day window.

Use this when the conversion happens outside the original click session — an email follow-up, a retargeted ad, or a long-fuse purchase where the partner never carried click_id through their funnel but still has a stable user hash from the DMP.

Independent of Type 1–3. This endpoint writes to a separate conversions_by_user collection and never touches the conversions table used by /api/conversion. The two families can run in parallel without cross-contamination.

Endpoints

POST {HOST}/api/conversion-by-user
GET {HOST}/api/conversion-by-user

Request fields

Send as JSON body (POST) or query string (GET). Body wins on conflict when both are present.

FieldTypeRequiredDescription
user_hash string yes Stable DMP hash for the user. Any non-empty printable string up to 128 chars — SHA-256 hex, SHA-512 hex, base64, etc. No format constraint.
amount number yes Conversion value. Must be a finite non-negative number.
txid string yes Your transaction ID. Up to 80 chars. Required — it's the dedupe key together with user_hash.
currency string no ISO 4217 code. Defaults to USD.

Attribution

We query proxy_transactions for the most recent doc where userHash matches and ts is within the last 90 days (ts >= now − 7,776,000,000 ms), ordered by ts descending, limit 1. When a match is found the conversion is attributed to that transaction — its txnId, proxySlug, proxyId, matched-rule fields and action metadata are copied onto the conversions_by_user row and echoed in the response body.

If no matching transaction is found in the window, the conversion is still recorded (data is valuable even without attribution) with attributed: false and attributionReason: "no_recent_txn".

If the attribution query itself fails — e.g. a transient Firestore error, or the composite index is still building — the endpoint still returns 200 and records the row with attributionReason: "lookup_error". Partners should treat this the same as no_recent_txn; the raw row can be re-attributed later from proxy_transactions once the underlying issue clears.

Dedupe

Dedupe key is user_hash + "__" + txid, sanitized to a safe Firestore doc ID. Unlike Type 1–3, the same user can convert multiple times legitimately — dedupe fires only when the same (user_hash, txid) pair is submitted twice. Repeat postbacks return the prior attribution flag:

{
  "ok":        true,
  "accepted":  false,
  "duplicate": true,
  "dedupeKey": "…user_hash__ORD-1001",
  "attributed": true,
  "amount":    12.5,
  "currency":  "USD"
}
Echoed amount and currency are the stored prior values, not the retry's request. If a partner retries a duplicate with a mutated payload (say a corrected amount), the persisted row is unchanged and the response reflects what actually landed. This keeps partner reconciliation aligned with our books even when a postback firing loop mutates its own body.

Response shape

Returns 200 OK for both accepted and duplicate postbacks; 400 for missing or malformed inputs.

// 200 — accepted & attributed
{
  "ok":         true,
  "accepted":   true,
  "duplicate":  false,
  "dedupeKey":  "…user_hash__ORD-1001",
  "attributed": true,
  "attribution": {
    "txnId":            "c1d2e3f4-…",
    "proxySlug":        "black-friday",
    "proxyId":          "px_9k",
    "matchedRuleId":    "rule_42",
    "matchedRuleLabel": "US mobile",
    "ageSeconds":       86400
  },
  "amount":    12.5,
  "currency": "USD"
}

// 200 — accepted, no txn found in the 90-day window
{
  "ok":                true,
  "accepted":          true,
  "duplicate":         false,
  "dedupeKey":         "…user_hash__ORD-1001",
  "attributed":        false,
  "attribution":       null,
  "attributionReason": "no_recent_txn",
  "amount":            12.5,
  "currency":          "USD"
}

// 400 — validation error
{
  "ok":    false,
  "error": "missing or malformed user_hash"
}
Divergence from Type 1–3. The /api/conversion family always returns 200 to keep partner retry loops calm. /api/conversion-by-user returns 400 for malformed input because attribution is part of the response body and the caller needs to know when it never ran. It also awaits the dedupe check, proxy-transaction query, and write before responding, whereas Type 1–3 respond immediately and process asynchronously.

POST · JSON body

curl -X POST {HOST}/api/conversion-by-user \
  -H 'Content-Type: application/json' \
  -d '{"user_hash":"3f8a2c…9b22","amount":12.50,"currency":"USD","txid":"ORD-1001"}'

GET · pixel / postback URL

{HOST}/api/conversion-by-user?user_hash=3f8a2c…9b22&amount=12.50&currency=USD&txid=ORD-1001
Operational note — required Firestore index. Attribution queries need a composite index on proxy_transactions(userHash ASC, ts DESC). It was created once when the endpoint shipped; if the collection is ever recreated the index has to be recreated too, otherwise every call silently falls back to attributionReason: "lookup_error" (rows still land, they just miss attribution). Recreate with:
gcloud firestore indexes composite create \
  --project=lpa-router \
  --collection-group=proxy_transactions \
  --field-config=field-path=userHash,order=ascending \
  --field-config=field-path=ts,order=descending

Integration checklist

  1. Configure your partner's offer to land on the Smart Router destination URL (the click_id auto-appends, no template change needed).
  2. In the partner's postback settings, drop in either the POST or GET URL from Endpoint above.
  3. If the partner's UI offers a macro placeholder for the click ID (e.g. {sub_id}, {aff_sub}), point it at click_id in our endpoint.
  4. Send a test conversion. The Reports view should show 1 conversion within ~5 seconds.
  5. Send a duplicate to confirm dedupe (accepted: false).

Questions? Ping the Smart Router maintainer.