API & Webhook Documentation
Send and receive WhatsApp messages through the ZapClaw gateway.
ZapClaw bridges WhatsApp Business numbers and your systems: send messages with a REST API, and receive inbound messages and delivery status updates on your own webhook URL. A machine-readable OpenAPI specification is available at openapi.yaml.
Last updated: 2026-05-22
- Getting started
- Endpoint reference — every public endpoint at a glance
- Coexistence limitations — what the gateway cannot do
- API — authentication, send endpoints, media, profile, account features, analytics, errors, rate limits
- Webhook — configuration, event payloads, signature verification, retries
- Errors — every HTTP status the API can return
- Use ZapClaw with Chatwoot — Meta passthrough for Chatwoot self-hosted, alongside your native webhook
- Changelog
Getting started
Base URL
https://api.zapclaw.app
Authentication
Every REST API request is authenticated with an API key sent in the
X-API-Key header. Create and manage keys in the ZapClaw dashboard under
API Keys. The full key is shown only once, on creation — store it securely.
X-API-Key: zc_live_xxxxxxxxxxxxxxxxxxxxxxxx
Requests carry a JSON body and should include Content-Type: application/json.
Endpoint reference
Every public endpoint at a glance. All paths are relative to the base URL and
sit under the /api/v1 base path. Every endpoint is authenticated
with the X-API-Key header.
| Method | Path | Description |
|---|---|---|
POST | /send/text | Send a text message |
POST | /send/template | Send an approved template message |
POST | /send/media | Send an image, video, document, audio or sticker message |
POST | /send/location | Send a static location pin |
POST | /send/contacts | Send one or more contact cards |
POST | /send/reaction | React to a message with an emoji |
POST | /send/interactive | Send an interactive message (buttons, list, call-to-action URL) |
POST | /send/typing | Show a typing indicator (also marks the message as read) |
POST | /send/read | Mark an inbound message as read |
GET | /send/messages | List recent messages |
GET | /send/messages/{id} | Get a single message by its ZapClaw id |
GET | /send/numbers | List connected WhatsApp numbers |
GET | /send/templates | List approved templates |
POST | /send/templates | Create a message template |
POST | /send/templates/{id} | Edit a template by its Meta template id |
DELETE | /send/templates/{name} | Delete a template by name |
POST | /send/templates/recreate | Replace a template still under review |
GET | /send/templates/replicable | Templates missing from a number |
POST | /send/templates/replicate | Copy templates onto another number |
GET | /media/{mediaId} | Download received media |
POST | /media | Upload a media file |
DELETE | /media/{mediaId} | Delete uploaded media from Meta |
GET | /profile | Read the business profile |
PATCH | /profile | Update business profile text fields |
POST | /profile/name | Submit a new display name |
POST | /profile/photo | Set the profile picture |
GET | /block | List blocked users |
POST | /block | Block users |
DELETE | /block | Unblock users |
GET | /qr-codes | List QR codes / managed links |
POST | /qr-codes | Create a QR code / managed link |
DELETE | /qr-codes/{code} | Delete a QR code |
GET | /automation | Read conversational automation config |
POST | /automation | Update conversational automation config |
GET | /analytics/pricing | Per-message pricing analytics |
Coexistence limitations
ZapClaw connects WhatsApp Business numbers through Meta's Coexistence (Coex) mode, which lets a business keep using the WhatsApp Business app on a phone while the same number is also reachable through the Cloud API. Coexistence carries a few hard constraints that you should design around. They are imposed by Meta and cannot be lifted by ZapClaw.
message.received or message.status event for it.
This is an unavoidable data gap: your records may be missing messages the
business sent from those surfaces. Replies sent from the WhatsApp Business
app on Android or iOS are reported normally.
API
Conventions
- from — the sender: one of your connected WhatsApp numbers, in E.164 digits
(e.g.
5511999999999). It identifies which number the message is sent from. - to — the recipient. Accepts either an E.164 phone number
(preferred when known) or a Meta Business-Scoped User ID — the
CC.alphanumericstring ZapClaw delivers asuser_idin inbound webhooks. ZapClaw auto-detects which form you sent. Phone is preferred because it preserves your right to keep receiving the phone in webhooks for that recipient; use the BSUID only for username-only contacts (June 2026+). - All responses are JSON. Successful sends return HTTP
201.
Send a text message
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
text | string | yes | Message body, up to 4096 characters |
previewUrl | boolean | no | When true, WhatsApp renders a link preview for the first URL found in text. Defaults to false. |
replyTo | string | no | The wamid of a message to quote. The message is delivered as a quoted reply in WhatsApp. |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/text \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"text": "Hello from ZapClaw"
}'
Response 201
{
"message": {
"id": "9f1c2d3e-...",
"to": "5511888888888",
"type": "text",
"wamid": "wamid.HBg...",
"status": "sent",
"sentAt": "2026-05-21T14:00:00.000Z"
}
}
Send a template message
Template messages can be sent at any time (outside the 24-hour customer service window). The template must be approved on your WhatsApp Business Account.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
templateName | string | yes | Approved template name |
language | string | no | Template language code (default pt_BR) |
components | array | no | Template components to fill variables (Meta format) |
replyTo | string | no | The wamid of a message to quote. The message is delivered as a quoted reply in WhatsApp. |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/template \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"templateName": "order_update",
"language": "pt_BR",
"components": [
{ "type": "body",
"parameters": [ { "type": "text", "text": "#1234" } ] }
]
}'
Send a media message
The media is referenced either by a public link or by a
mediaId handle obtained from Upload media.
Provide exactly one of the two.
Set type to sticker to send a WhatsApp sticker.
A sticker takes a link or a mediaId like the other
media types, but it does not accept a caption. Stickers must be
WebP images.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
type | string | yes | One of: image, video, document, audio, sticker |
link | string | one of | Public https URL of the media file |
mediaId | string | one of | Media handle from POST /api/v1/media |
caption | string | no | Caption (image, video, document). Not supported for sticker or audio. |
filename | string | no | File name (document only) |
replyTo | string | no | The wamid of a message to quote. The message is delivered as a quoted reply in WhatsApp. |
Exactly one of link or mediaId is required.
Sending both, or neither, returns HTTP 400.
Request — by public URL
curl -X POST https://api.zapclaw.app/api/v1/send/media \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"type": "image",
"link": "https://example.com/photo.jpg",
"caption": "Optional caption"
}'
Request — by uploaded media handle
curl -X POST https://api.zapclaw.app/api/v1/send/media \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"type": "image",
"mediaId": "1029384756102938",
"caption": "Optional caption"
}'
Request — sticker
curl -X POST https://api.zapclaw.app/api/v1/send/media \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"type": "sticker",
"link": "https://example.com/sticker.webp"
}'
Send a location
Sends a static location pin. The recipient sees a map preview they can open in their maps app.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
latitude | number | yes | Latitude, between -90 and 90 |
longitude | number | yes | Longitude, between -180 and 180 |
name | string | no | Name of the location (for example a venue name) |
address | string | no | Street address of the location |
replyTo | string | no | The wamid of a message to quote. The message is delivered as a quoted reply in WhatsApp. |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/location \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"latitude": -23.561414,
"longitude": -46.655881,
"name": "Avenida Paulista",
"address": "Av. Paulista, 1578 - São Paulo, SP"
}'
Response 201
{
"message": {
"id": "9f1c2d3e-...",
"to": "5511888888888",
"type": "location",
"wamid": "wamid.HBg...",
"status": "sent",
"sentAt": "2026-05-22T14:00:00.000Z"
}
}
Send contacts
Sends one or more contact cards. The contacts array follows the
WhatsApp Cloud API contacts object shape and is passed through to Meta
unchanged.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
contacts | array | yes | A non-empty array of WhatsApp Cloud API contact objects |
replyTo | string | no | The wamid of a message to quote. The message is delivered as a quoted reply in WhatsApp. |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/contacts \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"contacts": [
{
"name": {
"formatted_name": "Maria Silva",
"first_name": "Maria",
"last_name": "Silva"
},
"phones": [
{ "phone": "+5511777777777", "type": "WORK", "wa_id": "5511777777777" }
]
}
]
}'
Response 201
{
"message": {
"id": "9f1c2d3e-...",
"to": "5511888888888",
"type": "contacts",
"wamid": "wamid.HBg...",
"status": "sent",
"sentAt": "2026-05-22T14:00:00.000Z"
}
}
React to a message
Reacts to a message with an emoji. An empty or omitted emoji
removes a reaction you previously sent.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
messageId | string | yes | The wamid of the message being reacted to |
emoji | string | no | The reaction emoji. Empty or omitted removes a previous reaction. |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/reaction \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"messageId": "wamid.HBg...",
"emoji": "👍"
}'
Response 201
{
"message": {
"id": "9f1c2d3e-...",
"to": "5511888888888",
"type": "reaction",
"wamid": "wamid.HBg...",
"status": "sent",
"sentAt": "2026-05-22T14:00:00.000Z"
}
}
Send an interactive message
Sends an interactive message: reply buttons, a list, or a call-to-action URL.
The interactive object follows the WhatsApp Cloud API interactive
object shape and is passed through to Meta unchanged.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Sender number (E.164) |
to | string | yes | Recipient number (E.164) |
interactive | object | yes | The WhatsApp Cloud API interactive object |
replyTo | string | no | The wamid of a message to quote. The message is delivered as a quoted reply in WhatsApp. |
Request — reply buttons
curl -X POST https://api.zapclaw.app/api/v1/send/interactive \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"interactive": {
"type": "button",
"body": { "text": "Did your order arrive?" },
"action": {
"buttons": [
{ "type": "reply",
"reply": { "id": "yes", "title": "Yes" } },
{ "type": "reply",
"reply": { "id": "no", "title": "No" } }
]
}
}
}'
Request — list
curl -X POST https://api.zapclaw.app/api/v1/send/interactive \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"to": "5511888888888",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Support" },
"body": { "text": "How can we help you today?" },
"footer": { "text": "Pick an option" },
"action": {
"button": "Choose a topic",
"sections": [
{
"title": "Orders",
"rows": [
{ "id": "track", "title": "Track an order",
"description": "See where your package is" },
{ "id": "cancel", "title": "Cancel an order",
"description": "Cancel a recent purchase" }
]
},
{
"title": "Account",
"rows": [
{ "id": "billing", "title": "Billing question" }
]
}
]
}
}
}'
Response 201
{
"message": {
"id": "9f1c2d3e-...",
"to": "5511888888888",
"type": "interactive",
"wamid": "wamid.HBg...",
"status": "sent",
"sentAt": "2026-05-22T14:00:00.000Z"
}
}
Show a typing indicator
Shows a typing indicator to the contact. This also marks the referenced inbound message as read. The indicator clears after about 25 seconds, or as soon as the next message is sent, whichever comes first.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | The connected number that received the message (E.164) |
messageId | string | yes | The wamid of the inbound message to respond to |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/typing \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"messageId": "wamid.HBg..."
}'
Response 200
{ "status": "typing" }
Upload media
Uploads a file and returns a media_id handle that can then be sent
with Send a media message. Useful when the file is not
reachable at a public URL. The request is multipart/form-data.
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
file | binary | yes | The media file to upload |
from | string | yes | A connected WhatsApp number (E.164) |
Size limits
| Type | Maximum size |
|---|---|
| Image | 5 MB |
| Video | 16 MB |
| Audio | 16 MB |
| Document | 100 MB |
A file larger than the cap for its type returns HTTP 413.
Request
curl -X POST https://api.zapclaw.app/api/v1/media \
-H "X-API-Key: zc_live_xxx" \
-F "from=5511999999999" \
-F "file=@/path/to/photo.jpg"
Response 201
{
"media_id": "1029384756102938",
"mime_type": "image/jpeg",
"file_size": 12345
}
Download received media
Downloads the bytes of media received on an inbound WhatsApp message — an
image, audio, video or document. The response is the raw file, streamed with
the correct Content-Type header.
Inbound media messages arrive on your webhook with a ready-to-use
media_url field that points at this endpoint — fetch that URL with
your API key rather than building the path by hand.
Request
curl https://api.zapclaw.app/api/v1/media/1029384756102938 \
-H "X-API-Key: zc_live_xxx" \
-o received-photo.jpg
Errors
| Status | Meaning |
|---|---|
404 | The media id is unknown to your account |
410 | The media has aged out on Meta's side (around 30 days) |
Delete uploaded media
Deletes a media file you uploaded to Meta with Upload media. Use this to release a media handle once you no longer need it. Uploaded media also expires on Meta's side automatically after about 30 days.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl -X DELETE "https://api.zapclaw.app/api/v1/media/1029384756102938?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{ "deleted": true }
Mark a message as read
Sends a read receipt for an inbound message, marking it as read in the sender's WhatsApp app.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | The connected number that received the message (E.164) |
messageId | string | yes | The inbound message wamid (Meta message id) to mark as read |
Request
curl -X POST https://api.zapclaw.app/api/v1/send/read \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"messageId": "wamid.HBg..."
}'
Response 200
{ "status": "read" }
List connected numbers
Lists your connected WhatsApp numbers. Use this to discover valid
from values for the send endpoints.
Request
curl https://api.zapclaw.app/api/v1/send/numbers \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"numbers": [
{
"phoneNumberId": "964283400112599",
"displayPhoneNumber": "+1 555 161-7409",
"displayName": "My Business",
"label": "Support line",
"qualityRating": "GREEN",
"status": "active"
}
]
}
List messages
Lists recent messages — useful for delivery status lookups.
Query parameters
| Param | Required | Description |
|---|---|---|
from | no | Filter by sender number (E.164) |
limit | no | Max results, 1–100 (default 20) |
curl "https://api.zapclaw.app/api/v1/send/messages?limit=20" \
-H "X-API-Key: zc_live_xxx"
Get a single message
Looks up a single message by its ZapClaw id. Returns the same message shape
as List messages, or HTTP 404 if no
message with that id belongs to your account.
Request
curl https://api.zapclaw.app/api/v1/send/messages/9f1c2d3e-... \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"message": {
"id": "9f1c2d3e-...",
"direction": "outbound",
"to": "5511888888888",
"type": "text",
"wamid": "wamid.HBg...",
"status": "delivered",
"sentAt": "2026-05-21T14:00:00.000Z",
"createdAt": "2026-05-21T14:00:00.000Z"
}
}
List templates
Lists approved templates available to send. Optional query param from (E.164)
selects which connected number's templates to return.
Request
curl "https://api.zapclaw.app/api/v1/send/templates?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
Each template carries an id field: the Meta template id. Pass it
to Edit a template.
{
"templates": [
{
"id": "1234567890123456",
"name": "order_update",
"language": "pt_BR",
"category": "UTILITY",
"status": "APPROVED",
"bodyText": "Your order {{1}} has shipped."
}
]
}
Create a template
Creates a message template and submits it to Meta for review. A newly
created template is not sendable until Meta approves it; poll
List templates or watch for an
account.<field> webhook to learn when its status changes.
Describe the template with the fields below and ZapClaw builds Meta's
components array for you — including the example
shapes Meta demands, which differ by parameter format and are easy to get
wrong. You can still pass components yourself to bypass the
builder; that is this endpoint's original behaviour and remains supported.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
name | string | yes | Template name (lowercase, digits and underscores) |
category | string | yes | UTILITY, MARKETING or AUTHENTICATION |
language | string | no | Language code. Defaults to pt_BR |
bodyText | string | yes* | Body text with {{1}} or {{nome}} variables. Meta rejects a variable at the very start or end. *Not used by AUTHENTICATION |
headerText | string | no | Text header. Only used when headerFormat is TEXT |
footerText | string | no | Footer. Cannot contain variables |
headerFormat | string | no | TEXT, IMAGE, VIDEO or DOCUMENT. Omit for no header |
headerMediaUrl | string | no | Public https URL of the example asset for a media header. Required when headerFormat is a media type |
parameterFormat | string | no | POSITIONAL (default, {{1}}) or NAMED ({{order_id}}). A template must use one throughout |
variableExamples | object | no | Sample per variable, keyed by number or name. Meta requires one for every variable |
buttons | array | no | Up to 10. Types: QUICK_REPLY, URL, PHONE_NUMBER, COPY_CODE |
messageSendTtlSeconds | integer | no | Delivery retry window. Meta's ranges: AUTHENTICATION 30-900, UTILITY 30-43200, MARKETING 43200-2592000 |
accountIds | array | no | Create the same template on several numbers at once (see below) |
components | array | no | Raw Meta components. Bypasses the builder; every field above except name/category/language is then ignored |
bodyText, headerText, footerText or
buttons — they are ignored. Configure
codeExpirationMinutes (1-90, default 10),
addSecurityRecommendation (default true) and
otpButtonText; the body and the copy-code button are generated
for you.
Media headers
Set headerFormat to IMAGE, VIDEO or
DOCUMENT and give a public headerMediaUrl. ZapClaw
downloads that file and uploads it to Meta for approval.
That file is only the approval sample. At send time you
supply the real media in a header component parameter, so a
single approved template serves any number of different images.
Buttons
A URL button may carry exactly one variable and Meta requires
it at the very end of the URL — https://shop.com/order/{{1}}
works, https://shop.com/{{1}}/detail is rejected. Its value is
supplied by the button component at send time, so it is always numbered
{{1}} regardless of the template's parameter format.
A COPY_CODE button takes no text — WhatsApp
renders its own label — and its example is the sample coupon
code.
"buttons": [
{ "type": "URL", "text": "Track order", "url": "https://shop.com/o/{{1}}", "urlExample": "A-123" },
{ "type": "COPY_CODE", "example": "SAVE20" },
{ "type": "QUICK_REPLY", "text": "Talk to support" }
]
Creating on several numbers at once
A template lives per WhatsApp Business Account, so each connected number
needs its own copy. Pass accountIds to submit to several at
once. Each is reviewed independently by Meta, so the response reports
what actually landed — and a partial success is not rolled back,
because a template already accepted by Meta cannot be withdrawn just
because another number rejected it.
{
"created": [ { "id": "...", "name": "order_shipped", "status": "PENDING" } ],
"failed": [ { "accountId": "...", "phoneNumberId": "...", "error": "..." } ]
}
Request
curl -X POST https://api.zapclaw.app/api/v1/send/templates \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"name": "order_shipped",
"category": "UTILITY",
"language": "pt_BR",
"components": [
{ "type": "BODY",
"text": "Your order {{1}} has shipped." }
]
}'
Response 201
{
"template": {
"id": "1234567890123456",
"status": "PENDING",
"category": "UTILITY"
}
}
Edit a template
Edits an existing template, identified by its Meta template id
(the id field returned by List
templates). Provide at least one of category or
components. An edited template is re-submitted to Meta for
review.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
category | string | one of | New template category |
components | array | one of | New template components (Meta format) |
At least one of category or components is required.
Request
curl -X POST https://api.zapclaw.app/api/v1/send/templates/1234567890123456 \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"components": [
{ "type": "BODY",
"text": "Your order {{1}} is on its way." }
]
}'
Response 200
{ "updated": true }
Delete a template
Deletes a template by its name.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl -X DELETE "https://api.zapclaw.app/api/v1/send/templates/order_shipped?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{ "deleted": true }
Replace a template still under review
Meta forbids editing a template while its status is PENDING —
only APPROVED, REJECTED and PAUSED
templates can be edited. To change one that is still in review it has to be
deleted and resubmitted under the same name, which is what this endpoint
does. Nothing is lost: a PENDING template has never been sent.
Takes the same body as
Create a template. A
headerMediaUrl is fetched and validated before the
delete, so a bad URL cannot destroy the old template without producing a
new one.
For an already-approved template, use Edit a template instead.
Templates missing from a number
A template lives per WhatsApp Business Account, so a newly connected number starts with none of the templates your other numbers already have. This lists exactly those — the ones present elsewhere on your account but not on the target.
Query parameters
| Param | Required | Description |
|---|---|---|
targetAccountId | yes | The number that would receive the copies |
Copy templates onto another number
Recreates the named templates on the target number's WABA, from the copy stored on another of your numbers.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
targetAccountId | string | yes | The number that receives the copies |
names | array | yes | Template names to copy |
Meta reviews each copy on its own, so the response is per template rather than all-or-nothing:
{
"results": [
{ "name": "order_shipped", "ok": true, "template": { "id": "...", "status": "PENDING" } },
{ "name": "promo_may", "ok": false, "error": "Media template has no stored source URL; recreate it with headerMediaUrl" }
]
}
Read the business profile
Reads the WhatsApp business profile for one of your connected numbers.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl "https://api.zapclaw.app/api/v1/profile?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"profile": {
"about": "Open Monday to Friday, 9am to 6pm",
"address": "Av. Paulista, 1578 - São Paulo, SP",
"description": "We sell handmade leather goods.",
"email": "contato@example.com",
"websites": [ "https://example.com" ],
"vertical": "RETAIL",
"profile_picture_url": "https://scontent.whatsapp.net/..."
}
}
Update the business profile
Updates the business profile text fields. Provide at least one field beyond
from. To change the display name, use
Submit a display name; to change the photo, use
Set the profile picture.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
about | string | no | The profile "about" text |
address | string | no | Business street address |
description | string | no | Business description |
email | string | no | Contact email |
websites | array | no | Up to two website URLs |
vertical | string | no | Business industry vertical (Meta enum) |
Request
curl -X PATCH https://api.zapclaw.app/api/v1/profile \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"about": "Open Monday to Friday, 9am to 6pm",
"email": "contato@example.com",
"websites": [ "https://example.com" ]
}'
Response 200
{ "updated": true }
Submit a display name
Submits a new display name for one of your connected numbers. A display name change is reviewed by Meta before it takes effect, so the response confirms the request was submitted, not that the name is already live.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
name | string | yes | The new display name |
Request
curl -X POST https://api.zapclaw.app/api/v1/profile/name \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"name": "My Business"
}'
Response 200
{ "submitted": true }
Set the profile picture
Sets the business profile picture. The request is multipart/form-data.
The image file is capped at 5 MB.
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
file | binary | yes | The profile picture image |
from | string | yes | A connected WhatsApp number (E.164) |
Request
curl -X POST https://api.zapclaw.app/api/v1/profile/photo \
-H "X-API-Key: zc_live_xxx" \
-F "from=5511999999999" \
-F "file=@/path/to/logo.jpg"
Response 200
{ "updated": true }
List blocked users
Lists the users currently blocked on one of your connected numbers.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl "https://api.zapclaw.app/api/v1/block?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"blocked": [
{ "wa_id": "5511777777777" }
]
}
Block users
Blocks one or more users on a connected number. A blocked user can no longer send messages to that number.
result.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
numbers | array | yes | Non-empty array of recipient numbers (E.164) to block |
Request
curl -X POST https://api.zapclaw.app/api/v1/block \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"numbers": [ "5511777777777" ]
}'
Response 200
{
"result": {
"added_users": [
{ "input": "5511777777777", "wa_id": "5511777777777" }
]
}
}
Unblock users
Unblocks one or more previously blocked users on a connected number.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
numbers | array | yes | Non-empty array of recipient numbers (E.164) to unblock |
Request
curl -X DELETE https://api.zapclaw.app/api/v1/block \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"numbers": [ "5511777777777" ]
}'
Response 200
{
"result": {
"removed_users": [
{ "input": "5511777777777", "wa_id": "5511777777777" }
]
}
}
List QR codes
Lists the QR codes (managed links) created on one of your connected numbers.
A QR code links to a wa.me conversation that opens with a
prefilled message.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl "https://api.zapclaw.app/api/v1/qr-codes?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"qrCodes": [
{
"code": "4O4ABCDEFG123",
"prefilled_message": "Hi, I would like to know more",
"deep_link_url": "https://wa.me/message/4O4ABCDEFG123",
"qr_image_url": "https://scontent.whatsapp.net/v/..."
}
]
}
Create a QR code
Creates a QR code (managed link) for one of your connected numbers.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
prefilledMessage | string | yes | The message text prefilled when a user opens the link |
imageFormat | string | no | Format of the QR image: PNG (default) or SVG |
Request
curl -X POST https://api.zapclaw.app/api/v1/qr-codes \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"prefilledMessage": "Hi, I would like to know more",
"imageFormat": "PNG"
}'
Response 201
{
"qrCode": {
"code": "4O4ABCDEFG123",
"prefilled_message": "Hi, I would like to know more",
"deep_link_url": "https://wa.me/message/4O4ABCDEFG123",
"qr_image_url": "https://scontent.whatsapp.net/v/..."
}
}
Delete a QR code
Deletes a QR code by its code identifier.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl -X DELETE "https://api.zapclaw.app/api/v1/qr-codes/4O4ABCDEFG123?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{ "deleted": true }
Read conversational automation
Reads the conversational automation configuration for a connected number: the welcome message toggle, slash commands, and ice breaker prompts.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
Request
curl "https://api.zapclaw.app/api/v1/automation?from=5511999999999" \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"automation": {
"enable_welcome_message": true,
"commands": [
{ "command_name": "menu",
"command_description": "Show the main menu" }
],
"prompts": [ "Track my order", "Talk to support" ]
}
}
Update conversational automation
Updates the conversational automation configuration. Provide at least one
field beyond from.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | A connected WhatsApp number (E.164) |
enableWelcomeMessage | boolean | no | Whether the welcome message is shown when a customer opens a new conversation |
commands | array | no | Array of { command_name, command_description } objects |
prompts | array | no | Ice breaker prompts: an array of strings, maximum 4 |
Request
curl -X POST https://api.zapclaw.app/api/v1/automation \
-H "X-API-Key: zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"from": "5511999999999",
"enableWelcomeMessage": true,
"commands": [
{ "command_name": "menu",
"command_description": "Show the main menu" }
],
"prompts": [ "Track my order", "Talk to support" ]
}'
Response 200
{ "updated": true }
Pricing analytics
Returns Meta's per-message pricing analytics for a time window. Use it to
reconcile billing alongside the pricing and
conversation objects on the
message.status webhook.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | A connected WhatsApp number (E.164) |
start | yes | Start of the window, a Unix timestamp (epoch seconds) |
end | yes | End of the window, a Unix timestamp (epoch seconds) |
granularity | no | Bucket size: DAILY (default) or MONTHLY |
Request
curl "https://api.zapclaw.app/api/v1/analytics/pricing?from=5511999999999&start=1746057600&end=1748736000&granularity=DAILY" \
-H "X-API-Key: zc_live_xxx"
Response 200
{
"analytics": {
"data_points": [
{
"start": 1746057600,
"end": 1746144000,
"cost": 12.34,
"volume": 420
}
]
}
}
Rate limits
The REST API allows 1000 requests per 15 minutes per API key.
Exceeding the limit returns HTTP 429.
Webhook
Configuring the webhook
In the ZapClaw dashboard, under Webhook, set the URL where ZapClaw should deliver events and choose which events to receive. ZapClaw generates a signing secret — use it to verify that requests genuinely came from ZapClaw.
The signing secret is hidden by default; click the eye icon to reveal it or the copy button
to copy it. Use Generate new key to rotate it — a fresh secret is issued and
the previous one stops validating immediately. After rotating, update the secret in
every integration that checks the X-ZapClaw-Signature header (see
Signature verification). Rotate the key whenever you suspect
it has leaked.
ZapClaw sends an HTTP POST with a JSON body to your URL for each event.
Respond with any 2xx status to acknowledge.
The events are message.received (inbound message),
message.status (delivery status) and the
account.<field> family (WABA account events). Account events
are always delivered regardless of your event subscription.
message.received and
message.status event carries an account object whose
account.phone_number_id identifies which of your numbers it belongs to — use it
to route or filter messages per number. The dashboard's Webhook page lists
every connected number next to its Phone Number ID; you can also fetch them from
GET /api/v1/send/numbers.
Event: message.received
Delivered when one of your numbers receives an inbound WhatsApp message.
{
"event": "message.received",
"sequence": 1042,
"timestamp": "2026-05-21T14:00:00.000Z",
"data": {
"from": "5511888888888",
"user_id": "BR.1A2B3C4D5E6F7G8H9I0J",
"contact_name": "Maria Silva",
"contact_username": "maria_lima",
"message_id": "wamid.HBg...",
"type": "text",
"timestamp": "1747836000",
"text": { "body": "Hello" }
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
The top-level sequence is a strictly increasing integer
assigned to every forwarded message.received event in the
order ZapClaw received it from Meta. Because data.timestamp
has only second granularity, messages sent in quick succession can share
a timestamp; order by sequence to break the tie reliably.
user_id field on every webhook on
2026-03-31 — the Business-Scoped User ID. From June 2026 end users can adopt a @username and hide
their phone from businesses they haven't interacted with. When that
happens, from may be null on first-touch events.
Treat user_id as the durable identifier; keep
from only as a display attribute. ZapClaw stores both for
you when known and surfaces contact_username when the user
has set one.
data fields
The Always column marks fields present on every
message.received event. Fields marked Conditional
appear only in the situation described.
| Field | Always | Description |
|---|---|---|
user_id | yes | Meta Business-Scoped User ID (BSUID) of the sender. Format CC.alphanumeric where CC is an ISO country code. Stable per business — use this as your primary key for contacts. |
from | conditional | Sender's phone number in E.164. Present whenever Meta knows it. May be null for username-only contacts on first-touch (June 2026+). |
message_id | yes | Meta message id (wamid) |
type | yes | Message type: text, image, audio, video, document, sticker, and others |
timestamp | yes | Meta's send time, Unix epoch seconds as a string (see Timestamp formats) |
contact_name | conditional | Sender's WhatsApp profile name. Present on every event but may be null when the name is unknown. |
contact_username | conditional | Sender's @username, when they've adopted one (June 2026+). null otherwise. |
| media object ( image, audio, video, document, sticker) | conditional | Present only on media messages. The key matches type. See Media messages. |
text | conditional | Present only when type is text. Holds { "body": "..." }. |
context | conditional | Present only when the message is a reply to (or quote of) another message. See Replies. |
referral | conditional | Present only when the message came from a click-to-WhatsApp ad. Carries the ad attribution. |
identity | conditional | Present only when the contact's identity changed (security notification). |
errors | conditional | Present only when Meta reported an error for the message. |
The account object
Every message.received and message.status event carries a
top-level account object telling you which of your connected numbers the event
belongs to. Because one webhook URL serves all of your numbers, this is how you route or
filter per number.
| Field | Description |
|---|---|
phone_number_id | Meta Phone Number ID of the number that received the message (or whose outbound message changed status). Stable per number — match it against the IDs on the dashboard's Webhook page or the phoneNumberId from GET /api/v1/send/numbers. This is the reliable key for per-number routing. |
display_phone_number | The same number in human-readable E.164 (e.g. +1 555 161-7409). This is the value you pass as the from field when sending. |
display_name | The number's verified business name, when set. |
Timestamp formats
Webhook payloads carry two timestamps in two different formats. Do not confuse them.
| Field | Format | Example |
|---|---|---|
Top-level timestamp | ISO 8601 string (the moment ZapClaw delivered the event) | 2026-05-22T17:30:00.000Z |
data.timestamp | Unix epoch seconds, as a string (comes straight from Meta) | "1716394200" |
timestamp is an ISO 8601 string, while
data.timestamp is epoch seconds expressed as a string. Parse each
one with the matching format.
Media messages
For media messages (image, audio, video,
document, sticker), the media object inside data
includes a ready-to-use media_url field: a direct
download URL you can fetch with your API key. Use
media_url instead of the raw Meta id, which cannot be
resolved on its own.
The media object always carries id, mime_type,
sha256 and media_url. Audio messages also carry a
voice boolean (true for a voice note). Document
messages also carry a filename. The caption field
appears on image, video and document messages when the sender added one.
Image
{
"event": "message.received",
"timestamp": "2026-05-22T14:00:00.000Z",
"data": {
"from": "5511888888888",
"contact_name": "Maria Silva",
"message_id": "wamid.HBg...",
"type": "image",
"timestamp": "1747836000",
"image": {
"id": "1029384756102938",
"mime_type": "image/jpeg",
"sha256": "q1w2e3...",
"caption": "Here is the photo",
"media_url": "https://api.zapclaw.app/api/v1/media/1029384756102938"
}
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
Audio
Audio messages carry a voice boolean: true when the
message is a voice note, false for a regular audio file.
{
"event": "message.received",
"timestamp": "2026-05-22T14:00:00.000Z",
"data": {
"from": "5511888888888",
"contact_name": "Maria Silva",
"message_id": "wamid.HBg...",
"type": "audio",
"timestamp": "1747836000",
"audio": {
"id": "2938475610293847",
"mime_type": "audio/ogg; codecs=opus",
"sha256": "a1s2d3...",
"voice": true,
"media_url": "https://api.zapclaw.app/api/v1/media/2938475610293847"
}
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
Video
{
"event": "message.received",
"timestamp": "2026-05-22T14:00:00.000Z",
"data": {
"from": "5511888888888",
"contact_name": "Maria Silva",
"message_id": "wamid.HBg...",
"type": "video",
"timestamp": "1747836000",
"video": {
"id": "3847561029384756",
"mime_type": "video/mp4",
"sha256": "z1x2c3...",
"caption": "Watch this",
"media_url": "https://api.zapclaw.app/api/v1/media/3847561029384756"
}
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
Document
Document messages carry a filename with the original file name.
{
"event": "message.received",
"timestamp": "2026-05-22T14:00:00.000Z",
"data": {
"from": "5511888888888",
"contact_name": "Maria Silva",
"message_id": "wamid.HBg...",
"type": "document",
"timestamp": "1747836000",
"document": {
"id": "4756102938475610",
"mime_type": "application/pdf",
"sha256": "p1o2i3...",
"filename": "invoice-1234.pdf",
"caption": "Your invoice",
"media_url": "https://api.zapclaw.app/api/v1/media/4756102938475610"
}
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
Replies (quoted messages)
When the contact replies to a specific message, data includes a
context object. Its id is the wamid of the message
that was replied to: use it to look up which of your messages the contact
answered, and from is the number that sent the quoted message.
{
"event": "message.received",
"timestamp": "2026-05-22T14:00:00.000Z",
"data": {
"from": "5511888888888",
"contact_name": "Maria Silva",
"message_id": "wamid.HBg...",
"type": "text",
"timestamp": "1747836000",
"text": { "body": "Yes, that works for me" },
"context": {
"from": "5511999999999",
"id": "wamid.HBgQUVO_THE_QUOTED_MESSAGE"
}
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
Event: message.status
Delivered when an outbound message changes status (sent, delivered, read, failed).
{
"event": "message.status",
"timestamp": "2026-05-21T14:00:05.000Z",
"data": {
"message_id": "wamid.HBg...",
"status": "delivered",
"recipient_id": "5511888888888",
"timestamp": "1747836005"
},
"account": { "phone_number_id": "964283400112599" }
}
Billing fields: conversation & pricing
When Meta includes billing details on a status change, the data
object also carries a conversation object and a
pricing object, passed through from Meta unchanged. Use them to
reconcile billing per message. They are present only when Meta supplies them
(typically on the sent or delivered status of a
billable message), so treat both as conditional.
{
"event": "message.status",
"timestamp": "2026-05-21T14:00:05.000Z",
"data": {
"message_id": "wamid.HBg...",
"status": "sent",
"recipient_id": "5511888888888",
"timestamp": "1747836005",
"conversation": {
"id": "abcd1234efgh5678",
"origin": { "type": "utility" },
"expiration_timestamp": "1747922405"
},
"pricing": {
"billable": true,
"pricing_model": "PMP",
"category": "utility",
"type": "regular"
}
},
"account": { "phone_number_id": "964283400112599" }
}
For an aggregated view of message cost over a time window, see the Pricing analytics endpoint.
Event: account.<field>
WABA-level events about your WhatsApp Business Account (account bans and
restrictions, phone number quality changes, and template status updates) are
forwarded as account.<field> events, where <field>
is the Meta webhook field (for example account.account_update).
account_update with
ACCOUNT_RESTRICTION, ACCOUNT_VIOLATION,
DISABLED_UPDATE, ACCOUNT_DELETED or
ACCOUNT_OFFBOARDED), ZapClaw records it and every send
endpoint starts answering 403 with the
reason, instead of letting each message fail at Meta one at a time. Reads
keep working. The block clears automatically when Meta reinstates the
account (ACCOUNT_RECONNECTED, or a DISABLED_UPDATE
whose ban state is REINSTATE).
A scheduled ban (SCHEDULE_FOR_DISABLE) does not block
anything — Meta keeps delivering until the date, and so do we.
The data object is the raw Meta webhook value for that field.
Its shape depends on the field.
{
"event": "account.account_update",
"field": "account_update",
"timestamp": "2026-05-21T14:00:00.000Z",
"data": {
"phone_number": "+1 555 161-7409",
"event": "ACCOUNT_RESTRICTION",
"restriction_info": [
{ "restriction_type": "RESTRICTED_BIZ_INITIATED_MESSAGING",
"expiration_timestamp": "1748000000" }
]
},
"account": {
"phone_number_id": "964283400112599",
"display_name": "My Business",
"display_phone_number": "+1 555 161-7409"
}
}
Request headers
Every webhook POST carries these headers:
| Header | Description |
|---|---|
X-ZapClaw-Event | The event type, e.g. message.received. |
X-ZapClaw-Delivery | Unique id of this delivery attempt. |
X-ZapClaw-Signature | HMAC-SHA256 signature of the body (see below). |
X-ZapClaw-Redelivery | Present only on a manual replay triggered from the dashboard. Its value is the id of the original delivery being replayed. |
message_id) as the original. If you deduplicate by
message_id, check for X-ZapClaw-Redelivery: when it
is present, process the event even though you have seen that
message_id before, since the replay was requested on purpose.
Signature verification
Every webhook request includes an X-ZapClaw-Signature header: an
HMAC-SHA256 of the raw request body, keyed with your webhook signing secret, prefixed
with sha256=. Always verify it before trusting the payload.
If you rotate the signing secret (Webhook → Generate new key), signatures
are computed with the new secret the moment you rotate — update the secret your
verifier uses right away, or every request will fail the check until you do.
Node.js
const crypto = require('crypto');
function verify(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature), Buffer.from(expected));
}
Python
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Retries & acknowledgement
Respond with any 2xx status to acknowledge a webhook. If your endpoint
returns a non-2xx status, times out (10 seconds), or is unreachable, ZapClaw retries the
delivery up to 3 times with a backoff of 0s, 30s and 120s. Delivery
attempts are logged and visible in the dashboard.
Delivery guarantees
The message_id (Meta wamid) is stable and globally unique. It is
safe to use as a deduplication key. ZapClaw also deduplicates internally, so it
will not POST the same event to your URL twice, even though Meta resends
webhooks aggressively.
message.received events by the top-level sequence
(a strictly increasing integer); it disambiguates messages that share the
same second-granularity data.timestamp. Keep your handler
idempotent.
Errors
Errors return a non-2xx HTTP status with a JSON body of this shape:
{ "error": "human-readable message" }
When the failure originated at the WhatsApp Cloud API, the body also
carries meta_code — Meta's own numeric error code, forwarded
so you can branch on it without parsing the message:
{
"error": "The 24-hour customer service window with this contact is closed. Send an approved template to reopen the conversation.",
"meta_code": 131047
}
The table below lists every HTTP status the API can return.
| Status | When it occurs |
|---|---|
200 | Success. Returned by read endpoints and by POST /send/read and POST /send/typing. |
201 | Success. A resource was created: a message accepted and sent (the POST /send/* message endpoints), an uploaded file (POST /media), a created template (POST /send/templates) or a created QR code (POST /qr-codes). |
202 | Accepted. The request was accepted for asynchronous processing on Meta's side: a display name change (POST /profile/name) or a template create or edit (POST /send/templates, POST /send/templates/{id}) that is queued for Meta review. |
400 | Invalid request: a missing or malformed field, an unknown from number, or both link and mediaId supplied to POST /send/media. |
401 | Missing or invalid API key. |
403 | Meta has restricted or disabled the WhatsApp Business Account this number belongs to, so it cannot send. The message names the restriction. Reads keep working — only sending is blocked. Resolve it in the Meta Business Manager; ZapClaw clears the block automatically when Meta reinstates the account. |
404 | The requested message or media was not found, or does not belong to your account. |
410 | Received media has aged out on Meta's side (around 30 days) and can no longer be downloaded. |
413 | An uploaded file exceeds the size cap for its type. |
422 | The 24-hour customer service window with this contact is closed, so free-form messages are refused (meta_code: 131047). Send an approved template to reopen the conversation — template sends are never window-bound. |
429 | Rate limit exceeded (1000 requests / 15 minutes per API key). |
500 | An unexpected error on the ZapClaw side. |
502 | The Meta API returned a failure or could not be reached. |
503 | ZapClaw could not reach the Meta API after automatic retries. A transient upstream condition, not a problem with your request — retry the call. |
503.
Sends are retried only while the connection has not yet been established — the one
window in which re-issuing cannot duplicate a message — so a 503 on a
send means the message was never accepted by Meta and is safe to retry.
Use ZapClaw with Chatwoot
If you run Chatwoot
self-hosted as your conversation manager, ZapClaw ships a drop-in
compatibility mode that lets Chatwoot's native WhatsApp Cloud channel
talk to ZapClaw instead of graph.facebook.com directly.
You skip the Meta App + Business Verification + Tech Provider review
that Chatwoot otherwise requires, and your customers connect through
our Embedded Signup in minutes.
You do not have to choose. Native and Chatwoot are two independent destinations, each with its own URL, signing secret and on/off switch, and both can be active at the same time on the same number. A common setup is Chatwoot for human support plus a native endpoint driving automation — every inbound message is delivered to each, in the shape that destination expects. Configuring one never disables the other.
Native consumers (custom backends, CRMs, automations on n8n / Make / Zapier) receive the normalized ZapClaw JSON envelope — see Webhook.
What each destination gives you
Read this as "what arrives where", not as an either/or — you can enable one, the other, or both.
| Native | Chatwoot | |
|---|---|---|
| Best for | Custom backend, CRM, n8n / Make / Zapier | Chatwoot self-hosted |
| Inbound payload | ZapClaw JSON envelope ({ event, data, account }) | Raw Meta envelope ({ object, entry[] }) |
| Inbound signature header | X-ZapClaw-Signature | X-Hub-Signature-256 |
| Outbound API | /api/v1/send/* with X-API-Key | /v25.0/{phone_id}/messages with Authorization: Bearer |
| Media URLs | We enrich messages with media_url | Resolve via GET /v25.0/{media_id} |
| Chatwoot env changes | none | 2 env vars + restart |
| Code changes on your side | You write the webhook handler | Zero — Chatwoot already has one |
Inbound: ZapClaw → Chatwoot
In the ZapClaw dashboard, open Webhook. The page shows
both destinations at once; fill in the Chatwoot section
and save it. Set the URL to your Chatwoot base
(e.g. https://chatwoot.your-domain.com) — ZapClaw appends
/webhooks/whatsapp/<phone_number_id> automatically.
Saving here does not touch a native endpoint you may already have
configured; the two are stored and delivered separately.
ZapClaw then posts the raw Meta payload — exactly the
{ "object": "whatsapp_business_account", "entry": [...] }
envelope Chatwoot expects to receive from Meta — and signs it with
X-Hub-Signature-256 using your webhook signing secret.
On your Chatwoot self-hosted server, paste the env vars block
ZapClaw shows on the webhook page into your .env (or
docker-compose environment) and restart Chatwoot:
# From the Chatwoot setup panel in your ZapClaw dashboard:
WHATSAPP_APP_SECRET=whsec_REPLACE_WITH_YOUR_SECRET
WHATSAPP_CLOUD_BASE_URL=https://api.zapclaw.app
WHATSAPP_APP_SECRET is what Chatwoot uses for the
signature check on inbound webhooks; WHATSAPP_CLOUD_BASE_URL
makes Chatwoot's outbound calls hit ZapClaw instead of Meta. The
same two values come up in the ZapClaw dashboard in a ready-to-copy
block.
Outbound: Chatwoot → ZapClaw
ZapClaw exposes a Meta-compatible REST surface under
/v25.0/... (and any other version segment — we accept
them all and route to the version ZapClaw is pinned to). Chatwoot's
WhatsappCloudService works against it without code
changes once WHATSAPP_CLOUD_BASE_URL is set.
Use a ZapClaw API key as the access token Chatwoot stores for the
inbox. The Meta-compat endpoints accept the key three ways, because
Chatwoot itself is not consistent about which it uses:
X-API-Key (our native style),
Authorization: Bearer (Meta style — what Chatwoot's sends
use), and ?access_token= in the query string (what its
channel validation and template sync use).
Endpoints exposed
These mirror the Graph API exactly — same request bodies, same response shapes, same error envelope.
| Method | Path | Use |
|---|---|---|
POST | /v25.0/{phone_number_id}/messages | Send any message type (text, template, media, interactive, reaction, location, contacts) |
POST | /v25.0/{phone_number_id}/media | Upload an outbound media file (multipart) |
GET | /v25.0/{media_id} | Resolve an inbound media id to a ZapClaw download URL |
GET | /v25.0/{waba_id}/message_templates | List message templates for a WABA |
GET | /v25.0/{waba_id}/phone_numbers | List the connected numbers of a WABA — Chatwoot calls this when creating the inbox |
Quick smoke test (cURL)
Before configuring Chatwoot, you can sanity-check the Meta-compat endpoint with cURL. Both shapes below work — they go through the same code path:
# With Meta-style Authorization (what Chatwoot sends)
curl -X POST https://api.zapclaw.app/v25.0/<phone_number_id>/messages \
-H "Authorization: Bearer zc_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "5511999999999",
"type": "text",
"text": { "body": "Hello from ZapClaw" }
}'
# 200 OK
# {
# "messaging_product": "whatsapp",
# "contacts": [{ "input": "5511999999999", "wa_id": "5511999999999" }],
# "messages": [{ "id": "wamid.HBg...", "message_status": "accepted" }]
# }
Step-by-step setup
- Sign up at ZapClaw and connect your WhatsApp number through our Embedded Signup. You will not need to create a Meta app yourself.
- In API Keys, create a key — copy it once, you will paste it into Chatwoot.
- In Webhook, find the Chatwoot
section and set its URL to your Chatwoot base
(e.g.
https://chatwoot.your-domain.com). Save. A native endpoint configured in the section above keeps working — the two deliver independently. ZapClaw then shows a Chatwoot setup panel with:- a ready-to-copy two-line env var block,
- the Phone Number ID + Business Account ID for every connected number,
- shortcuts to API Keys and back here.
- On the Chatwoot self-hosted host, paste the env var block into
your
.env(ordocker-composeenvironment) and restart Chatwoot. - In Chatwoot, create an inbox of type WhatsApp →
WhatsApp Cloud. Fill in:
- Phone Number ID — from the credentials card on the ZapClaw webhook page.
- Business Account ID — same place.
- API key / Access token — your ZapClaw API key.
- Webhook verify token — anything; ZapClaw doesn't use this field (Meta is upstream of us).
- Send a test message from the Chatwoot inbox to a WhatsApp number. The conversation appears on both sides — inbound arrives in Chatwoot, outbound shows up in the ZapClaw dashboard's Conversations.
Troubleshooting
Most failures fit one of these patterns. ZapClaw logs every webhook delivery — open Logs in the dashboard to see the raw response Chatwoot returned, which usually pinpoints the issue faster than the Chatwoot logs do.
- Chatwoot rejects every webhook with 401 / "signature
mismatch". Double-check that
WHATSAPP_APP_SECRETon Chatwoot matches the Signing secret on the ZapClaw webhook page byte for byte. Trailing whitespace and accidental quotes count. Re-paste from the dashboard's copy button to be safe. - Chatwoot returns 404 for every webhook. The
URL we POST to is
<your-base>/webhooks/whatsapp/<phone_number_id>. That path only exists in Chatwoot once an inbox of type "WhatsApp Cloud" has been created for that phone number ID. Create the inbox first, then re-test. - Outbound returns 404 from ZapClaw. The phone
number ID passed to
/v25.0/{phone_number_id}/messagesmust belong to the same ZapClaw user as the API key used. Check both on the WhatsApp Numbers page; mismatched cross-account use is refused at the tenant guard. - Chatwoot keeps calling
graph.facebook.com. Older Chatwoot releases (pre 3.x) ignore the env var. Patchapp/services/whatsapp/providers/whatsapp_cloud_service.rbto readENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')inapi_base_path, rebuild the image, and restart. - Inbox stuck in "Connecting…" or "Pending". Usually Chatwoot trying to call Meta directly before the env var change took effect. Restart the Chatwoot worker pods, not just the web pods — the Sidekiq workers handle webhook subscription.
- Authentication templates return error 131062. Meta forbids sending one-tap / zero-tap / copy-code OTP templates when the recipient is addressed by BSUID instead of phone. Pass the recipient as a phone number for auth templates; utility and marketing templates accept both.
- Media downloads return 410. Inbound media is
served by
GET /v25.0/{media_id}, which proxies our own short-lived Meta URL. If Chatwoot caches the media id more than ~30 days it will eventually 410 — that is a Meta-side retention limit, not ours.
Changelog
Most recent changes first.
- New error statuses:
403when Meta has restricted the account (sending is refused with the reason instead of every message failing at Meta one by one),422when the 24-hour customer service window is closed, and503when Meta could not be reached after automatic retries. Error bodies now also carrymeta_codewhen the failure came from Meta. See Errors. - Automatic retries toward Meta: transient network
failures are retried before surfacing. Sends are retried only while the
connection has not been established, so a retry can never duplicate a
message — a
503on a send means Meta never accepted it. - Template category changes are tracked: Meta can
reclassify an approved template (for example UTILITY to MARKETING),
which changes what every send with it costs.
GET /api/v1/send/templates now returns
pendingCategoryandcategoryUpdateAtwhen a change has been announced, so the price rise is visible before it lands. - Chatwoot compatibility: the Meta-compat surface now
also accepts the API key as
?access_token=(used by Chatwoot's channel validation and template sync) and implementsGET /v25.0/{waba_id}/phone_numbers, which Chatwoot calls when creating an inbox. See Use ZapClaw with Chatwoot. - Native and Chatwoot webhooks at the same time: the two delivery modes are no longer mutually exclusive. Each is an independent destination with its own URL, signing secret and on/off switch, and both can be active on the same number — typically Chatwoot for human support alongside a native endpoint driving automation. Existing configurations are unaffected and keep delivering exactly as before. The dashboard's Webhook page now shows both sections at once instead of a mode selector.
- Chatwoot passthrough: added an opt-in
webhook integration mode that delivers the raw Meta payload
signed with
X-Hub-Signature-256, plus a Meta-compatible REST surface under/v25.0/.... See Use ZapClaw with Chatwoot. - Coexistence limitations: documented the fixed throughput cap (about 5 messages per second), the webhook data gap for messages sent from WhatsApp for Windows or WearOS, and the features Coexistence does not support (voice/video calls, the OBA green badge, ephemeral/view-once/group messages). See Coexistence limitations.
- Status webhook billing fields: the
message.statuswebhookdatanow includes Meta'sconversationandpricingobjects, when present, for billing reconciliation. - Template management: added
POST /api/v1/send/templates(create),POST /api/v1/send/templates/{id}(edit) andDELETE /api/v1/send/templates/{name}(delete).GET /api/v1/send/templatesnow returns anidfield (the Meta template id) per template. - Pricing analytics: added
GET /api/v1/analytics/pricingfor per-message pricing analytics over a time window. - Conversational automation: added
GET /api/v1/automationandPOST /api/v1/automationto read and update the welcome message toggle, slash commands and ice breaker prompts. - QR codes: added
POST /api/v1/qr-codes,GET /api/v1/qr-codesandDELETE /api/v1/qr-codes/{code}to manage QR codes and managed links. - Block API: added
POST /api/v1/block,DELETE /api/v1/blockandGET /api/v1/blockto block, unblock and list blocked users. - Business profile: added
GET /api/v1/profile,PATCH /api/v1/profile,POST /api/v1/profile/nameandPOST /api/v1/profile/phototo read and update the WhatsApp business profile. - Media delete: added
DELETE /api/v1/media/{mediaId}to delete uploaded media from Meta. - New message types: added
POST /api/v1/send/location(location pin) andPOST /api/v1/send/contacts(contact cards);POST /api/v1/send/medianow acceptstype: "sticker"; andPOST /api/v1/send/textaccepts an optionalpreviewUrlboolean to enable link previews. - Webhook redelivery: deliveries can be replayed from the dashboard; a replay carries the
X-ZapClaw-Redeliveryheader so it is not confused with the original. - Webhook ordering:
message.receivednow carries a top-levelsequence, a strictly increasing integer for ordering messages that share the same second-granularitydata.timestamp. - Media download & upload: added
GET /api/v1/media/{mediaId}to download received media andPOST /api/v1/mediato upload files for sending. - Message & number lookups: added
GET /api/v1/send/messages/{id}for single-message lookup,GET /api/v1/send/numbersfor connected numbers, andPOST /api/v1/send/readfor read receipts. - Rich messaging: added
POST /api/v1/send/typing(typing indicator),POST /api/v1/send/reaction(emoji reactions),POST /api/v1/send/interactive(interactive messages), and the optionalreplyTofield for quoted replies on the text, template and media send endpoints. - Enriched webhook payload:
message.receivednow includesmedia_urlon media objects,contact_name, replycontext, and click-to-WhatsAppreferralattribution. - Account events: added the
account.<field>webhook event family for WABA-level alerts (bans, restrictions, phone number quality, template status).