Webhooks and events
The event catalog, how to verify a signature, what retries look like, and how to replay what you missed.
Calls take minutes and campaigns take hours. Webhooks are how you find out what happened without polling for it.
Registering an endpoint
curl "$TONE_API/v1/integrations/webhooks" \
-X POST \
-H "Authorization: Bearer $TONE_ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/tone",
"events": ["call.completed", "call.failed", "campaign.completed"]
}'{
"data": {
"id": "d81c...",
"url": "https://your-app.example.com/hooks/tone",
"events": ["call.completed", "call.failed", "campaign.completed"],
"secret": "whsec_8f2a...",
"...": "..."
}
}secret appears once, in this response. Store it. If you lose it you can read it back at
GET /v1/integrations/webhooks/{id}/secret with an admin key, or roll it — but don't build on
being able to fetch it casually.
Managing endpoints requires admin scope: the URL decides where your call data goes.
Subscribe only to what you handle. Every event you're subscribed to is an HTTP request to your server, and an endpoint that 500s on events it doesn't care about will eventually be switched off.
The event catalog
| Event | Fires when | data contains |
|---|---|---|
call.initiated | A dial was accepted and the call row exists (outbound and inbound) | call |
call.answered | The far end connected to the media path | call |
call.completed | A call ended — any disposition | call |
call.failed | A call ended in a non-answer: failed, no_answer, or busy | call |
campaign.completed | Every recipient in a campaign has settled | campaign |
campaign.paused | A campaign paused — including the automatic wallet-floor pause | campaign |
compliance.check.blocked | The pre-dial gate refused a call | check |
wallet.balance.low | Your balance crossed your low-balance threshold | wallet |
number.suspended | Rent went unpaid past the grace period; the number stopped taking calls | number |
number.reactivated | The wallet covered the overdue rent and the number is back | number |
knowledge.build.completed | A knowledge base finished building and can answer questions | knowledgeBase, build |
knowledge.build.failed | A build failed; build.error says why | knowledgeBase, build |
call.failed fires alongside call.completed, not instead of it. Subscribe to call.completed
alone if you branch on disposition yourself; subscribe to call.failed as well if you have a
separate path for non-answers. Subscribing to both means two deliveries for one busy signal — which
is correct, just make sure that's what you intended.
New event types will be added. An unrecognised type should be logged and ignored, not thrown on.
The payload
Every event has the same envelope:
{
"id": "9e41b7c2-...",
"type": "call.completed",
"created_at": "2026-08-24T09:41:07.812Z",
"data": {
"call": {
"id": "3b7e...",
"status": "ended",
"disposition": "answered",
"durationSeconds": 45,
"billedPaise": 480,
"campaignId": null,
"summary": "The customer confirmed the order for Thursday delivery.",
"outputs": { "confirmed": true, "preferred_day": "thursday" },
"...": "..."
}
},
"links": {
"self": "https://apibeta.usetone.ai/v1/calls/3b7e...",
"recording": "https://apibeta.usetone.ai/v1/calls/3b7e.../recording",
"transcript": "https://apibeta.usetone.ai/v1/calls/3b7e.../transcript"
}
}idis the event id, repeated in thex-tone-event-idheader. Use it as your deduplication key — see Handling duplicates.datais keyed by subject —call,campaign,number,check,wallet, orknowledgeBase+build. For calls, campaigns, numbers and compliance checks the object is identical to what that resource's ownGETreturns, so there's one shape to learn and a webhook can never disagree with the API. Thewalletpayload is a three-field subset ofGET /v1/wallet(balance, threshold, currency), and the knowledge events carry the base's identity plus the build that just finished.linksare offered only when there's something behind them.recordingandtranscriptappear when they exist, so branch on presence rather than fetching and handling a 404.
Transcripts are not inlined. They're multi-kilobyte and would make us a bad neighbour on your
server; fetch links.transcript with your API key if you want one.
Verifying the signature
Verify every delivery. Your endpoint URL is reachable by anyone; the signature is what makes a request actually from Tone.
Each delivery carries:
x-tone-timestamp: 1756029667
x-tone-signature: v1=4f7c8e2a...
x-tone-event: call.completed
x-tone-event-id: 9e41b7c2-...
x-tone-delivery-attempt: 1The signature is HMAC-SHA256 over the literal string {timestamp}.{raw body}, hex-encoded.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyTone(rawBody, headers, secret) {
const timestamp = headers['x-tone-timestamp'];
const expected = `v1=${createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')}`;
// The header may carry SEVERAL space-delimited signatures during a secret
// rotation. Accept the message if any one matches.
const provided = String(headers['x-tone-signature']).split(' ');
const ok = provided.some(
(sig) =>
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
if (!ok) throw new Error('bad signature');
// Reject anything too old for your tolerance. Five minutes is typical.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) throw new Error('stale');
return JSON.parse(rawBody);
}import hmac, hashlib, time
def verify_tone(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers["x-tone-timestamp"]
expected = "v1=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
provided = headers["x-tone-signature"].split(" ")
if not any(hmac.compare_digest(sig, expected) for sig in provided):
return False
return abs(time.time() - int(timestamp)) <= 300Three things people get wrong:
- Sign the raw bytes, not a re-serialized object. If your framework parses JSON before you see
it,
JSON.stringify(req.body)will not reproduce the original bytes and every signature will fail. Configure a raw-body reader for this route. - Compare in constant time.
===on an HMAC leaks timing. - Split on spaces. During a secret rotation the header carries one
v1=signature per active secret. A verifier that treats the whole header as a single signature will start rejecting everything the moment you roll — and it'll look like an outage on our side.
The signature deliberately does not cover the URL path, so a proxy that rewrites your path won't break verification.
Rotating the secret
curl "$TONE_API/v1/integrations/webhooks/d81c.../roll-secret" \
-X POST -H "Authorization: Bearer $TONE_ADMIN_KEY"You get a new secret and a previousValidUntil about 24 hours out. During that window both
secrets sign every delivery — which is exactly why your verifier must handle multiple signatures.
Deploy the new secret at your leisure, and the old one stops after the window.
Responding
Return any 2xx. We don't read the body.
Anything else is a failure, and the split is deliberate:
4xx(except408and429) means your server understood and refused. We don't retry — retrying can't change a400.5xx,408,429, timeouts, connection errors are retried.
Reply fast, work later. We give up on a delivery after 10 seconds. Verify the signature,
enqueue the event, return 200. Doing your database writes and third-party calls inline is the
most common cause of a webhook endpoint that fails under exactly the load it needs to survive —
a campaign settles calls in clumps.
Retries
A failed delivery is retried with exponential backoff and jitter — 1, 2, 4, 8, 16, 32, 64, 128 minutes, then every four hours — for up to 14 attempts, which spans roughly a day. An overnight outage or a weekend deploy won't lose events.
x-tone-delivery-attempt tells you which attempt you're seeing. 1 is the first.
After 20 consecutive failures across all deliveries, the endpoint is switched off and we email your organization's owners and admins. Re-enable it from the dashboard once it's fixed. This isn't punitive: an endpoint that has been dead for a day is a queue that grows forever.
Handling duplicates
Design for at-least-once. A delivery whose response we never saw — you returned 200 a moment
after our 10-second timeout — gets retried, and you'll process the same event twice.
Deduplicate on x-tone-event-id (identical to the payload's id), which is stable across every
retry of that delivery. Storing it in a table with a unique constraint is the whole solution.
Also don't assume ordering. call.completed can arrive before call.answered if the first
attempt at one of them was retried. Make handlers order-independent, or reconcile against
GET /v1/calls/{id}, which is always current.
Replaying what you missed
Your endpoint was down, or you're reconciling. GET /v1/events lists everything we sent, newest
first, for the last 30 days:
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/events?type=call.completed&limit=100"{
"data": [
{
"id": "9e41b7c2-...",
"type": "call.completed",
"subjectType": "call",
"subjectId": "3b7e...",
"createdAt": "2026-08-24T09:41:07.812Z",
"delivery": {
"endpointId": "d81c...",
"status": "failed",
"attempts": 14,
"lastStatus": 502,
"deliveredAt": null
}
}
]
}The id is the same one we sent in x-tone-event-id, so reconciling against your own dedupe table
needs no mapping. delivery tells you what actually happened to it — including that it failed 14
times against a 502.
Fetch one with its payload:
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/events/9e41b7c2-..."One caveat worth knowing: the payload on this endpoint is re-rendered from current state, not replayed from a stored snapshot. If a transcript landed after the delivery went out, you'll see it here even though the original body didn't have it. That's usually what you want when reconciling — it's the truth now — but it means this is not a byte-for-byte record of what we signed.
An event subscribed by two endpoints appears twice, once per endpoint. Each is a genuinely separate delivery with its own outcome.