# WhatsApp API — Integration Guide

Hand this file to whoever is integrating. It is self-contained: base URL, auth,
every endpoint, working code in three languages, and the behaviours that will
otherwise cost you an afternoon.

**Base URL** `https://wa.adaminnovations.in`
**Interactive reference** https://wa.adaminnovations.in/docs
**Written guide** https://wa.adaminnovations.in/guide
**Postman collection** https://wa.adaminnovations.in/whatsapp-api.postman_collection.json

Import the collection, set `api_key` and `phone_number` in its Variables tab,
run *Start here → 1. List numbers* (which fills `session_id` for you), then
*2. Send a text message*. That is the fastest path to a working request.

---

## 1. Authentication

Every request carries a bearer API key:

```
Authorization: Bearer wa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

### Where the key comes from

**Each connected number has its own key.** In the console, open the number on the
board, expand it with **+**, and create a key under *API keys for this number*.

That key is **scoped to that number**: used against any other number on the
account it returns `403 session_not_permitted`. So one leaked key exposes one
line, and each integration can be revoked independently.

**A scoped key means you can stop sending `session_id`.** That field says which
of your numbers to send *from*, and a key that belongs to one number already
answers it:

```bash
curl -X POST https://wa.adaminnovations.in/v1/messages \
  -H "Authorization: Bearer $WA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"text","to":"919544446002","text":"Hi"}'
```

An account-wide key has nothing to fall back on and returns
`400 session_id is required` — the API will not guess which number you meant.
Sending `session_id` explicitly always works, with either kind of key, so it is
the safer default in shared code.

Keys are shown **once** at creation and stored hashed, so they cannot be
recovered. Lost one? Press **Regenerate**: a replacement is issued and the old
key is revoked in the same request, so deploy the new value promptly.

Treat a key like a password: server-side only, never in frontend code or a
mobile app, since it can send messages as the business. If one leaks, revoke it
in the console — it stops working immediately.

---

## 2. Quick start

```bash
# 1. Which numbers are connected?
curl https://wa.adaminnovations.in/v1/sessions \
  -H "Authorization: Bearer $WA_API_KEY"

# 2. Send a message
curl -X POST https://wa.adaminnovations.in/v1/messages \
  -H "Authorization: Bearer $WA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "YOUR_SESSION_ID",
    "type": "text",
    "to": "96550001234",
    "text": "Hello from our system."
  }'
```

`session_id` identifies which of your connected numbers sends the message. Get it
from `GET /v1/sessions` or the console.

Recipient numbers are **E.164 without the `+`** — `96550001234`, not
`+965 5000 1234`. The API normalises common formats, but sending the canonical
form avoids surprises.

---

## 3. Sending messages

`POST /v1/messages`

All types share `session_id`, `to`, and optional `reply_to` (a WhatsApp message id
to quote).

### Text

```json
{
  "session_id": "...",
  "type": "text",
  "to": "96550001234",
  "text": "Your order #1234 has shipped.",
  "preview_url": false
}
```

### Image, video, document

Media is referenced by URL and fetched server-side, so it must be publicly
reachable.

```json
{
  "session_id": "...",
  "type": "image",
  "to": "96550001234",
  "media": { "url": "https://yourapp.com/invoice.png" },
  "caption": "Your invoice"
}
```

Swap `"type"` for `video`, `document` or `sticker`. Documents accept `filename`.

Limits follow WhatsApp: images 5 MB, video/audio 16 MB, documents 100 MB.

### Or post the file itself

`POST /v1/messages/media` — no upload step, no URL to host, nothing kept.

```bash
curl -X POST https://wa.adaminnovations.in/v1/messages/media \
  -H "Authorization: Bearer $WA_API_KEY" \
  -F "file=@invoice.pdf" \
  -F "to=96550001234" \
  -F "caption=Your invoice"
