Postbacks: receiving conversions from affiliate networks
The admin panel is currently available in Russian only; menu items and button labels in this guide are translated.
Protection against "zero payout with no warning"
If the endpoint mapping has no amount parameter, or the network sends the money under a name
the mapping does not expect, a conversion with a payout of 0 and a 200 OK response to the
network would go unnoticed until you reconcile with the network's account. So every postback
whose final amount is 0 goes through checks (after the click has been found and its offer is
known) in order of priority, from the most reliable signal to the least obvious one:
- When a mapping is created or changed, an empty
sumfield cannot be saved without an explicit confirmation flag (theallow_no_sum=1query parameter): a regular save through the form or the API with an emptysumreturns a validation error. - The money is visible in another request parameter. If the amount parameter read by the
mapping came in as zero (
payout=0.00), and next to it there is an unread parameter that looks like an amount (by substring:sum,amount,payout,revenue,payment,price,value,commission,reward,profit,income,cost) with a non-zero value (amount=12.50), the conversion goes to theunread_sum_paramquarantine with a list of those parameters. The tracker does not pick a number on the operator's behalf: both readings may be right, so this is a manual review. - The amount parameter is not configured or missing from the request, but a money-like
parameter is present. Same
unread_sum_paramquarantine: the network apparently sent the payout under a name that is not connected in the mapping. - There is nothing that looks like an amount in the request at all. For the tracker this
is indistinguishable from "the event really has no payout" (for example, a
leadwith no deposit) and from "the network named the parameter with a word that is not in the hint list above". This is exactly the rare case that used to pass silently. Now, if the matched click's offer has a fixed payout model (payout_model=cpa/cpl) and a non-zero referencepayout, the conversion is recorded with that reference amount instead of zero, marked with the service field_sum_source=offer_default(visible in therawof the conversion or quarantine entry), thepostback_sum_offer_defaultalert is published and theinotrack_postback_sum_offer_default_totalmetric goes up. The money is not lost silently, but the substitution is visible and tells you to fix the mapping to the network's real parameter name. Forrevshare/hybridoffers the referencepayoutis not a specific conversion amount (it depends on the player's deposit or bet), so there is NO substitution, and the conversion is recorded as zero, as before. - The last line of defense, regardless of items 2–4: if an APPROVED (approve/ftd/dep/
rebill) conversion is still recorded with a zero payout on an offer with a non-zero reference
payout(for example, revshare/hybrid from item 4, or a legitimate technical zero on a CPA offer), thepostback_approved_zero_payoutalert is published and theinotrack_postback_approved_zero_payout_totalmetric goes up. This does not block the write (the network may be right about the zero), but it makes the case visible right away rather than after a month of reconciliation.
What is still not covered: the offer_default substitution (item 4) is the offer's
reference payout, not the number the network actually sent for a SPECIFIC conversion (for CPA
with a fixed rate these are usually the same, but not guaranteed: for example, the network may
actually pay less because of its own adjustments). For revshare/hybrid offers with no
explicit money parameter in the request, zero is still recorded with no quarantine and no
substitution: guessing a variable amount is more dangerous than leaving zero and relying on
the alert from item 5. Check by hand: after connecting a new network, send a test postback
(see below) and compare the amount in the response with what the network actually sent.
Incoming link format
GET/POST https://<tracker-domain>/pb/{token}?clickid={clickid}&status=approve&sum=25&txid=123
{token}is a separate token for each postback endpoint (not to be confused with the flow token). One endpoint = one connection to one specific network.- The method is
GETorPOST(includingapplication/x-www-form-urlencodedin the body). If the same parameter comes both in the query and in the body, the body wins. - Parameter names (
clickid,status,sum,txid...) are configured in the endpoint mapping for a specific network: this is not a fixed list but whatever you specify. - Optionally, an IP/CIDR allowlist for the endpoint: if it is set, requests from outside the
list get
403.
The response is almost always 200 OK, even if the postback ends up in quarantine or is
suppressed by dedup. This is deliberate: quarantine and dedup are the tracker's internal
states, not a reason to start a retry storm at the network for a request that was already
accepted.
⚠️ Important about when the response is sent: /pb/{token} is a fast synchronous
receiver. It only does offline checks (whether the mapping parses, whether there is a
clickid), immediately puts the postback into a persistent Postgres queue and answers the
network 200 accepted, with the body "accepted". The actual processing (dedup, click
lookup, amount check for quarantine, writing the conversion) is done asynchronously by a
background worker (it polls the queue every 500 ms). So the body of the immediate response
will NOT contain a literal duplicate or quarantined: click_not_found: these are outcomes you
need to look for in the quarantine list (GET /api/internal/postback/quarantine, or through the
proxy GET /api/v1/postback/quarantine) or in the intake queue log, not in the response to the
HTTP request itself. Only quarantined: no_clickid, quarantined: mapping_error and
quarantined: bad_sum are visible synchronously in the response: these are the only checks
that do not need the database.
A missing click does not lead to quarantine right away either: if the click has not yet made
it from the click path buffer into ClickHouse, the worker retries with backoff (up to 20
attempts) and moves the postback to the click_not_found quarantine only on the last one. So
the final status of a postback may become known a few seconds after the 200 accepted
response, not at the moment of the request.
Conversion status model
Funnel stage order: reg → lead → approve/reject → ftd → dep → rebill
(approve and reject are the same stage: the decision on the lead).
Each postback with a new status updates the same conversion instead of creating a
duplicate; the status change history is recorded separately (who, when, old status → new).
If the new status "goes back" in the funnel order (for example, lead arrives after ftd),
the write is not blocked (the network is the source of truth for its own data), but it is
marked as an atypical transition, which helps when debugging the mapping of a new network.
Separately from goal (reg/lead/approve/...), a collapsed three-value status is stored:
pending / approved (approve, ftd, dep, rebill) / rejected. Reports that need "approved
revenue" regardless of the specific funnel step use it.
Deduplication
Idempotency is by the combination (network, clickid, goal, txid): when the same set
arrives again, the conversion is not written a second time. The dedup check itself is part of
the asynchronous processing (see the warning above), so the HTTP response to a duplicate request
is still 200 accepted, not 200 duplicate: you cannot recognize a duplicate by the immediate
response, only by the processing log. The dedup key lives in Redis for 30 days. If the network
sends repeated postbacks for the same lead with a growing status (lead → approve → ftd),
each new goal is a new dedup point; old statuses do not block new ones.
Postback with no click / no clickid
- No clickid parameter at all →
no_clickidquarantine. - There is a clickid, but there is no click with that ID in the database (expired, a typo on
the network side) →
click_not_foundquarantine. If the click simply has not been written to ClickHouse yet, it is not quarantined on the first attempt: the background worker retries with backoff (see the warning above about asynchronous processing) and quarantines it only when the attempts run out. - The status parameter does not match any value in the mapping dictionary and does not look
like one of the standard values (
reg/lead/approve/reject/ftd/dep/rebill) →mapping_errorquarantine. - The amount arrived but is not a number (negative, non-numeric or ambiguous like
1,234) →bad_sumquarantine. - The amount arrived but is not read by the mapping (see the section above) →
unread_sum_paramquarantine.
Quarantined postbacks are not lost: they are stored in a separate table with the original request parameters, and from there the conversion can be matched to the right clickid by hand.
Attribution windows
Each offer has a click lifetime for attributing a conversion (attribution_hours) and a
model: last_click (default) or first_click. If attribution_hours is omitted or 0 in
POST /api/v1/offers, the offer gets the default of 720 hours (30 days). A conversion that
arrives after the window is still recorded, but flagged as outside the attribution window and
not counted as attributed.
Test postback
There is a separate dry-run endpoint that runs the mapping and all the checks (including the click lookup and the "amount is not read" detection) but does not write anything: handy for checking a new network mapping before giving the network the live link.
Network presets
Ready-made (starter) parameter mappings and status dictionaries exist for: Leadgid,
Pampadu, Unicom24, Guruleads, Leads.su, PIN-UP Partners, 3snet, Vavada, RevenueLab, Zeydoo,
RakeCore/Rollix, AppMetrica, CityAds, Admitad, plus custom for an arbitrary mapping. All of
them are in the drop-down list of the connection form in the panel. A preset does not change
connections created earlier: each has its own saved mapping.
⚠️ Important: parameter names and status values in the presets are typical for the CPA network market at the time of writing, but have not been checked line by line against the current documentation of each specific network. A preset is a starting point for filling in the form, not a guaranteed out-of-the-box integration. When connecting a network, check the mapping against its current documentation and fix it if needed (the endpoint mapping fully overrides any preset field).
Example preset fields (PIN-UP Partners): clickid → clickid, status → status,
sum → sum, txid → txid, status dictionary registration/reg → reg,
qualified → lead, ftd → ftd, deposit/repeated_dep → dep, default currency USD.
The full list of fields and dictionaries for the other presets is in the response of
GET /api/v1/postback/presets.
Checked against the networks' documentation on October 4, 2026:
- Leadgid: checked against the variable reference on the "Postbacks" screen of the
my.leadgid.com publisher account (there is no public help center: help.leadgid.com is
closed). Our clickid goes into the offer link's
aff_sub("SubID 1 you specified in the tracking link"), and the postback returns it as{aff_sub}.{status}values:pending→ lead,approved→ approve,rejected→ reject (the preset's old keyslead/hold/trashare kept). Amount{payout}, currency{currency}(without it, RUB), txid{lead_id}(one for all status changes of a lead), external offer id{offer_id}, goal{goal_id}in the extra fieldgoal_id. Leadgid does not send a postback for thepending→rejectedtransition (as stated in the account), so such rejections are only visible when reconciling with the network's statistics. There is no public API documentation (Swagger is only in the account), and there is no separate reconciliation adapter. - Admitad: checked against the "Postback URL" article in the Admitad knowledge base
(https://admitad.useresponse.com/knowledge-base/article/postback-url_5). The postback is set
up in the account: Tools → Postback URL, event "Action". Macros are in triple square brackets.
Our clickid goes into
subid4of the affiliate link (…/g/…/?subid4={clickid}): Admitad reservessubid4for the click id and transaction id, andsubid…subid3stay free for source labels. The postback returns it as[[[subid4]]]. Status[[[payment_status]]]:new→ lead,approved→ approve,declined→ reject,pending(an approved action was put back on hold) → lead. Amount[[[payment_sum]]](publisher earnings), currency[[[currency]]](the program currency; without it, RUB), txid[[[action_id]]](one for all status changes of an action), external offer id[[[offer_id]]].[[[order_id]]],[[[order_sum]]],[[[type]]](lead/sale) and[[[reward_ready]]](the "ready for withdrawal" flag; it does not affect the goal) go into extra fields. The preset's parameter names match Admitad's standard names, so both the account's "Simple mode" (full set of parameters) and "Advanced mode" work. - CityAds: checked against the "Conversion Postback" section of the publisher documentation
(http://userdocs.cityads.com/docs/udocs/en/latest/content/tools/ConversionPostback.html).
Macros are in curly braces. Our clickid goes into the link's
xid, and the postback returns it as{xid}. Status{status}:open→ lead,approved→ approve,rejected→ reject. Amount{payout}(publisher commission, decimal point as separator), currency{payout_currency}(the publisher account currency; the documentation example sendsRUR, which intake treats as rubles), txid{cpl_id}(shared by the lead and the sale of one order), external offer id{offer_id}.{action_type}(CPL/CPA) and{order_amount}go into extra fields. Amount, currency and status are only filled in the sale (CPS) postback; in the lead (CPL) postback the amount and currency are empty, so the conversion is recorded as a lead with no money. The postback is created in the account (Tools → Conversion Postback) and goes through CityAds moderation. - Guruleads: no public documentation on the link, macros and statuses was found (postback
help is only in the account). The
guruleadspreset is the earlier starting point, not checked. - Saleads: not found. The network accepts any labels in the link (saleads.pro/tools,
"Subaccounts"), but postback macros and statuses are only shown in the account ("Tools →
Global postback", a separate link for each event). There is no preset: connect with your own
mapping (
custom), and set the status in each of the account's links by hand.
Pampadu, Unicom24 and Leadgid: a ready-made link for the network's account
For these networks the tracker has a macro reference (GET /api/internal/postback/networks).
The "For the network" button in the list of connections builds the string from the
connection's actual mapping (GET .../endpoints/{id}/network-template). For Leadgid the string
is so far only returned by the API: in the panel the button is shown for Pampadu and Unicom24;
build the Leadgid link from the table below:
| Network | Where in the account | Link |
|---|---|---|
| Pampadu | For publishers → Postback, GET | https://<domain>/pb/<token>?clickid={sub1}&status={status}&sum={sum}&order_id={order_id} |
| Leadgid | Tools → Postbacks → Global → Create, GET, events "New conversion" and "Status update" | https://<domain>/pb/<token>?clickid={aff_sub}&status={status}&payout={payout}&lead_id={lead_id}&offer_id={offer_id}¤cy={currency}&goal_id={goal_id} |
| Unicom24 | Tools → Postback → Create, "Global", GET, statuses "In progress", "Approved", "Declined", "Paid" | https://<domain>/pb/<token>?click_id={click_id}&conv_status={conv_status}&payment_sum={payment_sum}&redirect_id={redirect_id}&offer_id={offer_id}&is_paid={is_paid}&sub_id1={sub_id1}&status={status} |
Statuses: Pampadu Pending, Check → lead (hold), Approved → approve, Declined → reject.
Unicom24 conv_status: pending → lead, approved → approve, declined → reject. The redirect
status {status} (receive/accept/…) and {is_paid} are written into the conversion's extra
fields. Unicom24's variables have no application id, so the txid is the redirect hash
{redirect_id}: it is the same for all status changes of the application. Check the Pampadu
amount macro in the account and click "Test" there: /pb responds 200 even if the test clickid
is not found (the request goes to the queue, then to quarantine).
Reconciling with the network report and backfill
Pampadu and Unicom24 have no statistics API. The "Reconcile with network" tab (admin only)
accepts a conversion export from the network's account: CSV (;, , or tab, UTF-8 or
Windows-1251) or XLSX, up to 10 MB and 50,000 rows. Columns are matched to the fields clickid,
status, application ID, payout, date, offer ID and currency. The mapping is picked by the
headers and saved on the connection.
Each row is compared with the current decision for (clickid, txid) in ClickHouse within a
window of up to 30 days. Possible outcomes: missing (we have no such conversion),
status_behind (we have a lead, the network has a decision), status_conflict, payout_diff,
ours_ahead, ours_only, ok, skipped (no clickid, row older than the window, no click,
status not recognized, duplicate row). If none of the application IDs in the file for a click
matched any of our transactions for that click, the row gets status_conflict, not missing:
most likely the wrong identifier was picked for the "Application ID" column, not the one the
postback sends, and a backfill would double the conversion (a showcase, for example,
may use a key like it:{clickid}:{order_id}). A lagging status is backfilled into the same transaction, with the
txid of our decision. "Check" only builds the report. "Backfill" puts missing and
status_behind into the regular postback intake queue, with parameters under the connection
mapping's names. From there they take the regular path: status dictionary, dedup, antifraud,
exchange rate, write, conversion webhook. How a backfill differs:
- the conversion is dated with the date from the file, clamped to [click, now];
- caps do not count it;
- outgoing postbacks to traffic sources are not sent by default (there is a switch in the form);
- the event and the
conversionwebhook have the fieldOrigin: "reconcile". The other fields are unchanged.
The service parameters _it_* in a queue row are only set by reconciliation. They are stripped
from network requests to /pb. Uploading the same file again does not duplicate anything. Runs
and their rows are stored, and the aggregate is written to the finance reconciliation data.
API reconciliation: Admitad, CityAds, Leads.su
For networks with a conversions API, reconciliation runs on its own, with no file. It is
enabled in the network connection settings (source "API"), field "API conversion
reconciliation" (config.api_reconcile). The default is report. Every
api_reconcile_interval_min minutes (60 by default, from 15 to 1440) the integration scheduler
fetches the network's conversions for the last api_reconcile_days days (7 by default, no more
than 30). Then the rows go into the same reconciliation as a file, using the advertiser's
postback connection for this network: the cityads, leads_su or admitad preset, or
config.reconcile_preset (an Admitad connection created before the preset existed is usually
custom; specify that).
| Network | API method | clickid | Conversion ID (txid) |
|---|---|---|---|
| Admitad | GET /statistics/actions/ |
the clickid_param label; if not set, subid, and if subid is empty, subid4 |
action_id |
| CityAds | GET …/xml/orderstatistics/{from}/{to}, by conversion date; the asynchronous report (204 + request_id) is read to the end |
xid |
submissionID; config.reconcile_txid = order takes orderID, none turns it off |
| Leads.su | GET conversions, all statuses |
the clickid_param label (aff_sub1 by default) |
id; config.reconcile_txid = none turns it off |
The adapter maps statuses to pending/approved/rejected. The preset dictionary turns them
into the postback macro values, for example open for CityAds or accepted for Leads.su.
Modes:
report: only a report in the connection's reconciliation history, labeled "API reconciliation ·"; backfill: report plus backfill. A lost postback (missing) and a status that lags on our side (status_behind) are put into the intake queue withOrigin: "reconcile", the same as the "Backfill" button. It can only be enabled after a report with matching txids, see below;off: reconciliation is off.
A repeated run does not duplicate anything. A late postback with the same txid is cut off by
dedup (network, clickid, goal, txid). Conversions younger than api_reconcile_grace_min
minutes (120 by default) wait for the next run, because their postback may not have arrived
yet. The aggregate for the period is written to the "Finance → Reconciliation" section.
An Admitad offer link from the catalog is the program's gotolink with subid4={clickid}
(config.clickid_param changes the label: subid, subid1…subid4). The import does not
touch a link the operator edited by hand: for example, subid={clickid} on older connections
stays, and reconciliation still reads the clickid from subid. If subid is taken by your own
source label and the clickid goes into subid4, set clickid_param = subid4 explicitly. The
Admitad deeplink generator (a link to a specific store page) is not connected to the catalog
import: the catalog does not know the target page, and a request per program on every sync is
extra load on the API. For now, take the page link from the Admitad account (Deeplink
generator) and put it into the offer with subid4={clickid}.
Each run is recorded in the integration's upload log (type stats). It shows the number of rows, deferred fresh rows and the reconciliation result.
txid match and enabling backfill
Backfill writes the conversion with the txid from the network's API. If that is not the same id
the network sends in the postback, a late postback will miss dedup and be recorded as a second
conversion with a payout. The CityAds documentation does not say which statistics id matches
the postback's {cpl_id}. Leads.su may not pass a conversion id in the postback at all. So each
run computes the txid match rate:
- M: network rows whose click id has a conversion on our side;
- N: of those, rows whose txid equals the txid of our conversion for that click.
The panel shows "txid match: N of M (X %)" for the integration: on the import page and in the
connection form next to the reconciliation mode. In the run log these are the fields
txid_compared, txid_matched, txid_alt (which API id field our txid matched) and
txid_advice. A click's second conversion with a lost postback does not count as a mismatch
if the click's other conversions matched.
backfill can be enabled in the panel and through the API (PUT /integrations/{id}) only if
the last report run, no older than 7 days, showed a match of 95 % or more with M of at least
20, and reconcile_txid and reconcile_preset have not changed since. Otherwise the core
responds 409 with the reason and the numbers. Changing reconcile_txid or reconcile_preset
while backfill is on is checked the same way. To check in advance:
GET /integrations/{id}/reconcile-check.
An administrator can enable backfill without this confirmation: the "I understand the risk of
duplicates" checkbox in the form, or the accept_duplicate_risk: true field in the API. Such an
enable is written to audit_log with the action integration.api_reconcile_backfill_forced
and the refusal text. A regular enable based on the report is written with the action
integration.api_reconcile_backfill.
Reconciliation does not change the mapping itself. When the match is low, the report suggests what to do:
| What the report shows | What to do |
|---|---|
CityAds: the postback txid matches orderID, not submissionID |
reconcile_txid = order, wait for a new report |
| Our conversions have no txid: the postback does not pass a conversion id | add the id macro to the postback link ({cpl_id} for CityAds, the conversion id in action_id for Leads.su), or reconcile_txid = none |
| No API id field matched | check which macro is in the txid parameter of the postback link; do not enable backfill |
Conversion antifraud
Each incoming conversion is checked for signs of fraud and, if triggered, marked with the
suspicious flag and a reason (in a separate report; it is not removed from the database
automatically, the decision stays with the operator):
- the conversion arrived with no matching click in the database;
- an abnormally fast click → conversion transition;
- a burst of conversions from one subnet/device;
- the source's CR is above a plausible threshold;
- a conversion on a click the bot module marked as a bot.
"Lost postbacks" alert
Reconciliation with the network in backfill mode fixes the numbers, but with a delay of its interval, and says nothing about the cause. If reconciliation keeps backfilling conversions, this network's postback is not getting through: the link in the network's account is turned off, points to an old domain or responds with an error.
After each reconciliation run started by an integration (via the API for Admitad, CityAds,
Leads.su, or via the Unicom24 account; on schedule or with the "Reconcile now" button), the
tracker counts the missing rows put into the intake queue. Updating a lagging status
(status_behind) does not count: a postback for that conversion did arrive. A "report only"
run (report, the default mode) backfills nothing and raises no alert: to get the alert,
enable backfill (backfill) on the integration. Reconciliation from an uploaded file is not
counted.
An event is created if a run backfilled at least 3 conversions or at least 10 % of the conversions the network returned for the reconciliation window. Both thresholds can be changed in the panel: "Alerts" → "Lost postbacks", fields "Min. volume" and "Threshold" (0 in "Threshold" turns off the share check).
| Where | What arrives |
|---|---|
| "Attention" in the panel and the app | An item per integration: the network, how many were backfilled in the last 24 hours and in the last run, an example click id, a "Postback settings" button (the connection card, /postbacks?id=…). Visible to administrators. |
| Push to the app | To the same administrators. Tapping it opens "Attention" on this item; the "Postback settings" button leads to the panel. Turned off in the app: "More" → "Notifications" → "Lost postbacks" (setting lost_postbacks, on by default). The switch is available to an administrator if the tracker version knows this kind; in an older app version there is no such item, and pushes still arrive. |
| Telegram | To the alert channels, if the bot is connected and this alert kind is on. The "Open in panel" button leads to the postback connection card. |
Push and Telegram messages are sent no more than once every 6 hours per integration ("Anti-spam interval" in the alert settings). Runs inside this window still land in the "Attention" item, so the 24-hour counter is complete. The item can be marked as resolved; it comes back with the next notification.
What to check when you get the alert: the postback link in the network's account (domain, connection token, click id macro) and the postback quarantine, in case the same conversions are there with an unmatched click id.
Outgoing postbacks (sending conversions to external systems)
According to flow rules, a conversion can additionally be sent out: a Postgres queue with
retries and exponential backoff, and a log of each delivery attempt. Adapters exist for:
Facebook CAPI, TikTok Events API, Google Ads offline conversion import, GA4 Measurement
Protocol, Yandex Metrica (offline conversions), AppMetrica, VK Ads (offline events), and arbitrary custom_http.
Auto-import of affiliate network offers/statistics and payout history
Separately from incoming postbacks (which the network sends you for each conversion), there is
a periodic hourly auto-import from the network's own API: the current list of offers (name,
geo, payout, status, caps) and aggregated conversion statistics for a period, for reconciling
with your postbacks (detecting network shaving). It is set up in the panel, in the
"Integrations" section; the network's credentials/token are encrypted with
INOTRACK_INTEG_ENC_KEY (see operations). Without the key the module
does not crash; cost-sync of ad platforms and this auto-import simply do not start.
⚠️ Like the incoming postback presets above, this is a starting point, not a guaranteed out-of-the-box integration: network APIs change, so instead of a separate hard-coded client for each network there is one configurable REST/JSON client: real HTTP requests with a timeout and retries to the address you specify yourself in the integration config, following the network's documentation at the time you connect it. Networks for this client: Leadgid, Pampadu, Guruleads, 3snet, PIN-UP Partners, Vavada. Connecting a network with a different API shape (a non-standard response, not token + JSON) requires a product change, not a setting; contact support.
Payout history: on each import the tracker compares the offer's payout with the previously
saved one. If the network changed payout (including a silent cut), this is written to the
payout history (the payout_history table) and immediately sent as a Telegram alert (the same channel as caps/CR/missing
postbacks), not discovered in a report a month later. A network offer is unique by the pair
(advertiser, external offer ID); re-importing the same offer updates the record instead of
creating duplicates.
Next steps
The full list of macros available in offer links and in the postback URL: macros. The public API for managing postback endpoints programmatically: API and MCP.