# Webhooks and events

Source: https://docs.usetone.ai/webhooks

> 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

```bash
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"]
  }'
```

```json
{
  "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:

```json
{
  "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](#handling-duplicates).
- **`data` is keyed by subject** — `call`, `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.

```js
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);
}
```

```python
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

```bash
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**:

```bash
curl -H "Authorization: Bearer $TONE_KEY" \
  "$TONE_API/v1/events?type=call.completed&limit=100"
```

```json
{
  "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:

```bash
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.
