# WAAPI — Webhook Setup

Receive incoming messages, delivery status and template events from WAAPI on your own server.
WAAPI handles Meta's webhook on your behalf — you only need to configure **one outbound URL** in the
dashboard.

---

## Overview

A WAAPI webhook is an outbound HTTP POST that WAAPI sends to **your server** every time something
happens on a WhatsApp number you own — a customer messages you, a message you sent is delivered or
read, a template is approved, and so on.

You don't need to talk to Meta directly. WAAPI already receives Meta's raw webhooks, verifies them,
deduplicates them, persists the data, and then forwards a clean, signed event to your endpoint.

Webhooks are managed in **Setup → Webhooks** per WhatsApp number. Each entry is scoped to one
account, has its own secret, headers, filters, and schedule.

---

## How it works

```
WhatsApp user
     │ sends / reads / replies
     ▼
Meta Cloud API
     │ delivers to WAAPI (handled for you)
     ▼
WAAPI Backend
     │ persists, dedupes, fans out to active webhooks
     ▼
Your server
     │ POST {your url}  +  X-Webhook-Signature: <hmac-sha256>
     │ + any custom headers you configured
     ▼
Respond 2xx
   (failures bump a counter — no automatic retries)
```

A single inbound event can fan out to multiple webhooks. For example, you can keep one webhook that
mirrors every event into your data lake, and a second one filtered to fire only on messages that
start with `ORDER`.

---

## Create a webhook

1. Open **Setup → Webhooks** in the WAAPI dashboard.
2. Pick the WhatsApp number this webhook belongs to.
3. Enter a friendly **name** and the public **URL** on your server.
4. Choose which **events** should trigger the webhook (or pick `all`).
5. *(Optional)* Set a **secret** — WAAPI will sign every payload with it.
6. *(Optional)* Add custom **headers**, message content **filters**, and an active **schedule**.
7. Save — your webhook starts firing immediately on the next matching event.

### Full configuration shape

```json
{
  "name": "Order Bot",
  "url": "https://api.yourapp.com/waapi/incoming",
  "events": ["message.received"],
  "secret": "whk_8f3c...",
  "headers": [
    { "key": "X-Tenant", "value": "acme" }
  ],
  "message_filters": [
    { "type": "starts_with", "value": "ORDER" }
  ],
  "filter_logic": "all",
  "schedule": {
    "type": "weekly",
    "days": [1, 2, 3, 4, 5],
    "start_time": "09:00",
    "end_time": "18:00"
  },
  "schedule_timezone": "Asia/Kolkata",
  "is_active": true
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | ✅ | Friendly name (max 100 chars) |
| `url` | string | ✅ | Public HTTPS endpoint on your server |
| `events` | string[] | ✅ | One or more event types or `["all"]` |
| `secret` | string | — | Used to sign every payload (X-Webhook-Signature) |
| `headers` | array | — | Custom headers added to every outbound request |
| `message_filters` | array | — | Text-content filters (only for `message.received`) |
| `filter_logic` | string | — | `any` (default) or `all` |
| `schedule` | object | — | Time-of-day rules — default `{ type: "always" }` |
| `schedule_timezone` | string | — | IANA tz (default: `Asia/Kolkata`) |
| `is_active` | boolean | — | Toggle without deleting (default: `true`) |

---

## Event types

| Event | Fires when |
|---|---|
| `message.received` | A customer sends a new message to your business number |
| `message.sent` | A message your account sends (via API or dashboard) is accepted by Meta |
| `message.status` | Delivery / read receipt — sent → delivered → read → failed |
| `template.approved` | A template you submitted is approved by Meta |
| `template.rejected` | A template is rejected |
| `template.pending` | A template moves into review |
| `account.update` | Phone-number or WABA-level policy / quality changes |
| `all` | Wildcard — match every supported event |

---

## Event payloads

### `message.received`

```json
{
  "event": "message.received",
  "timestamp": "2026-03-05T10:30:00.000Z",
  "phone_number_id": "PHONE_NUMBER_ID",
  "display_phone_number": "+15551234567",
  "from": "+919876543210",
  "customer_name": "Jane Doe",
  "wa_message_id": "wamid.HBgL...",
  "type": "text",
  "text": "Hello, I need help with my order",
  "media_url": null,
  "media_id": null
}
```

### `message.sent`

```json
{
  "event": "message.sent",
  "message_id": "6789abcdef1234567890abcd",
  "wa_message_id": "wamid.HBgL...",
  "conversation_id": "6789abcdef1234567890aaaa",
  "to": "+919876543210",
  "type": "text",
  "status": "sent",
  "timestamp": "2026-03-05T10:30:00.000Z"
}
```

### `message.status`

```json
{
  "event": "message.status",
  "wa_message_id": "wamid.HBgL...",
  "status": "delivered",
  "recipient": "+919876543210",
  "timestamp": "2026-03-05T10:30:04.000Z"
}
```

### `template.approved` / `template.rejected` / `template.pending`

```json
{
  "event": "template.approved",
  "template_name": "order_confirmation",
  "language": "en_US",
  "category": "UTILITY",
  "timestamp": "2026-03-05T10:30:00.000Z"
}
```

### `account.update`

```json
{
  "event": "account.update",
  "phone_number_id": "PHONE_NUMBER_ID",
  "display_phone_number": "+15551234567",
  "field": "quality_update",
  "value": { "quality_score": "GREEN" },
  "timestamp": "2026-03-05T10:30:00.000Z"
}
```

---

## Message content filters

Filters apply **only to `message.received`** and run against the incoming `text` (case-insensitive).
This lets you wire one webhook per intent — orders, support, OTPs — without parsing on your side.

| Filter type | Matches when text… |
|---|---|
| `contains` | contains the value |
| `not_contains` | does NOT contain the value |
| `equals` | is exactly the value |
| `not_equals` | is anything other than the value |
| `starts_with` | starts with the value |
| `ends_with` | ends with the value |

Combine multiple filters with `filter_logic`: `any` (fire if at least one matches) or `all`
(fire only when every filter matches).

```json
{
  "message_filters": [
    { "type": "starts_with", "value": "ORDER" },
    { "type": "not_contains", "value": "test" }
  ],
  "filter_logic": "all"
}
```

---

## Active schedule

Limit when a webhook fires — useful for routing business-hours traffic to one endpoint and after-hours
to another. Times are evaluated in `schedule_timezone` (default `Asia/Kolkata`).

| `schedule.type` | Behavior | Shape |
|---|---|---|
| `always` | Fires whenever the event matches (default) | `{ "type": "always" }` |
| `datetime` | Fires only inside the given start/end windows | `{ "type": "datetime", "ranges": [{ "start": "2026-03-05T09:00", "end": "2026-03-05T18:00" }] }` |
| `weekly` | Fires on the given weekdays, within `start_time` / `end_time` | `{ "type": "weekly", "days": [1,2,3,4,5], "start_time": "09:00", "end_time": "18:00" }` |
| `special` | Holiday / promo overrides on specific dates | `{ "type": "special", "entries": [{ "date": "2026-12-25", "start_time": "00:00", "end_time": "23:59", "label": "Christmas" }] }` |

> **Weekdays** use JavaScript's `Date.getDay()` convention: `0 = Sunday … 6 = Saturday`.

---

## Custom headers

Each webhook can carry arbitrary `key`/`value` headers on every request — handy for static tenant ids,
internal bearer tokens, or a custom signature your gateway expects.

```json
{
  "headers": [
    { "key": "X-Tenant", "value": "acme" },
    { "key": "Authorization", "value": "Bearer your_internal_token" }
  ]
}
```

> Avoid setting `Content-Type` — WAAPI always sends `application/json`.

---

## Signing & verification

When you set a `secret` on the webhook, WAAPI signs every body with HMAC-SHA256 and sends the hex
digest in `X-Webhook-Signature`. Verify it on your side before trusting the payload.

### Node.js / Express

```javascript
import crypto from 'crypto';
import express from 'express';

