Reference
Data API (public, read-only)
A read-only HTTP API for pulling data out of the CRM: conversations, messages, the contact directory, groups and tags. Built for n8n, Make, a data warehouse, a nightly backup, or your own scripts.
It writes nothing. Sending messages and pushing data into the CRM is what the
internal endpoints (/api/whatsapp/send, /api/agent/*) are for — different
surface, different secret.
The same reference lives inside the app at /api-docs, with a live tester.
Keys are created in Settings → API.
Base URL
https://your-crm.example.com/api/v1
Authentication
Every request carries an API key, created in Settings → API. The plaintext is shown once at creation; the server stores only its SHA-256 hash, so a lost key is revoked and replaced, never recovered.
Either header works:
Authorization: Bearer wac_live_xxxxxxxx_...
X-API-Key: wac_live_xxxxxxxx_...
A key reads its whole organization's data and never another's. Scopes narrow
that: read (full read) or the granular conversations:read, messages:read,
contacts:read, groups:read, tags:read, registros:read.
registros:write — the one scope that permits writes — is not implied by
read. It has to be granted by name, so a key handed to a reporting tool can
never load or clear the padrón.
Sanity check:
curl -H "Authorization: Bearer $WACRM_KEY" \
"https://your-crm.example.com/api/v1/meta"
Date windows and limits
Every list endpoint runs inside a mandatory date window. That's what keeps a misconfigured integration from asking for the entire history and taking the instance down with it.
| Rule | Value | Override |
|---|---|---|
Default window when from/to are absent |
last 30 days | PUBLIC_API_DEFAULT_RANGE_DAYS |
| Maximum window per request | 92 days | PUBLIC_API_MAX_RANGE_DAYS |
| Maximum page size | 500 rows | — |
| Rate limit per key | 120 req/min | — |
Rate limit for /export |
6 req/min | — |
Maximum rows per /export call |
50 000 | PUBLIC_API_EXPORT_MAX_ROWS |
Exceeding the window returns 400 invalid_parameter — split the download into
chunks. Exceeding the rate limit returns 429 with a Retry-After header.
A bare date in to means the end of that day, so
from=2026-08-01&to=2026-08-31 is all of August.
Pagination
Keyset (cursor) pagination, not OFFSET: page 900 costs what page 1 costs, and
rows aren't skipped when new messages arrive mid-download.
{
"data": [ /* rows */ ],
"meta": {
"count": 100,
"has_more": true,
"next_cursor": "MjAyNi0wOC0yN1QxNDoyMjowMS4xMjNafGE3Zi0...",
"from": "2026-07-28T00:00:00.000Z",
"to": "2026-08-27T23:59:59.999Z",
"limit": 100,
"order": "desc"
}
}
Loop until meta.has_more is false, passing meta.next_cursor as cursor
and keeping every other filter identical.
Endpoints
GET /api/v1/meta — scope read
Credential check and capability discovery: what the key may read, how much data exists, and the server's current limits.
GET /api/v1/conversations — scope conversations:read
| Param | Description |
|---|---|
from, to, limit, cursor, order |
Window + pagination |
date_field |
last_message_at (default), created_at, updated_at |
status |
open, pending, closed |
group_id |
Conversations whose contact is in that group |
contact_id, phone |
A single contact's conversations |
include |
contact, group |
GET /api/v1/conversations/{id}/messages — scope messages:read
One thread in chronological order (order=asc by default), with the
conversation header — contact and group included — so you can archive a whole
thread from one response.
GET /api/v1/messages — scope messages:read
Every message in the org, flat. This is the endpoint for "download all conversations": filter by date and page through the cursor.
| Param | Description |
|---|---|
from, to, limit, cursor, order |
Window + pagination |
conversation_id |
One thread |
contact_id, phone |
All of one contact's threads |
group_id |
All threads of a group |
sender_type |
customer, agent, bot |
content_type |
text, image, document, audio, video, location, template, note, interactive |
include |
conversation, contact |
GET /api/v1/contacts — scope contacts:read
The directory. For incremental syncs use date_field=updated_at — it returns
only what changed since your last pass.
| Param | Description |
|---|---|
from, to, limit, cursor, order |
Window + pagination |
date_field |
created_at (default), updated_at |
group_id, tag_id |
Filter by group / tag |
q |
Search name, phone, email, company |
phone |
Exact phone |
include |
tags, group, custom_fields |
GET /api/v1/groups — scope groups:read
The group taxonomy with contact counts. Bounded table: no date window, no
pagination. ?with_counts=false skips the tally.
GET /api/v1/tags — scope tags:read
Same shape, for tags.
GET /api/v1/registros — scope registros:read
The padrón loaded through the API: dni, telefono, correo. The date window
is optional here — a reference table is usually wanted whole, and limit +
the keyset cursor already bound each request.
| Param | Description |
|---|---|
dni, telefono, correo |
Exact match |
q |
Search across all three |
from, to |
Optional, over created_at |
limit, cursor, order |
As everywhere else |
GET /api/v1/registros/schema — scope registros:read
Real column names and types (read from information_schema, so a column added
later shows up without a redeploy), the current row count, the writable
columns, the load modes and the limits.
POST /api/v1/registros — scope registros:write
Bulk-load rows. This is the only writable endpoint in the API, and
registros:write is never granted by the read catch-all — a reporting key
cannot wipe the table.
{
"mode": "upsert", // append (default) | upsert | replace
"confirm": "REPLACE", // required only when mode = replace
"rows": [
{ "dni": "12345678-9", "telefono": "+56912345678", "correo": "ana@ejemplo.com" }
]
}
mode |
Behaviour |
|---|---|
append |
Insert. Fails with 409 if a dni already exists. |
upsert |
Insert, and update rows whose dni already exists. |
replace |
Delete every row of the organization, then load. Needs "confirm": "REPLACE". |
Rules:
dniis required and unique per organization — it's the keyupsertmerges on.telefonoandcorreoare optional.- Max 5 000 rows per request, 30 requests/minute. Inserted in batches of 500.
- Validated up front: one bad row returns
422with the index and reason for each, and nothing is loaded. A failure at row 4 000 can't leave you with half a table. - A
dnirepeated inside the same request is rejected before touching the database.
Response: { "success": true, "mode": "upsert", "deleted": 0, "inserted": 2, "received": 2 }
DELETE /api/v1/registros?confirm=TRUNCATE — scope registros:write
Clears every row belonging to your organization and reports how many it
removed. Without confirm=TRUNCATE it returns 428 and touches nothing.
It's a scoped DELETE, not a real TRUNCATE: the table is multi-tenant, and
TRUNCATE cannot be filtered — it would wipe every organization's rows, not
just yours.
GET /api/v1/export — scope depends on entity
Bulk download, streamed. The server walks the window in 500-row keyset chunks and emits each as it reads it, so memory stays flat whether the window holds 50 rows or 50 000.
| Param | Description |
|---|---|
entity |
messages (default), conversations, contacts, groups, tags |
format |
ndjson (default), csv, json |
from, to |
Same window cap as everywhere else |
status, sender_type, content_type, group_id, conversation_id |
Entity-appropriate filters |
NDJSON is the default because it's the only one of the three a consumer can
process without buffering the whole download. When the row cap is hit, the
response ends with a _truncated marker naming the timestamp to resume from.
curl -L -H "Authorization: Bearer $WACRM_KEY" \
"https://your-crm.example.com/api/v1/export?entity=messages&format=csv&from=2026-08-01&to=2026-08-31" \
-o messages-august.csv
Joining resources
The ids are the same everywhere:
contact_groups.id ──> contacts.group_id
contacts.id ──> conversations.contact_id
conversations.id ──> messages.conversation_id
tags.id <──> contact_tags <──> contacts.id
Two ways to resolve them:
- Server-side — pass
include=contact,group(orinclude=tags) and the row arrives with the relation embedded. Fewer requests, bigger responses. - Client-side — pull
/groupsand/contactsonce, key them byid, then page/messageswithoutinclude. Cheapest at volume.
You can also filter from the far end of the chain and let the server walk it:
/api/v1/messages?group_id=… returns the messages of every contact in that
group.
Paging through everything
CURSOR=""
while : ; do
RES=$(curl -s -H "Authorization: Bearer $WACRM_KEY" \
"$BASE/api/v1/messages?from=2026-08-01&to=2026-08-27&limit=500&cursor=$CURSOR")
echo "$RES" | jq -c '.data[]' >> messages.ndjson
[ "$(echo "$RES" | jq -r '.meta.has_more')" = "true" ] || break
CURSOR=$(echo "$RES" | jq -r '.meta.next_cursor')
done
Python, month by month (the window cap is 92 days, so a long history is walked in slices):
import os, requests
from datetime import date
from dateutil.relativedelta import relativedelta
BASE = "https://your-crm.example.com/api/v1"
H = {"Authorization": f"Bearer {os.environ['WACRM_KEY']}"}
def page(path, **params):
cursor = None
while True:
r = requests.get(BASE + path, headers=H,
params={**params, "limit": 500, "cursor": cursor})
r.raise_for_status()
body = r.json()
yield from body["data"]
if not body["meta"]["has_more"]:
return
cursor = body["meta"]["next_cursor"]
month, end = date(2025, 1, 1), date.today()
while month < end:
nxt = min(month + relativedelta(months=1), end)
for m in page("/messages", **{"from": month.isoformat(), "to": nxt.isoformat()}):
print(m["created_at"], m["sender_type"], m.get("content_text"))
month = nxt
n8n HTTP Request node:
Method: GET
URL: https://your-crm.example.com/api/v1/messages
Authentication: Generic Credential Type → Header Auth
Name: Authorization
Value: Bearer wac_live_xxxxxxxx_...
Query Parameters:
from {{ $now.minus(1, 'days').toFormat('yyyy-MM-dd') }}
to {{ $now.toFormat('yyyy-MM-dd') }}
limit 500
include contact
Options → Pagination:
Mode Response Contains Next URL
Next URL {{ $response.body.meta.next_cursor
? $request.url + '&cursor=' + $response.body.meta.next_cursor
: '' }}
Complete Expression {{ $response.body.meta.has_more === false }}
Errors
Every error is JSON with a stable error code and a human message.
| HTTP | error |
When |
|---|---|---|
| 400 | invalid_parameter |
Bad date, window over the cap, corrupt cursor. parameter names the field. |
| 401 | unauthorized |
Header missing, key unknown, revoked, or expired. |
| 403 | insufficient_scope |
Valid key, missing scope for that resource. |
| 404 | not_found |
Resource isn't in your organization. |
| 409 | duplicate_dni |
That dni is already loaded. Use mode=upsert or mode=replace. |
| 413 | too_many_rows |
More than 5 000 rows in one POST. Split it into batches. |
| 422 | invalid_rows |
Invalid rows: returns index and reason for each. Nothing was loaded. |
| 428 | confirmation_required |
A destructive call is missing its confirm. |
| 429 | Rate limit exceeded |
Over budget — wait Retry-After seconds. |
| 503 | not_configured |
Server is missing SUPABASE_SERVICE_ROLE_KEY. |
Setup
- Apply
supabase/migrations/018_public_api_keys.sqland019_wacrm_registros.sql(see Supabase setup). They createwacrm_api_keys,wacrm_registrosand the export indexes. Everything they create carries awacrm_prefix, so it can't collide with anapi_keystable belonging to another tool that shares the database — and it never touches one. - Make sure
SUPABASE_SERVICE_ROLE_KEYis set —/api/v1/*resolves keys with it and returns503without it. - Optionally tune
PUBLIC_API_MAX_RANGE_DAYS,PUBLIC_API_DEFAULT_RANGE_DAYSandPUBLIC_API_EXPORT_MAX_ROWS(see Environment variables). - Create a key in Settings → API and store it in your integration's secret manager.
Security notes
- Keys are hashed with SHA-256; the plaintext never touches the database. The secret is 24 bytes of CSPRNG output, so there's no dictionary to attack and a slow KDF would only make every request slower.
- Revoking is instant and soft — the row stays for the audit trail
(
last_used_at,request_count) but stops authenticating. - A key can read everything its organization owns. Treat it like a database password: server-side only, never in client code or a public repo.
- The rate limiter is in-process. On a single-instance VPS — the usual
deployment — that's exactly right; behind several instances, swap
src/lib/rate-limit.tsfor a shared store (the call sites won't change).