```

Multipart fields: `file` (required), `to` (required), and optional `session_id`,
`caption`, `filename`, `type`, `reply_to`. The response is identical to a normal
send.

The type is inferred from the file — images become `image`, `.webp` becomes
`sticker`, video and audio become themselves, anything else becomes `document`.
Pass `type` to override.

**Nothing is retained.** Files up to 16 MB never touch disk; they are held in
memory for the length of the request. Larger files are staged in storage only
because they will not fit in one request to the engine, and are deleted
immediately afterwards — including when the send fails.

Sending the *same* file repeatedly? Upload it once with `POST /v1/media` and
reuse the returned `media_id`, which avoids re-transferring the bytes each
time.

### Location

```json
{
  "session_id": "...",
  "type": "location",
  "to": "96550001234",
  "latitude": 29.3759,
  "longitude": 47.9774,
  "name": "Our shop",
  "address": "Kuwait City"
}
```

### Poll

```json
{
  "session_id": "...",
  "type": "poll",
  "to": "96550001234",
  "question": "Preferred delivery day?",
  "options": ["Monday", "Wednesday", "Friday"],
  "selectable_count": 1
}
```

### Response

```json
{
  "id": "8b1e...",
  "wa_message_id": "3EB0...",
  "session_id": "...",
  "to": "96550001234@s.whatsapp.net",
  "status": "sent",
  "created_at": "2026-08-05T10:22:12.000Z"
}
```

Store `wa_message_id` — delivery-status webhooks reference it.

---

## 4. Idempotency

Add `Idempotency-Key` to any send. Repeating a request with the same key returns
the original result instead of sending twice.

```bash
curl -X POST https://wa.adaminnovations.in/v1/messages \
  -H "Authorization: Bearer $WA_API_KEY" \
  -H "Idempotency-Key: order-1234-shipped" \
  -H "Content-Type: application/json" \
  -d '{...}'
```

Use it for anything triggered by a job queue, a webhook, or a user action that
could double-submit. Derive the key from your own domain object — an order id, a
notification id — not a random value, or retries will not match.

---

## 5. Receiving messages

Register an endpoint once:

```bash
curl -X POST https://wa.adaminnovations.in/v1/webhooks \
  -H "Authorization: Bearer $WA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.com/webhooks/whatsapp"}'
```

The response contains `secret` — **shown once**. Store it; you need it to verify
deliveries.

### Payload

```json
{
  "id": "evt_...",
  "event": "message.received",
  "session_id": "...",
  "timestamp": "2026-08-05T10:22:12.000Z",
  "data": {
    "wa_message_id": "3EB0...",
    "chat_jid": "96550001234@s.whatsapp.net",
    "from_phone": "96550001234",
    "push_name": "Ahmed",
    "is_group": false,
    "type": "text",
    "text": "Is this still available?",
    "media": null,
    "reply_to": null,
    "interactive": null,
    "timestamp": "2026-08-05T10:22:11.000Z"
  }
}
```

### Events

| Event | Meaning |
|---|---|
| `message.received` | Inbound message |
| `message.status` | Delivery receipt: `sent`, `delivered`, `read`, `failed` |
| `session.connected` | A number came online |
| `session.disconnected` | A number dropped; reconnecting automatically |
| `session.logged_out` | Unlinked from the phone — needs a new QR scan |
| `session.qr` | A QR code was issued |

### Verifying signatures — required

Every delivery carries:

```
X-WA-Signature: t=1735689600,v1=<hex hmac-sha256>
```

The signed payload is `${t}.${rawBody}` using your webhook secret.

> **Verify against the RAW request body**, not re-serialised JSON. Key order and
> whitespace are not preserved through a parse/stringify round trip, and any
> difference breaks the signature. This is the most common integration bug.

**Node / Express**

```js
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const app = express()

// express.raw, NOT express.json — the raw bytes are what was signed.
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-WA-Signature') ?? ''
  const raw = req.body.toString('utf8')
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))

  // The timestamp is inside the signed payload, so it cannot be moved forward.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(403)

  const expected = createHmac('sha256', process.env.WA_WEBHOOK_SECRET)
    .update(`${parts.t}.${raw}`)
    .digest('hex')

  if (
    expected.length !== (parts.v1 ?? '').length ||
    !timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  ) {
    return res.sendStatus(403)
  }

  const event = JSON.parse(raw)
  // Respond fast; do the real work asynchronously. Slow handlers get retried.
  res.sendStatus(200)

  handleEvent(event).catch(console.error)
})
```

**PHP / Laravel**

```php
Route::post('/webhooks/whatsapp', function (Request $request) {
    $header = $request->header('X-WA-Signature', '');
    $raw = $request->getContent();

    parse_str(str_replace(',', '&', $header), $parts);

    if (abs(time() - (int) ($parts['t'] ?? 0)) > 300) {
        abort(403);
    }

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, config('services.whatsapp.secret'));

    if (! hash_equals($expected, $parts['v1'] ?? '')) {
        abort(403);
    }

    $event = json_decode($raw, true);

    // Queue it — respond quickly or the delivery is retried.
    ProcessWhatsAppEvent::dispatch($event);

    return response()->noContent();
});
```

**Python / Flask**

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

@app.post("/webhooks/whatsapp")
def whatsapp_webhook():
    header = request.headers.get("X-WA-Signature", "")
    raw = request.get_data(as_text=True)
    parts = dict(p.split("=", 1) for p in header.split(","))

    if abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(403)

    expected = hmac.new(
        WEBHOOK_SECRET.encode(), f"{parts['t']}.{raw}".encode(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(403)

    handle(request.get_json())
    return "", 204
```

