Webhooks
Instead of polling the submission endpoints, you can have TNT Growth send
each submission's final status to an HTTPS endpoint you run. When a submission resolves (complete,
complete_with_errors, or deduped), we POST one signed event to your endpoint, with the same body the
status endpoint returns.
An event normally arrives within two minutes of the submission resolving. Pending, stalled, and future-deferred submissions are not sent until they resolve.
Setting up an endpoint
Send your account manager the HTTPS URL that should receive events. We register it and share a signing
secret with you once, through a secure channel. It starts with whsec_. Store it like a password: it is how
you prove an event came from us.
- The URL must be public
https. Private, loopback, and plainhttpaddresses are refused. - Events start with the next submission that resolves after registration. For anything earlier, use the reconciliation endpoint.
- An endpoint only receives events for your own submissions.
- To change the URL or replace a secret you have lost, contact your account manager.
To test your endpoint, send a conversion with a TNT_TEST_ click id (see
Testing Your Integration). It never reaches the ad platform, but it resolves like any other
submission, so its event is sent to you and you can check your signature verification end to end.
The request
POST https://your-endpoint.example.com/tnt-webhooks
content-type: application/json
webhook-id: 6f1d2c3b-8a4e-4f5d-9c6b-1a2b3c4d5e6f
webhook-timestamp: 1792159380
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "6f1d2c3b-8a4e-4f5d-9c6b-1a2b3c4d5e6f",
"type": "conversion_submission.resolved",
"createdAt": "2026-10-16T14:03:00.000Z",
"resolvedAt": "2026-10-16T14:01:12.345Z",
"data": {
"id": "0b9e8d7c-6a5f-4e3d-8c2b-1a0f9e8d7c6b",
"source": "META",
"externalAccountId": "123456789012345",
"expectedCount": 2,
"createdAt": "2026-10-16T14:00:58.000Z",
"scheduledFor": null,
"counts": { "success": 1, "failed": 0, "withheld": 1 },
"status": "complete",
"withheld": [{ "reason": "cross_channel", "claimedPlatform": "google", "count": 1 }]
}
}
| Field | Meaning |
|---|---|
id | The event id. It matches the webhook-id header and stays the same on every retry. |
type | Always conversion_submission.resolved today. Ignore types you do not recognise. |
createdAt | When the event was created. |
resolvedAt | When the submission resolved. Use it as resolvedSince when catching up. |
data | The submission, in the same shape as GET /api/conversions/submissions/:submissionId. data.id is the submission id the original POST returned, so you can match the event to your own send. |
Verifying the signature
Events are signed to the Standard Webhooks specification, so you can
verify them with one of its libraries (for example the standardwebhooks package for Node.js or Python) by
passing it your whsec_ secret. To verify by hand:
- Read the raw request body before parsing it. Re-serialising parsed JSON changes the bytes and the signature will not match.
- Join
webhook-id,webhook-timestamp, and the raw body with.between them. - Compute an HMAC-SHA256 of that string, keyed with the base64-decoded part of your secret after
whsec_, and base64-encode the result. - Compare it, in constant time, with each space-separated
v1,<signature>entry inwebhook-signature. - Reject the event if
webhook-timestampis more than five minutes from your clock, so a captured request cannot be replayed later.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyTntWebhook(secret, headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = (headers['webhook-signature'] ?? '').split(' ');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return false;
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.some((entry) => {
const [version, signature] = entry.split(',');
const received = Buffer.from(signature ?? '', 'base64');
return version === 'v1' && received.length === expected.length && timingSafeEqual(received, expected);
});
}
Responding
Return any 2xx status within 10 seconds to acknowledge the event. Anything else counts as a failed
attempt: a non-2xx status, a timeout, a connection error, or a redirect, which is not followed. Acknowledge
first and do slow work afterwards, so a busy moment on your side does not turn into retries.
Retries
A failed event is retried with growing gaps, eight attempts in all over about 28 hours:
| Attempt | Sent after the previous attempt failed |
|---|---|
| 1 | — (within about a minute of the event being created) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
After the eighth failure the event is not sent again. The submission itself is unaffected and is still returned by the reconciliation endpoint below.
Events can arrive more than once and out of order. A retry carries the same id, so record the ids you have
processed and skip repeats. Use data.status and resolvedAt rather than arrival order to decide what is
current.
Catching up after downtime
If your endpoint was unavailable for longer than the retry window, or you want to confirm nothing was missed, list everything that resolved since a point in time:
GET https://flex.tntgrowth.io/api/conversions/submissions?resolvedSince=<ISO-8601>
Pass the resolvedAt of the last event you processed before the outage as resolvedSince, follow
nextCursor until it is null, and skip any data.id you have already handled. Each listed submission has the
same shape as an event's data. See Submission Tracking for paging details.