The public API lets you drive your wacrm instance from your own scripts and automations — send messages, manage contacts, launch broadcasts — without going through the dashboard UI.
Status: stable. Authentication, scopes, rate limiting, the messages / contacts / conversations / broadcasts endpoints, and outbound event webhooks all ship now.
Every request authenticates with an API key, sent as a bearer token:
Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys are account-scoped: a key acts on exactly one account, the one it was created in. There is no cross-account access.
In the dashboard: Settings → API keys → New API key. Only admins and owners can create keys.
- Give the key a name (after the integration that will use it).
- Grant the scopes it needs — nothing more (see below).
- Copy the key. The full key is shown exactly once. wacrm stores only a SHA-256 hash, so it can never be shown again. If you lose it, revoke it and create a new one.
Settings → API keys → Revoke. Revocation is effective on the key's next request. Revoked keys stay in the list as an audit trail.
A key can do only what its scopes allow — independent of who created it. Grant the minimum.
| Scope | Allows |
|---|---|
messages:send |
Send WhatsApp messages |
messages:read |
Read messages and delivery status |
contacts:read |
List and read contacts |
contacts:write |
Create and update contacts |
conversations:read |
List and read conversations |
broadcasts:send |
Launch broadcast campaigns |
webhooks:manage |
Register and manage outbound webhooks |
A key with no scopes still authenticates and can call
GET /api/v1/me — useful for verifying a key works.
Every response uses one of two shapes:
Branch on error.code (stable); error.message is for humans and
may be reworded.
| Status | code |
Meaning |
|---|---|---|
| 401 | unauthorized |
Missing / malformed / unknown / revoked / expired key |
| 403 | forbidden |
Valid key, but missing the required scope |
| 429 | rate_limited |
Per-key rate limit exceeded |
| 400 | bad_request |
Malformed input |
| 404 | not_found |
No such resource |
| 500 | internal |
Server error |
Requests are limited per key: 120 requests per minute. On a
429, these headers tell you when to retry:
Retry-After— seconds until the window resetsX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset
The limiter is in-memory and per process. A single-instance deploy (the common case for a self-hosted fork) is fine as-is. If you scale to multiple instances, swap the limiter for a shared store (Redis/Upstash) — see the note at the top of
src/lib/rate-limit.ts. The limit is otherwise unenforced across instances.
Returns the account a key is bound to and the scopes it carries. Requires only a valid key (no scope). Use it to verify a key works and to discover its scopes.
curl https://your-crm.example.com/api/v1/me \
-H "Authorization: Bearer wacrm_live_xxx"{
"data": {
"account": { "id": "…", "name": "Acme Inc" },
"key": { "id": "…", "scopes": ["messages:send"] }
}
}Send a WhatsApp message to a phone number. Scope: messages:send. You
pass an E.164 number, not an internal id — the endpoint
finds-or-creates the contact + conversation, then sends.
curl -X POST https://your-crm.example.com/api/v1/messages \
-H "Authorization: Bearer wacrm_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "to": "+14155550123", "type": "text", "text": "Hi 👋" }'type is text (default), template, or a media kind (image /
video / document / audio). Media needs media_url (and optional
filename); text doubles as the caption. template needs a
template object:
{
"to": "+14155550123",
"type": "template",
"template": {
"name": "order_update",
"language": "en_US",
"params": ["A123"] // positional body vars, or a structured object
},
"reply_to_message_id": "<uuid>" // optional; must be in the same conversation
}Response (201):
{
"data": {
"message_id": "…",
"whatsapp_message_id": "wamid.…",
"conversation_id": "…",
"contact_id": "…",
"contact_created": true
}
}Domain error codes beyond the table above: whatsapp_not_configured
(400), meta_error (502 — the request reached Meta and it rejected the
send), template_malformed (500).
List contacts, newest first. Scope: contacts:read. Paginated (see
Pagination). Optional filters: ?search= (matches name
or phone) and ?tag=<tagId>.
{
"data": [
{
"id": "…", "phone": "+14155550123", "name": "Jane Doe",
"email": null, "company": "Acme", "avatar_url": null,
"tags": [{ "id": "…", "name": "vip", "color": "#3b82f6" }],
"created_at": "…", "updated_at": "…"
}
],
"meta": { "next_cursor": "…" }
}Create a contact. Scope: contacts:write. phone (E.164) is required;
name, email, company, and tags (an array of tag names, created
if missing) are optional. Find-or-create by phone: an existing
match returns 200 with the existing contact; a new contact returns
201. The response body is the serialized contact (same shape as the
list rows above).
Read or update one contact. Scopes: contacts:read / contacts:write.
PATCH updates only the fields you send (name, email, company);
pass tags (an array of tag names) to replace the contact's tags. A
contact in another account returns 404.
List conversations, newest first. Scope: conversations:read.
Paginated. Optional filters: ?status= (open / pending / closed)
and ?contact_id=. Each conversation embeds its contact + tags.
Read one conversation. Scope: conversations:read. 404 if it belongs
to another account.
List a conversation's messages, newest first. Scope: messages:read.
Paginated. Each message includes its direction (inbound /
outbound), status (delivery state), whatsapp_message_id, and
content_*. The conversation is verified to belong to your account
first (404 otherwise).
Launch a template broadcast to a list of recipients. Scope:
broadcasts:send. The broadcast + its recipient rows are persisted
immediately and the sends fan out in the background, so the call
returns fast — poll GET /api/v1/broadcasts/{id} for progress.
curl -X POST https://your-crm.example.com/api/v1/broadcasts \
-H "Authorization: Bearer wacrm_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "July promo",
"template_name": "promo_july",
"template_language": "en_US",
"recipients": [
{ "to": "+14155550123", "params": ["Jane"] },
{ "to": "+14155550124" }
]
}'Recipients are capped at 1000 per request — split larger sends.
Invalid phone numbers are dropped and counted as rejected. Response
(202):
{
"data": {
"broadcast_id": "…",
"status": "sending",
"total_recipients": 2,
"accepted": 2,
"rejected": 0
}
}Broadcast status + counts. Scope: broadcasts:send. status moves
sending → sent; delivered_count / read_count keep climbing as
Meta delivery webhooks arrive. 404 for another account's broadcast.
Every list endpoint pages the same way. Request a page size with
?limit= (default 50, max 100) and read the next page with the opaque
meta.next_cursor from the previous response:
GET /api/v1/contacts?limit=50
→ { "data": [ … ], "meta": { "next_cursor": "eyJ…" } }
GET /api/v1/contacts?limit=50&cursor=eyJ…
→ { "data": [ … ], "meta": { "next_cursor": null } } // last page
Cursors are keyset-based (stable under concurrent inserts). Pass the
cursor back verbatim — don't parse it. next_cursor: null means the
last page.
Rather than polling, register an endpoint and wacrm will POST to it when
things happen in your account. Migration required: apply
supabase/migrations/028_webhook_endpoints.sql.
| Event | Fires when |
|---|---|
message.received |
An inbound message arrives from a contact |
message.status_updated |
A message you sent changed delivery status |
conversation.created |
A new conversation is opened for a contact |
All under scope webhooks:manage.
POST /api/v1/webhooks— register{ "url": "https://…", "events": ["message.received"] }.urlmust behttps://. The response includessecretexactly once — store it to verify signatures; wacrm keeps only an encrypted copy.GET /api/v1/webhooks— list your endpoints (never returns the secret).GET /api/v1/webhooks/{id}— read one.PATCH /api/v1/webhooks/{id}— updateurl,events, oris_active(re-enabling clears the failure counter).DELETE /api/v1/webhooks/{id}— remove one.
curl -X POST https://your-crm.example.com/api/v1/webhooks \
-H "Authorization: Bearer wacrm_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/wacrm", "events": ["message.received"] }'
# → 201 { "data": { "id": "…", "url": "…", "events": [...], "secret": "whsec_…" } }Every delivery is a POST with this envelope; id is a unique per-
delivery uuid you can dedupe on, and data varies by event:
{
"id": "8f3c…",
"event": "message.received",
"occurred_at": "2026-07-01T12:00:00.000Z",
"account_id": "…",
"data": { /* per-event, see below */ }
}data by event:
// message.received
{ "conversation_id": "…", "contact_id": "…", "whatsapp_message_id": "wamid.…", "content_type": "text", "text": "Hi 👋" }
// conversation.created
{ "conversation_id": "…", "contact_id": "…" }
// message.status_updated
{ "whatsapp_message_id": "wamid.…", "conversation_id": "…", "status": "delivered" }Headers: X-Wacrm-Event, X-Wacrm-Webhook-Id, and X-Wacrm-Signature.
X-Wacrm-Signature: t=<unix_seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, "${t}.${rawBody}"). Recompute it over the raw
request body and compare in constant time; reject if t is more than
a few minutes old (replay protection).
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/);
const expected = crypto.createHmac('sha256', secret)
.update(`${t}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));Delivery is best-effort: a single attempt per event with a short
timeout, and redirects are not followed. message.status_updated
covers messages wacrm stores (inbox + API sends), not broadcast-only
sends, and — because providers re-send and re-order status callbacks —
the same status may arrive more than once or out of order; dedupe on
id and don't assume ordering. Each consecutive failure increments
failure_count; after enough consecutive failures the endpoint is
auto-disabled (is_active: false) — re-enable it with PATCH (which
resets the counter). Durable retry-with-backoff (a delivery queue) is a
future enhancement; today, treat missed deliveries as possible and
reconcile with the read endpoints when it matters.
Target restrictions (SSRF). The url must be https:// and must
resolve to a public address — requests to localhost, private/RFC1918
ranges, link-local (incl. cloud metadata 169.254.169.254), and similar
internal targets are refused at delivery time.
The public API now covers messaging, contacts, conversations, broadcasts, and outbound webhooks — the full scope of #245. Future ideas (deals/pipelines, templates, flows, a delivery queue for webhooks) are not yet scheduled.