> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phone.wixzel.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Events pushed to your server, signed, with a delivery log you can read.

Polling `GET /v1/calls` to notice a call ended works, and it is wasteful. A
webhook tells you instead: when something happens on your account we POST a
signed JSON body to a URL you own.

There is **one endpoint per account**, not a collection — so the API is
`GET /v1/webhook` and `PATCH /v1/webhook`, with no id anywhere.

## Set it up

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH https://api.phone.wixzel.com/v1/webhook \
    -H "Authorization: Bearer $WIXZEL_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://example.com/hooks/wixzel",
      "enabled": true,
      "events": ["callCompleted", "callTransferred", "transferFailed"]
    }'
  ```

  ```ts TypeScript theme={null}
  await wixzel.webhooks.update({
    url: 'https://example.com/hooks/wixzel',
    enabled: true,
    events: ['callCompleted', 'callTransferred', 'transferFailed'],
  });
  ```

  ```dart Dart theme={null}
  await client.webhooks.update(UpdateWebhook(
    url: 'https://example.com/hooks/wixzel',
    enabled: true,
    events: [
      WebhookEvent.callCompleted,
      WebhookEvent.callTransferred,
      WebhookEvent.transferFailed,
    ],
  ));
  ```
</CodeGroup>

Then mint a signing secret. It is returned **once**:

```bash theme={null}
curl -X POST https://api.phone.wixzel.com/v1/webhook/rotate-secret \
  -H "Authorization: Bearer $WIXZEL_API_KEY"
```

```json theme={null}
{ "object": "webhook_secret", "secret": "whsec_9f3c…" }
```

<Warning>
  Rotating replaces the secret immediately. A receiver that verifies signatures
  will reject every event until it has been redeployed with the new value, so
  rotate deliberately — not as part of a routine config change.
</Warning>

`GET /v1/webhook` never returns the secret, to anyone. It returns `secret_set`
instead. An endpoint that hands a signing secret back would turn any leaked
read-scoped key into the ability to forge signed events.

## Fields

| Field        | Meaning                                                                                            |
| ------------ | -------------------------------------------------------------------------------------------------- |
| `url`        | Where we POST. Must resolve to a **public** address. Null until set.                               |
| `enabled`    | The master switch. `false` sends nothing, whatever `events` says.                                  |
| `events`     | The events you receive. **Replaces** the subscription on update — send the full list, not a delta. |
| `secret_set` | Whether a signing secret exists. The secret itself is never returned.                              |

A URL pointing at loopback, link-local or private address space is refused with
`400 unsafe_webhook_url`, and refused again at delivery time. This server can
reach addresses you cannot, so it will not be aimed at them on request.

## Events

The ids are camelCase. That is deliberate and it is the one place in this API
where they are: these are not field names, they are the literal value of the
`event` key in the JSON we POST, and they have been since long before `/v1`.
Your handler's `switch` and your configuration use the same strings.

| Event                 | Fires when                                                |
| --------------------- | --------------------------------------------------------- |
| `inboundCall`         | A call arrives on one of your numbers.                    |
| `outboundCall`        | A call is placed.                                         |
| `callCompleted`       | A call finishes, with its duration and outcome.           |
| `leadCreated`         | A lead is created.                                        |
| `leadQualified`       | A lead is marked qualified.                               |
| `campaignCompleted`   | A campaign finishes its list.                             |
| `appointmentBooked`   | An appointment is booked, including by an agent mid-call. |
| `appointmentCanceled` | An appointment is cancelled.                              |
| `callTransferred`     | A call is handed to a human. SIP calls only.              |
| `transferFailed`      | A transfer to a human does not connect.                   |

An account that has never configured webhooks is subscribed to **all** of them.

Every event fires the same way whether the action came from the API, an SDK, an
MCP client or the console — the same record, the same event. Two deliberate
exceptions:

* **`POST /v1/leads/bulk` fires nothing.** A thousand-row import is one action
  by the person doing it; turning it into a thousand POSTs at your endpoint is
  a denial of service dressed as a feature. Import, then read the response.
* **`POST /v1/agents/{id}/test-call` fires `outboundCall` with `"test": true`
  in the payload.** It rings a real phone and spends real credit, so it is a
  real outbound call — but a receiver that does not want to act on probes can
  tell them apart on that field.

## The payload

```json theme={null}
{
  "event": "callCompleted",
  "timestamp": "2026-09-10T09:41:22.104Z",
  "data": { "…": "event-specific" }
}
```

Sent with these headers:

| Header                      | Value                                                     |
| --------------------------- | --------------------------------------------------------- |
| `X-Wixzel-Signature`        | HMAC-SHA256 of the raw body, hex, keyed with your secret. |
| `X-Wixzel-Event`            | The event id, so you can route before parsing.            |
| `X-Wixzel-Delivery-Attempt` | `1` on the first POST.                                    |

## Verifying the signature

Compute the HMAC over the **raw bytes** of the request body, before any JSON
parsing. Re-serialising the parsed object produces different bytes and a
different digest.

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

// express.raw({ type: 'application/json' }) — NOT express.json()
app.post('/hooks/wixzel', (req, res) => {
  const expected = createHmac('sha256', process.env.WIXZEL_WEBHOOK_SECRET!)
    .update(req.body)
    .digest('hex');
  const got = req.get('x-wixzel-signature') ?? '';

  if (got.length !== expected.length ||
      !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // …handle it, then answer quickly.
  res.sendStatus(200);
});
```

