Documentation
Send and receive WhatsApp messages over a REST API. Connect a number by scanning a QR code, then send from your own code. Interactive endpoint reference →
Postman collection
Every endpoint, ready to run. Fill in two variables and send — your number is picked up automatically.
Integration guide (.md)
This page as a single Markdown file, with signature-verification code for Node, PHP and Python. Hand it to a developer or paste it into an AI assistant.
1. Getting started
Three steps to your first message:
- Sign in to the console with your email and password.
- Click + Add number, enter a name and the phone number, then Connect and scan the QR code with that phone.
- Expand that number with +, create its API key, copy it, and send a message from your code.
The number you enter is the only number that may link to that session. If a different number scans the QR, it is unlinked immediately and its credentials destroyed.
2. Authentication
Two separate credentials, deliberately. API keys are for machines and cannot sign into the console. Email and password is for people and cannot send messages. Revoking one never breaks the other.
Every API request carries a bearer key:
curl https://wa.adaminnovations.in/v1/sessions \ -H "Authorization: Bearer wa_live_..."
Each number has its own key. Open a number on the console board, expand it with +, and create a key under API keys for this number. That key is scoped to that one number and is rejected on every other, so a leak is contained to a single line — and you can revoke one integration without touching the rest.
A scoped key means you never send a session ID. session_id exists to say which of your numbers to send from. A key that belongs to one number already answers that, so you can leave it out:
curl -X POST https://wa.adaminnovations.in/v1/messages \
-H "Authorization: Bearer wa_live_..." \
-H "Content-Type: application/json" \
-d '{"type":"text","to":"919544446002","text":"Hi"}'An account-wide key has nothing to fall back on and returns session_id is required — the API will not guess which number you meant.
Keys are shown once at creation and stored hashed, so we cannot recover one for you. If you lose it, press Regenerate: that issues a replacement and revokes the old key in the same step, so update your integration before you do it.
3. Connecting a number
Create the session, then request a QR code:
curl -X POST https://wa.adaminnovations.in/v1/sessions \
-H "Authorization: Bearer wa_live_..." \
-H "Content-Type: application/json" \
-d '{"name": "Support line", "phone_number": "96550001234"}'curl -X POST https://wa.adaminnovations.in/v1/sessions/SESSION_ID/connect \ -H "Authorization: Bearer wa_live_..."
The response contains a PNG data URI you can render directly, plus an 8-character pairing code you can type into the phone instead of scanning. Scan it in WhatsApp → Settings → Linked Devices.
QR codes rotate roughly every 20 seconds. Poll GET /v1/sessions/{id}/qr or subscribe to the session.qr webhook rather than showing one static image.
Statuses: provisioned → qr_pending → connecting → connected. Sending works only in connected.
4. Sending messages
curl -X POST https://wa.adaminnovations.in/v1/messages \
-H "Authorization: Bearer wa_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234-confirmation" \
-d '{
"session_id": "...",
"type": "text",
"to": "96550009999",
"text": "Your order has shipped."
}'Supported types: text, image, video, audio, document, sticker, location, contact, poll, interactive.
Idempotency-Key makes retries safe. Sending the same key twice returns the original result rather than delivering the message twice — worth using for anything triggered by a webhook or a job queue.
Media is sent by URL, which we fetch server-side:
{
"type": "image",
"to": "96550009999",
"media": { "url": "https://example.com/receipt.png" },
"caption": "Your receipt"
}Or post the file itself — no upload step, no URL to host, nothing stored afterwards:
curl -X POST https://wa.adaminnovations.in/v1/messages/media \ -H "Authorization: Bearer wa_live_..." \ -F "file=@invoice.pdf" \ -F "to=96550009999" \ -F "caption=Your invoice"
The type is worked out from the file: images send as image, .webp as sticker, video and audio as themselves, anything else as a document. Add -F "type=document" to override, or filename to control what the recipient sees.
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 written to 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 to POST /v1/media and reuse the media_id instead — that avoids re-transferring the bytes on every send.
5. Receiving messages
Register an endpoint and we POST every inbound message and status change to it:
curl -X POST https://wa.adaminnovations.in/v1/webhooks \
-H "Authorization: Bearer wa_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://your-app.com/whatsapp"}'The response includes a secret, shown once. You need it to verify that deliveries genuinely came from us.
Every delivery carries X-WA-Signature: t=<unix>,v1=<hmac-sha256> over ${t}.${rawBody}:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
// Reject stale deliveries — the timestamp is signed, so it cannot be moved.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}Verify against the raw request body, not re-serialised JSON. Key order and whitespace are not preserved through a parse, and any difference breaks the signature.
Failed deliveries retry at 1m, 5m, 30m, 2h and 6h, then dead-letter. Client errors other than 429 are not retried. Inspect attempts at GET /v1/webhooks/{id}/deliveries.
Events: message.received, message.status, session.qr, session.connected, session.disconnected, session.logged_out.
6. Buttons and lists
Send buttons using the same shape Meta’s official API accepts:
{
"type": "interactive",
"to": "96550009999",
"interactive": {
"type": "button",
"body": "How can we help?",
"action": {
"buttons": [
{ "id": "sales", "title": "Sales" },
{ "id": "support", "title": "Support" }
]
}
}
}On QR-connected numbers, buttons do not render. WhatsApp removed interactive message support from the protocol these numbers use. Nothing can restore it — not this API, not any other unofficial provider.
Instead, the message above is delivered as a numbered menu:
How can we help? 1. Sales 2. Support Reply with a number (1-2).
When the recipient replies 1, your webhook receives:
{
"interactive": {
"button_reply": { "id": "sales", "title": "Sales" }
}
}That is exactly what the official Cloud API sends. Your code reads button_reply.id either way, so moving a number to the official API later changes nothing except that the buttons become tappable.
Lists work the same way — up to 10 sections of 10 rows, where buttons cap at 3. Note "type": "list"; leaving the type out of the interactive object is the most common mistake and returns Invalid discriminator value. Expected 'button' | 'list'.
{
"type": "interactive",
"to": "96550009999",
"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" }]
}
]
}
}
}Section titles survive as headings, and descriptions are kept inline:
*Our services* Pick what you need. *Support* 1. Order status — Track a recent order 2. Returns *Sales* 3. Book a demo _Reply with a number (1-3)._ _Reply any time_
A list reply arrives as interactive.list_reply.id rather than button_reply — again matching the official API.
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
Two independent limits:
| Limit | Default | Why |
|---|---|---|
| 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 a strong signal to WhatsApp’s abuse detection, which is why sends are not evenly spaced. This cannot be disabled. Exceeding it returns 429 send_rate_limit_exceeded.
8. Errors
Every error uses the same shape:
{
"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 the message — messages may be reworded. Quote request_id when reporting a problem.
The complete list is at /error-codes. The ones you will actually meet:
session_not_connected— the number is not linked. Connect and scan.session_logged_out— unlinked from the phone. Needs a fresh QR scan.session_wrong_number— a different number scanned the QR. Credentials were wiped.send_rate_limit_exceeded— slow down. Protects the number.recipient_not_on_whatsapp— that number has no WhatsApp account.
9. Limitations
Worth knowing before you build:
- Buttons and lists do not render on QR-connected numbers. They arrive as numbered menus — see section 6.
- Accounts can be banned. This uses the unofficial WhatsApp protocol. Rate limits reduce the risk but cannot remove it. Do not use it for cold bulk outreach, and warm new numbers up gradually.
- No message templates, Flows, or the green tick. Those are official Cloud API features.
- The phone must link once, but need not stay online afterwards.
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 — the request and webhook shapes are identical, so switching does not change your integration.
10. Postman collection
Download the collection and import it into Postman (File → Import, or drag the file in). Every endpoint on this page is in it, grouped and documented.
Open the collection’s Variables tab and fill in two values:
| Variable | Value |
|---|---|
| api_key | Your key from the console. Shown once when you create it. |
| phone_number | The recipient, with country code and no + — e.g. 919544446002. |
You do not need to paste a session ID. Run Start here → 1. List numbers once and a test script stores your connected number in session_id automatically. Then run 2. Send a text message — that is the whole setup.
The collection ships with a placeholder key, not a working one. Anything you paste into the variables stays on your machine — but if you share an exported copy, clear api_key first, since Postman exports variable values along with the requests.