Marsal WhatsApp API
A simple REST API to send WhatsApp messages and templates, run OTP verification, and manage your conversations, contacts and campaigns — from any programming language.
Prefer not to read? Jump to Tools & SDKs for the OpenAPI description, the Postman collection and the PHP client.
Authentication
Send your API key as a Bearer token on every request. Create keys in Developers · API. A key is scoped to your workspace and grants full access — keep it secret.
Phone numbers
Always send recipient numbers in international format, digits only (no +, spaces or dashes). Example: a Saudi number becomes 9665xxxxxxxx, an Egyptian number 2010xxxxxxxx.
Response format
Every response is JSON. Success returns "ok": true; a business error returns "ok": false with an error string and a matching HTTP status.
{ "ok": true, "message_id": 4210, "wamid": "wamid.HBg...", "to": "9665xxxxxxxx" }
Send a text message POST
Free text can only be sent inside the 24‑hour customer‑service window. Outside it, use a template.
| Field | Type | Description |
|---|---|---|
to | string | Recipient, international digits only. Required. |
type | string | text (default) or template. |
body | string | Message text. |
curl -X POST "https://usemarsal.cloud/api/v1/messages" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "to": "9665xxxxxxxx", "type": "text", "body": "أهلاً بيك" }'
const r = await fetch("https://usemarsal.cloud/api/v1/messages", {
method: "POST",
headers: { Authorization: "Bearer msk_live_xxxx", "Content-Type": "application/json" },
body: JSON.stringify({ to: "9665xxxxxxxx", type: "text", body: "أهلاً بيك" })
});
console.log(await r.json());
import requests
r = requests.post("https://usemarsal.cloud/api/v1/messages",
headers={"Authorization": "Bearer msk_live_xxxx"},
json={"to": "9665xxxxxxxx", "type": "text", "body": "أهلاً بيك"})
print(r.json())
$ch = curl_init("https://usemarsal.cloud/api/v1/messages");
curl_setopt_array($ch, [CURLOPT_POST=>true, CURLOPT_RETURNTRANSFER=>true,
CURLOPT_HTTPHEADER=>["Authorization: Bearer msk_live_xxxx","Content-Type: application/json"],
CURLOPT_POSTFIELDS=>json_encode(["to"=>"9665xxxxxxxx","type"=>"text","body"=>"أهلاً بيك"])]);
$res = json_decode(curl_exec($ch), true);
Send a template POST
Approved templates can be sent any time. Pass the name, language, and body variables in order.
| Field | Type | Description |
|---|---|---|
type | string | Must be template. |
template | string | Approved template name. |
language | string | e.g. ar. |
params | array | Body variables, in order. |
curl -X POST "https://usemarsal.cloud/api/v1/messages" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "to": "9665xxxxxxxx", "type": "template", "template": "order_confirmation", "language": "ar", "params": ["Ahmed", "#9402"] }'
await fetch("https://usemarsal.cloud/api/v1/messages", { method:"POST",
headers:{ Authorization:"Bearer msk_live_xxxx","Content-Type":"application/json" },
body: JSON.stringify({ to:"9665xxxxxxxx", type:"template",
template:"order_confirmation", language:"ar", params:["Ahmed","#9402"] }) });
requests.post("https://usemarsal.cloud/api/v1/messages",
headers={"Authorization":"Bearer msk_live_xxxx"},
json={"to":"9665xxxxxxxx","type":"template",
"template":"order_confirmation","language":"ar","params":["Ahmed","#9402"]})
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ "to"=>"9665xxxxxxxx","type"=>"template", "template"=>"order_confirmation","language"=>"ar","params"=>["Ahmed","#9402"]]));
List conversations GET
Optional status = all|open|pending|closed. Returns each chat with the contact, unread count, and whether the 24h window is open.
curl -X GET "https://usemarsal.cloud/api/v1/conversations?limit=50" \ -H "Authorization: Bearer msk_live_xxxx"
const r = await fetch("https://usemarsal.cloud/api/v1/conversations?limit=50",
{ headers: { Authorization: "Bearer msk_live_xxxx" } });
requests.get("https://usemarsal.cloud/api/v1/conversations?limit=50",
headers={"Authorization":"Bearer msk_live_xxxx"})
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer msk_live_xxxx"]);
Get a conversation's messages GET
Returns messages (in/out) for a conversation. Pass after_id to fetch only newer ones.
curl -X GET "https://usemarsal.cloud/api/v1/conversations/123/messages" \ -H "Authorization: Bearer msk_live_xxxx"
Reply inside a chat POST
Sends a free‑text reply in an existing conversation (inside the 24h window). Pauses the AI bot for that chat (human takeover), same as the inbox.
curl -X POST "https://usemarsal.cloud/api/v1/conversations/123/messages" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "body": "تمام هجهزلك الطلب" }'
await fetch("https://usemarsal.cloud/api/v1/conversations/123/messages", { method:"POST",
headers:{ Authorization:"Bearer msk_live_xxxx","Content-Type":"application/json" },
body: JSON.stringify({ body:"تمام هجهزلك الطلب" }) });
requests.post("https://usemarsal.cloud/api/v1/conversations/123/messages",
headers={"Authorization":"Bearer msk_live_xxxx"}, json={"body":"تمام هجهزلك الطلب"})
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["body"=>"تمام هجهزلك الطلب"]));
Mark read & toggle AI POST
curl -X POST "https://usemarsal.cloud/api/v1/conversations/123/ai" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "enabled": 0 }'
List contacts GET
curl -X GET "https://usemarsal.cloud/api/v1/contacts?limit=100" \ -H "Authorization: Bearer msk_live_xxxx"
Add a contact POST
curl -X POST "https://usemarsal.cloud/api/v1/contacts" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "wa_id": "9665xxxxxxxx", "name": "Ahmed", "email": "a@x.com" }'
await fetch("https://usemarsal.cloud/api/v1/contacts", { method:"POST",
headers:{ Authorization:"Bearer msk_live_xxxx","Content-Type":"application/json" },
body: JSON.stringify({ wa_id:"9665xxxxxxxx", name:"Ahmed", email:"a@x.com" }) });
requests.post("https://usemarsal.cloud/api/v1/contacts",
headers={"Authorization":"Bearer msk_live_xxxx"},
json={"wa_id":"9665xxxxxxxx","name":"Ahmed","email":"a@x.com"})
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["wa_id"=>"9665xxxxxxxx","name"=>"Ahmed"]));
Send an OTP POST
Marsal generates a 6‑digit code, sends it, and stores only its hash. Pass a template to deliver any time; omit it for plain text inside the 24h window.
curl -X POST "https://usemarsal.cloud/api/v1/otp/send" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "to": "9665xxxxxxxx", "template": "otp_code", "ttl": 300 }'
await fetch("https://usemarsal.cloud/api/v1/otp/send", { method:"POST",
headers:{ Authorization:"Bearer msk_live_xxxx","Content-Type":"application/json" },
body: JSON.stringify({ to:"9665xxxxxxxx", template:"otp_code", ttl:300 }) });
requests.post("https://usemarsal.cloud/api/v1/otp/send",
headers={"Authorization":"Bearer msk_live_xxxx"},
json={"to":"9665xxxxxxxx","template":"otp_code","ttl":300})
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["to"=>"9665xxxxxxxx","template"=>"otp_code","ttl"=>300]));
Verify an OTP POST
Returns { "ok": true, "verified": true|false }. Codes expire after the TTL and lock after 5 wrong attempts.
curl -X POST "https://usemarsal.cloud/api/v1/otp/verify" \
-H "Authorization: Bearer msk_live_xxxx" \
-H "Content-Type: application/json" \
-d '{ "to": "9665xxxxxxxx", "code": "123456" }'
const { verified } = await (await fetch("https://usemarsal.cloud/api/v1/otp/verify", { method:"POST",
headers:{ Authorization:"Bearer msk_live_xxxx","Content-Type":"application/json" },
body: JSON.stringify({ to:"9665xxxxxxxx", code:"123456" }) })).json();
r = requests.post("https://usemarsal.cloud/api/v1/otp/verify",
headers={"Authorization":"Bearer msk_live_xxxx"}, json={"to":"9665xxxxxxxx","code":"123456"})
print(r.json()["verified"])
$verified = json_decode(curl_exec($ch), true)["verified"];
Templates GET
Your approved message templates (name, language, category, status).
curl -X GET "https://usemarsal.cloud/api/v1/templates" \ -H "Authorization: Bearer msk_live_xxxx"
Numbers GET
Your connected WhatsApp numbers, quality rating and status.
curl -X GET "https://usemarsal.cloud/api/v1/numbers" \ -H "Authorization: Bearer msk_live_xxxx"
Campaigns GET
curl -X GET "https://usemarsal.cloud/api/v1/campaigns" \ -H "Authorization: Bearer msk_live_xxxx"
Dashboard stats GET
Counts for contacts, open conversations, unread, messages today, and campaigns.
curl -X GET "https://usemarsal.cloud/api/v1/dashboard" \ -H "Authorization: Bearer msk_live_xxxx"
Poll for new messages GET
Returns inbound messages newer than since_id, plus the latest id — poll it every few seconds to build a live inbox or trigger your own automations.
curl -X GET "https://usemarsal.cloud/api/v1/updates?since_id=4200" \ -H "Authorization: Bearer msk_live_xxxx"
let since = 0;
setInterval(async () => {
const r = await fetch(`https://usemarsal.cloud/api/v1/updates?since_id=${since}`,
{ headers: { Authorization: "Bearer msk_live_xxxx" } });
const data = await r.json();
since = data.last_id;
data.messages.forEach(m => console.log(m.from, m.body));
}, 5000);
import time, requests
since = 0
while True:
d = requests.get(f"https://usemarsal.cloud/api/v1/updates?since_id={since}",
headers={"Authorization":"Bearer msk_live_xxxx"}).json()
since = d["last_id"]
for m in d["messages"]: print(m["from"], m["body"])
time.sleep(5)
$d = json_decode(file_get_contents("https://usemarsal.cloud/api/v1/updates?since_id=$since"), true);
Webhooks
Polling /updates is fine for a script. For a production integration, let Marsal call you: register an endpoint and we POST a signed JSON body the moment something happens — a message arrives, a send is delivered, a chat is assigned or closed, a campaign finishes, Meta approves a template.
Workspace → Developers · API → Webhooks. Paste your https URL, tick the events you want, and a signing secret is generated for you. Webhooks are part of the Enterprise plan and above.
Open the webhooks panel →The envelope
Every event uses the same wrapper; only data changes per event.
{
"event": "message.received",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": { ... }
}
Headers
| Header | Value |
|---|---|
X-Marsal-Event | The event name, e.g. message.received. |
X-Marsal-Delivery | 32-hex id, stable across retries — use it to deduplicate. |
X-Marsal-Attempt | 1 on the first try, then 2, 3… |
X-Marsal-Timestamp | Unix seconds at which this attempt was signed. |
X-Marsal-Signature-V2 | Authoritative. t=<unix>,v1=<hex> where hex = HMAC-SHA256 of "<t>.<raw body>" with your secret. |
X-Marsal-Signature | Legacy. HMAC-SHA256 of the raw body alone — no timestamp, so it cannot detect a replay. Still sent so existing integrations keep working; verify V2 in anything new. |
Event reference
| Event | Fires when |
|---|---|
message.received | A customer sent you a WhatsApp message. |
message.sent | Your workspace sent a message (agent, API, bot, auto-reply or scheduled send). |
message.status | WhatsApp reported sent / delivered / read / failed for one of your messages. |
conversation.assigned | A chat was handed to an agent, moved between agents, or released. |
conversation.closed | A chat was closed from the inbox, the API or after a service rating. |
csat.received | A customer replied with a 1–5 satisfaction score. |
contact.created | A contact was added to your workspace for the first time. |
campaign.finished | A broadcast campaign stopped sending (completed or aborted). |
template.status_changed | Meta approved, rejected or deleted one of your message templates. |
Broadcast campaigns do not emit message.sent per recipient — a 50,000-contact campaign would mean 50,000 deliveries. Subscribe to campaign.finished for the totals, and to message.status if you need per-recipient delivery state (each of those carries campaign_id).
message.received
A customer sent you a WhatsApp message.
{
"event": "message.received",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"conversation_id": 8421,
"human_reply_at": null,
"contact": {
"id": 5107,
"wa_id": "966550521672",
"name": "Ahmed"
},
"type": "text",
"text": "هل المنتج متوفر؟"
}
}
message.sent
Your workspace sent a message (agent, API, bot, auto-reply or scheduled send).
{
"event": "message.sent",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"message_id": 91733,
"conversation_id": 8421,
"contact_id": 5107,
"type": "text",
"text": "أهلاً بيك، أيوه متوفر ✅",
"wa_message_id": "wamid.HBgMOTY2NTUwNTIxNjcy",
"status": "sent",
"sender_user_id": 42,
"template_id": null
}
}
message.status
WhatsApp reported sent / delivered / read / failed for one of your messages.
{
"event": "message.status",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"wa_message_id": "wamid.HBgMOTY2NTUwNTIxNjcy",
"status": "delivered",
"error": null,
"campaign_id": null,
"recipient_wa_id": "966550521672"
}
}
conversation.assigned
A chat was handed to an agent, moved between agents, or released.
{
"event": "conversation.assigned",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"conversation_id": 8421,
"from_user_id": null,
"to_user_id": 42,
"reason": "manual",
"contact_id": 5107
}
}
conversation.closed
A chat was closed from the inbox, the API or after a service rating.
{
"event": "conversation.closed",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"conversation_id": 8421,
"contact_id": 5107,
"source": "inbox",
"assigned_user_id": 42
}
}
csat.received
A customer replied with a 1–5 satisfaction score.
{
"event": "csat.received",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"conversation_id": 8421,
"contact_id": 5107,
"rating": 5
}
}
contact.created
A contact was added to your workspace for the first time.
{
"event": "contact.created",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"contact_id": 5107,
"wa_id": "966550521672",
"name": "Ahmed",
"email": null,
"source": "inbound"
}
}
campaign.finished
A broadcast campaign stopped sending (completed or aborted).
{
"event": "campaign.finished",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"campaign_id": 311,
"name": "عرض رمضان",
"status": "sent",
"total": 1200,
"sent": 1187,
"delivered": 1102,
"read": 840,
"failed": 13
}
}
template.status_changed
Meta approved, rejected or deleted one of your message templates.
{
"event": "template.status_changed",
"tenant_id": 12,
"delivery_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"ts": 1758326400,
"data": {
"template_id": 77,
"name": "order_confirmation",
"language": "ar",
"category": "UTILITY",
"old_status": "PENDING",
"status": "APPROVED"
}
}
Verify a signature
Compute the HMAC over "<timestamp>.<raw request body>" and compare it to v1 in constant time. Use the raw body exactly as received — parsing and re-encoding the JSON changes the bytes and the signature will not match. Reject anything older than five minutes so a captured delivery cannot be replayed at you later.
PHP
<?php
$secret = 'your_webhook_secret'; // from the Developers panel
$raw = file_get_contents('php://input');
$hdr = $_SERVER['HTTP_X_MARSAL_SIGNATURE_V2'] ?? '';
$t = 0; $v1 = '';
foreach (explode(',', $hdr) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $v1 = $v;
}
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if ($v1 === '' || !hash_equals($expected, $v1)) { http_response_code(401); exit('bad signature'); }
if (abs(time() - $t) > 300) { http_response_code(401); exit('stale'); }
$event = json_decode($raw, true);
// Deduplicate on the delivery id — a retry repeats it.
// if (already_seen($_SERVER['HTTP_X_MARSAL_DELIVERY'])) { http_response_code(200); exit; }
error_log($event['event'] . ' ' . json_encode($event['data']));
http_response_code(200); // anything outside 2xx is retried
echo 'ok';
Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
const SECRET = process.env.MARSAL_WEBHOOK_SECRET;
// express.json() would hand you a parsed object; keep the raw bytes for the HMAC.
app.post("/marsal-webhook", express.raw({ type: "*/*" }), (req, res) => {
const header = req.get("X-Marsal-Signature-V2") || "";
const parts = Object.fromEntries(header.split(",").map(p => p.trim().split("=")));
const t = parseInt(parts.t, 10);
const v1 = parts.v1 || "";
const expected = crypto.createHmac("sha256", SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8"), b = Buffer.from(v1, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).send("bad signature");
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(401).send("stale");
const evt = JSON.parse(req.body.toString("utf8"));
console.log(req.get("X-Marsal-Delivery"), evt.event, evt.data);
res.status(200).send("ok"); // reply fast; do the slow work after
});
app.listen(3000);
Retries, timeouts & endpoint requirements
| Rule | Value |
|---|---|
| Success | Any 2xx. Everything else — including 3xx — counts as a failure. |
| Timeout | 5s total, 3s to connect. Acknowledge first, process afterwards. |
| Retries | Up to 6 attempts. The gap doubles each time (30s, 60s, 2m, 4m, 8m…, capped at 1h) with ±20% jitter so a recovering endpoint is not hit by a thundering herd. |
| Ordering | Not guaranteed. A retried event can arrive after a newer one — use ts and the ids in data, not arrival order. |
| At-least-once | A delivery can repeat (we retried something your server did receive). Deduplicate on X-Marsal-Delivery. |
| Body size | Capped at 256 KB. An oversized payload arrives with "_truncated": true and its scalar fields only — fetch the detail over the REST API. |
| Response size | We read at most 64 KB of your reply, and only to show it in the delivery log. |
What your endpoint must be
| Requirement | Why |
|---|---|
| https only, port 443 | The payload carries customer messages and phone numbers; plain http is refused at registration and at send time. |
| Valid TLS certificate | The chain and host name are verified. Self-signed certificates fail. |
| Publicly routable address | Loopback, private (RFC1918), link-local, CGNAT and reserved ranges are refused — for IPv4 and IPv6, and for every address the name resolves to. The address is re-checked on every attempt and pinned for the request, so a name that later points somewhere private stops being called. |
| No redirects | A 3xx is not followed and is treated as a failure. Register the final URL. |
| No credentials in the URL | https://user:pass@host/ is rejected. Put the secret in the signature check, not the URL. |
How to test
Open Developers · API, expand a webhook and use Send test event: it POSTs a fully signed sample of whichever event you pick and shows you the HTTP status, the round-trip time and the first 480 characters of your response. The attempt is written to the delivery log like any other.
Building the receiver first? Point the webhook at a public request-bin service to see the exact bytes and headers, then swap in your own URL. A local http://localhost or ngrok-over-http endpoint will be refused — expose it over https.
When something is wrong, the Delivery log on the same page shows every attempt with its status, try count, response code, round-trip time and the error or response snippet, plus a Retry now button that replays the exact stored body.
Tools & SDKs
Everything on this page is also available as a machine-readable description, so you can generate a client, mock the API, or click through it in Postman instead of copying cURL commands by hand.
OpenAPI 3.1 description
Every endpoint, parameter, response shape and error code above — plus the nine webhook events and their signature headers — in one file. Import it into Postman, Insomnia, Bruno or Stoplight, or run it through an OpenAPI generator to build a client in your own language.
curl -O https://usemarsal.cloud/openapi.yaml # generate a client in any language the generator supports npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g python -o ./marsal-client
Postman collection
Generated from the description above, with a folder per resource and a working
example on every request. Import both files, pick the Marsal Cloud environment, and
set api_key to a key from
Developers · API — every request inherits
the bearer token from the collection, so nothing else needs configuring.
| File | What it is |
|---|---|
marsal.postman_collection.json | The collection — 15 requests, schema v2.1. |
marsal.postman_environment.json | The environment — base_url, api_key, conversation_id. |
Firing a request from Postman sends a real WhatsApp message from your workspace
and is billed like any other send. The GET requests and
/otp/verify are safe to explore with.
PHP SDK
A small, dependency-free client for PHP 8.0+ — cURL and nothing else. It covers every endpoint on this page, throws a typed exception per error, retries 429s and 5xx with backoff, and ships a webhook verifier that checks the V2 signature and the timestamp window for you.
MIT licensed. Unzip it anywhere and require
src/autoload.php — no Composer needed.
<?php
require __DIR__ . '/marsal-php-sdk/src/autoload.php';
$marsal = new \Marsal\Api\Client(getenv('MARSAL_API_KEY'));
$marsal->sendText('9665xxxxxxxx', 'أهلاً بيك');
$marsal->sendTemplate('9665xxxxxxxx', 'order_confirmation', 'ar', ['Ahmed', '#9402']);
// verifying a webhook, including the replay window
$event = (new \Marsal\Api\WebhookVerifier(getenv('MARSAL_WEBHOOK_SECRET')))->verifyRequest();
echo $event->name; // message.received
Other languages. PHP is the only client library we publish today — there is no official Node.js, Python, C#, Java or Go SDK yet. The examples throughout this page are plain HTTP in each language and work as they stand, and the OpenAPI description above will generate a typed client for any language your generator supports.
Endpoint index
| Method | Path | Description |
|---|---|---|
| POST | /messages | Send text or template |
| POST | /otp/send | Send an OTP |
| POST | /otp/verify | Verify an OTP |
| GET | /conversations | List conversations |
| GET | /conversations/{id}/messages | Get messages |
| POST | /conversations/{id}/messages | Reply in a chat |
| POST | /conversations/{id}/read | Mark read |
| POST | /conversations/{id}/ai | Toggle AI bot |
| GET | /contacts | List contacts |
| POST | /contacts | Add a contact |
| GET | /templates | List templates |
| GET | /numbers | List numbers |
| GET | /campaigns | List campaigns |
| GET | /dashboard | Workspace stats |
| GET | /updates | Poll new inbound |
Errors
| HTTP | error | Meaning |
|---|---|---|
| 401 | unauthenticated | Missing or invalid API key. |
| 403 | no_workspace | Key not linked to a workspace. |
| 404 | conversation_not_found | No such conversation in your workspace. |
| 409 | no_whatsapp_number / session_closed | No connected number, or the 24h window is closed. |
| 422 | to_required / body_required | A required field is missing. |
| 502 | send_failed | WhatsApp rejected the message (see detail). |
Ready to build?
Grab your API key and send your first WhatsApp message in minutes.
Get your API key →