# Webhooks

> Docs index: https://wahasend.com/llms.txt · Everything in one file: https://wahasend.com/docs.md · API base URL: https://app.wahasend.com/api/v1

WahaSend posts events to one URL of yours: replies from customers, delivery receipts, a number that disconnects, an assistant that needs a person, a new order. **Developer** gets every event (feature `webhooks`). **Assistant** and **Shop** owners can set a webhook too and get their own product's events: `bot.needs_review` and `bot.handoff`, plus `commerce.order` with Shop. Set the address in the dashboard (**For developers** → Webhook URL) or with the API:

<a id="webhook-set"></a>

#### Set or clear the webhook URL

`PUT /api/v1/me/webhook`

One URL per organization. `null` stops the events. The first URL also creates the signing secret, returned once here as `signingSecret` (read it again with `GET /me/webhook`).

- Unlocked by: Assistant, Shop, Developer (`webhooks | bots`)
- Account key only

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `webhookUrl` | string (URL) \| null | yes | Public http(s) address, up to 2000 characters. Private and local addresses are refused. |

**Example**

curl:

```bash
curl -X PUT https://app.wahasend.com/api/v1/me/webhook \
  -H "X-API-Key: $WAHASEND_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9"
}'
```

JavaScript:

```js
const res = await fetch('https://app.wahasend.com/api/v1/me/webhook', {
  method: 'PUT',
  headers: { 'X-API-Key': process.env.WAHASEND_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9"
  }),
});
const data = await res.json();
```

Python:

```python
import os
import requests

r = requests.put(
    "https://app.wahasend.com/api/v1/me/webhook",
    headers={"X-API-Key": os.environ["WAHASEND_API_KEY"]},
    json={
        "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9",
    },
)
print(r.json())
```

**Response 200**

```json
{
  "success": true,
  "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9",
  "signingSecret": "whsec_5f0c…64 hex characters…"
}
```

**Errors to expect**

