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.

01

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: data on success, error on failure.
  • Phone numbers always in E.164 format, with + and the country code.
urlBase URL
https://crm.walbby.com/api/v1
Your system calls the WSC public API over HTTPS with a key; WSC talks to the customer's WhatsApp and sends signed webhooks back to your system.
02

Authentication with API keys

Every request carries an API key. Keys are created in the dashboard by administrators and act on a single account.

  1. Go to Settings → API keys → New API key.
  2. Name it after the integration and check only the scopes it needs.
  3. 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.

bashGET /api/v1/me
curl https://crm.walbby.com/api/v1/me \  -H "Authorization: Bearer wacrm_live_xxx"
json200 OK
{  "data": {    "account": {      "id": "8b1f…",      "name": "Loja Exemplo"    },    "key": {      "id": "c41a…",      "scopes": ["inbox.reply", "contacts.view"]    }  }}
Illustration with fictional data.
03

Scopes

A key can only do what its scopes allow, whoever created it. Grant the minimum each integration needs.

ScopeAllows
contacts.viewList and read contacts
contacts.createCreate contacts
contacts.editUpdate contacts and their tags
inbox.viewList and read conversations and messages
inbox.replySend WhatsApp messages
broadcasts.viewRead the status and counts of a broadcast
broadcasts.sendLaunch broadcasts
billing.viewList and read invoices
billing.editCreate, pay and cancel invoices
webhooks.manageRegister and manage webhooks
Scopes of a module your plan does not include answer 403 forbidden. A key with no scopes still authenticates and can call /api/v1/me.
04

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.

json
// 2xx{ "data": { … } } // 4xx / 5xx{  "error": {    "code": "forbidden",    "message": "This API key is missing the 'inbox.reply' scope"  }}
StatuscodeMeaning
400bad_requestInvalid input: a missing field, a phone without +, a wrong type
401unauthorizedKey missing, malformed, unknown or revoked
403forbiddenValid key without the required scope, or a module outside the plan
404not_foundThe resource does not exist or belongs to another account
409conflictA transition is not allowed, such as canceling a paid invoice
429rate_limitedToo many requests for this key: wait and try again
500internalUnexpected 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.

05

Rate limits

Each key can make 120 requests per minute. Every response tells you how much is left.

  • X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset come in the responses.
  • Over the limit, the answer is 429 rate_limited with Retry-After in seconds.
  • For large volumes, prefer broadcasts (up to 1,000 recipients per call) over one call per message.
When the window resets, the counter goes back to 120.
06

Pagination

Every list pages the same way: by an opaque cursor, which stays stable while new records arrive.

  • limit sets the page size: 50 by default, 100 at most.
  • Pass meta.next_cursor back as cursor exactly as you received it, without decoding it.
  • next_cursor: null means you reached the last page.
http
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
07

Endpoints

All the operations of the public API and the scope each one needs.

MethodPathScopeWhat it does
GET/api/v1/me—The key's account and scopes
GET/api/v1/channels—The account's WhatsApp numbers
POST/api/v1/messagesinbox.replySend a message to a phone number
GET/api/v1/contactscontacts.viewList contacts (search, tag)
POST/api/v1/contactscontacts.createCreate or find a contact by phone
GET/api/v1/contacts/{id}contacts.viewRead a contact
PATCH/api/v1/contacts/{id}contacts.editUpdate fields and tags
GET/api/v1/conversationsinbox.viewList conversations (status, contact)
GET/api/v1/conversations/{id}inbox.viewRead a conversation
GET/api/v1/conversations/{id}/messagesinbox.viewMessages of a conversation
POST/api/v1/broadcastsbroadcasts.sendLaunch a template broadcast
GET/api/v1/broadcasts/{id}broadcasts.viewBroadcast status and counts
GET/api/v1/invoicesbilling.viewList invoices (status, overdue, dates)
POST/api/v1/invoicesbilling.editCreate an invoice (idempotent)
GET/api/v1/invoices/{id}billing.viewRead an invoice
POST/api/v1/invoices/{id}/paybilling.editMark as paid
POST/api/v1/invoices/{id}/cancelbilling.editCancel an open invoice
GET/api/v1/webhookswebhooks.manageList webhooks
POST/api/v1/webhookswebhooks.manageRegister a webhook
PATCH/api/v1/webhooks/{id}webhooks.manageChange URL, events or reactivate
DELETE/api/v1/webhooks/{id}webhooks.manageRemove a webhook
08

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.

  • to must 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, document or audio. For media, text becomes the caption.
  • channel_id chooses the number that sends (see GET /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.
bashText
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 🚚" }'
jsonTemplate
{  "to": "+5511999990000",  "type": "template",  "template": {    "name": "order_update",    "language": "pt_BR",    "params": ["A123"]  },  "channel_id": "<id from GET /api/v1/channels>"}
jsonDocument with caption
{  "to": "+5511999990000",  "type": "document",  "media_url": "https://example.com/boleto-1042.pdf",  "filename": "boleto-1042.pdf",  "text": "Segue o seu boleto"}
json
// 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:

FeatureOfficial APIWpp Web
Text and mediaYesYes
TemplatesYes, approved by MetaNo: answers unsupported_by_channel
Buttons and listsNativeNumbered menu; the reply 1, 2… is matched to the option
Broadcasts through the APIYesOnly from the dashboard, with paced sending

Errors specific to sending

codeMeaning
whatsapp_not_configuredThe account (or the channel_id given) has no connected number
unsupported_by_channelThe number does not support that type, such as a template on Wpp Web
meta_errorWhatsApp refused the send; the message explains why
account_suspendedThe account is suspended
09

Contacts and conversations

Keep your base in sync and read the history of any customer.

  • POST /contacts is find-or-create by phone: 201 when it creates, 200 with the existing contact when the phone is already there.
  • tags takes tag names; the ones that do not exist yet are created. In PATCH, tags replaces the contact's tags.
  • The same customer writing to two of your numbers has two conversations, each with its channel_id.
bashPOST /api/v1/contacts
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)
10

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 is 400.
  • params per recipient fill the template variables in order.
  • Follow progress with GET /api/v1/broadcasts/:id: delivered and read keep growing as WhatsApp confirms.
bashPOST /api/v1/broadcasts
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 } }
11

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_id or by phone (the contact is created if needed).
  • Idempotent by external_id: sending the same one again returns the existing invoice with created: false, never a second charge.
  • /pay and /cancel can be retried safely: they answer already_paid or already_canceled.
  • Canceling a paid invoice answers 409 conflict.
bashPOST /api/v1/invoices
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" }'
12

Webhooks

Instead of polling the API, register a URL and WSC will POST to it every time something happens in the account.

EventFires when
message.receivedA message arrives from a contact
message.status_updatedA message you sent changes status (delivered, read…)
conversation.createdA new conversation opens for a contact
invoice.createdAn invoice is created, by hand, by a recurring charge or by the API
invoice.paidAn invoice is marked paid
invoice.canceledAn invoice is canceled

Registering an endpoint

bashPOST /api/v1/webhooks
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.

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

nodeExpress
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);});
python
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

Time after the event. In this example, your endpoint was down for a few minutes and the 5th attempt got through.
  • Answer any 2xx within 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.
13

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.
jsonMCP client configuration
{  "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"      }    }  }}
Ask Walbby for the MCP server package. To let it change data, add WACRM_ENABLE_WRITES with the value true to env.
14

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.
Found a security problem? Write to [email protected].