Skip to main content

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​

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.

Two spellings, on purpose

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:

SituationResult
OmittedConversion delivered as sent. We then try to match a session on the click ID you did send.
Not a UUIDIgnored without a lookup. Conversion delivered as sent.
Names no session we holdIgnored. Conversion delivered as sent.
Names a session belonging to a different accountRefused, 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 hasResult
That platform's own click IDSent, whatever utmSource says
No utmSource, or a blank oneSent
A utmSource we do not recognize as a paid platform (e.g. klaviyo, newsletter)Sent
A utmSource naming the same platformSent
A utmSource naming a different paid platform, and no click ID for this oneWithheld

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:

PlatformutmSource values
Googlegoogle, adwords, googleads, google_ads, google-ads, gads, youtube
Metameta, facebook, fb, instagram, ig, fb_ig
Microsoft (Bing)bing, microsoft, msn
TikToktiktok, tik_tok, tiktok_ads
LinkedInlinkedin
Redditreddit
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 names each claiming platform in a withheld array — both present only when something was withheld, so a submission with nothing withheld returns the shape it always has:

{
"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.failed means the ad platform rejected the conversion. A withheld pair is excluded from counts.success and never counted in counts.failed.
  • complete does not by itself mean everything was delivered. A submission whose only non-delivered pairs were withheld still finishes as complete, because every pair reached a final outcome. Compare counts.success with expectedCount when 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 foreign utm_source.

Each platform guide documents the same fields against its own response; see Google Ads → Withheld Conversions for the fullest version.

Send everything, with the attribution fields intact

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 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.