Overview
The API is organised around a small number of predictable HTTP endpoints. Every request and response body is JSON. You authenticate with a secret API key that belongs to one WhatsApp Chat CRM account (a tenant), so a key can only ever read and write that account's data.
external_id, tag them, and read their conversations.Quick start
- Sign in and open Settings → Developer API, then click
Generate Key. Copy the key — it starts with
whsec_. (Owners can do this; a manager needs the Manage API Keys permission.) - Make sure WhatsApp is connected under Meta Settings — message endpoints need it.
- Send your first request:
A successful call returns 201 (created) or 200 (already
existed and was updated):
{
"success": true,
"isNewContact": true,
"contactId": "329cfdf6-a303-44b9-bffc-fc3336caafb5"
}
Authentication
Send your key in the x-api-key header on every request.
x-api-key: whsec_your_secret_key_here
| Situation | Result |
|---|---|
| Header missing | 401 Unauthorized: Missing x-api-key header. |
| Wrong or revoked key | 403 Forbidden: Invalid or revoked API key. |
| Subscription expired | 402 SUBSCRIPTION_EXPIRED — every endpoint pauses until the owner recharges |
A key identifies the account, not a person, and has full access to every endpoint on this page. Create one key per integration so you can revoke a single one without downtime. Revocation is immediate.
Conventions & limits
| Topic | Rule |
|---|---|
| Format | JSON in and out. Send Content-Type: application/json. UTF-8. |
| Phone numbers | Digits only, including the country code, no +, no
leading zeros: 919876543210. Spaces, dashes and + are
stripped, but a missing country code is not guessed. |
| Timestamps | ISO-8601 in UTC, e.g. 2026-09-18T12:44:24.000Z. |
| Rate limit | 300 requests per minute per account, shared by all keys. Over the
limit you get 429 with standard RateLimit-* headers. |
| Body size | Max 100 KB per request. Split big bulk batches (about 100 recipients per call is a good size). |
| Timeouts | Sends are made to WhatsApp inline: roughly 0.3–2 s each. Bulk calls take about that × recipients — use a client timeout of 60 s or more. |
| 24-hour window | WhatsApp allows free-text only within 24 h of the customer's last message. Outside it, or for brand-new customers, use a template. |
Data types
Shared shapes used across endpoints, written as TypeScript so they can be pasted into your project.
type PhoneNumber = string; // digits incl. country code: "919876543210"
type Timestamp = string; // ISO-8601 UTC
type WhatsAppMessageId = string; // Meta "wamid", e.g. "wamid.HBgMOTE5ODc2NTQz..."
interface ErrorResponse {
error: string; // code or human-readable message
message?: string; // extra explanation on some errors
[extra: string]: unknown; // e.g. useTemplateEndpoint, expires_at
}
interface BulkResultItem {
phone: PhoneNumber;
success: boolean;
messageId?: WhatsAppMessageId; // when success = true
error?: string; // when success = false
}
interface Customer {
id: string; // UUID
name: string | null;
phone_number: PhoneNumber;
last_interacted_at: Timestamp | null;
created_at: Timestamp;
}
interface Message {
id: string;
wamid: WhatsAppMessageId | null;
context_wamid: WhatsAppMessageId | null; // set when replying to another message
direction: "inbound" | "outbound";
message_type: "text" | "image" | "video" | "document" | "audio" | "template" | "interactive" | string;
body: string | null; // JSON *string* when message_type = "template"
media_url: string | null; // signed relative link, valid 10 minutes
status: "sent" | "delivered" | "read" | "failed" | "received" | string;
is_read: 0 | 1;
created_at: Timestamp;
}
Send a message
Sends a plain-text WhatsApp message. Works only while the customer's 24-hour
window is open (they messaged you in the last 24 h). Otherwise you get
409 WINDOW_CLOSED — fall back to Send a template.
Request body
| Field | Type | Description | |
|---|---|---|---|
to |
string | required | Recipient phone number, e.g. 919876543210. |
message |
string | required | Message text (WhatsApp allows up to about 4096 characters). |
Responses
{ "success": true, "messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS..." }
{
"error": "WINDOW_CLOSED",
"message": "This customer's 24-hour session window is closed. Send an approved template via /v1/messages/send-template instead, or wait for the customer to message first.",
"useTemplateEndpoint": "/v1/messages/send-template"
}
| Status | When |
|---|---|
| 400 | to or message missing; or WhatsApp isn't connected for this account. |
| 409 | 24-hour window closed, or a brand-new customer. Use a template. |
| 500 | WhatsApp rejected it (invalid number, not on WhatsApp…). error carries Meta's own message. |
messageId and check before retrying, or
you may deliver a duplicate.Send a template
Sends a Meta-approved message template. Templates work at any time, including to customers who have never written to you — this is how you start a conversation. Create and approve templates in the dashboard first.
Request body
| Field | Type | Description | |
|---|---|---|---|
to |
string | required | Recipient phone number. |
templateName |
string | required | Exact approved template name, e.g. order_update. |
languageCode |
string | optional | Default en_US. Must match the template's approved language (en, hi, en_GB…). |
variables |
string[] | optional | Values for the body placeholders in order: variables[0] → {{1}}, variables[1] → {{2}}… The count must equal the template's placeholders. |
Responses
{ "success": true, "messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS..." }
| Status | When |
|---|---|
| 400 | to / templateName missing, or WhatsApp not connected. |
| 500 | Meta rejected it — typically template does not exist, not approved yet, or parameter count mismatch. |
Bulk send messages
Sends the same text to many recipients. The 24-hour rule is applied per recipient: anyone whose window is closed is skipped and reported as a failed item — they never fail the whole batch. Recipients are processed one after another.
Request body
| Field | Type | Description | |
|---|---|---|---|
recipients |
string[] | required | Non-empty array of phone numbers. |
message |
string | required | Text sent to everyone. |
Response
{
"total": 2,
"results": [
{ "phone": "919876543210", "success": true, "messageId": "wamid.HBg..." },
{ "phone": "919876543211", "success": false,
"error": "WINDOW_CLOSED: 24-hour session window is closed for this recipient — use /v1/messages/bulk-template instead." }
]
}
results[] and read success per recipient. A 400
is returned only if recipients/message are missing or WhatsApp isn't
connected.Bulk send templates
Sends one template to many recipients, each with its own variables — ideal for renewal reminders and order updates. Works regardless of the 24-hour window.
Request body
| Field | Type | Description | |
|---|---|---|---|
templateName |
string | required | Approved template name. |
languageCode |
string | optional | Default en_US. |
batch |
{ to: string; variables?: string[] }[] | required | Non-empty. Each item: recipient to and that recipient's variables. |
Response
{
"total": 2,
"results": [
{ "phone": "919876543210", "success": true, "messageId": "wamid.HBg..." },
{ "phone": "919876543211", "success": false, "error": "(#132000) Number of parameters does not match the expected number of params" }
]
}
List customers
Returns every customer of the account, most recently active first. No query parameters.
Response
{
"count": 1,
"customers": [
{
"id": "329cfdf6-a303-44b9-bffc-fc3336caafb5",
"name": "Asha Verma",
"phone_number": "919876543210",
"last_interacted_at": "2026-09-18T12:44:24.000Z",
"created_at": "2026-09-10T08:01:00.000Z"
}
]
}
Create or update a customer
An upsert: adds a customer, or updates the one that already exists. Use it from lead forms, landing pages, billing systems — anything that discovers a customer.
How a customer is matched
external_idequals an existing customer's → that customer is updated.- Otherwise
phone_numberequals an existing customer → updated, and theexternal_idis attached if they had none. - Otherwise a new customer is created.
Request body
| Field | Type | Description | |
|---|---|---|---|
phone_number |
string | required | Digits incl. country code. |
full_name |
string | optional | Max 100 characters. |
alternate_phone_number |
string | optional | A second number, digits only. |
email |
string | optional | Max 191 characters. Not format-validated. |
address |
string | optional | Max 5000 characters. |
external_id |
string | optional | Your own id for this customer (subscription id, account number…). Max 191 chars, must be unique per account. Lets later calls target the customer without their phone number. |
field_data |
{ name: string; value: string }[] | optional | Free-form extra details (same shape as Meta Lead Ads). Name ≤ 100, value ≤ 2000 chars. Entries without a name are ignored. When sent, it replaces the previous custom fields as a whole. |
Responses
{ "success": true, "isNewContact": true, "contactId": "329cfdf6-a303-44b9-bffc-fc3336caafb5" }
| Status | Error |
|---|---|
| 400 | Parameter "phone_number" is required. |
| 400 | A valid phone number is required. — the value contained no digits. |
external_id with a different phone_number, the existing customer is
updated and their phone number is not changed — this endpoint never overwrites the
phone number of an existing customer.Message history
The full conversation with one customer, oldest first. Put the phone number in the URL path,
e.g. /api/v1/customers/919876543210/messages.
Path parameter
| Name | Type | Description |
|---|---|---|
:phone |
string | Customer phone number with country code. Non-digits are ignored. |
Response
{
"customer": { "id": "329cfdf6-...", "name": "Asha Verma", "phone_number": "919876543210" },
"totalMessages": 2,
"messages": [
{
"id": "b1f0...", "wamid": "wamid.HBg...", "context_wamid": null,
"direction": "inbound", "message_type": "text", "body": "Hi, is my order ready?",
"media_url": null, "status": "received", "is_read": 1,
"created_at": "2026-09-18T12:40:02.000Z"
},
{
"id": "c9a2...", "wamid": "wamid.HBg...", "context_wamid": null,
"direction": "outbound", "message_type": "template",
"body": "{\"is_template\":true,\"template_name\":\"order_update\",\"header\":\"\",\"body\":\"Hi Asha, order #4821 ships Friday.\",\"footer\":\"\",\"buttons\":[]}",
"media_url": null, "status": "delivered", "is_read": 1,
"created_at": "2026-09-18T12:44:24.000Z"
}
]
}
- Template messages:
bodyis a JSON string —JSON.parseit. Variables are already substituted. - Media:
media_urlis a relative, self-authenticating link such as/api/media/abc123.jpg?exp=…&sig=…. Prefix your base URL. It needs no API key and expires after 10 minutes — download immediately; don't store the link. - 404
Customer not found.if that number isn't a customer. No pagination: the whole history is returned.
Add or remove tags
Lets your other systems flag things on a customer. Example: your billing app sees a
plan expiring in 2 days and tags the customer 2 D Exp, so your support team sees it
in the CRM and reaches out.
Request body
| Field | Type | Description | |
|---|---|---|---|
external_id |
string | one of two | Preferred way to identify the customer (tried first). |
phone_number |
string | one of two | Fallback identifier. |
tags |
string[] | optional* | Tag names to add, all at once, on top of existing tags. Unknown names are created automatically. Case-insensitive (vip = VIP). Max 50 chars each. |
remove_tags |
string[] | optional* | Tag names to remove. Names the customer doesn't have are ignored. |
* At least one of tags / remove_tags is required. Adds are applied first, then removals.
Responses
{
"success": true,
"customer": { "id": "329cfdf6-...", "name": "Asha Verma", "phone_number": "919876543210" },
"appliedTags": ["2 D Exp", "VIP"],
"removedTags": ["Trial"]
}
| Status | Error |
|---|---|
| 400 | Provide either "external_id" or "phone_number" to identify the customer. |
| 400 | Provide at least one tag in "tags" or "remove_tags". |
| 404 | Customer not found. — create them first with Create / update customer. |
Errors
Every error is a JSON object with an error field (some add message and
extras).
| HTTP | Meaning | Retry? |
|---|---|---|
| 400 | Missing or invalid parameter, or WhatsApp not connected for this account. | No — fix the request |
| 401 | x-api-key header missing. |
No |
| 402 | SUBSCRIPTION_EXPIRED — response also includes message and expires_at. |
No — owner must recharge |
| 403 | Invalid or revoked API key. | No |
| 404 | Customer not found. | No |
| 409 | WINDOW_CLOSED — free-text not allowed outside the 24-hour window. |
No — send a template |
| 429 | Rate limit (300 requests/min/account) exceeded. | Yes — back off; see RateLimit-Reset |
| 500 | WhatsApp/Meta rejected a message (error holds Meta's text), or an internal error. Also returned for a body over 100 KB or malformed JSON (Internal Server Error). |
Only generic errors. A Meta rejection will fail again |
{
"error": "SUBSCRIPTION_EXPIRED",
"message": "Your subscription plan has expired. All inbound/outbound services and APIs are halted.",
"expires_at": "2026-09-01T00:00:00.000Z",
"rechargeEndpoint": "/api/billing/overview"
}
Incoming-message webhooks
The endpoints above are you → CRM. To be told when a customer writes to you (CRM → you), open Settings → Integrations and add a destination URL. Every incoming customer message is forwarded there as Meta's raw, unmodified WhatsApp Cloud API webhook JSON, so any existing "WhatsApp Cloud API" node, parser or library works unchanged.
| Setting | Details |
|---|---|
| URL | Any http(s) URL you control (HTTPS strongly recommended). |
| Method | POST (recommended — JSON body) or GET (payload sent as one
?payload=<url-encoded JSON> query parameter, for receivers that only accept GET). |
| Authentication | None; Bearer (Authorization: Bearer <token>); or an
API-key header (X-API-Key: <token> — header name is configurable). Plus any number of extra static headers. Check this header on your side. |
| Timeout | 8 seconds. Reply 200 immediately and process asynchronously. |
| Retries | None. A failed delivery is logged (status/error visible on the Integrations screen) but not re-sent. Be highly available, or reconcile using Message history. |
| What's sent | Only events containing a customer message (value.messages[]).
Delivery/read status updates are not forwarded — read a sent message's
status from Message history. |
| Ordering / duplicates | No ordering guarantee. De-duplicate on the message id (wamid). |
{
"object": "whatsapp_business_account",
"entry": [{
"id": "<WABA_ID>",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "918602505909", "phone_number_id": "<PHONE_NUMBER_ID>" },
"contacts": [{ "profile": { "name": "Asha Verma" }, "wa_id": "919876543210" }],
"messages": [{
"from": "919876543210",
"id": "wamid.HBgM...",
"timestamp": "1789755000",
"type": "text",
"text": { "body": "Hi, is my order ready?" }
}]
}
}]
}]
}
Use the Test button on an integration to send a sample event to your URL while you build.
Integration recipes
Billing app → tag expiring customers
for sub in subscriptions.expiring_within(days=5):
POST /api/v1/customers { phone_number: sub.phone, external_id: sub.id, full_name: sub.name } # ensure they exist
POST /api/v1/customers/tags { external_id: sub.id, tags: ["2 D Exp"] }
for sub in subscriptions.renewed_today():
POST /api/v1/customers/tags { external_id: sub.id, remove_tags: ["2 D Exp"] }
Lead form → CRM, then a welcome template
POST /api/v1/customers { phone_number, full_name, email, field_data: [ ...all other form answers... ] }
POST /api/v1/messages/send-template { to: phone_number, templateName: "welcome", variables: [full_name] }
# A new customer has no open window, so free text would return 409 — always start with a template.
Send a message with automatic fallback
r = POST /api/v1/messages/send { to, message }
if r.status == 409 and r.body.error == "WINDOW_CLOSED":
POST /api/v1/messages/send-template { to, templateName: "generic_update", variables: [message] }
Retry helper (Node.js)
Best practices
- Keep keys server-side and out of source control. Use environment variables or a secrets manager.
- One key per integration, named after it. Rotate by creating a new key, deploying, then revoking the old one.
- Use HTTPS and set sensible client timeouts (60 s for bulk).
- Retry only what is safe. Retry
429and generic5xxwith exponential back-off + jitter. Customer and tag calls are idempotent; message sends are not — check before resending. - Always inspect bulk
results[]— HTTP 200 doesn't mean every message went out. - Store your own
external_idon every customer you create so you never depend on phone numbers alone. - Authenticate your webhook receiver with the Bearer/API-key header option, and respond fast.
- Don't log full phone numbers or message bodies to shared systems without need.
Known limitations
- No pagination or filtering on List customers and Message history.
- Bulk sends run one after another inside a single HTTP request — there is no queue or job id.
- Templates support body variables only.
- A key has access to every endpoint; there are no per-endpoint scopes.
- No endpoints yet for tickets, tasks, or assigning a customer to a staff member.
- Outgoing webhooks have no automatic retries, no signature header, and don't include delivery-status events.
Changelog
| Date | Change |
|---|---|
| 2026-09-26 | Added external_id to Create / update customer; added Add / remove tags. |
| Earlier | Send message, send template, bulk send, bulk template, list customers, message history. |