Compare with a constant-time function. A plain `===` on a hex digest leaks the
correct value one byte at a time to anyone who can measure the difference.

## Answer quickly

We wait **8 seconds** for a response. Anything outside 2xx is a failure,
including a 3xx — we do not follow redirects, because a redirect is how a POST
gets replayed somewhere it was never vetted for.

Do the work after you answer. A handler that finishes a database write before
returning 200 turns your own slowness into a retry.

## Retries

A failed delivery is retried **twice**, about 5 seconds and 30 seconds later, on
failures that can plausibly succeed next time:

* no response at all — DNS failure, refused connection, timeout;
* `408`, `429`, or any `5xx`.

Every other `4xx` is you telling us the request is wrong, and sending it twice
more does not make it right.

<Note>
  Retries are held in the API process's memory. A deploy or a restart between
  attempts drops the pending one — the attempts already made are still recorded,
  but the event is not re-queued. Treat webhooks as best-effort notification and
  reconcile against `GET /v1/calls` if you need a guarantee.
</Note>

## Did it fire?

That question used to be answerable only by us, reading server logs. Now:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.phone.wixzel.com/v1/webhook/deliveries?status=failed&limit=5" \
    -H "Authorization: Bearer $WIXZEL_API_KEY"
  ```

  ```ts TypeScript theme={null}
  const page = await wixzel.webhooks.deliveries({ status: 'failed', limit: 5 });
  for (const attempt of page.data) {
    console.log(attempt.event, attempt.response_status, attempt.error);
  }
  ```

  ```dart Dart theme={null}
  final page = await client.webhooks.deliveries(status: 'failed', limit: 5);
  for (final attempt in page.data) {
    print('${attempt.event.value} ${attempt.responseStatus} ${attempt.error}');
  }
  ```
</CodeGroup>

```json theme={null}
{
  "object": "list",
  "data": [
    {
      "object": "webhook_delivery",
      "event": "callCompleted",
      "url": "https://example.com/hooks/wixzel",
      "attempt": 2,
      "status": "failed",
      "response_status": 502,
      "error": "HTTP 502",
      "duration_ms": 812,
      "will_retry": true,
      "occurred_at": "2026-09-10T09:41:27.882Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

One record **per attempt**, newest first — so an event delivered on its third
try is three records, and a receiver that flaps looks like one.

The field to read first is `response_status`. `null` means nothing answered at
all: wrong hostname, closed port, or a timeout. A number means your endpoint was
reached and what it said. That distinction is the whole difference between "my
URL is wrong" and "my handler threw".

<Note>
  `error` never contains your response body. A receiver that echoes the request
  back would otherwise put customer names and phone numbers into a log, through
  the side door. Only the status line and transport errors are kept, and records
  age out after three months.
</Note>

## Test before you enable

```bash theme={null}
curl -X POST https://api.phone.wixzel.com/v1/webhook/test \
  -H "Authorization: Bearer $WIXZEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event": "callCompleted"}'
```

It POSTs one sample payload now and hands back what your endpoint answered, in
the same shape as a delivery record. It goes through the same code as a real
event — same headers, same signature, and it appears in the delivery list — but
it deliberately ignores `enabled` and your event subscription, because the
question it answers is whether your endpoint works *before* you turn delivery
on. The payload carries obviously fake data and `"test": true`.

## Scopes

`webhooks:read` for the configuration and the delivery list; `webhooks:write`
to change the URL, rotate the secret or send a test.

<Warning>
  `webhooks:write` can repoint where **every event on the account** is delivered.
  Treat it like a credential that can redirect your data, because it is one.
</Warning>