### Retries

Return **2xx** quickly. Anything else is retried at 1m, 5m, 30m, 2h, 6h, then
dead-lettered. Client errors other than 429 are not retried — a 400 or 404 is
treated as a permanent rejection.

Inspect attempts at `GET /v1/webhooks/{id}/deliveries`.

---

## 6. Buttons and lists — read this

You can send interactive messages using Meta's shape:

```json
{
  "session_id": "...",
  "type": "interactive",
  "to": "96550001234",
  "interactive": {
    "type": "button",
    "body": "How can we help?",
    "action": {
      "buttons": [
        { "id": "sales", "title": "Sales" },
        { "id": "support", "title": "Support" }
      ]
    }
  }
}
```

`"type": "button"` inside `interactive` is **required**. Omitting it returns
`400 Invalid discriminator value. Expected 'button' | 'list'` — the most common
mistake with this endpoint.

Lists use `"type": "list"` and allow far more options: up to 10 sections of 10
rows, where buttons cap at 3.

```json
{
  "type": "interactive",
  "to": "96550001234",
  "interactive": {
    "type": "list",
    "header": "Our services",
    "body": "Pick what you need.",
    "footer": "Reply any time",
    "action": {
      "button": "View options",
      "sections": [
        {
          "title": "Support",
          "rows": [
            { "id": "order_status", "title": "Order status", "description": "Track a recent order" },
            { "id": "returns", "title": "Returns" }
          ]
        },
        { "title": "Sales", "rows": [{ "id": "demo", "title": "Book a demo" }] }
      ]
    }
  }
}
```

Sections are flattened into one numbered menu, keeping titles as headings and
descriptions inline. A list reply arrives as `interactive.list_reply.id` rather
than `button_reply.id`.

Field limits: button titles 20 chars, list row titles 24, descriptions 72, the
`action.button` label 20, header and footer 60, body 1024.

> **On QR-connected numbers, buttons do not render as tappable buttons.**
> WhatsApp removed interactive message support from the protocol these numbers
> use. No unofficial provider can restore it.

The message is delivered as a numbered menu instead:

```
How can we help?

1. Sales
2. Support

Reply with a number (1-2).
```

When the recipient replies `1`, your webhook receives:

```json
{
  "event": "message.received",
  "data": {
    "text": "1",
    "interactive": {
      "button_reply": { "id": "sales", "title": "Sales" },
      "list_reply": null
    }
  }
}
```

**Integrate against `interactive.button_reply.id`.** That is byte-identical to
what Meta's official Cloud API sends, so if a number later moves to the official
API your code does not change — the buttons simply become tappable.

Replies match on `1`, `1.`, `1)` or the option title, case-insensitively.
Out-of-range numbers and ordinary conversation are ignored, and each menu is
consumed once so a repeated reply cannot double-fire.

---

## 7. Rate limits

| Limit | Default | Purpose |
|---|---|---|
| Per API key | 600 req/min | Ordinary API protection |
| **Per number** | **20 msg/min** | **Ban avoidance** |

The per-number limit also enforces a minimum gap between sends with random
jitter — machine-regular timing is what WhatsApp's abuse detection looks for.
**It cannot be disabled.**

Exceeding it returns `429` with `send_rate_limit_exceeded` and a
`retry_after_seconds` in `details`. Back off and retry; do not hammer.

If you need to send a batch, queue it on your side and let it drain at the
allowed rate. Bursting will fail, and repeatedly forcing it risks the number.

---

## 8. Errors

Every error has the same shape:

```json
{
  "error": {
    "code": "session_not_connected",
    "type": "session",
    "message": "Session is not connected. Connect it and scan the QR code first.",
    "request_id": "req_5cb3cf5245794d5888658936"
  }
}
```

**Branch on `code`, never on `message`** — messages may be reworded. Log
`request_id`; quote it when reporting a problem.

