◆ Developer quickstart

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.

01

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.

jsonc
{ "data": { ... } }                               // success
{ "data": [ ... ], "meta": { "next_cursor": null } } // list
{ "error": { "code": "bad_request", "message": "…" } } // failure
02

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

ScopeLets the key…
messages:sendSend messages and templates
messages:readRead messages & delivery status
conversations:readList conversations
contacts:writeRead & manage contacts
webhooks:manageRegister outbound webhooks (to receive)

Verify it works

Shell
curl https://wa.brandlypro.com/api/v1/me \
  -H "Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxx"
# -> { "data": { "account": {...}, "key": { "scopes": [...] } } }
03

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.

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

04

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.

Shell
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

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

jsonc
{
  "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

jsonc
{
  "to": "+14155550123",
  "type": "document",          // image | video | document | audio
  "media_url": "https://…/invoice.pdf",
  "filename": "invoice.pdf",   // document only
  "text": "Your invoice"       // optional caption
}
jsonc — 201 Created
{
  "data": {
    "message_id": "…",                 // internal id
    "whatsapp_message_id": "wamid.…",
    "conversation_id": "…",
    "contact_id": "…",
    "contact_created": true
  }
}
05

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.

Shell
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
EventFires when
message.receivedAn inbound message arrives from a contact
message.status_updatedA sent message → sent / delivered / read / failed
conversation.createdA 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.

06

Errors at a glance

codeHTTPMeaning
unauthorized401Missing / bad / revoked / expired key
forbidden403Key lacks the required scope
rate_limited429Per-key budget hit — honour Retry-After
bad_request400Malformed payload
whatsapp_not_configured400Account has no connected number yet
meta_error4xxMeta rejected the send (message forwarded)
07

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.

JavaScript · ESM
// 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.