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.
All three handlers below share the same logic. Pick whichever fits the partner platform you're integrating with.
/api/conversion — pixel-style postbacks with query-string params./c/:click_id — short URL when the partner platform only supports path-based callbacks (no query templating).
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}
{clickId} manually, Smart Router won't add a second click_id
param. The URL stays clean.
Send as JSON body (POST) or query string (GET). Field names are case-insensitive and most accept several aliases.
| Field | Aliases | Type | Required | Description |
|---|---|---|---|---|
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. |
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"}'
{HOST}/api/conversion?click_id=8a4f1c3e-3a91-4d2e-9b22-7f01dd33ee10&amount=12.50¤cy=USD&txid=ORD-1001
/c/:click_id{HOST}/c/8a4f1c3e-3a91-4d2e-9b22-7f01dd33ee10?amount=12.50
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:
click_id format is invalid (wrong length or characters).click_id is unknown — we never minted it.click_id (duplicate).
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.
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.
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.
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.
conversions_by_user collection and never touches the
conversions table used by /api/conversion. The
two families can run in parallel without cross-contamination.
Send as JSON body (POST) or query string (GET). Body wins on conflict when both are present.
| Field | Type | Required | Description |
|---|---|---|---|
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. |
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 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"
}
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.
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" }
/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.
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"}'
{HOST}/api/conversion-by-user?user_hash=3f8a2c…9b22&amount=12.50¤cy=USD&txid=ORD-1001
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
click_id auto-appends, no template change needed).{sub_id}, {aff_sub}), point it at click_id in our endpoint.accepted: false).Questions? Ping the Smart Router maintainer.