wacrm/docs/public-api.md
Arnas Donauskas 8cb5792823 feat(api): public REST API v1 — messages, contacts, conversations, broadcasts (#245)
Complete the public API roadmap on top of the existing key-auth layer
(#290). Re-lands POST /api/v1/messages (lost when the stacked #291 never
reached main) by extracting the dashboard send route's core into
`sendMessageToConversation`, then adds:

- Contacts: GET/POST /api/v1/contacts, GET/PATCH /api/v1/contacts/{id}
- Conversations: GET list + /{id} + /{id}/messages
- Broadcasts: POST /api/v1/broadcasts + GET /{id} (persist rows, fan out
  in after(), poll for status)

All list endpoints share one keyset-pagination contract
({ data, meta: { next_cursor } }). No migration required — scopes and
tables already exist. Versioned as 0.3.0 (folded into [Unreleased]);
docs/public-api.md updated; outbound webhooks left as the sole roadmap
item. API stays v1 (additive).

Includes fixes from an adversarial code review: cursor validation
against filter injection, broadcast counts deferred to the DB aggregate
trigger, terminal broadcast status reflects failures, duplicate-phone
de-dup, up-front send-param validation (no orphan conversations),
PATCH null-clear semantics, error-checked tag sync, bounded tag filter,
unified audit-user attribution, and a maxDuration on the broadcast
fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:10:19 +03:00

296 lines
9.5 KiB
Markdown

# Public API (`/api/v1`)
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, and the
> messages / contacts / conversations / broadcasts endpoints ship now.
> The one remaining roadmap item is outbound event webhooks — see
> [Roadmap](#roadmap).
## 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.
1. Give the key a name (after the integration that will use it).
2. Grant the **scopes** it needs — nothing more (see below).
3. 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.
### Revoking a key
**Settings → API keys → Revoke.** Revocation is effective on the
key's next request. Revoked keys stay in the list as an audit trail.
## Scopes
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 |
A key with **no scopes** still authenticates and can call
`GET /api/v1/me` — useful for verifying a key works.
## Response envelope
Every response uses one of two shapes:
```jsonc
// success
{ "data": { /* ... */ } }
// failure
{ "error": { "code": "forbidden", "message": "This API key is missing the 'messages:send' scope" } }
```
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 |
## Rate limits
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 resets
- `X-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.
## Endpoints
### `GET /api/v1/me`
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.
```bash
curl https://your-crm.example.com/api/v1/me \
-H "Authorization: Bearer wacrm_live_xxx"
```
```json
{
"data": {
"account": { "id": "…", "name": "Acme Inc" },
"key": { "id": "…", "scopes": ["messages:send"] }
}
}
```
### `POST /api/v1/messages`
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.
```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`). Media needs `media_url` (and optional
`filename`); `text` doubles as the caption. `template` needs a
`template` object:
```jsonc
{
"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):
```json
{
"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).
### `GET /api/v1/contacts`
List contacts, newest first. Scope: `contacts:read`. Paginated (see
[Pagination](#pagination)). Optional filters: `?search=` (matches name
or phone) and `?tag=<tagId>`.
```json
{
"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": "…" }
}
```
### `POST /api/v1/contacts`
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).
### `GET` / `PATCH /api/v1/contacts/{id}`
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`.
### `GET /api/v1/conversations`
List conversations, newest first. Scope: `conversations:read`.
Paginated. Optional filters: `?status=` (`open` / `pending` / `closed`)
and `?contact_id=`. Each conversation embeds its contact + tags.
### `GET /api/v1/conversations/{id}`
Read one conversation. Scope: `conversations:read`. `404` if it belongs
to another account.
### `GET /api/v1/conversations/{id}/messages`
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).
### `POST /api/v1/broadcasts`
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.
```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" }
]
}'
```
Recipients are capped at **1000 per request** — split larger sends.
Invalid phone numbers are dropped and counted as `rejected`. Response
(202):
```json
{
"data": {
"broadcast_id": "…",
"status": "sending",
"total_recipients": 2,
"accepted": 2,
"rejected": 0
}
}
```
### `GET /api/v1/broadcasts/{id}`
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.
## Pagination
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.
## Roadmap
The one remaining item tracked in
[#245](https://github.com/ArnasDon/wacrm/issues/245):
- **Outbound event webhooks** — register a URL to receive signed POSTs
when things happen in your account (`message.received`,
`message.status_updated`, `conversation.created`) so automations can
*react* to inbound activity instead of polling. Planned:
a `webhook_endpoints` table (next migration, `028_*`), a
`webhooks:manage` scope, `/api/v1/webhooks` management endpoints, and
HMAC-`X-Wacrm-Signature`-signed delivery with retry/backoff.