# QAF WhatsApp API — Developer guide

> Markdown version of https://qaf-sender.qafdev.com/developers (canonical). Index: https://qaf-sender.qafdev.com/llms.txt. API base URL: https://api.qaf-sender.qafdev.com. Last updated: 2026-11-03.

## 1. Overview

QAF exposes a **WhatsApp Cloud API** gateway with multi-tenant accounts. Use this guide to connect an external system (CRM, backend, bot, ERP): authentication, discovery, sending messages, contacts, webhooks, idempotency and error handling.

> **Note:** This guide is for third-party and customer developers. Dashboard admin flows such as WABA onboarding and Meta phone verification are not covered here.

```
Your system  ──Bearer account token──►  QAF API  ──Meta token──►  WhatsApp
     ▲                                      │
     └──────── outbound signed webhooks ────┘
```

| You get | Via |
|---------|-----|
| Send text, template, media, interactive & location | `POST /v1/whatsapp/messages/*` |
| List & fetch messages | `GET /v1/whatsapp/messages` |
| Discover phones & templates | `GET /v1/whatsapp/phone-numbers`, `/templates` |
| Manage contacts & the 24h window | `/v1/whatsapp/contacts` |
| Receive inbound, status & call events | Your HTTPS webhook URL |

---

## 2. Base URL & versioning

| Environment | Base URL |
|-------------|----------|
| Production | `https://api.qaf-sender.qafdev.com` |
| Local | `http://localhost:3065` |

**Canonical API prefix:** `/v1`

| Path | Status |
|------|--------|
| `/v1/whatsapp/...` | **Use this** |
| `/whatsapp/...` | Deprecated alias (same handlers) |
| `/webhooks` | Meta only (unversioned) |
| `/media/:filename` | Media files (unversioned; **signed** with `?exp=` & `sig=`) |

Example:

```http
POST https://api.qaf-sender.qafdev.com/v1/whatsapp/messages/text
```

Breaking changes will ship as `/v2`. `/v1` will remain until a published deprecation window ends.

---

## 3. Authentication

### Account API token (required for integrations)

1. In the QAF dashboard, open your **Account → API token** (or call `POST /v1/accounts/{accountId}/token` with a user JWT as owner/admin).
2. Copy **`token`** and **`webhookSecret`** immediately — they are shown **once**.
3. Send on every request:

```http
Authorization: Bearer <account_api_token>
Content-Type: application/json
```

| Secret | Purpose |
|--------|---------|
| `token` | Bearer auth for `/v1/whatsapp/*` |
| `webhookSecret` | Verify `X-QafLab-Signature` on outbound webhooks |

Rotating the token invalidates the previous Bearer. Generate a new webhook URL verification secret as part of the same rotate response.

### JWT (dashboard only)

Dashboard users use a short-lived JWT (`POST /v1/auth/login`). For `/v1/whatsapp/*` with a JWT they must also send:

```http
X-Account-Id: <account-uuid>
```

> **Note:** Integrators should use the **account API token**, not a user JWT. JWTs are short-lived and tied to a dashboard user.

---

## 4. Quick start

```bash
BASE=https://api.qaf-sender.qafdev.com
TOKEN=your_account_api_token

# 1) Who am I?
curl -s "$BASE/v1/whatsapp/me" \
  -H "Authorization: Bearer $TOKEN" | jq

# 2) List sender numbers & templates
curl -s "$BASE/v1/whatsapp/phone-numbers" -H "Authorization: Bearer $TOKEN" | jq
curl -s "$BASE/v1/whatsapp/templates" -H "Authorization: Bearer $TOKEN" | jq

# 3) Optional: set your webhook URL (use phone.id from step 2)
curl -s -X PATCH "$BASE/v1/whatsapp/phone-numbers/PHONE_UUID/webhook-subscription" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.example.com/hooks/qaf"}'

# 4) Send a text (202 Accepted, status "queued")
curl -s -X POST "$BASE/v1/whatsapp/messages/text" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "phoneNumber": "+15550973736",
    "recipientPhoneNumber": "+966501234567",
    "message": "Hello from our CRM"
  }' | jq
```

---

## 5. Concepts

### Account → WABA → phone number

```
Account
  └── WABA (WhatsApp Business Account) — one or many
        └── Phone number(s) — your WhatsApp sender lines
```

When sending, `phoneNumber` is **your** business line (E.164 display number, e.g. `+15550973736`, or Meta’s phone number id).  
`recipientPhoneNumber` is the **customer** in E.164 (`+` and country code).

### E.164 format

```text
^\+[1-9]\d{1,14}$
```

