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:

  • dni is required and unique per organization — it's the key upsert merges on. telefono and correo are optional.
  • Max 5 000 rows per request, 30 requests/minute. Inserted in batches of 500.
  • Validated up front: one bad row returns 422 with 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 dni repeated 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 (or include=tags) and the row arrives with the relation embedded. Fewer requests, bigger responses.
  • Client-side — pull /groups and /contacts once, key them by id, then page /messages without include. 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

  1. Apply supabase/migrations/018_public_api_keys.sql and 019_wacrm_registros.sql (see Supabase setup). They create wacrm_api_keys, wacrm_registros and the export indexes. Everything they create carries a wacrm_ prefix, so it can't collide with an api_keys table belonging to another tool that shares the database — and it never touches one.
  2. Make sure SUPABASE_SERVICE_ROLE_KEY is set — /api/v1/* resolves keys with it and returns 503 without it.
  3. Optionally tune PUBLIC_API_MAX_RANGE_DAYS, PUBLIC_API_DEFAULT_RANGE_DAYS and PUBLIC_API_EXPORT_MAX_ROWS (see Environment variables).
  4. 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.ts for a shared store (the call sites won't change).