| Code | HTTP | What to do |
|---|---|---|
| `missing_api_key` / `invalid_api_key` | 401 | Check the Authorization header |
| `session_not_permitted` | 403 | This key is scoped to a different number |
| `session_not_found` | 404 | Wrong `session_id`, or it belongs to another account |
| `session_not_connected` | 409 | The number is offline. Alert an operator |
| `session_logged_out` | 409 | Unlinked from the phone. Needs a new QR scan |
| `session_wrong_number` | 409 | A different number scanned the QR. Credentials wiped |
| `recipient_not_on_whatsapp` | 404 | That number has no WhatsApp account |
| `send_rate_limit_exceeded` | 429 | Back off; see `details.retry_after_seconds` |
| `validation_failed` | 400 | See `details` for the offending field |
| `idempotency_key_reused` | 409 | Same key, different body — a bug on your side |
| `engine_unavailable` | 503 | Transient. Retry with backoff |

Full list: https://wa.adaminnovations.in/error-codes

---

## 9. Endpoint reference

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/v1/sessions` | List connected numbers |
| `POST` | `/v1/sessions` | Provision a number |
| `GET` | `/v1/sessions/{id}` | Session detail and live status |
| `POST` | `/v1/sessions/{id}/connect` | Start and get a QR code |
| `GET` | `/v1/sessions/{id}/qr` | Current QR / pairing code |
| `POST` | `/v1/sessions/{id}/logout` | Unlink the number |
| `POST` | `/v1/sessions/{id}/restart` | Reconnect without re-scanning |
| `DELETE` | `/v1/sessions/{id}` | Delete permanently |
| `POST` | `/v1/messages` | Send |
| `POST` | `/v1/messages/media` | Upload a file and send it in one request |
| `GET` | `/v1/messages` | History |
| `POST` | `/v1/messages/{waId}/reaction` | React with an emoji |
| `POST` | `/v1/messages/{waId}/read` | Mark as read |
| `PATCH` | `/v1/messages/{waId}` | Edit (within 15 minutes) |
| `DELETE` | `/v1/messages/{waId}` | Delete for everyone |
| `POST` | `/v1/presence` | Typing / recording indicator |
| `GET` | `/v1/contacts/{phone}/exists` | Is this number on WhatsApp? |
| `POST` | `/v1/webhooks` | Register an endpoint |
| `GET` | `/v1/webhooks` | List endpoints |
| `POST` | `/v1/webhooks/{id}/test` | Send a signed test delivery |
| `GET` | `/v1/webhooks/{id}/deliveries` | Delivery attempts |
| `DELETE` | `/v1/webhooks/{id}` | Remove an endpoint |
| `GET` | `/v1/capabilities` | What this engine supports |
| `GET` | `/v1/api-keys` | List keys — console session only |
| `POST` | `/v1/api-keys` | Issue a key — console session only |
| `POST` | `/v1/api-keys/{id}/rotate` | Replace a key — console session only |
| `DELETE` | `/v1/api-keys/{id}` | Revoke a key — console session only |
| `GET` | `/health` | Service status — no auth |

---

## 10. Before you go live

**Check `exists` before first contact.** Sending to numbers that are not on
WhatsApp is a pattern that gets sessions flagged:

```bash
curl "https://wa.adaminnovations.in/v1/contacts/96550001234/exists?session_id=..." \
  -H "Authorization: Bearer $WA_API_KEY"
```

**Handle `session_not_connected`.** Numbers do drop — a phone unlinks the device,
WhatsApp forces a reconnect. Queue and retry rather than dropping the message,
and alert someone if a session stays down.

**Never expose the API key to a browser or mobile app.** Proxy through your own
backend.

**Respect the send limit.** Queue on your side; do not burst.

---

## 11. Known limitations

| | |
|---|---|
| Buttons and lists | Delivered as numbered text menus — see §6 |
| Message templates, Flows, green tick | Official Cloud API only |
| Ban risk | Real. This uses the unofficial protocol |
| Groups | Supported, but not covered in this guide |
| Media by `media_id` | Use a public `url` instead |

**Ban risk is the one to take seriously.** This connects through the unofficial
WhatsApp protocol. Rate limits and jitter reduce the risk but cannot remove it.
Do not use it for cold bulk outreach. Warm new numbers up gradually. Do not put a
number you cannot afford to lose on it without understanding that trade-off.

If you need guaranteed delivery, working buttons, or a support SLA, the official
WhatsApp Cloud API is the right choice — this API supports it as an alternative
engine, and the request and webhook shapes are identical, so switching does not
change your integration.
