# Webhooks

> The events fones sends to your endpoint (phone ready, SMS received, balance low...), how they are signed and how retries work.

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

One HTTPS endpoint per account. Set it in the console under Developers (the console's session only: an API key cannot move where events go). Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com/), so the official `standardwebhooks` libraries verify them.

## Delivery

Each event is a `POST` with a JSON body:

```json
{
  "id": "evt_3n8w1c",
  "type": "sms.received",
  "created_at": "2026-10-22T10:24:31Z",
  "data": {
    "phone_id": "ph_7k2m9q",
    "number": "+15555550134",
    "from": "+15555550188",
    "text": "Your Market code is 482913. Do not share it.",
    "code": "482913",
    "received_at": "2026-10-22T10:24:29Z"
  }
}
```

Three headers come with it:

- `webhook-id`: the event id. It stays the same across retries: use it to skip duplicates.
- `webhook-timestamp`: when this attempt was signed, in unix seconds.
- `webhook-signature`: `v1,<base64>`, the HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<raw body>` keyed with your secret. Right after a rotation it holds two signatures separated by a space (see below); one match is enough.

Answer with any 2xx within 10 seconds, then do the slow work. Anything else is retried with backoff for about a day. Events can arrive more than once and out of order.

## The signing secret

The secret looks like `whsec_<base64>`. It is shown once: in the answer of the first `PUT /v1/webhook` (or in the console when you set the URL) and when you rotate it. Changing the URL keeps it; `GET /v1/webhook` only gives its last 4 characters.

`POST /v1/webhook/rotate-secret` (or Rotate in the console) answers the new secret with `previous_secret_until`: for the next 24 hours every delivery is signed with both the new secret and the old one (`v1,<new> v1,<old>`), so you can switch your receiver without missing an event. After that only the new one signs.

## Verify the signature

Check the signature on the raw body, before parsing it. The libraries also refuse a timestamp more than 5 minutes away.

Node (`npm install standardwebhooks`):

```ts
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.WEBHOOK_SECRET!); // whsec_...

// In your handler, with the body as it came (a string or a Buffer):
const event = wh.verify(rawBody, {
  'webhook-id': req.headers['webhook-id'],
  'webhook-timestamp': req.headers['webhook-timestamp'],
  'webhook-signature': req.headers['webhook-signature'],
}); // throws WebhookVerificationError when it does not match
```

Python (`pip install standardwebhooks`):

```python
import os
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["WEBHOOK_SECRET"])  # whsec_...
event = wh.verify(raw_body, dict(request.headers))  # raises WebhookVerificationError
```

Without a library (Web Crypto: Node 20+, Workers, Deno, Bun):

```ts
export async function verify(req: Request, secret: string) {
  const id = req.headers.get('webhook-id') ?? '';
  const ts = req.headers.get('webhook-timestamp') ?? '';
  const body = await req.text();
  if (!id || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null;
  const raw = Uint8Array.from(atob(secret.replace(/^whsec_/, '')), (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${id}.${ts}.${body}`)));
  const expected = btoa(String.fromCharCode(...mac));
  const given = (req.headers.get('webhook-signature') ?? '').split(' ').map((s) => s.replace(/^v1,/, ''));
  // compare in constant time in production (e.g. crypto.timingSafeEqual in Node)
  return given.includes(expected) ? JSON.parse(body) : null;
}
```

## Events

| Event | When | `data` |
| --- | --- | --- |
| `phone.ready` | a phone you bought is ready to drive | the phone |
| `phone.failed` | a phone could not be set up; the charge is refunded | the phone, with `error` |
| `phone.renewed` | a phone renewed for another month from the balance, with its add-ons, in one charge | the phone, with `renewed_until` |
| `phone.ended` | a phone reached its end, with its add-ons | the phone, with `reason`: `cancelled` (renewal off), `unpaid` (3 days of grace passed) or `released` |
| `number.ready` | a number added to a phone is active | `{ phone_id, number }` |
| `number.failed` | a number added to a phone could not be set up; refunded | `{ phone_id, error }` |
| `proxy.ready` | a static IP added to a phone is active | `{ phone_id, ip }` |
| `proxy.failed` | a static IP added to a phone could not be set up; refunded | `{ phone_id, error }` |
| `number.removed` | a number was taken off a phone (no refund once it worked) | `{ phone_id }` |
| `proxy.removed` | a static IP was taken off a phone (no refund once it worked) | `{ phone_id }` |
| `sms.received` | a text reached a phone's number; `number` is the number that received it | `{ phone_id, number, from, text, code, received_at }` |
| `balance.low` | the balance will not cover what renews in the next 7 days | `{ balance_cents, renewals_cents }` |

"The phone" is the same object as `GET /v1/phones/:id`. Every event is recorded in the same transaction as the change it reports, so none is lost; its `id` is fixed for that change, so a retry or a replay never sends a second one.

## Test it

`POST /v1/webhook/test` (or the button in the console) sends a `webhook.test` event (`data`: `{ message }`) to your endpoint and returns `{ id, delivered, status_code, error }`. The console lists the recent deliveries with their status.