- [`VALIDATION_ERROR`](https://wahasend.com/docs/errors.md#validation_error) (400): Validation failed. Check the details for specific field errors.
- [`PRODUCT_REQUIRED`](https://wahasend.com/docs/errors.md#product_required) (403): This feature is not included in your products.

<a id="webhook-get"></a>

#### Webhook URL and signing secret

`GET /api/v1/me/webhook`

The URL and the secret to [check signatures](https://wahasend.com/docs/webhooks.md#webhook-security) with. Owners and admins only.

- Unlocked by: Assistant, Shop, Developer (`webhooks | bots`)
- Account key only

> `signingSecret` is null when no URL was ever set (or on a server without encryption set up: deliveries are then unsigned).

**Example**

curl:

```bash
curl https://app.wahasend.com/api/v1/me/webhook \
  -H "X-API-Key: $WAHASEND_API_KEY"
```

JavaScript:

```js
const res = await fetch('https://app.wahasend.com/api/v1/me/webhook', {
  headers: { 'X-API-Key': process.env.WAHASEND_API_KEY },
});
const data = await res.json();
```

Python:

```python
import os
import requests

r = requests.get(
    "https://app.wahasend.com/api/v1/me/webhook",
    headers={"X-API-Key": os.environ["WAHASEND_API_KEY"]},
)
print(r.json())
```

**Response 200**

```json
{
  "success": true,
  "webhookUrl": "https://example.com/hooks/wahasend-7Qe2xLp9",
  "signingSecret": "whsec_5f0c…64 hex characters…"
}
```

**Errors to expect**

- [`PRODUCT_REQUIRED`](https://wahasend.com/docs/errors.md#product_required) (403): This feature is not included in your products.
- [`ROLE_FORBIDDEN`](https://wahasend.com/docs/errors.md#role_forbidden) (403): Your role in this organization does not allow this. Ask an owner or admin.

<a id="webhook-secret-regenerate"></a>

#### Replace the signing secret

`POST /api/v1/me/webhook/regenerate-secret`

A new secret; deliveries are signed with it at once and the old one no longer verifies. Update your receiver right after.

- Unlocked by: Assistant, Shop, Developer (`webhooks | bots`)
- Account key only

**Example**

curl:

```bash
curl -X POST https://app.wahasend.com/api/v1/me/webhook/regenerate-secret \
  -H "X-API-Key: $WAHASEND_API_KEY"
```

JavaScript:

```js
const res = await fetch('https://app.wahasend.com/api/v1/me/webhook/regenerate-secret', {
  method: 'POST',
  headers: { 'X-API-Key': process.env.WAHASEND_API_KEY },
});
const data = await res.json();
```

Python:

```python
import os
import requests

r = requests.post(
    "https://app.wahasend.com/api/v1/me/webhook/regenerate-secret",
    headers={"X-API-Key": os.environ["WAHASEND_API_KEY"]},
)
print(r.json())
```

**Response 200**

```json
{
  "success": true,
  "signingSecret": "whsec_91ab…64 hex characters…",
  "warning": "Update your receiver with this secret now. Deliveries are signed with it from now on."
}
```

**Errors to expect**

- [`PRODUCT_REQUIRED`](https://wahasend.com/docs/errors.md#product_required) (403): This feature is not included in your products.
- [`WEBHOOK_SIGNING_UNAVAILABLE`](https://wahasend.com/docs/errors.md#webhook_signing_unavailable) (503): Webhook signing is not available on this server yet (SECRETS_ENCRYPTION_KEY is not set).

<a id="webhook-delivery"></a>

### How events are delivered

- `POST` with `Content-Type: application/json` and `User-Agent: WahaSend/1.0`, body `{ event, data, timestamp }`.
- Headers: `X-WahaSend-Event` (the event name), `X-WahaSend-Delivery` (one id per event, the same on every retry: use it to drop duplicates) and `X-WahaSend-Signature` ([check it](https://wahasend.com/docs/webhooks.md#webhook-security)).
- Answer any 2xx within 10 seconds. Anything else (or a timeout, or a redirect: redirects are not followed) is retried: 5 attempts in all, waiting about 5 s, 10 s, 20 s and 40 s between them. Then the event is dropped.
- Events go to the URL set at delivery time; a private or local address is refused then too.
- Order is not guaranteed and an event can come twice: make your handler idempotent (use `messageId` / `wahaMessageId`).

Every event:

```json
{
  "event": "message.delivered",
  "data": { … },
  "timestamp": "2026-09-30T10:30:15.204Z"
}
```

<a id="webhook-security"></a>

### Checking that it's us

Every event is signed with your organization's **signing secret** (`whsec_…`, made with your first webhook URL; see it in the dashboard or with `GET /me/webhook`). The header looks like this:

```http
X-WahaSend-Signature: t=1790762475,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

1. Read `t` (Unix seconds) and `v1` from the header.
2. Compute HMAC-SHA256 with your secret over `t + "." + body`, where body is the **raw** request body exactly as received (not re-serialized JSON). Hex-encode it.
3. Compare it with `v1` in constant time. Refuse the event if they differ.
4. Refuse it too if `t` is more than 5 minutes away from your clock: an old event replayed by someone else then fails.

Node.js (Express):

```js
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.WAHASEND_WEBHOOK_SECRET; // whsec_…

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = Object.fromEntries((header || '').split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as bytes: sign-check those, then parse
app.post('/hooks/wahasend', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.get('X-WahaSend-Signature'))) return res.status(400).end();
  const { event, data } = JSON.parse(raw);
  // … handle it (dedupe on req.get('X-WahaSend-Delivery'))
  res.sendStatus(200);
});
```

Python (Flask):

```python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WAHASEND_WEBHOOK_SECRET"].encode()  # whsec_…

def verify(raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_seconds:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/hooks/wahasend")
def wahasend():
    raw = request.get_data()  # the raw bytes, before any JSON parsing
    if not verify(raw, request.headers.get("X-WahaSend-Signature", "")):
        abort(400)
    event = request.get_json()
    # … handle it (dedupe on request.headers["X-WahaSend-Delivery"])
    return "", 200
```

> Replace the secret with **New key** in the dashboard (or `POST /me/webhook/regenerate-secret`): events are signed with the new one at once, so update your receiver right after. Keep using HTTPS for the URL.

<a id="webhook-events"></a>

### Events

| Event | When | Who gets it |
| --- | --- | --- |
| `message.inbound` | Someone wrote to your number. | Developer |
| `message.delivered` · `message.read` · `message.failed` | A message sent by text send, media send, batch, campaign, OTP, reminder or assistant changed status. (`message.sent` is rare: sends are already marked sent.) | Developer |
| `poll.vote` | Someone voted in a poll you sent. | Developer |
| `session.needs_scan` · `session.disconnected` · `session.recovered` | A number must be linked again, went down, or came back. | Developer |
| `bot.needs_review` · `bot.handoff` | An assistant could not answer, or handed the chat to a person. | Assistant, Shop or Developer |
| `commerce.order` | A customer placed a cash-on-delivery order with the shop assistant. | Shop or Developer |

message.inbound:

```json
{
  "event": "message.inbound",
  "data": {
    "sender": "22212345678",
    "senderName": "Aïcha",
    "body": "Is the blue dress still available?",
    "messageType": "chat",
    "wahaMessageId": "false_22212345678@c.us_3A1F9C2B7E",
    "containerId": "6d2b9c1e-8f4a-4e37-a5c2-1b9e7d3f0a64",
    "numberPhone": "22200000000",
    "timestamp": "2026-09-30T10:41:07.512Z"
  },
  "timestamp": "2026-09-30T10:41:07.790Z"
}
```

- `sender` is the phone digits, or the `…@lid` id when WhatsApp hides the number.
- Optional fields: `isGroup` + `groupId` (group messages), `latitude` + `longitude` (a location, `messageType: "location"`), `selectedButtonId`, `selectedRowId`.
- Messages without text (a photo with no caption, a sticker) are not sent as events. A voice note arrives only on accounts with an assistant, as its transcript (`messageType: "voice"`).
- `containerId` and `numberPhone` say which of your numbers received it (`numberPhone` is null for a number not linked yet).

message.delivered (also .read, .failed):

```json
{
  "event": "message.delivered",
  "data": {
    "messageId": "4f0c6a1e-2b7d-4c1a-9e35-7d8f2a6b1c90",
    "recipient": "22212345678",
    "type": "alert",
    "status": "delivered",
    "wahaMessageId": "true_22212345678@c.us_3EB0C767D26A1D2E5A8F",
    "containerId": "6d2b9c1e-8f4a-4e37-a5c2-1b9e7d3f0a64",
    "numberPhone": "22200000000",
    "timestamp": "2026-09-30T10:30:15.101Z"
  },
  "timestamp": "2026-09-30T10:30:15.204Z"
}
```

`type` is `alert` (text and media sends, batches, reminders), `bulk` (campaigns, with `campaignId` added), `otp` or `bot_reply`. `containerId` / `numberPhone` name the number it went from (null on messages from before they were recorded).

session.disconnected:

```json
{
  "event": "session.disconnected",
  "data": {
    "status": "FAILED",
    "message": "Your WhatsApp number has disconnected and automatic recovery failed. Please reconnect it from your dashboard.",
    "retriesExhausted": true,
    "lastAttempt": "2026-09-30T03:12:40.000Z",
    "containerId": "6d2b9c1e-8f4a-4e37-a5c2-1b9e7d3f0a64",
    "numberPhone": "22200000000",
    "session": "n6d2b9c1e8f4a4e37",
    "timestamp": "2026-09-30T03:12:40.020Z"
  },
  "timestamp": "2026-09-30T03:12:40.300Z"
}
```

When the WhatsApp server hosting the number is down, `session.disconnected` carries `"reason": "instance_down"` instead of `retriesExhausted`; the number comes back by itself. `session.needs_scan` carries `"status": "SCAN_QR_CODE"`; `session.recovered` carries `"status": "WORKING"` and `recoveredAt`. All three have `containerId`, `numberPhone` and `session`.

bot.needs_review / bot.handoff:

```json
{
  "event": "bot.needs_review",
  "data": {
    "botId": "8b2e…",
    "conversationId": "f41c…",
    "contactPhone": "22212345678",
    "question": "Do you deliver to Rosso?",
    "lang": "fr",
    "timestamp": "2026-09-30T11:02:10.000Z"
  },
  "timestamp": "2026-09-30T11:02:10.180Z"
}
```

`reason` is added when it is not a plain "couldn't answer": `"away"` (outside business hours; no `lang` then) or `"ai_quota"` (the month's AI allowance is used up).

commerce.order:

```json
{
  "event": "commerce.order",
  "data": {
    "botId": "8b2e…",
    "contactPhone": "22212345678",
    "orderNumber": "A-1042",
    "total": 12500,
    "currency": "MRU",
    "timestamp": "2026-09-30T12:20:44.000Z"
  },
  "timestamp": "2026-09-30T12:20:44.260Z"
}
```

`poll.vote` passes on WhatsApp's vote as it comes (`vote`, `poll`) with your `containerId` and `numberPhone` added.
