Send WhatsApp messages from your own systems
A REST API over the official WhatsApp Business Platform. Send template and session messages, upload media, manage contacts, and receive every inbound message and status change on your own server through signed webhooks.
Introduction
Everything lives under one base URL. All ids are UUIDs, all timestamps are ISO 8601 in UTC, and every request and response is JSON unless you are uploading a file.
https://chatlineapi.codecano.com/v1The API is versioned in the path. v1 will not change in a breaking way; new fields may be added, so parse JSON leniently and ignore what you do not recognise.
Quickstart
-
Create an API key
In the dashboard, open Settings → Developers and create a key. It is shown once, so store it somewhere safe. Give it only the scopes you need —
messages:sendis enough to send. -
Send your first message
Use an approved template, so it works whether or not the customer has messaged you recently.
bashcurl -X POST https://chatlineapi.codecano.com/v1/messages \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+919876543210", "type": "template", "template": { "name": "order_update", "language": "en", "body": [ { "type": "text", "text": "Priya" }, { "type": "text", "text": "#84512" } ] } }'javascriptconst res = await fetch("https://chatlineapi.codecano.com/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.WA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: "+919876543210", type: "template", template: { name: "order_update", language: "en", body: [ { type: "text", text: "Priya" }, { type: "text", text: "#84512" }, ], }, }), }); const message = await res.json(); if (!res.ok) throw new Error(message.error.message); console.log(message.id, message.status); // → "3f1b9c22-…" "queued"php<?php $ch = curl_init('https://chatlineapi.codecano.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('WA_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'to' => '+919876543210', 'type' => 'template', 'template' => [ 'name' => 'order_update', 'language' => 'en', 'body' => [ ['type' => 'text', 'text' => 'Priya'], ['type' => 'text', 'text' => '#84512'], ], ], ]), ]); $message = json_decode(curl_exec($ch), true); if (curl_getinfo($ch, CURLINFO_HTTP_CODE) >= 400) { throw new Exception($message['error']['message']); } echo $message['id'];pythonimport os, requests res = requests.post( "https://chatlineapi.codecano.com/v1/messages", headers={"Authorization": f"Bearer {os.environ['WA_API_KEY']}"}, json={ "to": "+919876543210", "type": "template", "template": { "name": "order_update", "language": "en", "body": [ {"type": "text", "text": "Priya"}, {"type": "text", "text": "#84512"}, ], }, }, timeout=15, ) message = res.json() if not res.ok: raise RuntimeError(message["error"]["message"]) print(message["id"], message["status"]) # → 3f1b9c22-… queued -
Receive replies
Add a webhook endpoint under Settings → Developers → Webhooks and subscribe to
message.receivedandmessage.status. See Webhooks for the payloads and how to verify the signature.
Authentication
Send your API key as a bearer token on every request. Keys look like wa_live_ followed by 48 hexadecimal characters.
Authorization: Bearer wa_live_0123abcd4567ef89...Keys belong to a workspace, not to a user, and carry scopes that limit what they can do. A request with a key that lacks the required scope is refused with 403 and a message naming the missing scope.
| Scope | Grants |
|---|---|
| messages:send | Send messages and upload nothing else |
| messages:read | Look up the status of a message you sent |
| templates:read | List and read your approved templates |
| contacts:read | List and read contacts |
| contacts:write | Create, update and delete contacts |
| media:write | Upload files to attach to messages |
| calls:read | Read the log of WhatsApp voice calls |
Keep keys on your server
A key can send messages on your behalf and read your contacts. Never put one in a browser, a mobile app or a public repository. If a key leaks, revoke it in the dashboard — it stops working immediately.
Rate limits
Each key has its own limit, 60 requests per minute by default, adjustable per key in the dashboard. Every response tells you where you stand.
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1789000060Exceeding the limit returns 429 with a Retry-After header in seconds. Wait that long and retry; do not retry immediately in a loop.
This limit is about your API usage. WhatsApp separately limits how fast a phone number can send and how many conversations you may start per day — the platform handles both for you by queueing and pacing messages, which is why a send returns 202 rather than blocking.
Errors
Every failure has the same shape. Show message to a human; branch on details.code when you need to handle a specific case.
{
"error": {
"code": "conflict",
"message": "The 24-hour window is closed. Only approved template messages can be sent until the customer replies.",
"details": { "code": "window_closed" },
"requestId": "9f2a1c44-7b0e-4d33-8a11-6c5e2f7b9d04"
}
}| Status | Meaning |
|---|---|
| 400 | The request body failed validation, or a business rule rejected it. details names the field or the rule. |
| 401 | Missing, unknown, revoked or expired API key. |
| 403 | The key lacks the required scope, or your plan does not include API access. |
| 404 | No such resource in your workspace. Ids from another workspace always look like this. |
| 409 | A WhatsApp rule blocks the send. See details.code: window_closed, opted_out, number_inactive, account_disconnected. |
| 413 | File too large. |
| 429 | Rate limit exceeded. Respect Retry-After. |
| 5xx | Our problem. Retry with backoff; quote requestId if it persists. |
Always log requestId. It identifies the exact request in our logs and is the fastest way for support to find what happened.
WhatsApp rules you cannot avoid
These are Meta's rules, not ours. They apply to every message, whether you send it from this API, the dashboard or a campaign.
The 24-hour window
You may send any message type within 24 hours of the customer's last message to you. Outside that window only an approved template is allowed, and attempting anything else returns 409 with details.code = "window_closed".
Only a message from the customer opens or extends the window. Your own messages never do.
- Opt-outs are binding. A customer who stops marketing messages, by replying STOP or through WhatsApp's own setting, cannot receive marketing templates. Sends are refused with
opted_out. Utility and authentication templates still reach them. - Templates must be approved. Create and submit them in the dashboard; Meta reviews them. Sending an unapproved template fails with error
132001. - Quality affects throughput. Meta lowers your messaging limit if people block or report your messages. Watch
account.alertwebhooks.
Sending messages
One endpoint sends every message type. The type field decides which object is required alongside it. If your workspace has more than one connected number, add from with the number's id from GET /phone-numbers; with a single number you can leave it out.
Template message
The only kind you can send outside the 24-hour window. The template must already be approved on your WhatsApp account. Parameters fill {{1}}, {{2}} in order, or by name for templates with named parameters.
POST https://chatlineapi.codecano.com/v1/messages{
"to": "+919876543210",
"type": "template",
"template": {
"name": "order_update",
"language": "en",
"body": [
{ "type": "text", "text": "Priya" },
{ "type": "text", "text": "#84512" }
]
}
}Text
A session message: only accepted within 24 hours of the customer’s last message. Set previewUrl to render a link preview.
POST https://chatlineapi.codecano.com/v1/messages{
"to": "+919876543210",
"type": "text",
"text": { "body": "Your order ships tomorrow.", "previewUrl": false }
}Image, video, document
Upload the file first with POST /media, then send the id it returns. Audio and stickers take no caption; documents take a filename.
POST https://chatlineapi.codecano.com/v1/messages{
"to": "+919876543210",
"type": "image",
"image": { "mediaId": "8f2c1a44-9d0e-4b77-8e3a-6c1d2f5b7a91", "caption": "Your receipt" }
}Location
Coordinates in decimal degrees. The name and address are shown on the map card in WhatsApp.
POST https://chatlineapi.codecano.com/v1/messages{
"to": "+919876543210",
"type": "location",
"location": { "latitude": 18.5204, "longitude": 73.8567, "name": "Pickup point", "address": "FC Road, Pune" }
}What you get back
Every send returns 202 Accepted with the stored message. It has not reached WhatsApp yet — status is queued and wamid is null until Meta accepts it. Watch it with message status.
{
"id": "3f1b9c22-0a44-4c1e-9f77-2d5e8b1a6c30",
"status": "queued",
"type": "template",
"to": "919876543210",
"conversationId": "b7a1c9e4-2f31-4a88-9c0d-5e6f7a8b9c01",
"contactId": "c2d4e6f8-1a3b-4c5d-8e9f-0a1b2c3d4e5f",
"wamid": null,
"errorCode": null,
"errorMessage": null,
"createdAt": "2026-09-14T10:00:00.000Z",
"sentAt": null,
"deliveredAt": null,
"readAt": null,
"failedAt": null
}Message status
A send returns immediately with status: "queued". The message then moves through WhatsApp's lifecycle:
queued— stored by us, waiting its turn in the send queue.accepted— WhatsApp took it and assigned awamid.sent,delivered,read— reported by WhatsApp.readonly arrives if the customer has read receipts enabled.failed— final.errorCodeanderrorMessageexplain why; see error codes.heldorpaused— WhatsApp is holding the message for a quality review, or the number is paused.
Prefer webhooks over polling. Subscribe to message.status and you are told about every transition. Polling GET /messages/{id} is fine for a one-off check but wasteful at volume.
WhatsApp error codes
When WhatsApp refuses or fails a message, we pass its numeric code through as errorCode, with a plain-language errorMessage. These are the ones you will actually meet.
Message blocked before it left
These come back on POST /messages itself, as a 409 or 400. Nothing was sent and nothing was charged.
| Code | Meaning | What to do |
|---|---|---|
| 131047 | 24-hour window closed More than 24 hours have passed since this contact last replied. | Send an approved template instead. |
| 131050 | Contact opted out This contact stopped marketing messages from your business inside WhatsApp. | They are excluded from campaigns until they opt back in. |
| 131049 | Marketing limit for this user WhatsApp is limiting marketing messages to this user right now. | Retry after 24 hours. |
Delivery failed at WhatsApp
The message was accepted, then failed. You see these on GET /messages/{id} and in the message.status webhook.
| Code | Meaning | What to do |
|---|---|---|
| 131026 | Cannot deliver This number cannot receive WhatsApp messages (not on WhatsApp, outdated app, or has not accepted the terms). | — |
| 131021 | Same number You cannot message your own WhatsApp number. | — |
| 130403 | Contact blocked You have blocked this contact on WhatsApp. | — |
| 130497 | Country restriction Meta does not allow this account to message users in this country. | — |
| 131051 | Unsupported message type This message type is not supported. | — |
Template problems
Fix the template or the parameters you send with it.
| Code | Meaning | What to do |
|---|---|---|
| 132000 | Template variables mismatch The number of variables does not match the template: … | — |
| 132001 | Template not found The template does not exist in this language or is not approved. | — |
| 132005 | Template text too long The template text is too long after filling variables. | — |
| 132007 | Template policy violation The template content violates WhatsApp policy. | — |
| 132012 | Template variable format A template variable is formatted incorrectly: … | — |
| 132015 | Template paused Meta paused this template for low quality. | Edit it or use another template. |
| 132016 | Template disabled Meta permanently disabled this template for low quality. | Create a new template. |
Rate limits and temporary failures
Handled and retried automatically by the platform. You do not need to retry these yourself.
| Code | Meaning | What to do |
|---|---|---|
| 130429 | Sending too fast This number is sending faster than Meta allows. | Slowing down and retrying. |
| 131056 | Too many messages to this contact Too many messages were sent to this contact in a short time. | Retrying. |
| 131057 | Number in maintenance Meta is upgrading this number's capacity. | Retrying in a minute. |
| 131016 | Meta is temporarily unavailable A Meta service is temporarily unavailable. | Retrying. |
| 80007 | Meta rate limit This WhatsApp account hit Meta's API rate limit. | Retrying later. |
Account problems
These need attention in the dashboard or in WhatsApp Manager; sending stays blocked until they are resolved.
| Code | Meaning | What to do |
|---|---|---|
| 131042 | Payment problem Meta could not bill this message: … | Add or fix a payment method in WhatsApp Manager > Billing. |
| 131037 | Display name not approved Meta will not deliver until this number's display name is approved. | Check the display name status. |
| 131045 | Number not registered This number is not registered for the Cloud API. | Complete registration under WhatsApp > Numbers. |
| 131048 | Number paused by Meta Meta paused this number because too many people blocked or reported it. | Check your quality rating and review recent campaigns. |
| 131031 | Account restricted Meta has restricted or disabled this WhatsApp account: … | Check Account Health and your Meta Business Manager. |
| 190 | WhatsApp connection expired Your WhatsApp access token has expired or was revoked. | Click "Reconnect WhatsApp". |
Media problems
Check the file type and size against WhatsApp’s limits before uploading.
| Code | Meaning | What to do |
|---|---|---|
| 131052 | Media download failed Meta could not process the media (size or type): … | — |
| 131053 | Media upload failed Meta rejected the media file (check the file type): … | — |
API reference
Generated from the same schemas the API validates against, so it cannot drift from the implementation. The machine-readable version is at openapi.json and imports directly into Postman or Insomnia.
Messages
POST/messages
Send a message
Template messages can be sent any time. Session messages (text, media, location) are only accepted within 24 hours of the customer's last message; otherwise the API answers 409 with details.code = "window_closed". Marketing templates to opted-out contacts answer 409 "opted_out". The message is queued and sent asynchronously; poll GET /messages/{id} or subscribe to the message.status webhook.
Body
SendMessageResponses
- 202 Queued → Message
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 409 window_closed | opted_out | number_inactive | account_disconnected
- 429 Rate limit exceeded (see Retry-After)
GET/messages/{id}
Get message status
Parameters
| id pathrequired | string |
Responses
- 200 OK → Message
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
Media
POST/media
Upload a file
multipart/form-data with a single field "file". Images ≤ 5 MB, video/audio ≤ 16 MB, documents ≤ 100 MB (WhatsApp limits). Use the returned id as mediaId.
Body
| filerequired | string binary |
Responses
- 201 Stored → Media
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 413 Too large
- 415 Unsupported type
- 429 Rate limit exceeded (see Retry-After)
Templates
GET/templates
List templates
Parameters
| status query | string APPROVED, PENDING, REJECTED, PAUSED … |
| category query | string |
| search query | string |
| page queryrequired | integer |
| pageSize queryrequired | integer |
Responses
- 200 OK
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
GET/templates/{id}
Get a template
Parameters
| id pathrequired | string |
Responses
- 200 OK → Template
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
Contacts
GET/contacts
List contacts
Parameters
| search query | string Name or phone |
| page queryrequired | integer |
| pageSize queryrequired | integer |
Responses
- 200 OK
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
POST/contacts
Create a contact
Body
CreateContactResponses
- 201 Created → Contact
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 409 Number already exists (details.contactId)
- 429 Rate limit exceeded (see Retry-After)
GET/contacts/{id}
Get a contact
Parameters
| id pathrequired | string |
Responses
- 200 OK → Contact
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
PATCH/contacts/{id}
Update a contact
Parameters
| id pathrequired | string |
Body
UpdateContactResponses
- 200 OK → Contact
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
DELETE/contacts/{id}
Delete a contact
Parameters
| id pathrequired | string |
Responses
- 204 Deleted
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
Numbers
GET/phone-numbers
List connected numbers
Responses
- 200 OK
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
Events
POST/events
Record an event
Tell us something happened in your systems. The contact is named by contactId, or by phone — in which case they are created if new, unless createContact is false. Send an idempotencyKey and a retried delivery lands once: the second call answers 200 with created = false instead of 201, and starts no automation. Events with a matching trigger start a flow; the payload becomes the variables of that flow.
Body
RecordEventResponses
- 200 Already recorded under this idempotencyKey (created = false) → Event
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
GET/events
List events
Parameters
| name query | string |
| contactId query | string |
| days queryrequired | integer |
| page queryrequired | integer |
| pageSize queryrequired | integer |
Responses
- 200 OK
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
Calls
GET/calls
List calls
Newest first. Calls are answered and placed by people in the dashboard; this is their log. Subscribe to the call.received and call.ended webhooks to hear about them as they happen.
Parameters
| direction query | string inbound = the customer called; outbound = your team called |
| status query | string |
| contactId query | string |
| page queryrequired | integer |
| pageSize queryrequired | integer |
Responses
- 200 OK
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
GET/calls/{id}
Get a call
Parameters
| id pathrequired | string |
Responses
- 200 OK → Call
- 400 Validation error or business rule (details.code)
- 401 Missing, unknown, revoked or expired API key
- 403 Scope missing or feature not in plan
- 404 Not found in this workspace
- 429 Rate limit exceeded (see Retry-After)
Schemas
SendMessage
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| typerequired | string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| templaterequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||
| typerequired | string | ||||
| textrequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||||
| typerequired | string | ||||||
| imagerequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||||
| typerequired | string | ||||||
| videorequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||
| typerequired | string | ||||
| audiorequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||||
| typerequired | string | ||||||
| documentrequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||
| typerequired | string | ||||
| stickerrequired |
|
| torequired | string Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country. | ||||||||
| from | string Sender: the phone number id from GET /v1/phone-numbers (our id or the Meta phone_number_id). Optional when the workspace has one active number. | ||||||||
| typerequired | string | ||||||||
| locationrequired |
|
Message
| idrequired | string uuid |
| statusrequired | string ("queued" | "sending" | "accepted" | "held" | "paused" | "sent" | "delivered" | "read" | "played" | "failed") queued → accepted → sent → delivered → read; failed carries errorCode |
| typerequired | string |
| torequired | string WhatsApp id of the recipient (digits) |
| conversationIdrequired | string uuid |
| contactIdrequired | string uuid |
| wamidrequired | string,null Meta message id once accepted |
| errorCoderequired | One of: integer null |
| errorMessagerequired | string,null |
| createdAtrequired | string date-time |
| sentAtrequired | One of: string null |
| deliveredAtrequired | One of: string null |
| readAtrequired | One of: string null |
| failedAtrequired | One of: string null |
Media
| idrequired | string uuid Use as mediaId when sending |
| mimeTyperequired | string |
| sizeBytesrequired | integer |
| originalNamerequired | string,null |
| createdAtrequired | string date-time |
Template
| idrequired | string uuid |
| namerequired | string |
| languagerequired | string |
| categoryrequired | string |
| statusrequired | string |
| parameterFormatrequired | string ("POSITIONAL" | "NAMED") |
| componentsrequired | array of object Meta template components (HEADER/BODY/FOOTER/BUTTONS) |
| qualityScorerequired | string,null |
| updatedAtrequired | string date-time |
Contact
| idrequired | string uuid | ||||||
| waIdrequired | string | ||||||
| phoneE164required | string | ||||||
| namerequired | string,null | ||||||
| profileNamerequired | string,null | ||||||
| attributesrequired | object | ||||||
| tagsrequired | array of
| ||||||
| optInrequired | boolean | ||||||
| optedOutrequired | boolean | ||||||
| optOutSourcerequired | string,null | ||||||
| lastInboundAtrequired | One of: string null | ||||||
| createdAtrequired | string date-time |
Call
| idrequired | string uuid | ||||||
| directionrequired | string ("inbound" | "outbound") | ||||||
| statusrequired | string ("ringing" | "connecting" | "in_progress" | "completed" | "missed" | "rejected" | "failed") missed = nobody answered (either direction); rejected = declined | ||||||
| contactrequired |
| ||||||
| phoneNumberIdrequired | string uuid Which of your numbers the call was on | ||||||
| conversationIdrequired | One of: string null | ||||||
| userIdrequired | One of: string null The teammate who answered or placed the call | ||||||
| userNamerequired | string,null | ||||||
| startedAtrequired | string date-time When it started ringing | ||||||
| answeredAtrequired | One of: string null | ||||||
| endedAtrequired | One of: string null | ||||||
| durationSecondsrequired | One of: integer null Null when the call was never answered | ||||||
| errorCoderequired | One of: integer null Meta error code when the call failed | ||||||
| errorMessagerequired | string,null | ||||||
| hasRecordingrequired | boolean |
CreateContact
| phonerequired | string Phone number (E.164 or local digits of the workspace country) |
| name | string |
| attributes | object Free-form attributes used as template variables in campaigns Free-form attributes used as template variables in campaigns |
| optIn | boolean Record marketing consent collected by you |
| optInSource | string |
RecordEvent
| namerequired | string |
| payloadrequired | object |
| idempotencyKey | string |
| occurredAt | string date-time |
| contactIdrequired | string uuid |
| namerequired | any |
| payloadrequired | object |
| idempotencyKey | string |
| occurredAt | string date-time |
| phonerequired | string |
| createContactrequired | boolean |
Event
| idrequired | string uuid |
| namerequired | string Normalised: lower-cased, spaces collapsed |
| contactIdrequired | string uuid |
| payloadrequired | object |
| occurredAtrequired | string date-time |
| createdrequired | boolean False when an idempotencyKey matched an event we already had |
EventListItem
| idrequired | string uuid |
| namerequired | string |
| contactIdrequired | string uuid |
| payloadrequired | object |
| occurredAtrequired | string date-time |
| createdAtrequired | string date-time |
UpdateContact
| name | One of: string null |
| attributes | object Replaces all attributes Replaces all attributes |
| optIn | boolean |
| optInSource | One of: string null |
| optedOut | boolean true = exclude from marketing; false clears keyword/manual/api opt-outs only |
PhoneNumber
| idrequired | string uuid Use as "from" |
| phoneNumberIdrequired | string Meta phone_number_id (also accepted as "from") |
| displayPhoneNumberrequired | string |
| verifiedNamerequired | string,null |
| isActiverequired | boolean |
| qualityRatingrequired | string,null |
WebhookPayload
| idrequired | string uuid Delivery id (idempotency key: retries reuse it) |
| eventrequired | string ("message.received" | "message.status" | "template.status" | "contact.opted_out" | "account.alert" | "call.received" | "call.ended" | "ping") |
| createdAtrequired | string date-time |
| organizationId | string uuid |
| datarequired | object Event-specific body (see the docs page) Event-specific body (see the docs page) |
Error
| errorrequired |
|
Webhooks
Register an endpoint under Settings → Developers → Webhooks, choose the events you care about, and copy the signing secret — it is shown once. We then POST JSON to your URL whenever something happens.
POST https://your-server.example/webhooks/whatsapp
Content-Type: application/json
X-Webhook-Id: 7c1e9a02-4b33-4f81-9d20-8e5a1b6c3d47
X-Webhook-Event: message.received
X-Webhook-Timestamp: 1789000000
X-Webhook-Signature: t=1789000000,v1=9f86d081884c7d659a2feaa0c55ad015...
{
"id": "7c1e9a02-4b33-4f81-9d20-8e5a1b6c3d47",
"event": "message.received",
"createdAt": "2026-09-14T10:00:00.000Z",
"organizationId": "a3b5c7d9-...",
"data": { }
}Two rules for your endpoint
Answer 2xx within 10 seconds. Do the real work afterwards — queue it, then respond. Anything slower counts as a failure and is retried.
Deduplicate on X-Webhook-Id. Retries and manual replays reuse the same id, so you may receive the same event more than once.
Verify the signature
Never trust an unsigned request. The header is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Compute it over the raw bytes, before any JSON parsing, compare in constant time, and reject timestamps older than five minutes.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
// The raw body is required: parse it as a Buffer, not JSON.
app.post('/webhooks/whatsapp', express.raw({ type: '*/*' }), (req, res) => {
const header = req.get('X-Webhook-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const raw = req.body.toString('utf8');
// Reject anything older than five minutes (replay protection).
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(401);
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${parts.t}.${raw}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);
const event = JSON.parse(raw);
// Use event.id for idempotency: retries reuse the same id.
console.log(event.event, event.data);
res.sendStatus(200); // answer within 10 seconds
});<?php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE']), $sig); // t=..., v1=...
if (abs(time() - (int) $sig['t']) > 300) { http_response_code(401); exit; }
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig['v1'])) { http_response_code(401); exit; }
$event = json_decode($raw, true);
// $event['id'] is the idempotency key; retries reuse it.
error_log($event['event']);
http_response_code(200);import hmac, hashlib, time, json
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/whatsapp")
def receive():
raw = request.get_data() # bytes, before any parsing
parts = dict(p.split("=", 1) for p in request.headers["X-Webhook-Signature"].split(","))
if abs(time.time() - int(parts["t"])) > 300: # replay protection
return "", 401
expected = hmac.new(
WEBHOOK_SECRET.encode(),
f"{parts['t']}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts["v1"]):
return "", 401
event = json.loads(raw) # event["id"] is the idempotency key
print(event["event"], event["data"])
return "", 200Event payloads
The envelope is always the same; data changes per event.
message.received
A customer sends you a message.
{
"messageId": "a1b2c3d4-...",
"conversationId": "b7a1c9e4-...",
"contact": { "id": "c2d4e6f8-...", "waId": "919876543210", "phoneE164": "+919876543210", "name": "Priya" },
"phoneNumberId": "d3e5f7a9-...",
"type": "text",
"content": { "body": "Is my order ready?" },
"wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgU...",
"replyToWamid": null,
"timestamp": "2026-09-14T10:00:00.000Z"
}message.status
A message you sent was delivered, read or failed.
{
"messageId": "3f1b9c22-...",
"wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS...",
"conversationId": "b7a1c9e4-...",
"contactId": "c2d4e6f8-...",
"status": "delivered",
"errorCode": null,
"errorMessage": null,
"timestamp": "2026-09-14T10:00:05.000Z"
}template.status
Meta approves, rejects, pauses or disables one of your templates.
{
"templateId": "e4f6a8b0-...",
"name": "order_update",
"language": "en",
"status": "APPROVED",
"event": "APPROVED",
"reason": null
}contact.opted_out
A customer opts out of marketing, by keyword, by button, inside WhatsApp, or because Meta refused a marketing send.
{
"contactId": "c2d4e6f8-...",
"waId": "919876543210",
"source": "keyword",
"at": "2026-09-14T10:00:00.000Z"
}account.alert
Something needs attention on your WhatsApp account: quality drop, limit change, token expiry.
{
"alertId": "f5a7b9c1-...",
"severity": "critical",
"event": "TOKEN_INVALID",
"title": "WhatsApp connection lost",
"detail": "Reconnect WhatsApp to continue sending.",
"phoneNumberId": null
}call.received
A customer calls one of your numbers on WhatsApp and it starts ringing in the dashboard.
{
"callId": "0d9b6f1e-...",
"direction": "inbound",
"status": "ringing",
"contact": { "id": "c2d4e6f8-...", "waId": "919876543210", "phoneE164": "+919876543210", "name": "Priya" },
"phoneNumberId": "d3e5f7a9-...",
"conversationId": "b7a1c9e4-...",
"userId": null,
"startedAt": "2026-09-30T10:00:00.000Z",
"answeredAt": null,
"endedAt": null,
"durationSeconds": null,
"errorCode": null
}call.ended
A call is over, in either direction. Sent once per call. status is completed, missed (nobody answered), rejected or failed. When a teammate hangs up, durationSeconds is our own measurement; GET /calls/{id} later carries the figure Meta reports.
{
"callId": "0d9b6f1e-...",
"direction": "inbound",
"status": "completed",
"contact": { "id": "c2d4e6f8-...", "waId": "919876543210", "phoneE164": "+919876543210", "name": "Priya" },
"phoneNumberId": "d3e5f7a9-...",
"conversationId": "b7a1c9e4-...",
"userId": "a8c0e2f4-...",
"startedAt": "2026-09-30T10:00:00.000Z",
"answeredAt": "2026-09-30T10:00:08.000Z",
"endedAt": "2026-09-30T10:02:22.000Z",
"durationSeconds": 134,
"errorCode": null
}Retries and failures
If your endpoint does not answer with a 2xx, we retry on a fixed schedule:
Every attempt is recorded with the HTTP status and the first 500 characters of your response, visible under Developers → Delivery log, where you can also replay a delivery once your server is fixed. After 10 consecutive exhausted deliveries the endpoint is disabled automatically and the workspace is notified.