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

# Webhooks

> Receive real-time WhatsApp events on your own infrastructure with signed, retried, idempotent deliveries.

Webhooks let your code react to events from your WhatsApp numbers in real time. Every delivery is signed, retried, and recorded so you can debug from the dashboard.

<Note>
  The Webhooks dashboard lives at [wassist.app/developers/webhooks](https://wassist.app/developers/webhooks). Create one there, copy the signing secret, and you're done.
</Note>

## How a delivery works

```mermaid theme={null}
sequenceDiagram
    participant W as Wassist
    participant Q as Delivery queue
    participant You as Your endpoint

    W->>Q: dispatch event (writes WebhookDelivery row)
    Q->>You: POST with X-Wassist-Signature + Delivery ID
    You-->>Q: 2xx
    Note over Q: 4xx → no retry; 5xx/network → retry with backoff
    Q->>W: update delivery + record attempt
```

Behind the scenes:

1. The event is committed to our outbox as a `WebhookDelivery` with a stable UUID.
2. A worker POSTs the JSON body to your URL.
3. We record an attempt row with status code, duration, and response body.
4. On `5xx` or network errors we retry up to **3 times** with exponential backoff
   (10s, 30s, 90s).
5. After **20 consecutive failures** the webhook is auto-disabled and you get a banner in the dashboard.

## Headers

| Header                | Purpose                                                                               |
| --------------------- | ------------------------------------------------------------------------------------- |
| `Content-Type`        | Always `application/json`.                                                            |
| `User-Agent`          | `Wassist-Webhook/1.0`                                                                 |
| `X-Wassist-Event`     | The event name, e.g. `message.received`.                                              |
| `X-Wassist-Delivery`  | Stable UUID. **Use this as your idempotency key.** It does not change across retries. |
| `X-Wassist-Timestamp` | Unix seconds. The time we signed the request.                                         |
| `X-Wassist-Signature` | `t=<ts>,v1=<hex hmac sha256>` — Stripe-style.                                         |

## Verifying signatures

Compute `HMAC-SHA256` over the string `<timestamp>.<raw body>` using the webhook's signing secret and compare it in constant time to the `v1` component.

<Tabs>
  <Tab title="Node.js">
    ```ts theme={null}
    import crypto from "node:crypto";

    app.post("/webhooks/wassist", express.raw({ type: "application/json" }), (req, res) => {
      const header = req.header("x-wassist-signature") ?? "";
      const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
      const signedPayload = `${parts.t}.${req.body.toString("utf-8")}`;
      const expected = crypto
        .createHmac("sha256", process.env.WASSIST_WEBHOOK_SECRET!)
        .update(signedPayload)
        .digest("hex");

      const ok = crypto.timingSafeEqual(
        Buffer.from(parts.v1, "hex"),
        Buffer.from(expected, "hex"),
      );
      if (!ok) return res.status(400).send("bad signature");

      // Replay protection: reject events more than 5 minutes old.
      if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
        return res.status(400).send("stale");
      }

      // Idempotency: drop duplicate deliveries.
      const deliveryId = req.header("x-wassist-delivery")!;
      if (await alreadyProcessed(deliveryId)) return res.status(200).send("ok");

      await handle(JSON.parse(req.body.toString("utf-8")));
      await markProcessed(deliveryId);
      res.status(200).send("ok");
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hmac, hashlib, time

    def verify(request, secret: str) -> bool:
        header = request.headers.get("X-Wassist-Signature", "")
        parts = dict(p.split("=", 1) for p in header.split(","))
        ts, received = parts.get("t", ""), parts.get("v1", "")
        signed = f"{ts}.".encode() + request.body
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(received, expected):
            return False
        return abs(time.time() - int(ts)) <= 300
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    func verify(r *http.Request, secret string) bool {
        body, _ := io.ReadAll(r.Body)
        header := r.Header.Get("X-Wassist-Signature")
        var ts, sig string
        for _, p := range strings.Split(header, ",") {
            kv := strings.SplitN(p, "=", 2)
            if len(kv) == 2 {
                if kv[0] == "t" { ts = kv[1] }
                if kv[0] == "v1" { sig = kv[1] }
            }
        }
        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write([]byte(ts + "."))
        mac.Write(body)
        expected := hex.EncodeToString(mac.Sum(nil))
        if !hmac.Equal([]byte(sig), []byte(expected)) {
            return false
        }
        epoch, _ := strconv.ParseInt(ts, 10, 64)
        return time.Now().Unix()-epoch <= 300
    }
    ```
  </Tab>
</Tabs>

## Event catalog

### `message.received`

Fired when a customer sends a message to one of your numbers.

```json theme={null}
{
  "event": "message.received",
  "timestamp": "2026-06-23T16:48:00.000Z",
  "phoneNumber": "+447700900100",
  "from": "+447700900200",
  "contact": { "name": "Alex", "phoneNumber": "+447700900200" },
  "message": {
    "id": "01HXYZ...",
    "waId": "wamid.HBgL...",
    "body": "Hey, can I add another item to my order?",
    "media": [],
    "buttons": []
  },
  "conversationId": "5a3f...e2"
}
```

### `test.ping`

Triggered by the dashboard **Send test event** button. Same envelope as a real event with a stub payload — use it to validate signature verification in CI.

## Best practices

* **Idempotency.** Store the `X-Wassist-Delivery` ID and reject duplicates. We use the same ID across all retries.
* **Respond fast.** Send a `2xx` within 10 seconds and do real work asynchronously. We treat slow responses as failures and retry.
* **Replay protection.** Reject events where `X-Wassist-Timestamp` is more than 5 minutes from now.
* **Rotate secrets.** From the dashboard, **Rotate secret** generates a new secret. The old one stops working immediately.
* **Local development.** Use the CLI: `wassist listen` tunnels real events to `localhost` without exposing a public URL.

## Replaying deliveries

Every delivery is stored with its full payload. From the dashboard you can:

* Filter deliveries by status (all / failed / succeeded).
* Open a delivery to see request headers, response body, and the timeline of every retry attempt.
* Click **Replay** to send a fresh copy of the same payload (it gets a new `X-Wassist-Delivery` ID).

You can also replay programmatically via [`POST /api/v1/webhook-deliveries/{id}/replay/`](/api-reference/webhooks/replay-delivery).
