Skip to main content

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 plain http addresses 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 }]
}
}
FieldMeaning
idThe event id. It matches the webhook-id header and stays the same on every retry.
typeAlways conversion_submission.resolved today. Ignore types you do not recognise.
createdAtWhen the event was created.
resolvedAtWhen the submission resolved. Use it as resolvedSince when catching up.
dataThe 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:

  1. Read the raw request body before parsing it. Re-serialising parsed JSON changes the bytes and the signature will not match.
  2. Join webhook-id, webhook-timestamp, and the raw body with . between them.
  3. Compute an HMAC-SHA256 of that string, keyed with the base64-decoded part of your secret after whsec_, and base64-encode the result.
  4. Compare it, in constant time, with each space-separated v1,<signature> entry in webhook-signature.
  5. Reject the event if webhook-timestamp is 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:

AttemptSent after the previous attempt failed
1— (within about a minute of the event being created)
21 minute
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 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.