Conversion API
The TNT Growth Conversion API allows you to submit offline conversion data to advertising platforms. This enables accurate attribution of leads and sales back to the ad clicks that generated them.
Platform Guides
- Google Ads Conversions — Submit conversions, check submission status, and investigate per-conversion results
- Meta (Facebook) Conversions — Submit server-side events with user matching data
- Reddit Conversions — Submit offline events with click ID attribution
- TikTok Conversions — Submit Events API conversions with user matching data
- LinkedIn Conversions — Submit conversions with first-party tracking attribution
- Bing (Microsoft Ads) Conversions — Submit offline conversions via Microsoft Click ID or enhanced (hashed email/phone) attribution
- OpenAI Conversions — Submit conversion events with OpenAI attribution references and user matching data
Duplicate submissions and resends
For Meta and TikTok, supply a stable eventId / event_id for each event. Within the same client and pixel, an event already in progress or recorded as delivered is deduplicated, including resends after 24 hours. Use a different ID for a genuinely different event. Events without an explicit ID retain the existing queue-based fallback; they do not receive this durable ID guarantee.
Google /add uses its existing conversion identity. For click-based conversions, the same customer, conversion action and click ID identify the same conversion; changing only the timestamp does not force another delivery. Without a click ID, the identity uses the available user identifiers and timestamp, or the timestamp alone when no user identifiers are supplied.
A failed attempt can be retried when its delivery policy and supported retry path allow it. For the durable identities described above, a proven delivery is permanently deduplicated: there is no automatic expiry or time window after which the same event can be sent again. deduped means this request did not enqueue another delivery; an earlier submission may still be processing. Check that original submission to confirm its outcome.
Delivery errors and recovery
Failed-conversion lists now expose nullable errorClass alongside errorCategory and retryable.
See Delivery Errors and Retry Status for the classification meanings, legacy
null values and platform-specific retry availability.
Delivery visibility
Already sending conversions through TNT Growth? Your account manager can share a private Conversion Delivery Portal link so you can review sent vs failed delivery, charts, and Records (including click IDs) without using the Conversion API.
Authentication
All API requests require an x-api-key header containing your client-specific API key. Contact your TNT Growth account manager to obtain your key.
curl -X POST https://flex.tntgrowth.io/api/google/conversions/add \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '[...]'
All endpoints are mounted under the /api prefix on the Flex host: https://flex.tntgrowth.io/api/....
The Attribution Handle (tntId)
Every conversion endpoint below accepts an optional tntId — the handle for the attribution session the TNT Growth pixel opened when the visitor first arrived on your site from an ad. It is a v4 UUID.
That session holds what the browser could see at the moment of the click and nothing else can see later: the click identifiers (gclid, gbraid, wbraid, fbclid, msclkid, ttclid, li_fat_id, rdt_cid), the UTM parameters, the landing URL, and match signals such as the IP address and user agent. Sending the handle back with a conversion days or weeks later lets us fill in whatever that conversion is missing, without you having to store any of it yourself.
The browser side is tnt_id — the cookie name, the ?tnt_id query parameter, the <meta name="tnt_id"> tag, and the hidden form field the pixel injects. The API request body field is tntId, camelCased like every other field in these payloads.
What we do with it
We look up the session the handle names — scoped to your own account — and use it to fill gaps only. A session never overwrites a value you supplied: if you send a gclid and the session holds a different one, yours wins. Each platform guide lists exactly which of its fields a session can fill.
What happens when it is missing or wrong
Nothing fails. tntId is optional on every endpoint and has no error path:
| Situation | Result |
|---|---|
| Omitted | Conversion delivered as sent. We then try to match a session on the click ID you did send. |
| Not a UUID | Ignored without a lookup. Conversion delivered as sent. |
| Names no session we hold | Ignored. Conversion delivered as sent. |
| Names a session belonging to a different account | Refused, and raises a security alert on our side. Nothing from that session is used. |
A conversion is worth more than the attribution on it, so the handle is never allowed to block one.
Where to get it
If you run the TNT Growth pixel you do not have to do anything — it attaches the handle to every conversion it sends itself, and appends a hidden tnt_id input to your forms so the handle also reaches your CRM.
You need tntId here when your own backend submits the conversion. Read it from the tnt_id field on the submitted form, or from the CRM record that form created, and pass it through. See Good and Bad Installations for the pixel side of that.
When we withhold a conversion from a platform
Sending a conversion to a platform that did not serve the click lets that platform claim it. Meta and TikTok, for example, match on email, phone and IP address, so a lead that came from a Google ad and is also uploaded to Meta gets counted on both. Each platform then reports more conversions than it drove, and its bidding learns from traffic another channel paid for.
To prevent that, every delivery to a platform passes one check before it is sent:
| The conversion has | Result |
|---|---|
| That platform's own click ID | Sent, whatever utmSource says |
No utmSource, or a blank one | Sent |
A utmSource we do not recognize as a paid platform (e.g. klaviyo, newsletter) | Sent |
A utmSource naming the same platform | Sent |
A utmSource naming a different paid platform, and no click ID for this one | Withheld |
One stricter rule is available for Meta, Google and Microsoft (Bing), switched on per platform by TNT Growth
for your account. With it on, that platform receives only conversions carrying its own click ID: fbc for Meta,
gclid, gbraid or wbraid for Google, microsoftClickId for Microsoft, sent directly or recovered from
tntId. Every other conversion, including an email- or phone-only match, is withheld from that platform
whatever its utmSource. Platforms without the rule are unaffected, so keep sending every conversion to every
endpoint as before.
The click ID that counts is the platform's own: gclid, gbraid or wbraid for Google, fbclid
(sent as fbc) for Meta, msclkid for Microsoft, ttclid for TikTok, li_fat_id for LinkedIn,
rdt_cid for Reddit, and oppref for OpenAI. On endpoints that accept
tntId, a click ID recovered from it counts the same as one you send.
The OpenAI endpoint does not accept tntId, so send oppref directly. An email or phone match never
counts, because shared matching data is exactly how one channel's conversion reaches another
channel's account.
utmSource is compared lowercased, trimmed and whole, never as a substring, so organic referrers such
as m.facebook.com or www.google.ca are not read as paid claims. These values name a paid platform:
| Platform | utmSource values |
|---|---|
google, adwords, googleads, google_ads, google-ads, gads, youtube | |
| Meta | meta, facebook, fb, instagram, ig, fb_ig |
| Microsoft (Bing) | bing, microsoft, msn |
| TikTok | tiktok, tik_tok, tiktok_ads |
linkedin | |
reddit | |
| OpenAI (ChatGPT Ads) | openai, chatgpt.com, openai.com |
Withholding is switched on per platform by TNT Growth. Your account manager can confirm which platforms currently enforce it.
A withheld conversion is not discarded. We keep the full payload, so it can still be delivered later if the rules change.
You can see exactly what was withheld. The status endpoint reports the count in
counts.withheld and lists each reason in a withheld array — both present only when something was withheld,
so a submission with nothing withheld returns the shape it always has. A cross_channel entry names the
platform the utmSource claimed; a click_id_required entry, from the click-ID rule above, has no
claimedPlatform. An other entry, also without claimedPlatform, means the conversion was withheld for a
reason this page does not yet document:
{
"expectedCount": 2,
"counts": { "success": 1, "failed": 0, "withheld": 1 },
"withheld": [{ "reason": "cross_channel", "claimedPlatform": "meta", "count": 1 }],
"status": "complete"
}
Three things to know when you read that:
- A withhold is not a failure.
counts.failedmeans the ad platform rejected the conversion. A withheld pair is excluded fromcounts.successand never counted incounts.failed. completedoes not by itself mean everything was delivered. A submission whose only non-delivered pairs were withheld still finishes ascomplete, because every pair reached a final outcome. Comparecounts.successwithexpectedCountwhen you need delivery confirmed.- Retrying will not change it. The same payload is withheld again. Fix it at source by sending that
platform's click ID alongside
utmSource— a native click ID always wins over a foreignutm_source.
Each platform guide documents the same fields against its own response; see Google Ads → Withheld Conversions for the fullest version.
You do not need routing logic of your own. Send each conversion to every platform you advertise on and
let this check decide. It only works on the data it receives, so always pass utmSource and any click
IDs or tntId exactly as captured.
Submission Tracking
Every conversion submission returns one or more submissionIds. To have each result pushed to you as it resolves, register a webhook. To reconcile a batch, list the submissions that have finished instead of polling each id:
GET /api/conversions/submissions?resolvedSince=<ISO-8601>&cursor=<opaque>&limit=<n>
resolvedSince is required. limit defaults to 100 and cannot exceed 500. When nextCursor is present, pass it back as cursor to read the next page. Rows are ordered by resolution time, then id, so a page boundary is stable when several submissions finish at the same instant. Each item has the same shape as the single-submission endpoint below, including counts.withheld and withheld.
A submission is listed once every expected pair is accounted for (complete, complete_with_errors, or deduped). Pending, stalled, and future-deferred submissions are left out. It then waits a minute before it appears here. The stamp is taken before the write commits, and that minute keeps a later page from skipping a row that commits just after a newer stamp is already visible. Optional source limits the list to one platform. Your API key returns only your own submissions.
When nextCursor is null, keep resumeCursor and send it back as cursor on the next run, with the same resolvedSince. That cursor starts after the last submission on the page. If the page was empty, resumeCursor is null: keep the cursor and resolvedSince you already used. A watermark set to the request clock drops a submission that was still inside the one-minute wait, because its stamp is earlier than the clock and the next run starts after it.
Submissions that were already finished when this shipped are stamped at dedupedAt when that is set, and otherwise at createdAt. Pass a resolvedSince at the start of the history you want. Anything that finishes after this ships is stamped at the time it finished.
To check one submission:
GET /api/conversions/submissions/:submissionId
See each platform's guide for details on submission and status checking.
New to the API? See Testing Your Integration — send safe test conversions (they run the full pipeline but never reach the ad platform) and confirm they processed.