Tone docs

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

EventFires whendata contains
call.initiatedA dial was accepted and the call row exists (outbound and inbound)call
call.answeredThe far end connected to the media pathcall
call.completedA call ended — any dispositioncall
call.failedA call ended in a non-answer: failed, no_answer, or busycall
campaign.completedEvery recipient in a campaign has settledcampaign
campaign.pausedA campaign paused — including the automatic wallet-floor pausecampaign
compliance.check.blockedThe pre-dial gate refused a callcheck
wallet.balance.lowYour balance crossed your low-balance thresholdwallet
number.suspendedRent went unpaid past the grace period; the number stopped taking callsnumber
number.reactivatedThe wallet covered the overdue rent and the number is backnumber
knowledge.build.completedA knowledge base finished building and can answer questionsknowledgeBase, build
knowledge.build.failedA build failed; build.error says whyknowledgeBase, 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"
  }
}
  • id is the event id, repeated in the x-tone-event-id header. Use it as your deduplication key — see Handling duplicates.
  • data is keyed by subjectcall, campaign, number, check, wallet, or knowledgeBase + build. For calls, campaigns, numbers and compliance checks the object is identical to what that resource's own GET returns, so there's one shape to learn and a webhook can never disagree with the API. The wallet payload is a three-field subset of GET /v1/wallet (balance, threshold, currency), and the knowledge events carry the base's identity plus the build that just finished.
  • links are offered only when there's something behind them. recording and transcript appear 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: 1

The 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)) <= 300

Three things people get wrong:

  1. 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.
  2. Compare in constant time. === on an HMAC leaks timing.
  3. 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 (except 408 and 429) means your server understood and refused. We don't retry — retrying can't change a 400.
  • 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.

Was this page helpful?

On this page