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_LIVE_ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/tone",
"events": ["call.completed", "call.failed", "campaign.completed"]
}'Endpoints are managed with a live admin key or in the dashboard. Because one endpoint
receives both test and live events, a tone_test_ key gets 403 adding, changing or removing
one, or reading or rolling its secret — it can still list them.
{
"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.
Every endpoint signs with its own secret. The org-level GET /v1/integrations/webhooks/signing-secret
is only for endpoints created before per-endpoint secrets existed.
An endpoint is not tied to an environment: it receives both test and live events, signed with the
same secret. The envelope's environment (test or live) says which one an event came from; it
is null only for org-wide events such as a knowledge-base build.
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 — outbound and inbound alike. call.answeredAt says when | call |
call.completed | A call ended — any disposition | call |
call.failed | A call ended in a non-answer: failed, no_answer, or busy | call |
call.transfer_started | The agent began handing the call to a person and their phone is ringing | call, transfer |
call.transferred | The person took the call. Not sent on VoiceLink numbers, which never say whether the person answered | call, transfer |
call.transfer_failed | Nobody took it, or the caller hung up first. The agent comes back on the line if the caller is still there. On Exotel numbers it can also follow a call.transferred a few minutes after the call, when Exotel's call record shows the person never answered | call, transfer |
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.renewal_due | A renewal is due and your balance cannot cover it. number.releaseAt says when it is released unless you add money | number |
number.released | A renewal went unpaid past its releaseAt; the number is gone | number |
number.suspended | No longer sent — unpaid renewals are now released. Kept so existing subscriptions stay valid | number |
number.reactivated | A number suspended under the old policy had its overdue rent paid | 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.
On an answered call with a Tone agent, call.completed waits up to about 90 seconds for the call's
summary. Every other call — BYO, unanswered, failed, voicemail — is delivered at once.
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",
"environment": "live",
"created_at": "2026-08-24T09:41:07.812Z",
"data": {
"call": {
"id": "3b7e...",
"status": "ended",
"disposition": "answered",
"startedAt": "2026-08-24T09:40:18.000Z",
"answeredAt": "2026-08-24T09:40:22.000Z",
"durationSeconds": 45,
"billedPaise": 480,
"campaignId": null,
"summary": "The customer confirmed the order for Thursday delivery.",
"outputs": { "confirmed": true, "preferred_day": "thursday" },
"...": "..."
}
},
"links": {
"self": "https://api.usetone.ai/v1/calls/3b7e...",
"recording": "https://api.usetone.ai/v1/calls/3b7e.../recording",
"transcript": "https://api.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.- Transfer events carry the attempt they are about as
data.transfer(the call's owntransferfield is its latest attempt, which can be a later one by the time a retried delivery arrives), andlinks.handoff: a page showing the person what the caller has said so far. Anyone holding that link can open it for 24 hours, so send it only to the person taking the call.
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.
Only one rotation runs at a time. A second roll while the previous secret's 24-hour window is still
open answers 409, naming when it closes. Read the current secret with
GET /v1/integrations/webhooks/{id}/secret.
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 — in your key's environment only: a test key lists test events, a
live key everything else:
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/events?type=call.completed&limit=100"{
"data": [
{
"id": "9e41b7c2-...",
"type": "call.completed",
"environment": "live",
"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.