Send and receive WhatsApp messages through a number connected to one of your agents. Every endpoint lives under /api/v1/agents/{agentId}/whatsapp.
Authenticate every request with your team API key as a Bearer token. The key must own the agent, otherwise the request returns 404 AGENT_NOT_FOUND.
Authorization: Bearer <YOUR_TEAM_API_KEY>One endpoint, one rule: include the field that matches what you want to send. Media is sent by public URL. to is an E.164 number (2547XXXXXXXX) or a full JID. Provide exactly one content field (plus text as a caption where it applies).
| Field | Sends |
|---|---|
| text | A text message — or the caption for an image / video / document. |
| imageUrl | Send an image from a public URL. |
| videoUrl | Send a video from a public URL. |
| audioUrl | Send audio; add "voice": true to send it as a voice note. |
| documentUrl | Send a document; optional documentFilename for a nice name. |
| stickerUrl | Send a sticker (a .webp URL). |
| location | { latitude, longitude, name?, address? } |
| contact | { fullName, phone, organization? } |
Example
curl -X POST https://chatzuri.com/api/v1/agents/AGENT_ID/whatsapp/messages \
-H "Authorization: Bearer $CHATZURI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "254700000000", "text": "Your code is 123456" }'Response — 200 OK
{
"status": "sent",
"messageId": "BAE5F...",
"to": "254700000000",
"timestamp": "2026-07-19T10:30:00.000Z"
}Errors
| Status | Code | When |
|---|---|---|
| 400 | INVALID_REQUEST | Malformed JSON, missing 'to', nothing to send, or more than one content field. |
| 401 | UNAUTHORIZED | Missing or invalid API key. |
| 404 | AGENT_NOT_FOUND | The agent doesn't exist or isn't owned by your team. |
| 409 | CHANNEL_NOT_CONFIGURED | The number is not linked. Someone has to scan a QR code — retrying will not help. |
| 429 | RATE_LIMITED | Send rate limit or monthly message quota reached. |
| 502 | PROCESSING_ERROR | The messaging service was unreachable or errored. |
| 503 | CHANNEL_RECONNECTING | Still linked, connection re-establishing. Retry in a few seconds. |
Start the connection, then poll the status endpoint — while pairing it returns a qr (a PNG data URL) to render for the user to scan. Once scanned, connected becomes true. Each agent has its own number.
# 1. Start the connection (returns a QR while pairing)
curl -X POST https://chatzuri.com/api/v1/agents/AGENT_ID/whatsapp/session \
-H "Authorization: Bearer $CHATZURI_API_KEY"
# 2. Poll status until connected — render "qr" for the user to scan
curl https://chatzuri.com/api/v1/agents/AGENT_ID/whatsapp/session \
-H "Authorization: Bearer $CHATZURI_API_KEY"
# → { "connected": true, "linked": true, "state": "connected",
# "needsScan": false, "phone": "2547...", "qr": null }The status response also answers the question that actually matters when something looks wrong: is the number still linked, or does someone need to scan again?
| Field | Means |
|---|---|
| linked | The credentials are registered with WhatsApp — what the phone shows under Linked Devices. |
| live | We hold an open connection right now. |
| state | provisioned · connected · reconnecting · disconnected |
| needsScan | The one to branch on. True only when someone with the phone must scan a QR. |
| detail | A sentence you can show a user as-is. |
state: "reconnecting" is not an error. A deploy, a restart or a brief network problem drops the connection while the number stays linked; it comes back on its own. Show it as pending, never as a failure, and don't offer a re-pair button — that is what needsScan is for.
{
"connected": false,
"linked": true,
"live": false,
"state": "reconnecting",
"needsScan": false,
"detail": "Your number is still linked — the connection is re-establishing. There is nothing for you to do.",
"phone": "254700000000"
}Set a webhook URL and incoming messages are delivered to your app. The response returns a signing secret — keep it safe.
curl -X PUT https://chatzuri.com/api/v1/agents/AGENT_ID/whatsapp/webhook \
-H "Authorization: Bearer $CHATZURI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/whatsapp/inbound" }'
# → { "mode": "passthrough", "url": "...", "secret": "whsec_...", "events": ["message.received"] }Inbound payload — text
{
"event": "message.received",
"sessionId": "wab-AGENT_ID",
"agentId": "AGENT_ID",
"from": "254700000000",
"chatId": "254700000000@s.whatsapp.net",
"message": { "id": "ABC", "type": "text", "text": "hi", "fromMe": false },
"timestamp": "2026-07-19T10:30:00.000Z"
}Inbound payload — media
{
"event": "message.received",
"sessionId": "wab-AGENT_ID",
"agentId": "AGENT_ID",
"from": "254700000000",
"chatId": "254700000000@s.whatsapp.net",
"message": { "id": "ABC", "type": "image", "text": "caption?", "fromMe": false },
"media": {
"type": "image",
"mimetype": "image/jpeg",
"filename": "photo.jpg",
"url": "https://chatzuri.com/api/v1/agents/AGENT_ID/whatsapp/media/ABC",
"size": 84213
},
"timestamp": "2026-07-19T10:30:00.000Z"
}Verify each delivery with the secret and the x-chatzuri-signature header:
import crypto from "crypto";
// Read the RAW body — JSON.stringify(req.body) will NOT reproduce the bytes
// that were signed, because key order and whitespace are part of them.
// express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })
// x-chatzuri-signature-v1: t=<unix>,v1=<hex> ← prefer this
function verifyV1(rawBody, header, secret, toleranceSec = 300) {
const m = /^t=(d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m) return false;
const [, t, sig] = m;
// Reject anything outside the window so a captured delivery cannot be
// replayed against you indefinitely.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// timingSafeEqual THROWS on a length mismatch — compare lengths first.
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// x-chatzuri-signature: sha256=<hex> ← legacy, still sent
function verifyLegacy(rawBody, header, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Hash the raw request body. Key order is part of the signature, so re-serialising a parsed object will not reproduce the bytes that were signed.
Delivery is best-effort (short timeout, one retry) — respond 2xx quickly and do slow work asynchronously. The webhook URL accepts a comma-separated list; every endpoint gets the same signed bytes, and one failing endpoint does not stop the others.
Off by default. Once you enable groupsEnabled, a group message reaches you only when your number is @-mentioned — everything else in the group is dropped before it gets to you. Group payloads add a group block; one-to-one payloads are unchanged.
{
"event": "message.received",
"sessionId": "wab-AGENT_ID",
"agentId": "AGENT_ID",
"from": "254733333333",
"chatId": "120363000000000000@g.us",
"group": {
"id": "120363000000000000@g.us",
"subject": "Support Team",
"participant": "254733333333@s.whatsapp.net",
"mentionedUs": true
},
"message": { "id": "ABC", "type": "text", "text": "@bot balance?", "fromMe": false },
"timestamp": "2026-08-29T10:30:00.000Z"
}from is who spoke; chatId is the thread, and where a reply belongs. Answering a public question privately is not answering it.
Off by default (statusCallbacks). When enabled, receipts for messages you sent arrive on the same webhook as message.status, with a status of sent, delivered, read or failed.
{
"event": "message.status",
"sessionId": "wab-AGENT_ID",
"agentId": "AGENT_ID",
"messageId": "3EB0...",
"status": "delivered",
"to": "254711111111",
"timestamp": "2026-08-29T10:30:07.000Z"
}WhatsApp emits these out of order — a delivered can arrive after a read. We apply them in order and forward only a receipt that actually moved the message forward, so you will never see a read followed by a delivered for the same message.
A PATCH changes only the settings it names. Unknown keys are rejected rather than quietly ignored.
| Setting | Default | Does |
|---|---|---|
| groupsEnabled | false | Answer in group chats when the number is @-mentioned. |
| readReceipts | false | Send blue ticks for messages you receive. |
| statusCallbacks | false | Forward delivery receipts to your webhook as message.status. |
| typingIndicator | true | Show “typing…” before an agent reply. |
curl -X PATCH https://chatzuri.com/api/v1/agents/AGENT_ID/whatsapp/settings \
-H "Authorization: Bearer $CHATZURI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "groupsEnabled": true, "readReceipts": true }'
# → { "groupsEnabled": true, "readReceipts": true,
# "statusCallbacks": false, "typingIndicator": true }Streams the decrypted file with its original content type — this is the media.url value from the webhook. Fetch it with your API key. Files are retained for a limited window, so download soon after delivery.
Returns { phone, exists, jid } — use it before sending to skip numbers that aren't on WhatsApp.
60 messages per minute, per team. Sends also count against your plan's monthly message quota. Hitting either returns 429 RATE_LIMITED with a Retry-After header.
Every response carries the ceiling — on success as well as on a 429 — so you can pace yourself rather than discover it the hard way:
X-RateLimit-Limit: 60
X-RateLimit-Window: 60
Retry-After: 34 # on 429 onlyThere is no bulk path on this channel, by design. It is a real WhatsApp account, and bulk sending from one number is the fastest way to get it banned. The channel is built for OTPs, notifications and conversations. For volume, use SMS or a campaign.