Chatzuri
Pricing

API Documentation

  • Getting Started

    Getting Setup
  • Support

    Support Tickets API
  • Teams

    Teams Overview
  • Create a Team
  • Get Teams
  • Update a Team
  • Delete a Team
  • Agents

    Create an Agent
  • Message an Agent
  • Update an Agent
  • Delete an Agent
  • Get Agents
  • Stream Messages
  • Update Agent Settings
  • Upload Agent Icon
  • Delete Agent Icon
  • Upload Agent Profile Picture
  • Delete Agent Profile Picture
  • Data

    Get Leads
  • Get Conversations
  • Messaging

    WhatsApp API
  • Integrations

    Webhooks API
  1. Home
  2. API Docs
  3. WhatsApp API

WhatsApp API

Send and receive WhatsApp messages through a number connected to one of your agents. Every endpoint lives under /api/v1/agents/{agentId}/whatsapp.

Authentication

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>

Send a message

POST/api/v1/agents/{agentId}/whatsapp/messages

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).

FieldSends
textA text message — or the caption for an image / video / document.
imageUrlSend an image from a public URL.
videoUrlSend a video from a public URL.
audioUrlSend audio; add "voice": true to send it as a voice note.
documentUrlSend a document; optional documentFilename for a nice name.
stickerUrlSend 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

StatusCodeWhen
400INVALID_REQUESTMalformed JSON, missing 'to', nothing to send, or more than one content field.
401UNAUTHORIZEDMissing or invalid API key.
404AGENT_NOT_FOUNDThe agent doesn't exist or isn't owned by your team.
409CHANNEL_NOT_CONFIGUREDThe number is not linked. Someone has to scan a QR code — retrying will not help.
429RATE_LIMITEDSend rate limit or monthly message quota reached.
502PROCESSING_ERRORThe messaging service was unreachable or errored.
503CHANNEL_RECONNECTINGStill linked, connection re-establishing. Retry in a few seconds.

Connect a number

POST/api/v1/agents/{agentId}/whatsapp/session
GET/api/v1/agents/{agentId}/whatsapp/session
DELETE/api/v1/agents/{agentId}/whatsapp/session

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?

FieldMeans
linkedThe credentials are registered with WhatsApp — what the phone shows under Linked Devices.
liveWe hold an open connection right now.
stateprovisioned · connected · reconnecting · disconnected
needsScanThe one to branch on. True only when someone with the phone must scan a QR.
detailA 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" }

Receive messages

PUT/api/v1/agents/{agentId}/whatsapp/webhook
GET/api/v1/agents/{agentId}/whatsapp/webhook
DELETE/api/v1/agents/{agentId}/whatsapp/webhook

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.

Group messages

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.

Delivery receipts

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.

Channel settings

GET/api/v1/agents/{agentId}/whatsapp/settings
PATCH/api/v1/agents/{agentId}/whatsapp/settings

A PATCH changes only the settings it names. Unknown keys are rejected rather than quietly ignored.

SettingDefaultDoes
groupsEnabledfalseAnswer in group chats when the number is @-mentioned.
readReceiptsfalseSend blue ticks for messages you receive.
statusCallbacksfalseForward delivery receipts to your webhook as message.status.
typingIndicatortrueShow “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 }

Download inbound media

GET/api/v1/agents/{agentId}/whatsapp/media/{msgId}

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.

Check a number

GET/api/v1/agents/{agentId}/whatsapp/contacts/{phone}

Returns { phone, exists, jid } — use it before sending to skip numbers that aren't on WhatsApp.

Rate limits

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 only

There 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.

Chatzuri

AI-powered agents are transforming customer interactions by providing instant, intelligent responses around the clock. They help businesses reduce operational costs, improve response times, and scale support without compromising quality. These agents understand natural language, learn from conversations, and integrate with existing systems to offer personalized experiences that enhance customer satisfaction and loyalty.

Chatzuri

AI-powered agents are transforming customer interactions by providing instant, intelligent responses around the clock. They help businesses reduce operational costs, improve response times, and scale support without compromising quality. These agents understand natural language, learn from conversations, and integrate with existing systems to offer personalized experiences that enhance customer satisfaction and loyalty.

Product

  • Pricing
  • Security
  • Affiliates

Resources

  • API
  • Guides
  • Blog
  • Help

Company

  • About us
  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DPA

About

  • Teams
  • Singapore, Nairobi

© 2026 Chatzuri. All rights reserved.

Chatzuri uses AI and can make mistakes.

Terms of ServicePrivacy PolicyCookie PolicyChatzuri