# Webhooks

> Receive signed events from Nexafon on your server, set up an endpoint, and handle deliveries safely.

Source: https://docs.nexafone.so/v1/webhooks/

Webhooks tell your systems what happened in a workspace: a message arrived, a call was missed, an order was paid.
Each delivery is an HTTPS `POST` of a JSON event, signed so you can check it came from Nexafon.

## 1. Add an endpoint

In the dashboard open **Developers → Webhooks**, add your URL and pick the [events](/v1/webhooks/events/) it should
receive. Or create it through the API with a secret key that has `developers:manage`:

```bash
curl -X POST "https://api.nexafone.so/api/v1/workspaces/$WORKSPACE_ID/webhook-endpoints" \
  -H "Authorization: Bearer $NEXAFON_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/nexafon", "events": ["message.received", "call.missed"]}'
```

The answer carries the endpoint’s **signing secret** (`whsec_…`) once. Store it with your other secrets.

The URL must be `https` and reach the public internet: Nexafon resolves the host and refuses private, link-local and
cloud metadata addresses, connects to the address it checked, and never follows redirects. A workspace can have up to
20 endpoints, each subscribed to up to 64 event types.

## 2. Receive and verify

```ts title="server.ts"
if (req.method === 'POST' && req.url === '/hooks/nexafon') {
  try {
    // The raw body, exactly as received: parsing it first changes the bytes the signature covers.
    const event = verifyWebhook(
      req.headers,
      await rawBody(req),
      process.env.NEXAFON_WEBHOOK_SECRET ?? '',
    )
    // Deliveries can repeat: skip an event.id you have already handled.
    console.log(event.type, event.id)
    res.writeHead(204).end()
  } catch (err) {
    if (!(err instanceof WebhookVerificationError)) throw err
    res.writeHead(400).end()
  }
  return
}
```

[Signatures](/v1/webhooks/signatures/) has the receiver in PHP, Python and Go, and the check without a helper.

## 3. Answer fast

Answer any `2xx` within **10 seconds**, then do the work: put the event on your own queue first if handling it takes
longer. Anything else (a timeout, `3xx`, `4xx`, `5xx`) counts as a failure and is
[retried](/v1/webhooks/retries/) for up to 3 days.

## The event

```json
{
  "id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
  "type": "message.received",
  "createdAt": "2026-09-21T14:13:18Z",
  "workspaceId": "aaaaaaaa-0000-0000-0000-000000000000",
  "data": {
    "message": { "id": "…", "conversationId": "…", "direction": "inbound", "type": "text", "text": "Hello" },
    "conversation": { "id": "…", "status": "open", "contact": { "phone": "252611234567" } }
  }
}
```

| Field | |
| --- | --- |
| `id` | Unique per event. Retries, replays and redeliveries send the same `id`: **dedupe on it** |
| `type` | The [event type](/v1/webhooks/events/) |
| `createdAt` | When it happened (RFC 3339, UTC) |
| `workspaceId` | The workspace (store) it happened in |
| `partner` | On a partner’s stores only: `{ "storeExternalRef": "…" }` ([partner webhooks](/v1/partners/webhooks/)) |
| `data` | The event’s objects, in the same shape as the [API](/v1/api/) returns them |

Every delivery also carries these headers:

| Header | |
| --- | --- |
| `Nexafon-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` ([signatures](/v1/webhooks/signatures/)) |
| `Nexafon-Event-Id` | The event’s `id` |
| `Nexafon-Event-Type` | The event’s `type` |
| `Nexafon-Delivery-Attempt` | 1 for the first attempt, then 2, 3… |
| `User-Agent` | `Nexafon-Webhooks/1.0` |

Ordering is best effort per endpoint: use `createdAt` and the objects’ own versions where order matters.
