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.

Base URL
https://chatlineapi.codecano.com/v1

The 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

  1. 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:send is enough to send.

  2. Send your first message

    Use an approved template, so it works whether or not the customer has messaged you recently.

    bash
    curl -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" }
          ]
        }
      }'
  3. Receive replies

    Add a webhook endpoint under Settings → Developers → Webhooks and subscribe to message.received and message.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.

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

ScopeGrants
messages:sendSend messages and upload nothing else
messages:readLook up the status of a message you sent
templates:readList and read your approved templates
contacts:readList and read contacts
contacts:writeCreate, update and delete contacts
media:writeUpload files to attach to messages
calls:readRead 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.

Response headers
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1789000060

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

409 Conflict
{
  "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"
  }
}
StatusMeaning
400The request body failed validation, or a business rule rejected it. details names the field or the rule.
401Missing, unknown, revoked or expired API key.
403The key lacks the required scope, or your plan does not include API access.
404No such resource in your workspace. Ids from another workspace always look like this.
409A WhatsApp rule blocks the send. See details.code: window_closed, opted_out, number_inactive, account_disconnected.
413File too large.
429Rate limit exceeded. Respect Retry-After.
5xxOur 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.

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.

Endpoint
POST https://chatlineapi.codecano.com/v1/messages
Request body
{
  "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.

Endpoint
POST https://chatlineapi.codecano.com/v1/messages
Request body
{
  "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.

Endpoint
POST https://chatlineapi.codecano.com/v1/messages
Request body
{
  "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.

Endpoint
POST https://chatlineapi.codecano.com/v1/messages
Request body
{
  "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.

202 Accepted
{
  "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→accepted→sent→delivered→readorfailed

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.

CodeMeaningWhat to do
13104724-hour window closed
More than 24 hours have passed since this contact last replied.
Send an approved template instead.
131050Contact opted out
This contact stopped marketing messages from your business inside WhatsApp.
They are excluded from campaigns until they opt back in.
131049Marketing 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.

CodeMeaningWhat to do
131026Cannot deliver
This number cannot receive WhatsApp messages (not on WhatsApp, outdated app, or has not accepted the terms).
—
131021Same number
You cannot message your own WhatsApp number.
—
130403Contact blocked
You have blocked this contact on WhatsApp.
—
130497Country restriction
Meta does not allow this account to message users in this country.
—
131051Unsupported message type
This message type is not supported.
—

Template problems

Fix the template or the parameters you send with it.

CodeMeaningWhat to do
132000Template variables mismatch
The number of variables does not match the template: …
—
132001Template not found
The template does not exist in this language or is not approved.
—
132005Template text too long
The template text is too long after filling variables.
—
132007Template policy violation
The template content violates WhatsApp policy.
—
132012Template variable format
A template variable is formatted incorrectly: …
—
132015Template paused
Meta paused this template for low quality.
Edit it or use another template.
132016Template 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.

CodeMeaningWhat to do
130429Sending too fast
This number is sending faster than Meta allows.
Slowing down and retrying.
131056Too many messages to this contact
Too many messages were sent to this contact in a short time.
Retrying.
131057Number in maintenance
Meta is upgrading this number's capacity.
Retrying in a minute.
131016Meta is temporarily unavailable
A Meta service is temporarily unavailable.
Retrying.
80007Meta 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.

CodeMeaningWhat to do
131042Payment problem
Meta could not bill this message: …
Add or fix a payment method in WhatsApp Manager > Billing.
131037Display name not approved
Meta will not deliver until this number's display name is approved.
Check the display name status.
131045Number not registered
This number is not registered for the Cloud API.
Complete registration under WhatsApp > Numbers.
131048Number paused by Meta
Meta paused this number because too many people blocked or reported it.
Check your quality rating and review recent campaigns.
131031Account restricted
Meta has restricted or disabled this WhatsApp account: …
Check Account Health and your Meta Business Manager.
190WhatsApp 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.

CodeMeaningWhat to do
131052Media download failed
Meta could not process the media (size or type): …
—
131053Media 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

SendMessage

Responses

  • 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 pathrequiredstring

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

filerequiredstring 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 querystring
APPROVED, PENDING, REJECTED, PAUSED …
category querystring
search querystring
page queryrequiredinteger
pageSize queryrequiredinteger

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 pathrequiredstring

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 querystring
Name or phone
page queryrequiredinteger
pageSize queryrequiredinteger

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

CreateContact

Responses

  • 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 pathrequiredstring

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 pathrequiredstring

Body

UpdateContact

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)

DELETE/contacts/{id}

Delete a contact

Parameters

id pathrequiredstring

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

RecordEvent

Responses

  • 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 querystring
contactId querystring
days queryrequiredinteger
page queryrequiredinteger
pageSize queryrequiredinteger

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 querystring
inbound = the customer called; outbound = your team called
status querystring
contactId querystring
page queryrequiredinteger
pageSize queryrequiredinteger

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 pathrequiredstring

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

One of:
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
templaterequired
namerequiredstring
Approved template name
languagestring
Template language code (en_US). Optional when the name is unique.
headerarray of
One of:
typerequiredstring
textrequiredstring
parameterNamestring
For templates with named parameters
typerequiredstring
fallbackValuerequiredstring
coderequiredstring
amount1000requiredinteger
typerequiredstring
fallbackValuerequiredstring
typerequiredstring ("image" | "video" | "document")
mediaIdrequiredstring uuid
Id returned by POST /v1/media
typerequiredstring
payloadrequiredstring
typerequiredstring
couponCoderequiredstring
bodyarray of
One of:
typerequiredstring
textrequiredstring
parameterNamestring
For templates with named parameters
typerequiredstring
fallbackValuerequiredstring
coderequiredstring
amount1000requiredinteger
typerequiredstring
fallbackValuerequiredstring
typerequiredstring ("image" | "video" | "document")
mediaIdrequiredstring uuid
Id returned by POST /v1/media
typerequiredstring
payloadrequiredstring
typerequiredstring
couponCoderequiredstring
buttonsarray of
indexrequiredinteger
subTyperequiredstring ("quick_reply" | "url" | "copy_code")
parametersrequiredarray of
One of:
typerequiredstring
textrequiredstring
parameterNamestring
For templates with named parameters
typerequiredstring
fallbackValuerequiredstring
coderequiredstring
amount1000requiredinteger
typerequiredstring
fallbackValuerequiredstring
typerequiredstring ("image" | "video" | "document")
mediaIdrequiredstring uuid
Id returned by POST /v1/media
typerequiredstring
payloadrequiredstring
typerequiredstring
couponCoderequiredstring
One of:
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
textrequired
bodyrequiredstring
previewUrlboolean
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
imagerequired
mediaIdrequiredstring uuid
Id returned by POST /v1/media
captionstring
filenamestring
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
videorequired
mediaIdrequiredstring uuid
Id returned by POST /v1/media
captionstring
filenamestring
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
audiorequired
mediaIdrequiredstring uuid
Id returned by POST /v1/media
filenamestring
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
documentrequired
mediaIdrequiredstring uuid
Id returned by POST /v1/media
captionstring
filenamestring
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
stickerrequired
mediaIdrequiredstring uuid
Id returned by POST /v1/media
filenamestring
torequiredstring
Recipient phone number in E.164 (+919876543210). Digits without + are interpreted in the workspace country.
fromstring
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.
typerequiredstring
locationrequired
latituderequirednumber
longituderequirednumber
namestring
addressstring

Message

idrequiredstring uuid
statusrequiredstring ("queued" | "sending" | "accepted" | "held" | "paused" | "sent" | "delivered" | "read" | "played" | "failed")
queued → accepted → sent → delivered → read; failed carries errorCode
typerequiredstring
torequiredstring
WhatsApp id of the recipient (digits)
conversationIdrequiredstring uuid
contactIdrequiredstring uuid
wamidrequiredstring,null
Meta message id once accepted
errorCoderequired
One of:
integer
null
errorMessagerequiredstring,null
createdAtrequiredstring date-time
sentAtrequired
One of:
string
null
deliveredAtrequired
One of:
string
null
readAtrequired
One of:
string
null
failedAtrequired
One of:
string
null

Media

idrequiredstring uuid
Use as mediaId when sending
mimeTyperequiredstring
sizeBytesrequiredinteger
originalNamerequiredstring,null
createdAtrequiredstring date-time

Template

idrequiredstring uuid
namerequiredstring
languagerequiredstring
categoryrequiredstring
statusrequiredstring
parameterFormatrequiredstring ("POSITIONAL" | "NAMED")
componentsrequiredarray of object
Meta template components (HEADER/BODY/FOOTER/BUTTONS)
qualityScorerequiredstring,null
updatedAtrequiredstring date-time

Contact

idrequiredstring uuid
waIdrequiredstring
phoneE164requiredstring
namerequiredstring,null
profileNamerequiredstring,null
attributesrequiredobject
tagsrequiredarray of
idrequiredstring uuid
namerequiredstring
colorrequiredstring
optInrequiredboolean
optedOutrequiredboolean
optOutSourcerequiredstring,null
lastInboundAtrequired
One of:
string
null
createdAtrequiredstring date-time

Call

idrequiredstring uuid
directionrequiredstring ("inbound" | "outbound")
statusrequiredstring ("ringing" | "connecting" | "in_progress" | "completed" | "missed" | "rejected" | "failed")
missed = nobody answered (either direction); rejected = declined
contactrequired
idrequiredstring uuid
namerequiredstring,null
phoneE164requiredstring
phoneNumberIdrequiredstring 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
userNamerequiredstring,null
startedAtrequiredstring 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
errorMessagerequiredstring,null
hasRecordingrequiredboolean

CreateContact

phonerequiredstring
Phone number (E.164 or local digits of the workspace country)
namestring
attributesobject Free-form attributes used as template variables in campaigns
Free-form attributes used as template variables in campaigns
optInboolean
Record marketing consent collected by you
optInSourcestring

RecordEvent

One of:
namerequiredstring
payloadrequiredobject
idempotencyKeystring
occurredAtstring date-time
contactIdrequiredstring uuid
namerequiredany
payloadrequiredobject
idempotencyKeystring
occurredAtstring date-time
phonerequiredstring
createContactrequiredboolean

Event

idrequiredstring uuid
namerequiredstring
Normalised: lower-cased, spaces collapsed
contactIdrequiredstring uuid
payloadrequiredobject
occurredAtrequiredstring date-time
createdrequiredboolean
False when an idempotencyKey matched an event we already had

EventListItem

idrequiredstring uuid
namerequiredstring
contactIdrequiredstring uuid
payloadrequiredobject
occurredAtrequiredstring date-time
createdAtrequiredstring date-time

UpdateContact

name
One of:
string
null
attributesobject Replaces all attributes
Replaces all attributes
optInboolean
optInSource
One of:
string
null
optedOutboolean
true = exclude from marketing; false clears keyword/manual/api opt-outs only

PhoneNumber

idrequiredstring uuid
Use as "from"
phoneNumberIdrequiredstring
Meta phone_number_id (also accepted as "from")
displayPhoneNumberrequiredstring
verifiedNamerequiredstring,null
isActiverequiredboolean
qualityRatingrequiredstring,null

WebhookPayload

idrequiredstring uuid
Delivery id (idempotency key: retries reuse it)
eventrequiredstring ("message.received" | "message.status" | "template.status" | "contact.opted_out" | "account.alert" | "call.received" | "call.ended" | "ping")
createdAtrequiredstring date-time
organizationIdstring uuid
datarequiredobject Event-specific body (see the docs page)
Event-specific body (see the docs page)

Error

errorrequired
coderequiredstring
bad_request | unauthorized | forbidden | not_found | conflict | rate_limited | …
messagerequiredstring
detailsany
Field-level details, e.g. { code: "window_closed" }
requestIdrequiredstring

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.

Request we send
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.

javascript
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
});

Event payloads

The envelope is always the same; data changes per event.

message.received

A customer sends you a message.

data
{
  "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.

data
{
  "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.

data
{
  "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.

data
{
  "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.

data
{
  "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.

data
{
  "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.

data
{
  "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:

immediately→1 min→5 min→30 min→2 h→12 h→exhausted

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.