Send & receive WhatsApp from your own code
A small, dependency-free HTTP API to send messages and approved templates, and receive inbound replies and delivery status over signed webhooks. Plain HTTPS + JSON — no SDK required.
Overview
Everything runs through the public API under /api/v1, authenticated with an API key. Two things you do from code — send messages and templates, and receive inbound events — plus one you do once in the dashboard: create a template.
The response envelope
Every response is one of three shapes, so you can write a single parser. Branch on error.code (stable); the message is human-facing.
{ "data": { ... } } // success
{ "data": [ ... ], "meta": { "next_cursor": null } } // list
{ "error": { "code": "bad_request", "message": "…" } } // failureGet an API key
In the dashboard: Settings → API keys → New API key (admins & owners only). Grant only the scopes your integration needs. The full key is shown once — copy it.
| Scope | Lets the key… |
|---|---|
messages:send | Send messages and templates |
messages:read | Read messages & delivery status |
conversations:read | List conversations |
contacts:write | Read & manage contacts |
webhooks:manage | Register outbound webhooks (to receive) |
Verify it works
curl https://wa.brandlypro.com/api/v1/me \
-H "Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxx"
# -> { "data": { "account": {...}, "key": { "scopes": [...] } } }Create a template
WhatsApp templates are reviewed by Meta before you can send them. Create them in the dashboard — not the API.
Settings → Templates → New template: write the body, header, and buttons, then submit. Review usually takes minutes to a few hours. Once the status is APPROVED, send it by name (step 4).
Template creation is intentionally kept out of the key surface: it’s an infrequent, review-gated design step, not a per-request operation.
Advanced: create a template directly on Meta’s Graph API
If you manage templates entirely outside the dashboard, call Meta directly with your WABA id and access token. The template won’t appear in the dashboard until the next template sync.
curl -X POST "https://graph.facebook.com/v21.0/<WABA_ID>/message_templates" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"components": [
{ "type": "BODY", "text": "Hi {{1}}, your order {{2}} has shipped." }
]
}'Placeholders {{1}}, {{2}} are filled at send time by the params array below.
Send a message or template
POST /api/v1/messages — scope messages:send. You send to a phone number (E.164); the contact and conversation are found-or-created for you.
Plain text
Free-form text is only allowed inside a 24-hour customer service window (opened by an inbound message). To start a conversation cold, send a template.
curl -X POST https://wa.brandlypro.com/api/v1/messages \
-H "Authorization: Bearer wacrm_live_…" \
-H "Content-Type: application/json" \
-d '{ "to": "+14155550123", "type": "text", "text": "Hello from the API!" }'Template
curl -X POST https://wa.brandlypro.com/api/v1/messages \
-H "Authorization: Bearer wacrm_live_…" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550123",
"type": "template",
"template": {
"name": "order_update",
"language": "en_US",
"params": ["Jane", "A123"]
}
}'template.params accepts either an array — body variables in order, {{1}}, {{2}}, … (or a named template’s names, in the order they first appear) — or an object with any of body, headerText, headerMediaUrl, headerMediaId and buttonParams (keyed by button index), for templates with a header or dynamic buttons.
Named variables
Templates created with named variables such as {{first_name}} take their values as a name → value map, directly in params or under body. Header keys such as headerMediaUrl sit alongside — required when the template has an image, video or document header. POST /api/v1/broadcasts accepts the same map as each recipient’s params.
{
"to": "+14155550123",
"type": "template",
"template": {
"name": "order_ready", // "Hi {{first_name}}, order {{order_id}} is ready"
"language": "en_US",
"params": {
"first_name": "Jane",
"order_id": "A123",
"headerMediaUrl": "https://…/banner.jpg" // only if the template has a media header
}
}
}Media & the 201 response
{
"to": "+14155550123",
"type": "document", // image | video | document | audio
"media_url": "https://…/invoice.pdf",
"filename": "invoice.pdf", // document only
"text": "Your invoice" // optional caption
}{
"data": {
"message_id": "…", // internal id
"whatsapp_message_id": "wamid.…",
"conversation_id": "…",
"contact_id": "…",
"contact_created": true
}
}Receive messages & status
Inbound messages and delivery updates are pushed to your URL via signed webhooks. Register an endpoint (scope webhooks:manage); the signing secret is returned once.
curl -X POST https://wa.brandlypro.com/api/v1/webhooks \
-H "Authorization: Bearer wacrm_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/hooks/wacrm",
"events": ["message.received", "message.status_updated"]
}'
# -> { "data": { "id": "…", "secret": "whsec_…" } } <- shown ONCE| Event | Fires when |
|---|---|
message.received | An inbound message arrives from a contact |
message.status_updated | A sent message → sent / delivered / read / failed |
conversation.created | A new conversation opens |
Verify the signature (required)
Each delivery carries X-Wacrm-Signature: t=<unix>,v1=<hex>. The signed message is t + "." + rawBody, keyed by the endpoint secret. Verify over the raw body, and reject a t older than ~5 minutes.
Return 2xx quickly — deliveries are single-attempt with a short timeout, and an endpoint that fails ~15 times in a row is auto-disabled.
Errors at a glance
| code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing / bad / revoked / expired key |
forbidden | 403 | Key lacks the required scope |
rate_limited | 429 | Per-key budget hit — honour Retry-After |
bad_request | 400 | Malformed payload |
whatsapp_not_configured | 400 | Account has no connected number yet |
meta_error | 4xx | Meta rejected the send (message forwarded) |
The full client
A complete, dependency-free client (Node 18+ global fetch + node:crypto). Copy it into your project — same functions in JavaScript or TypeScript.
// wacrm-client.mjs — send & receive from your own code.
// Zero-dependency: Node 18+ global fetch + node:crypto.
import { createHmac, timingSafeEqual } from 'node:crypto';
const BASE_URL = (process.env.WACRM_BASE_URL ?? '').replace(/\/$/, '');
const API_KEY = process.env.WACRM_API_KEY ?? '';
// One wrapper: attach the key, parse the { data } / { error } envelope.
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(BASE_URL + '/api/v1' + path, {
method,
headers: {
Authorization: 'Bearer ' + API_KEY,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json().catch(() => null);
if (!res.ok) {
const err = json?.error ?? { code: 'unknown', message: res.statusText };
throw new Error('[' + res.status + ' ' + err.code + '] ' + err.message);
}
return json;
}
export const whoAmI = () => api('/me').then((r) => r.data);
export const sendText = (to, text) =>
api('/messages', { method: 'POST', body: { to, type: 'text', text } })
.then((r) => r.data);
// params: ["Jane","A123"] (in order) or { body, headerText, buttonParams, … };
// a named template may take { first_name: "Jane" } directly.
export const sendTemplate = (to, name, language = 'en_US', params = []) =>
api('/messages', {
method: 'POST',
body: { to, type: 'template', template: { name, language, params } },
}).then((r) => r.data);
export const sendMedia = (to, type, mediaUrl, { caption, filename } = {}) =>
api('/messages', {
method: 'POST',
body: { to, type, media_url: mediaUrl, text: caption, filename },
}).then((r) => r.data);
// Register a webhook; the returned secret is shown once — store it.
export const registerWebhook = (url, events) =>
api('/webhooks', {
method: 'POST',
body: { url, events: events ?? ['message.received', 'message.status_updated'] },
}).then((r) => r.data);
// Verify X-Wacrm-Signature: t=<unix>,v1=<hex> over the RAW body.
export function verifySignature(header, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
String(header || '').split(',').map((kv) => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
})
);
const t = Number(parts.t);
const v1 = (parts.v1 || '').toLowerCase();
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
if (expected.length !== v1.length) return false;
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Wire into any server. Reading the RAW body is essential.
export function makeReceiver(secret, onEvent) {
return (rawBody, signatureHeader) => {
if (!verifySignature(signatureHeader, rawBody, secret)) {
throw new Error('invalid signature');
}
onEvent(JSON.parse(rawBody));
};
}Run it: node wacrm-client.mjs +14155550123 (or npx tsx wacrm-client.ts …). With no number it just verifies the key and prints its scopes.