const app = express();

app.post('/waapi/incoming', express.json(), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const expected = crypto
    .createHmac('sha256', process.env.WAAPI_WEBHOOK_SECRET)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (signature !== expected) return res.sendStatus(401);

  switch (req.body.event) {
    case 'message.received':  /* … */ break;
    case 'message.status':    /* … */ break;
    case 'template.approved': /* … */ break;
  }

  res.sendStatus(200);
});
```

### Python

```python
import hmac, hashlib, os

def verify(body_bytes, signature):
    expected = hmac.new(
        os.environ['WAAPI_WEBHOOK_SECRET'].encode(),
        body_bytes,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

### PHP

```php
function verify($body, $signature) {
    $expected = hash_hmac('sha256', $body, getenv('WAAPI_WEBHOOK_SECRET'));
    return hash_equals($expected, $signature);
}
```

> **Use the raw bytes you received.** Re-stringifying parsed JSON can change key ordering and break
> the hash. If your framework gives you the raw body buffer, use that directly with `createHmac`.

---

## Delivery & retries

| Property | Value |
|---|---|
| Method | `POST` |
| `Content-Type` | `application/json` |
| Signature header | `X-Webhook-Signature` (only when a secret is configured) |
| Timeout | 10 seconds per request |
| Success | Any 2xx response — `failure_count` is reset to 0 |
| Failure | Non-2xx or timeout — `failure_count` is incremented |
| Automatic retry | **None.** WAAPI does not retry. Use a queue on your side if you need durability. |

> **Idempotency on your side:** Multiple webhooks can subscribe to the same event, and a single Meta
> delivery can be retried inside WAAPI. Use `wa_message_id` or `message_id` as your dedup key.

---

## Troubleshooting

| Symptom | Most likely cause |
|---|---|
| Webhook is not firing | `is_active` is off, the event isn't subscribed, or the schedule is outside the current time |
| Fires for some messages only | A `message_filters` rule is rejecting the rest — try `filter_logic: "any"` |
| 401 on your server | Signature mismatch — make sure you're hashing the raw body bytes with the exact secret |
| Timeout / repeated 5xx | Your handler is too slow — return 2xx first, then process async |
| Missing events | Add the event type, or use `"all"` as a wildcard |
| Duplicate processing | Dedupe on `wa_message_id` / `message_id` on your side |
| Plan limit hit | Webhook creation is plan-gated (per-account count + monthly creation cap). Check **Setup → Billing**. |

---

## Tip — test from the playground

Use the **API Tester** at `/docs/api/tester` to send a real message to your number and watch the
webhook fire on your server in real time.
