Integrate your systems with WSC
A REST API with JSON, authenticated by API keys with scopes, and signed webhooks to receive events. Everything you need to send messages, sync contacts and automate billing.
Overview
Your system calls the WSC API to act (send, create, query); WSC calls your system back through webhooks when something happens. Each key belongs to one account and only does what its scopes allow.
- HTTPS only, JSON in and out (UTF-8).
- Every response comes in an envelope:
dataon success,erroron failure. - Phone numbers always in E.164 format, with
+and the country code.
https://crm.walbby.com/api/v1Authentication with API keys
Every request carries an API key. Keys are created in the dashboard by administrators and act on a single account.
- Go to Settings → API keys → New API key.
- Name it after the integration and check only the scopes it needs.
- Copy the key: it is shown only once. If you lose it, revoke it and create another.
Send the key as a bearer token in the Authorization header. GET /api/v1/me needs no scope and is the quickest way to check a key.
curl https://crm.walbby.com/api/v1/me \ -H "Authorization: Bearer wacrm_live_xxx"{ "data": { "account": { "id": "8b1f…", "name": "Loja Exemplo" }, "key": { "id": "c41a…", "scopes": ["inbox.reply", "contacts.view"] } }}Scopes
A key can only do what its scopes allow, whoever created it. Grant the minimum each integration needs.
| Scope | Allows |
|---|---|
| contacts.view | List and read contacts |
| contacts.create | Create contacts |
| contacts.edit | Update contacts and their tags |
| inbox.view | List and read conversations and messages |
| inbox.reply | Send WhatsApp messages |
| broadcasts.view | Read the status and counts of a broadcast |
| broadcasts.send | Launch broadcasts |
| billing.view | List and read invoices |
| billing.edit | Create, pay and cancel invoices |
| webhooks.manage | Register and manage webhooks |
403 forbidden. A key with no scopes still authenticates and can call /api/v1/me.Responses and errors
Every response has one of two shapes. Branch your code on error.code, which is stable; error.message is for people and may change.
// 2xx{ "data": { … } } // 4xx / 5xx{ "error": { "code": "forbidden", "message": "This API key is missing the 'inbox.reply' scope" }}| Status | code | Meaning |
|---|---|---|
| 400 | bad_request | Invalid input: a missing field, a phone without +, a wrong type |
| 401 | unauthorized | Key missing, malformed, unknown or revoked |
| 403 | forbidden | Valid key without the required scope, or a module outside the plan |
| 404 | not_found | The resource does not exist or belongs to another account |
| 409 | conflict | A transition is not allowed, such as canceling a paid invoice |
| 429 | rate_limited | Too many requests for this key: wait and try again |
| 500 | internal | Unexpected error on our side: retry with backoff |
Resources from another account always answer 404, never 403: the API does not reveal what exists outside your account.
Rate limits
Each key can make 120 requests per minute. Every response tells you how much is left.
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Resetcome in the responses.- Over the limit, the answer is
429 rate_limitedwithRetry-Afterin seconds. - For large volumes, prefer broadcasts (up to 1,000 recipients per call) over one call per message.
Pagination
Every list pages the same way: by an opaque cursor, which stays stable while new records arrive.
limitsets the page size: 50 by default, 100 at most.- Pass
meta.next_cursorback ascursorexactly as you received it, without decoding it. next_cursor: nullmeans you reached the last page.
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 pageEndpoints
All the operations of the public API and the scope each one needs.
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /api/v1/me | — | The key's account and scopes |
| GET | /api/v1/channels | — | The account's WhatsApp numbers |
| POST | /api/v1/messages | inbox.reply | Send a message to a phone number |
| GET | /api/v1/contacts | contacts.view | List contacts (search, tag) |
| POST | /api/v1/contacts | contacts.create | Create or find a contact by phone |
| GET | /api/v1/contacts/{id} | contacts.view | Read a contact |
| PATCH | /api/v1/contacts/{id} | contacts.edit | Update fields and tags |
| GET | /api/v1/conversations | inbox.view | List conversations (status, contact) |
| GET | /api/v1/conversations/{id} | inbox.view | Read a conversation |
| GET | /api/v1/conversations/{id}/messages | inbox.view | Messages of a conversation |
| POST | /api/v1/broadcasts | broadcasts.send | Launch a template broadcast |
| GET | /api/v1/broadcasts/{id} | broadcasts.view | Broadcast status and counts |
| GET | /api/v1/invoices | billing.view | List invoices (status, overdue, dates) |
| POST | /api/v1/invoices | billing.edit | Create an invoice (idempotent) |
| GET | /api/v1/invoices/{id} | billing.view | Read an invoice |
| POST | /api/v1/invoices/{id}/pay | billing.edit | Mark as paid |
| POST | /api/v1/invoices/{id}/cancel | billing.edit | Cancel an open invoice |
| GET | /api/v1/webhooks | webhooks.manage | List webhooks |
| POST | /api/v1/webhooks | webhooks.manage | Register a webhook |
| PATCH | /api/v1/webhooks/{id} | webhooks.manage | Change URL, events or reactivate |
| DELETE | /api/v1/webhooks/{id} | webhooks.manage | Remove a webhook |
Sending messages
Send to a phone number, not to an internal id. WSC finds or creates the contact and the conversation, sends the message and returns the ids.
tomust have+and the country code. Spaces, dashes and parentheses are accepted; a number without+is refused, because it could go to the wrong country.type:text(default),template,image,video,documentoraudio. For media,textbecomes the caption.channel_idchooses the number that sends (seeGET /api/v1/channels); without it, the account's main number is used.- On the official API, outside the 24-hour window since the customer's last message, WhatsApp only delivers approved templates.
curl -X POST https://crm.walbby.com/api/v1/messages \ -H "Authorization: Bearer wacrm_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "to": "+5511999990000", "type": "text", "text": "Olá! Seu pedido saiu para entrega 🚚" }'{ "to": "+5511999990000", "type": "template", "template": { "name": "order_update", "language": "pt_BR", "params": ["A123"] }, "channel_id": "<id from GET /api/v1/channels>"}{ "to": "+5511999990000", "type": "document", "media_url": "https://example.com/boleto-1042.pdf", "filename": "boleto-1042.pdf", "text": "Segue o seu boleto"}// 201 Created{ "data": { "message_id": "5f0c…", "whatsapp_message_id": "wamid.HBgM…", "conversation_id": "e2a9…", "channel_id": "71d3…", "contact_id": "9c44…", "contact_created": true }}Official API and Wpp Web numbers
In GET /api/v1/channels, provider is meta_cloud for numbers on the official API and wpp_web for Wpp Web numbers. What changes between them:
| Feature | Official API | Wpp Web |
|---|---|---|
| Text and media | Yes | Yes |
| Templates | Yes, approved by Meta | No: answers unsupported_by_channel |
| Buttons and lists | Native | Numbered menu; the reply 1, 2… is matched to the option |
| Broadcasts through the API | Yes | Only from the dashboard, with paced sending |
Errors specific to sending
| code | Meaning |
|---|---|
| whatsapp_not_configured | The account (or the channel_id given) has no connected number |
| unsupported_by_channel | The number does not support that type, such as a template on Wpp Web |
| meta_error | WhatsApp refused the send; the message explains why |
| account_suspended | The account is suspended |
Contacts and conversations
Keep your base in sync and read the history of any customer.
POST /contactsis find-or-create by phone:201when it creates,200with the existing contact when the phone is already there.tagstakes tag names; the ones that do not exist yet are created. InPATCH,tagsreplaces the contact's tags.- The same customer writing to two of your numbers has two conversations, each with its
channel_id.
curl -X POST https://crm.walbby.com/api/v1/contacts \ -H "Authorization: Bearer wacrm_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "phone": "+5511999990000", "name": "Mariana Costa", "tags": ["lead", "site"] }' # 201 → new contact · 200 → that phone already existed (same contact back)Broadcasts
Send an approved template to up to 1,000 recipients per call. The call returns at once and sending continues in the background.
- Invalid phone numbers are dropped and counted in
rejected; if none is valid, the answer is400. paramsper recipient fill the template variables in order.- Follow progress with
GET /api/v1/broadcasts/:id: delivered and read keep growing as WhatsApp confirms.
curl -X POST https://crm.walbby.com/api/v1/broadcasts \ -H "Authorization: Bearer wacrm_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Promo outubro", "template_name": "promo_outubro", "template_language": "pt_BR", "recipients": [ { "to": "+5511999990000", "params": ["Mariana"] }, { "to": "+5511988887777" } ] }' # 202 → { "data": { "broadcast_id": "…", "status": "sending",# "total_recipients": 2, "accepted": 2, "rejected": 0 } }Invoices (billing)
Issue charges from your ERP or gateway and let WSC handle the reminders on WhatsApp. When your system confirms payment, mark it paid and the reminders stop.
- Identify the customer by
contact_idor byphone(the contact is created if needed). - Idempotent by
external_id: sending the same one again returns the existing invoice withcreated: false, never a second charge. /payand/cancelcan be retried safely: they answeralready_paidoralready_canceled.- Canceling a paid invoice answers
409 conflict.
curl -X POST https://crm.walbby.com/api/v1/invoices \ -H "Authorization: Bearer wacrm_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "phone": "+5511999990000", "description": "Mensalidade outubro", "amount": 150, "due_date": "2026-10-30", "payment_url": "https://pay.example.com/erp-42", "external_id": "erp-42" }' # Mark it paid when your gateway confirms:curl -X POST https://crm.walbby.com/api/v1/invoices/<id>/pay \ -H "Authorization: Bearer wacrm_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "method": "pix", "external_id": "pix-e2e-889" }'Webhooks
Instead of polling the API, register a URL and WSC will POST to it every time something happens in the account.
| Event | Fires when |
|---|---|
| message.received | A message arrives from a contact |
| message.status_updated | A message you sent changes status (delivered, read…) |
| conversation.created | A new conversation opens for a contact |
| invoice.created | An invoice is created, by hand, by a recurring charge or by the API |
| invoice.paid | An invoice is marked paid |
| invoice.canceled | An invoice is canceled |
Registering an endpoint
curl -X POST https://crm.walbby.com/api/v1/webhooks \ -H "Authorization: Bearer wacrm_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/wsc", "events": ["message.received", "invoice.paid"] }' # 201 → { "data": { "id": "…", "url": "…", "events": [ … ], "secret": "whsec_…" } }# The secret is shown only in this response. Store it.What arrives
Every delivery has the same envelope; data changes by event. The id identifies the event and is the same on every retry: use it to discard duplicates.
POST https://example.com/hooks/wscX-Wacrm-Event: message.receivedX-Wacrm-Webhook-Id: 3e7b…X-Wacrm-Signature: t=1760270400,v1=5d41402abc4b2a76b9719d911017c592… { "id": "8f3c…", "event": "message.received", "occurred_at": "2026-10-12T12:00:00.000Z", "account_id": "8b1f…", "data": { "conversation_id": "e2a9…", "contact_id": "9c44…", "whatsapp_message_id": "wamid.HBgM…", "content_type": "text", "text": "Oi! Ainda tem o modelo azul?" }}Verifying the signature
X-Wacrm-Signature carries t (a timestamp) and v1 (an HMAC-SHA256). Recompute the HMAC over t.raw body with the endpoint's secret, compare in constant time, and refuse old timestamps.
import crypto from 'node:crypto';import express from 'express'; const app = express();const SECRET = process.env.WSC_WEBHOOK_SECRET; // whsec_… // Keep the raw body: the signature covers the exact bytes received.app.post('/hooks/wsc', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('X-Wacrm-Signature') ?? ''; const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/) ?? []; if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) { return res.sendStatus(400); // missing or too old: possible replay } const expected = crypto .createHmac('sha256', SECRET) .update(`${t}.${req.body}`) .digest('hex'); const valid = expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); if (!valid) return res.sendStatus(401); const event = JSON.parse(req.body); // Answer fast, dedupe on event.id, do the work in the background. res.sendStatus(200);});import hashlib, hmac, time def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts.get("t", "0"), parts.get("v1", "") if abs(time.time() - int(t)) > tolerance: return False # too old: possible replay expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, v1)Retries
- Answer any
2xxwithin 10 seconds. Another status, a timeout or a redirect counts as a failure, and the event is tried again: 11 attempts over about 15 hours. - Delivery is at least once and order is not guaranteed: dedupe on
id. - Each attempt is signed again with a new timestamp, so a retry stays inside your tolerance window.
- Every delivery and its attempts show in Settings → API → Webhooks, where a failed event can be resent. An endpoint that keeps failing is paused until you reactivate it.
AI assistants (MCP)
WSC has a Model Context Protocol server: plug it into Claude, Cursor or any MCP client and ask about your CRM in plain language.
- It uses the public API, with your key and its scopes: the assistant can never do more than the key allows.
- Read-only by default. Sending messages and changing data have to be switched on explicitly.
- Broadcasts need a second switch and an explicit confirmation on each send.
{ "mcpServers": { "WSC": { "command": "node", "args": ["/path/to/wsc-mcp/dist/index.js"], "env": { "WACRM_BASE_URL": "https://crm.walbby.com", "WACRM_API_KEY": "wacrm_live_xxx" } } }}WACRM_ENABLE_WRITES with the value true to env.Security
What WSC guarantees and what we recommend on your side.
- Keys are stored only as a hash: nobody, not even Walbby, can see a key again after it is created.
- Each key acts on a single account; resources from other accounts answer
404. - Revocation takes effect on the next request. Revoked keys stay listed as an audit trail.
- Webhooks only go to
https://public addresses; internal and private addresses are refused. - The webhook secret is shown once and every delivery is signed with a timestamp against replay.
- Keep keys on the server, never in the browser or an app, and use one key per integration.