---
title: "Public API"
description: "Drive wacrm from your own scripts: scoped API keys, REST endpoints for messages/contacts/conversations/broadcasts, and outbound event webhooks."
section: "Reference"
canonical: https://wacrm.tech/docs/public-api
dateModified: 2026-07-01
---
# Public API

The public REST API lets you drive your wacrm instance from your own
scripts and automations — send messages, manage contacts, browse
conversations, launch broadcasts, and subscribe to events — without
going through the dashboard UI. Everything lives under `/api/v1` on
your own deployment (e.g. `https://your-crm.example.com/api/v1`).

> **Migrations required.** The API ships in two migrations — apply
> `026_api_keys.sql` (keys) and `028_webhook_endpoints.sql` (webhooks)
> against your Supabase project. See [Supabase setup](https://wacrm.tech/docs/supabase-setup).

## Authentication

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.

### Creating a key

In the dashboard: **Settings → API keys → New API key** (only **admins
and owners** can create keys — see [Settings](https://wacrm.tech/docs/settings) and
[Members](https://wacrm.tech/docs/members)).

1. Name the key after the integration that will use it.
2. Grant only the **scopes** it needs (see below).
3. Copy the key. **It's shown exactly once** — wacrm stores only a
   SHA-256 hash. If you lose it, revoke and create a new one.

Revoke under **Settings → API keys → Revoke**; revocation takes effect
on the key's next request.

## Scopes

A key can do only what its scopes allow. 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` — handy for verifying a key works.

## Response format

Every response is one of two shapes:

```jsonc
// success
{ "data": { /* ... */ } }
// failure
{ "error": { "code": "forbidden", "message": "…" } }
```

Branch on `error.code` (stable); `message` is for humans. Codes:
`unauthorized` (401), `forbidden` (403, missing scope),
`rate_limited` (429), `bad_request` (400), `not_found` (404),
`internal` (500). Send endpoints add domain codes like
`whatsapp_not_configured` and `meta_error` (502 — Meta rejected the
send).

Requests are rate-limited **per key** at **120/minute**; a `429`
returns `Retry-After` and `X-RateLimit-*` headers.

## Pagination

List endpoints return a `meta.next_cursor` and take `?limit=` (default
50, max 100) and `?cursor=`:

```
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 opaque and keyset-based (stable under concurrent inserts).
Pass the cursor back verbatim; `null` means the last page.

## Endpoints

### `GET /api/v1/me`

Returns the account a key is bound to and its scopes. No scope
required — the quickest way to verify a key.

### `POST /api/v1/messages`

Send a message to a phone number (`messages:send`). You pass an
**E.164 number**, not an internal id — the endpoint finds-or-creates
the contact and conversation, then sends.

```bash
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`). Templates take a `template`
object (`name`, `language`, `params`); media takes `media_url` (+
optional `filename`), with `text` as the caption. Returns `201` with
`message_id`, `whatsapp_message_id`, `conversation_id`, `contact_id`,
and `contact_created`.

### Contacts

- `GET /api/v1/contacts` — list, newest first (`contacts:read`).
  Filters: `?search=` (name/phone), `?tag=<tagId>`.
- `POST /api/v1/contacts` — create by `phone` (E.164); optional `name`,
  `email`, `company`, `tags` (`contacts:write`). **Find-or-create:** an
  existing match returns `200`, a new contact `201`.
- `GET` / `PATCH /api/v1/contacts/{id}` — read / update. `PATCH`
  changes only the fields you send; `null` clears a field, and `tags`
  (an array of names) replaces the contact's tags.

### Conversations & messages

- `GET /api/v1/conversations` — list (`conversations:read`). Filters:
  `?status=`, `?contact_id=`. Each embeds its contact + tags.
- `GET /api/v1/conversations/{id}` — read one.
- `GET /api/v1/conversations/{id}/messages` — the thread's messages,
  newest first, with delivery `status` and `direction`
  (`messages:read`).

### `POST /api/v1/broadcasts`

Launch a template broadcast (`broadcasts:send`). Persists the broadcast
and its recipients, then sends in the background — returns `202` right
away; poll `GET /api/v1/broadcasts/{id}` for progress.

```bash
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" }
        ]
      }'
```

Capped at **1000 recipients per request**; invalid numbers are dropped
and reported as `rejected`. See also [Broadcasts](https://wacrm.tech/docs/broadcasts).

## Webhooks

Instead of polling, register an HTTPS endpoint and wacrm POSTs to it
when things happen. (This is distinct from the `webhook` step inside
[Automations](https://wacrm.tech/docs/automations), which calls out mid-flow.)

**Events:** `message.received`, `message.status_updated`,
`conversation.created`.

**Manage** (all `webhooks:manage`):

- `POST /api/v1/webhooks` — register `{ "url": "https://…", "events": [ … ] }`.
  `url` must be `https://`. The response includes the signing `secret`
  **exactly once** — store it; wacrm keeps only an encrypted copy.
- `GET /api/v1/webhooks` and `GET /api/v1/webhooks/{id}` — list / read
  (never returns the secret).
- `PATCH /api/v1/webhooks/{id}` — update `url`, `events`, or
  `is_active`.
- `DELETE /api/v1/webhooks/{id}` — remove.

### Delivery payload

```jsonc
{
  "id": "8f3c…",                 // unique per delivery — dedupe on this
  "event": "message.received",
  "occurred_at": "2026-07-01T12:00:00.000Z",
  "account_id": "…",
  "data": { /* varies by event */ }
}
```

Headers: `X-Wacrm-Event`, `X-Wacrm-Webhook-Id`, `X-Wacrm-Signature`.

### Verifying the signature

`X-Wacrm-Signature: t=<unix_seconds>,v1=<hex>` where
`v1 = HMAC-SHA256(secret, "${t}.${rawBody}")`. Recompute it over the
**raw request body**, compare in constant time, and reject a `t` more
than a few minutes old (replay protection).

```js
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 semantics

Delivery is **best-effort**: one attempt per event, no redirects
followed. Providers re-send and re-order status callbacks, so an event
may arrive more than once or out of order — **dedupe on `id` and don't
assume ordering**. Repeated failures auto-disable an endpoint
(`is_active: false`); re-enable it with `PATCH`. For durability,
reconcile with the read endpoints when it matters.

For security, webhook targets must be public `https://` URLs —
requests to `localhost`, private ranges, and link-local addresses
(including cloud metadata) are refused.