Valid: `+966501234567`, `+15550973736`  
Invalid: `966501234567`, `0501234567`

### Templates

Templates are synced/created in the QAF dashboard. When sending, use the **local template UUID** (`id` from `GET /v1/whatsapp/templates`), not only the Meta template name.

### Contacts

Contacts are **per account** (unique on `account + phoneNumber`). Outbound sends automatically create/link a contact for the recipient.

---

## 6. Message resource

Single-message send, list, get, and mark-read endpoints return the same **message resource** (Twilio-style). Bulk create and poll return batch resources instead (see [§7.4](#74-bulk-template-send)):

```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "messageId": null,
  "waMessageId": null,
  "accountId": "account-uuid",
  "from": "+15550973736",
  "to": "+966501234567",
  "direction": "outbound",
  "type": "text",
  "source": "cloud_api",
  "status": "queued",
  "content": { "text": "Hello from our CRM" },
  "contact": {
    "id": "contact-uuid",
    "phoneNumber": "+966501234567",
    "displayName": null,
    "username": null
  },
  "phoneNumber": {
    "id": "phone-uuid",
    "displayPhone": "+15550973736",
    "phoneNumberId": "162897436914787",
    "name": "Support"
  },
  "errorCode": null,
  "errorMessage": null,
  "createdAt": "2026-09-24T10:00:00.000Z",
  "updatedAt": "2026-09-24T10:00:00.000Z",
  "uri": "/v1/whatsapp/messages/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

| Field | Description |
|-------|-------------|
| `id` | Local UUID — use with `GET /v1/whatsapp/messages/:id` |
| `messageId` / `waMessageId` | Meta WhatsApp message id (`wamid`); `null` while the message is `queued` |
| `from` / `to` | E.164 parties |
| `direction` | `inbound` \| `outbound` |
| `type` | `text` \| `template` \| `image` \| `document` \| `audio` \| `video` \| … |
| `source` | `cloud_api` (default) \| `biz_app` (sent from the WhatsApp Business App on a Coexistence number) \| `history_sync` (imported Business App history). Only `cloud_api` inbound messages open the 24h customer-service window |
| `status` | `queued` \| `sent` \| `delivered` \| `read` \| `failed` \| `null` |
| `content` | Type-specific payload |
| `contact` | Linked contact. `username` is the customer's WhatsApp handle when Meta shares it, otherwise `null` |
| `uri` | Relative path to fetch this message |

Sends return `202` with `status: "queued"`; a worker then delivers the message to Meta. `sent`, `delivered`, `read` and `failed` arrive later via **outbound webhooks** (`message.sent`, `message.status_updated`), or poll `GET /v1/whatsapp/messages/{id}`.

```text
POST → 202 queued → sent → delivered → read
              └──────┴──► failed
bulk items: queued → sending → sent | failed
```

---

## 7. API reference

All paths below are relative to the base URL and require:

```http
Authorization: Bearer <account_api_token>
```

### 7.1 Discovery

#### `GET /v1/whatsapp/me`

Returns the authenticated account, WABAs, and phone numbers (no Meta secrets).

#### `GET /v1/whatsapp/phone-numbers`

List sender lines for the account.

Typical item:

```json
{
  "id": "phone-uuid",
  "phoneNumberId": "162897436914787",
  "displayPhone": "+15550973736",
  "name": "Support",
  "isVerified": true,
  "isActive": true,
  "webhookSubscription": { "id": "...", "url": "https://..." }
}
```

Use `displayPhone` (or `phoneNumberId`) as `phoneNumber` when sending.

#### `GET /v1/whatsapp/templates`

Optional query: `?wabaId=<local-waba-uuid>`

Use each item’s `id` as `templateId` when sending.

#### `PATCH /v1/whatsapp/phone-numbers/:id/webhook-subscription`

```json
{ "url": "https://your-app.example.com/hooks/qaf" }
```

- Production: **HTTPS** to a public host  
- Development: `http://localhost` may be allowed  
- Private / SSRF-prone hosts are rejected  

`:id` is the local phone UUID (`phone-numbers[].id`).

---

### 7.2 Contacts

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/whatsapp/contacts` | List (`limit`, `offset`, optional `phoneNumberId`) |
| `POST` | `/v1/whatsapp/contacts` | Create |
| `GET` | `/v1/whatsapp/contacts/:id` | Get one (optional `phoneNumberId` for the session window) |
| `PATCH` | `/v1/whatsapp/contacts/:id` | Update |

**Create / update body:**

```json
{
  "phoneNumber": "+966501234567",
  "displayName": "Ahmed",
  "username": "ahmed.wa"
}
```

`username` is the customer's WhatsApp handle. QAF fills it in when Meta includes it on inbound messages; it is also optional on create and update.

#### 24h customer-service window

List or get contacts **with** `phoneNumberId` (your business line) to see Meta's 24-hour customer-service window for that line:

| Field | Description |
|-------|-------------|
| `windowStartedAt` | When the current window opened (the customer's last inbound message) |
| `windowExpiresAt` | When it closes, 24 hours after `windowStartedAt` |
| `freeformAllowed` | `true` while the window is open |

Each inbound customer message resets the window for another 24 hours.

> **Tip:** Check `freeformAllowed` before sending. While it is `true` you can send text, media, quick-reply, location and list messages; when it is `false`, send an approved template instead.

---

### 7.3 Send messages

Every send is asynchronous. The API returns `202 Accepted` with the message resource in `status: "queued"` (usually with `waMessageId: null`), and a worker delivers it to Meta. Listen for `message.sent` / `message.status_updated` or poll `GET /v1/whatsapp/messages/{id}`.

| Endpoint | Sends | Needs open 24h window |
|----------|-------|-----------------------|
| `POST /v1/whatsapp/messages/text` | Free-form text | yes |
| `POST /v1/whatsapp/messages/template` | Approved template | no |
| `POST /v1/whatsapp/messages/media` | Image, document, audio or video | yes |
| `POST /v1/whatsapp/messages/quick-reply` | Text with 1–3 reply buttons | yes |
| `POST /v1/whatsapp/messages/location` | Location pin | yes |
| `POST /v1/whatsapp/messages/list` | List picker | yes |

Sends that need the window fail with `422` when it is closed (see [§7.2](#72-contacts)). Every endpoint above accepts an optional `Idempotency-Key` (see [§9](#9-idempotency)). To send one template to many recipients, use [bulk template send](#74-bulk-template-send).

#### Text — `POST /v1/whatsapp/messages/text`

Returns `202` with `status: "queued"`.

```json
{
  "phoneNumber": "+15550973736",
  "recipientPhoneNumber": "+966501234567",
  "message": "Hello from our CRM",
  "previewUrl": false
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `phoneNumber` | yes | Your sender (E.164 or Meta phone number id) |
| `recipientPhoneNumber` | yes | Customer E.164 |
| `message` | yes | Body text |
| `previewUrl` | no | Link preview (default `false`) |

Optional header: `Idempotency-Key` (see [§9](#9-idempotency)).

#### Template — `POST /v1/whatsapp/messages/template`

Same async contract as text: `202` with `status: "queued"`. Fails fast with `422` when the local template `status` is not `APPROVED` (paused, rejected, pending or disabled).

```json
{
  "phoneNumber": "+15550973736",
  "recipientPhoneNumber": "+966501234567",
  "templateId": "uuid-from-get-templates",
  "languageCode": "en_US",
  "components": [
    {
      "type": "body",
      "parameters": [
        { "type": "text", "text": "Ahmed" }
      ]
    }
  ]
}
```

#### Media — `POST /v1/whatsapp/messages/media`

```json
{
  "phoneNumber": "+15550973736",
  "recipientPhoneNumber": "+966501234567",
  "mediaType": "image",
  "mediaUrl": "https://cdn.example.com/photo.jpg",
  "caption": "Optional caption"
}
```

| Field | Notes |
|-------|-------|
| `mediaType` | `image` \| `document` \| `audio` \| `video` |
| `mediaUrl` | Public HTTPS URL QAF will fetch and send via Meta |
| `caption` | Not supported for `audio` |
| `filename` | Recommended for `document` |

#### Quick reply — `POST /v1/whatsapp/messages/quick-reply`

Interactive message with 1–3 reply buttons, sent inside the 24h window (`422` otherwise).

```json
{
  "phoneNumber": "+15550973736",
  "recipientPhoneNumber": "+966501234567",
  "body": "Did you receive your order?",
  "buttons": [
    { "id": "yes", "title": "Yes" },
    { "id": "no", "title": "No" }
  ]
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `body` | yes | Message text |
| `buttons` | yes | 1–3 buttons, each with an `id` and a `title` |
| `header` | no | Text, max 60 characters |
| `footer` | no | Text, max 60 characters |

> **Tip:** Outside the 24h window, send an approved template with `QUICK_REPLY` buttons instead.

#### Location — `POST /v1/whatsapp/messages/location`

Sends a location pin. 24h window required.

```json
{
  "phoneNumber": "+15550973736",
  "recipientPhoneNumber": "+966501234567",
  "latitude": 24.7136,
  "longitude": 46.6753,
  "name": "Riyadh office",
  "address": "King Fahd Rd, Riyadh"
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `latitude` / `longitude` | yes | Coordinates of the pin |
| `name` | no | Place name |
| `address` | no | Street address |

#### List picker — `POST /v1/whatsapp/messages/list`

A button that opens a list of choices. 24h window required.

```json
{
  "phoneNumber": "+15550973736",
  "recipientPhoneNumber": "+966501234567",
  "body": "Which shipping option?",
  "button": "Options",
  "sections": [
    {
      "title": "Speed",
      "rows": [
        { "id": "express", "title": "Express", "description": "1–2 days" },
        { "id": "standard", "title": "Standard", "description": "3–5 days" }
      ]
    }
  ]
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `body` | yes | Message text |
| `button` | yes | Label of the button that opens the list |
| `sections` | yes | Each with a `title` and `rows` |
| `sections[].rows` | yes | Each row has an `id`, a `title` and an optional `description`. Max **10 rows** across all sections |

---

### 7.4 Bulk template send

Each bulk API request queues **one approved template** for 1–500 recipients. Use the local template `id` from `GET /v1/whatsapp/templates`. The `phoneNumber` sender accepts an E.164 display number, Meta phone number id, or local phone UUID. The request is asynchronous: `202 Accepted` means the batch was queued, not that recipients received it.

#### Create — `POST /v1/whatsapp/messages/template/bulk`

```http
Authorization: Bearer <account_api_token>
Content-Type: application/json
Idempotency-Key: crm-campaign-20260926-001
```

Broadcast example (shared values for every recipient):

```json
{
  "phoneNumber": "+15550973736",
  "templateId": "uuid-of-local-template",
  "languageCode": "en_US",
  "components": [
    { "type": "body", "parameters": [{ "type": "text", "text": "Weekend sale — 20% off" }] }
  ],
  "recipients": [
    { "recipientPhoneNumber": "+966501111111" },
    { "recipientPhoneNumber": "+966502222222" }
  ]
}
```

Personalized example (each recipient overrides the shared components):

```json
{
  "phoneNumber": "+15550973736",
  "templateId": "otp-template-uuid",
  "languageCode": "en_US",
  "recipients": [
    {
      "recipientPhoneNumber": "+966501234567",
      "components": [{ "type": "body", "parameters": [{ "type": "text", "text": "482193" }] }]
    },
    {
      "recipientPhoneNumber": "+966509876543",
      "components": [{ "type": "body", "parameters": [{ "type": "text", "text": "917204" }] }]
    }
  ]
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `phoneNumber` | yes | Sender line |
| `templateId` | yes | Local template UUID |
| `languageCode` | yes | Template language, such as `en_US` or `ar` |
| `components` | no | Shared template parameters for broadcast |
| `recipients` | yes | 1–500 items |
| `recipients[].recipientPhoneNumber` | yes | Customer E.164 |
| `recipients[].components` | no | Replaces shared components for that recipient |

The optional `Idempotency-Key` deduplicates **batch creation** for 24 hours per account. It does not deduplicate each Meta send. Reuse the same key when retrying an uncertain create request; use a new key for a new batch.

Create returns `202 Accepted` with a summary:

```json
{
  "id": "batch-uuid",
  "status": "queued",
  "total": 2,
  "queued": 2,
  "sent": 0,
  "failed": 0,
  "languageCode": "en_US",
  "templateId": "uuid-of-local-template",
  "phoneNumber": { "id": "...", "displayPhone": "+15550973736", "phoneNumberId": "..." },
  "createdAt": "...",
  "updatedAt": "...",
  "uri": "/v1/whatsapp/messages/template/bulk/batch-uuid"
}
```

#### Poll — `GET /v1/whatsapp/messages/template/bulk/{batchId}`

Poll the returned `uri` (or use the path above) with the same account token. The response includes the summary plus `items[]`. Each item has `id`, `recipientPhoneNumber`, `status`, `messageId`, `waMessageId`, `localMessageId`, `error`, `createdAt`, and `updatedAt`. Message ids are `null` until the item succeeds.

| Scope | Status values |
|-------|---------------|
| Batch | `queued` → `processing` → `completed`; `failed` if every item fails |
| Item | `queued` \| `sending` \| `sent` \| `failed` |

The `whatsapp-send` queue runs 5 jobs at a time and retries transient errors up to 3 times. An item failure does not stop the batch; use status webhooks to track delivery.

The dashboard parses CSV or `.xlsx` locally. It requires a **Phone Number** column and includes only valid `+964` recipients; `964` and `00964` are normalized, while other or empty values are reported and skipped. Duplicates remain unless **Exclude duplicate rows** is selected, which keeps the first occurrence.

Map file columns to template variables, inspect the first ten highlighted messages, then confirm. Imports over 500 are split into sequential batches with separate idempotency keys; retry resumes at the failed batch. The dashboard polls each accepted batch.

For example, upload a file with columns `Phone Number` and `Days`, then map the template's body `{{1}}` to `Days`:

```csv
Phone Number,Days
+9647801234567,2
,3
9647809876543,4
+966501234567,6
+9647801234567,5
```

Here the empty and `+966` rows are skipped, `964…` is normalized, and the repeated `+9647801234567` can be excluded.

---

### 7.5 Read receipts & history

#### Mark read — `POST /v1/whatsapp/messages/{waMessageId}/read`

`{waMessageId}` is Meta’s `wamid` (not the local UUID).  
Returns the message resource with `status: "read"`.

#### List — `GET /v1/whatsapp/messages`

| Query | Description |
|-------|-------------|
| `contactId` | Filter by contact UUID |
| `phoneNumberId` | Filter by sender (display phone, Meta id, or local UUID) |
| `limit` | Page size |
| `offset` | Offset |

#### Get — `GET /v1/whatsapp/messages/{id}`

`{id}` is the **local** message UUID.

---

### 7.6 Endpoint checklist

| Method | Path |
|--------|------|
| `GET` | `/v1/whatsapp/me` |
| `GET` | `/v1/whatsapp/phone-numbers` |
| `PATCH` | `/v1/whatsapp/phone-numbers/:id/webhook-subscription` |
| `GET` | `/v1/whatsapp/templates` |
| `GET` | `/v1/whatsapp/contacts` |
| `POST` | `/v1/whatsapp/contacts` |
| `GET` | `/v1/whatsapp/contacts/:id` |
| `PATCH` | `/v1/whatsapp/contacts/:id` |
| `POST` | `/v1/whatsapp/messages/text` |
| `POST` | `/v1/whatsapp/messages/template` |
| `POST` | `/v1/whatsapp/messages/template/bulk` |
| `GET` | `/v1/whatsapp/messages/template/bulk/:batchId` |
| `POST` | `/v1/whatsapp/messages/media` |
| `POST` | `/v1/whatsapp/messages/quick-reply` |
| `POST` | `/v1/whatsapp/messages/location` |
| `POST` | `/v1/whatsapp/messages/list` |
| `POST` | `/v1/whatsapp/messages/:waMessageId/read` |
| `GET` | `/v1/whatsapp/messages` |
| `GET` | `/v1/whatsapp/messages/:id` |

---

## 8. Outbound webhooks

When a phone number has a webhook subscription URL, QAF POSTs events to your server.

> **Warning:** Delivery is **fail-closed**. If the account has **no** webhook signing secret (created when you generate or rotate the account API token), QAF will **not** POST events to your URL. Set the webhook URL **and** make sure a token / `webhookSecret` exists.

Deliveries are queued and retried with exponential backoff; once retries are exhausted the delivery moves to a dead-letter queue.

| Your response | What QAF does |
|---------------|---------------|
| `2xx` | Delivery complete |
| `429` or `5xx` | Retries with backoff |
| Any other `4xx` | Permanent failure, no retry |

Every attempt carries `X-QafLab-Delivery-Id`, which stays the same across retries of one delivery.

> **Tip:** Store `X-QafLab-Delivery-Id` and skip deliveries you have already processed, so a retry after a slow response does not handle the same event twice.

### 8.1 Events

| `event` | When |
|---------|------|
| `message.received` | Inbound customer message saved |
| `message.sent` | Outbound message saved |
| `message.status_updated` | Delivery / read / failure update |
| `call.started` | Calling API `connect` |
| `call.ended` | Calling API `terminate`; includes `content.duration` (seconds) when the call was answered |

### 8.2 Payload

```json
{
  "event": "message.received",
  "timestamp": "2026-09-24T10:00:00.000Z",
  "data": { /* message resource — same shape as §6 */ }
}
```

### 8.3 Headers

```http
Content-Type: application/json
User-Agent: QafLab-Webhook-Subscription-Dispatcher/1.0
X-QafLab-Signature: <hmac-sha256-hex-of-raw-body>
X-QafLab-Delivery-Id: <stable-id-for-this-delivery>
```

### 8.4 Verify signature (required)

Compute HMAC-SHA256 of the **raw request body string** with `webhookSecret`. Compare to `X-QafLab-Signature` using a constant-time compare.

```text
QAF:          sig = HMAC-SHA256(rawBody, webhookSecret)
              POST yourUrl  (X-QafLab-Signature: sig)
Your server:  expected = HMAC-SHA256(rawBody, webhookSecret)
              timingSafeEqual(expected, sig) ? 200 : 401
```

**Node.js:**

```js
import crypto from 'crypto';

function verifyQafWebhook(rawBody, signatureHeader, webhookSecret) {
  const expected = crypto
    .createHmac('sha256', webhookSecret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(signatureHeader || '', 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Express: use express.raw() or capture rawBody before JSON parse
app.post('/hooks/qaf', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyQafWebhook(raw, req.get('X-QafLab-Signature'), process.env.QAF_WEBHOOK_SECRET)) {
    return res.status(401).send('invalid signature');
  }
  const event = JSON.parse(raw);
  // handle event.event / event.data
  res.status(200).json({ ok: true });
});
```

**Python:**

```python
import hmac, hashlib

def verify_qaf_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

> **Tip:** Respond with **2xx quickly** and do the heavy work asynchronously (queue the event, then return).

---

## 9. Idempotency

Send endpoints accept an optional header:

```http
Idempotency-Key: <unique-string-max-255-chars>
```

| Behavior | Result |
|----------|--------|
| Same key + same body within ~24h | Cached success response replayed |
| Same key + different body | `422` — key already used with another payload |
| Same key while first request in flight | `409` — in progress |
| No header | Every call is a new send |

> **Tip:** Always send a UUID per logical send (for example your CRM message id). Retrying with the same key is safe; the cached response is replayed.

Applies to:

- `POST /v1/whatsapp/messages/text`
- `POST /v1/whatsapp/messages/template`
- `POST /v1/whatsapp/messages/media`
- `POST /v1/whatsapp/messages/quick-reply`
- `POST /v1/whatsapp/messages/location`
- `POST /v1/whatsapp/messages/list`

Bulk template batch creation has its own 24-hour per-account idempotency behavior; see [§7.4](#74-bulk-template-send).

---

## 10. Errors & rate limits

### HTTP status codes

| Status | Meaning |
|--------|---------|
| `202` | Accepted — message or bulk batch queued for Meta delivery |
| `400` | Validation / missing account context / Meta client error message |
| `401` | Missing or invalid Bearer token |
| `403` | Authenticated but not allowed |
| `404` | Resource not found for this account |
| `409` | Conflict (e.g. idempotency in progress, duplicate contact) |
| `422` | Idempotency key reused with a different body; template not `APPROVED`; free-form send outside the 24h customer-service window |
| `429` | Rate limited — global IP throttle, or the per-account / per-recipient send budget |

Typical validation body:

```json
{
  "statusCode": 400,
  "message": [
    "recipientPhoneNumber must be in E.164 format (e.g. +1234567890)"
  ],
  "error": "Bad Request"
}
```

### When you get `422`

- **Template not approved** — template sends fail fast when the local template `status` is not `APPROVED` (paused, rejected, pending or disabled).
- **Window closed** — free-form text and media (and quick-reply, location and list) sends fail when there is no open 24h customer-service window for that contact and business phone: the customer has not messaged you, or the window expired. Send an approved template instead.
- **Idempotency conflict** — the `Idempotency-Key` was already used with a different body (see [§9](#9-idempotency)).

### Rate limits

| Limit | Default | Applies to |
|-------|---------|------------|
| Global | ~**120 requests / minute** | Each client IP (auth endpoints are stricter) |
| Per recipient | **5 sends / 60 s** | Each recipient, scoped to your account |
| Per account | **60 sends / 60 s** | All sends from your account |

Every text, template and media send consumes both send budgets. They are server defaults (`SEND_THROTTLE_RECIPIENT_LIMIT` / `SEND_THROTTLE_RECIPIENT_TTL_SECONDS` and `SEND_THROTTLE_ACCOUNT_LIMIT` / `SEND_THROTTLE_ACCOUNT_TTL_SECONDS`) and may differ per deployment.

> **Tip:** `429` responses may include `retryAfterSeconds` and `scope` (`recipient` or `account`). Treat `429` as retryable: wait `retryAfterSeconds` when present, otherwise back off exponentially.

Request bodies are capped (~**1 MB**).

---

## 11. Best practices

1. **Store secrets securely** — account token + webhook secret; never commit them.
2. **Always use `/v1`** — don’t depend on the unversioned `/whatsapp` alias.
3. **Send `Idempotency-Key`** on every outbound message.
4. **Verify webhook signatures** before trusting the payload.
5. **Use E.164** for all customer numbers.
6. **Cache** `GET /phone-numbers` and `GET /templates`; refresh on dashboard changes.
7. **Prefer `id` (local UUID)** for your DB foreign keys; keep `waMessageId` for Meta correlation.
8. **Handle status webhooks** — the initial send returns `202` with `status: "queued"` (and usually `waMessageId: null`); `sent` / `delivered` / `read` / `failed` arrive via your webhook, or poll `GET /v1/whatsapp/messages/{id}`.
9. **Rotate tokens** if leaked; update your webhook secret at the same time.
10. **Return 2xx fast** on your webhook endpoint.

---

## 12. Code samples

### Node.js — send text

```js
const BASE = process.env.QAF_BASE_URL; // https://api.qaf-sender.qafdev.com
const TOKEN = process.env.QAF_ACCOUNT_TOKEN;

async function sendText({ from, to, message, idempotencyKey }) {
  const res = await fetch(`${BASE}/v1/whatsapp/messages/text`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({
      phoneNumber: from,
      recipientPhoneNumber: to,
      message,
    }),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`QAF ${res.status}: ${JSON.stringify(err)}`);
  }
  return res.json();
}
```

### Node.js — list templates then send

```js
async function sendApprovedTemplate({ from, to, templateId, languageCode, bodyParams }) {
  const res = await fetch(`${BASE}/v1/whatsapp/messages/template`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({
      phoneNumber: from,
      recipientPhoneNumber: to,
      templateId,
      languageCode,
      components: [
        {
          type: 'body',
          parameters: bodyParams.map((text) => ({ type: 'text', text })),
        },
      ],
    }),
  });
  return res.json();
}
```

### cURL — mark as read

```bash
curl -X POST "$BASE/v1/whatsapp/messages/wamid.HBgLMT.../read" \
  -H "Authorization: Bearer $TOKEN"
```

---

## 13. Changelog notes

| Area | Behavior |
|------|----------|
| API version | URI `/v1` |
| Send response | Full message resource (not only `{ messageId }`) |
| Async sends | Every send returns `202` with `status: "queued"`; a worker delivers to Meta |
| Interactive & location | Quick-reply, list-picker and location sends; 24h window required |
| Customer-service window | Contacts expose `windowStartedAt`, `windowExpiresAt` and `freeformAllowed`; free-form sends outside the window return `422` |
| Send budgets | Per-recipient and per-account limits; `429` may include `retryAfterSeconds` and `scope` |
| Webhook delivery | Exponential retries, dead-letter queue, `X-QafLab-Delivery-Id`; `call.started` / `call.ended` events |
| Contact usernames | `username` (WhatsApp handle) on contacts and on the message `contact` |
| Auth | Account API token (hashed at rest); raw token shown once |
| Webhooks | HMAC `X-QafLab-Signature` with `webhookSecret`; no secret → no delivery |
| Media URLs | Use opaque signed `localUrl`/`mediaUrl` as-is (`?exp=` & `sig=`); do not rebuild `/media/{id}` |
| Idempotency | Optional `Idempotency-Key` on send (24h Redis window) |
| Multi-WABA | Supported; phones/templates belong to a WABA |
| Bulk templates | Async batch create and status polling, 1–500 recipients, optional per-recipient components |
| Coexistence | WhatsApp Business App numbers can share the line with Cloud API; phones may include `isCoexistence`, `isOnBizApp`, `coexistenceSyncStatus`, and messages include `source` |

---

## 14. AI agents & LLM tooling

This guide is published for people **and** for AI agents (Claude, ChatGPT, Claude Code, Codex, Cursor, Copilot, …). Use the **Use this guide with AI** panel at the top of the page, or point your agent at the machine-readable URLs below.

### 14.1 Machine-readable URLs

| URL | What it is |
|-----|-----------|
| [https://qaf-sender.qafdev.com/developers.md](https://qaf-sender.qafdev.com/developers.md) | This guide as plain Markdown. Same content as this page, no navigation or scripts, fewer tokens |
| [https://qaf-sender.qafdev.com/llms.txt](https://qaf-sender.qafdev.com/llms.txt) | [llms.txt](https://llmstxt.org/) index: one-paragraph summary, the rules below, and links |
| [https://qaf-sender.qafdev.com/llms-full.txt](https://qaf-sender.qafdev.com/llms-full.txt) | The index followed by the complete guide, in one file |

Agents and scripts should fetch the `.md` URL instead of the HTML page. The HTML page advertises it with `<link rel="alternate" type="text/markdown">` and points to the index with `rel="describedby"`, as the llms.txt convention recommends. Each file carries a **Last updated** date and the canonical page URL so an agent can cite its source.

### 14.2 Paste into any chat agent

Replace the task in angle brackets, then paste into ChatGPT, Claude, Gemini, or any assistant that can fetch URLs:

```text
Read https://qaf-sender.qafdev.com/developers.md before answering. It documents the QAF WhatsApp Cloud API (base URL https://api.qaf-sender.qafdev.com, prefix /v1, Bearer account token).

I want to <describe your task, e.g. send a WhatsApp text message from Node.js and verify incoming webhooks>.

Follow the guide exactly: use /v1 paths, send an Idempotency-Key on every send, use E.164 numbers, verify X-QafLab-Signature on webhooks, and never hard-code secrets.
```

**Open in ChatGPT** and **Open in Claude** at the top of this page open a new conversation with this prompt prefilled.

### 14.3 Coding agents: add a rules file to your repo

Coding agents read a rules file at the root of your project on every task. Save the block below as `CLAUDE.md` (Claude Code) or `AGENTS.md` (Codex, Cursor, Copilot, and most other agents). Keep it short; the agent fetches the full guide when it needs detail.

```markdown
# QAF WhatsApp API

Docs: https://qaf-sender.qafdev.com/developers.md — fetch and read it before changing integration code.

- Base URL is https://api.qaf-sender.qafdev.com; every path starts with /v1/whatsapp. Never use the unversioned /whatsapp alias.
- Authenticate with `Authorization: Bearer <account API token>`. Read secrets from environment variables; never commit them.
- Send `Idempotency-Key: <uuid>` on every POST /v1/whatsapp/messages/* request.
- Phone numbers are E.164 (`+966501234567`). `phoneNumber` is the business sender line, `recipientPhoneNumber` is the customer.
- Templates: use the local template `id` from GET /v1/whatsapp/templates together with `languageCode`.
- Sends return 202 with `status: "queued"`, not delivered. Track delivery with status webhooks or by polling the returned `uri`.
- Free-form text, media, quick-reply, location and list sends need an open 24h customer-service window (contact `freeformAllowed`, fetched with `phoneNumberId`); otherwise send an approved template.
- Webhooks: verify `X-QafLab-Signature` (HMAC-SHA256 of the raw body with `webhookSecret`, constant-time compare) before trusting a payload, then respond 2xx quickly. Deduplicate retries on `X-QafLab-Delivery-Id`.
- Treat 429 as retryable: wait `retryAfterSeconds` when present, otherwise back off. 409 = idempotent request still in flight; 422 = idempotency key reused with a different body, template not APPROVED, or 24h window closed.
```

### 14.4 One-shot tasks from the terminal

Claude Code:

```bash
claude "Read https://qaf-sender.qafdev.com/developers.md, then add a sendWhatsAppText(to, message) helper to this project. Use the account token from the QAF_ACCOUNT_TOKEN env var, send an Idempotency-Key, and surface the API error body on non-2xx responses."
```

Codex CLI:

```bash
codex "Read https://qaf-sender.qafdev.com/developers.md and implement an Express route POST /hooks/qaf that verifies X-QafLab-Signature with QAF_WEBHOOK_SECRET using a constant-time compare, then logs message.status_updated events."
```

Any agent with a shell can also pull the guide straight into its context:

```bash
curl -s https://qaf-sender.qafdev.com/developers.md
```

### 14.5 What agents must not do

> **Never:**
> - Call the Meta Graph API directly for numbers managed by QAF. Go through `/v1/whatsapp/*` so idempotency, contacts and webhooks stay consistent.
> - Rebuild `/media/{id}` URLs. Use the signed `mediaUrl` / `localUrl` values exactly as returned.
> - Treat a `202`, `status: "queued"` or `status: "sent"` as delivery. Wait for `message.status_updated`.
> - Send free-form messages when `freeformAllowed` is `false`. Fall back to an approved template.
> - Log or print the account token or `webhookSecret`.

---

## 15. Go-live checklist

Before you go live:

- [ ] Account API token stored in a secrets manager
- [ ] Webhook URL configured on the correct phone UUID
- [ ] Signature verification implemented and tested
- [ ] `Idempotency-Key` on all sends
- [ ] E.164 validation on your side
- [ ] Template fallback when the 24h window is closed
- [ ] Handling for `message.received`, `message.sent`, `message.status_updated`, `call.started` and `call.ended`
- [ ] Base path uses `/v1`
