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

# Webhooks

> Subscribe to events, verify the HMAC signature, and handle retries.

Webhooks notify you in real time when something happens in your workspace (a new contact, a delivered
message, a finished campaign…) via a `POST` to your URL.

## Create an endpoint

You need the `webhooks:manage` scope. The URL must be **https**.

```bash theme={null}
curl -X POST https://api.apyconnect.io/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "url": "https://your-server.com/webhooks/apyconnect",
        "events": ["contact.created", "message.*"] }'
```

The response includes a **`secret`** (`whsec_…`) shown **only once** — store it to verify the
signature. Rotate it with `POST /v1/webhooks/{id}/rotate_secret`.

`events` accepts exact names (`contact.created`), per-resource wildcards (`contact.*`), or `*` (all).
See the full catalog at `GET /v1/webhooks/events`.

## Event shape

```json theme={null}
{
  "id": "evt_…",
  "type": "contact.created",
  "created_at": "2026-07-22T12:00:00.000Z",
  "data": { "object": "contact", "id": "per_…", "name": "Ada Lovelace" }
}
```

`POST` headers:

```
X-ApyConnect-Event: contact.created
X-ApyConnect-Delivery: whd_…
X-ApyConnect-Timestamp: 1721649600
X-ApyConnect-Signature: sha256=<hmac_hex>
```

## Verify the signature

The signature is `HMAC-SHA256` of `` `${timestamp}.${raw_body}` `` with your `secret`. Recompute and
compare in **constant time**. Reject if the timestamp is older than \~5 min (anti-replay).

```js theme={null}
import crypto from "node:crypto";

function verify(req, secret) {
  const ts = req.headers["x-apyconnect-timestamp"];
  const sig = req.headers["x-apyconnect-signature"];        // "sha256=…"
  const expected = "sha256=" + crypto.createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`).digest("hex");
  const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  return ok && fresh;
}
```

<Warning>Verify over the **raw body** (exact bytes), not the re-serialized JSON.</Warning>

## Delivery, retries, and auto-disable

* **At-least-once**: the same event may arrive more than once. Deduplicate by `id` (or
  `X-ApyConnect-Delivery`). Don't assume order; use `created_at`.
* Respond **2xx** quickly (\< 8 s). If you fail, we retry with **exponential backoff**.
* After many consecutive failures, the endpoint becomes **`auto_disabled`** (circuit breaker). Fix
  your server and reactivate it with `PATCH /v1/webhooks/{id}` → `{"status":"active"}` (resets the counter).

## Event catalog (excerpt)

`message.received` · `message.sent` · `message.delivered` · `message.read` · `message.failed` ·
`conversation.created` · `conversation.assigned` · `conversation.closed` · `contact.created` ·
`contact.updated` · `contact.merged` · `deal.stage_changed` · `deal.won` · `ticket.created` ·
`campaign.completed` · `whatsapp.template.approved` · `job.completed` … (full list at `/v1/webhooks/events`).
