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

9.5 KiB

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.

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:

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

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"] }
  }
}

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.

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

GET /api/v1/contacts

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": "…" }
}

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.

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
  }
}

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:

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