This guide takes an external system — a CRM, an ERP, an online store, a booking system, a website back end or any custom application — from zero to a working WhatsApp integration with Scale: sending messages, following their delivery, receiving replies and reacting to connection changes. Every example below is the exact shape the Scale API accepts and returns.
Getting started
With the Scale API an external system can:
- send WhatsApp messages from your Scale workspace: text, approved templates, images, videos, audio and documents;
- check the status of every message it sent (pending, sent, delivered, read, failed, unknown);
- receive signed webhooks when a customer writes, when a message is sent, delivered, read or fails, and when WhatsApp is connected or disconnected;
- let a customer connect their own WhatsApp number through a page hosted by Scale;
- read the workspace's WhatsApp connection status and its message usage.
Your system (CRM, ERP, store, app)
│ HTTPS + Authorization: Bearer sk_live_…
▼
Scale Public API (https://getscale.me/wp-json/scale/v1/)
│
▼
Scale WhatsApp (your workspace, its Inbox, its allowance)
│
▼
Meta WhatsApp Cloud APIYour system only ever talks to Scale. The WhatsApp connection, its Meta credentials and the conversation with Meta stay inside Scale; the API never returns them. Everything your system sends through the API also appears in the Scale Inbox as part of the same conversation.
Prerequisites
| You need | Where it comes from |
|---|---|
| A Scale workspace with an active subscription | Your Scale account. API messages use the same message allowance as the rest of the workspace. |
| Public API access for the workspace | Enabled by Scale for your workspace. Without it every API call answers 403 API_ACCESS_DISABLED and the Developer & API page is not shown. |
| An API key | Dashboard → Settings → Developer & API → API keys → Create API key. The key is shown once. |
| A connected WhatsApp number | Dashboard → Settings → Channels, or through the hosted onboarding described below. |
| A webhook URL (optional, recommended) | Your own https:// endpoint, saved in Settings → Developer & API → Webhook. |
| An onboarding return URL (optional) | Settings → Developer & API → Onboarding return URLs — only if you use hosted onboarding with a redirect back to your site. |
https:// addresses on the public internet.Base URL
https://getscale.me/wp-json/scale/v1/Every endpoint in this guide is relative to it: POST /messages means POST https://getscale.me/wp-json/scale/v1/messages. Requests and responses are JSON (Content-Type: application/json).
Authentication
Send your API key in the Authorization header of every request:
Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX- A key is
sk_live_followed by 40 letters and digits. It belongs to ONE workspace, and everything it does happens in that workspace. - Header only. A key in the URL (
?api_key=,?key=,?token=,?access_token=,?apikey=) is refused with401 INVALID_API_KEYeven when a valid header is also present — revoke any key that was ever put in a URL. - Server side only. Never put the key in browser JavaScript, a mobile app or anything a customer can download. Keep it in your server's secret storage.
- Shown once. Scale stores only a hash of the key; after creation you see its first characters (
sk_live_xxxxxx…) and nothing else. Lost it? Create a new key and revoke the old one. - A workspace can have up to 20 active keys. Several systems can each have their own; they share the workspace's rate limit and allowance.
curl https://getscale.me/wp-json/scale/v1/usage \
-H "Authorization: Bearer sk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"Every answer is JSON. A success has "success": true plus its data; a failure has the same shape everywhere:
{
"success": false,
"error": {
"code": "INVALID_API_KEY",
"message": "The API key is missing or invalid.",
"details": {}
},
"request_id": "req_k2V9pQ7sL1mN4xR8tY3wZ6aB"
}Every response carries an X-Scale-Request-Id header with the same req_… id — quote it if you contact support. Answers past authentication also carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (see Rate limits).
Quick start
- Create an API key — Settings → Developer & API → API keys. Copy it into your server's secret storage.
- Connect WhatsApp — Settings → Channels in the dashboard, or send your customer through hosted onboarding (next section). Check it with
GET /whatsapp/status:"can_send": truemeans you are ready. - Configure a webhook — Settings → Developer & API → Webhook: your
https://URL. Copy the signing secret (whsec_…) shown once, and press "Send test event". - Send a first message —
POST /messageswith anIdempotency-Key. - Receive webhook events —
message.sent, thenmessage.deliveredandmessage.read;message.receivedwhen the customer answers. - Check a message's status —
GET /messages/{id}with themsg_…id you got back.
curl https://getscale.me/wp-json/scale/v1/whatsapp/status \
-H "Authorization: Bearer $SCALE_API_KEY"curl -X POST https://getscale.me/wp-json/scale/v1/messages \
-H "Authorization: Bearer $SCALE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-123-message-1" \
-d '{"to":"201012345678","type":"text","text":{"body":"Hello from our store! Your order #123 is confirmed."}}'{
"success": true,
"message": {
"id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"wamid": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"status": "accepted",
"type": "text",
"to": "201012345678",
"created_at": "2026-09-24T10:00:00Z"
},
"replayed": false
}curl https://getscale.me/wp-json/scale/v1/messages/msg_Q2w3E4r5T6y7U8i9O0p1A2s3 \
-H "Authorization: Bearer $SCALE_API_KEY"WhatsApp onboarding
Hosted onboarding lets the workspace connect its WhatsApp number without the dashboard: your system asks Scale for a one-time link, the person opens it, completes Meta's signup on a page Scale hosts, and is sent back to your site. Meta's signup runs on Scale — Meta's credentials never reach your system.
- (Optional) Register your return URL: Settings → Developer & API → Onboarding return URLs.
- Your server calls
POST /whatsapp/onboarding/session. - Send the person to the
onboarding_urlfrom the answer (a browser redirect or a link). - They complete Meta's Embedded Signup on the Scale page (entering their 6-digit WhatsApp PIN if the number asks for one).
- Scale sends their browser to your
return_urlwith the result — and, if you configured a webhook, sendswhatsapp.connected. - Your server confirms with
GET /whatsapp/onboarding/session/{id}orGET /whatsapp/status.
Start a session
curl -X POST https://getscale.me/wp-json/scale/v1/whatsapp/onboarding/session \
-H "Authorization: Bearer $SCALE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"return_url":"https://crm.example.com/scale/connected","state":"crm-account-42"}'| Field | Required | Meaning |
|---|---|---|
return_url | No | Where Scale sends the browser at the end. Must EXACTLY match a URL registered in Settings → Developer & API → Onboarding return URLs, or the call answers 422 INVALID_RETURN_URL. Without it, the page tells the person they can close it. |
state | No | Your own reference, up to 200 visible ASCII characters. Returned to you on the return URL. |
{
"success": true,
"session_id": "onb_Z1x2C3v4B5n6M7a8S9d0F1g2",
"onboarding_url": "https://getscale.me/?scale_onboarding=1#t=Hk3mP9sQ2vX7yB4nC8dF1gJ6kL0zR5tW3eA9uI2oN7pM4qS8",
"expires_at": "2026-09-24T10:30:00Z",
"return_url": "https://crm.example.com/scale/connected"
}- The link is valid for 30 minutes and opens once: the first browser to open it is bound to the session; opening it again answers "This link has already been used".
- Only one session is live per workspace: creating a new one cancels any previous session that has not finished.
- If the workspace already has a working WhatsApp connection, the call answers
409 WHATSAPP_ALREADY_CONNECTED— disconnect in Scale first to connect a different number. 503 ONBOARDING_UNAVAILABLEmeans onboarding is not available on this Scale installation right now.
Return URLs
| Rule | Detail |
|---|---|
| HTTPS | https:// only, a public host name, at most 500 characters. |
| Exact match | The return_url in the request must be character-for-character one of the registered URLs. |
| Not allowed | Credentials (user:pass@), a #fragment, or a query that already contains session_id, status, state or error — Scale adds those. |
| How many | Up to 10 per workspace. |
When the person finishes, cancels or the connection fails, Scale redirects the browser to your return URL with these query parameters added (empty ones are left out):
https://crm.example.com/scale/connected?session_id=onb_Z1x2C3v4B5n6M7a8S9d0F1g2&status=connected&state=crm-account-42
https://crm.example.com/scale/connected?session_id=onb_Z1x2C3v4B5n6M7a8S9d0F1g2&status=failed&state=crm-account-42&error=meta_pin_requiredGET /whatsapp/onboarding/session/{id} before you rely on it.Check a session
curl https://getscale.me/wp-json/scale/v1/whatsapp/onboarding/session/onb_Z1x2C3v4B5n6M7a8S9d0F1g2 \
-H "Authorization: Bearer $SCALE_API_KEY"{
"success": true,
"session_id": "onb_Z1x2C3v4B5n6M7a8S9d0F1g2",
"status": "connected",
"error": null,
"return_url": "https://crm.example.com/scale/connected",
"created_at": "2026-09-24T10:00:00Z",
"expires_at": "2026-09-24T10:30:00Z",
"completed_at": "2026-09-24T10:04:12Z",
"whatsapp": {
"phone_number_id": "109876543210987",
"waba_id": "123456789012345"
}
}| status | Meaning |
|---|---|
created | Session created, link not opened yet. |
opened | The link was opened; Meta's signup is in progress. |
connecting | Scale is finishing the connection with Meta. |
pin_required | The number needs its 6-digit WhatsApp PIN; the page asks for it. |
connected | Done. whatsapp holds the phone number ID and WABA ID. |
failed | It did not connect; error holds the reason code. |
cancelled | The person cancelled, or a newer session replaced this one (error: superseded). |
expired | The 30 minutes passed before it finished. |
A session belongs to its workspace: another workspace's id answers 404 ONBOARDING_SESSION_NOT_FOUND.
Sending messages
POST /messages sends ONE message to ONE recipient. The body always has to and type, plus one object named after the type.
| Field | Rule |
|---|---|
to | The recipient's full international number, WITH the country code: 201012345678. A leading + or 00, spaces and dashes are accepted and removed; 8–15 digits. A national number without its country code is not converted. |
type | text, template, image, video, audio or document. |
Idempotency-Key header | Optional but strongly recommended — see Idempotency. |
- The 24-hour window.
textand media are only accepted within 24 hours of the customer's last message to your number; otherwise the answer is422 OUTSIDE_MESSAGING_WINDOWwithdetails.alternative: "template". Templates can be sent at any time, and a template to a new number also creates the conversation in Scale. - One message at a time per workspace. Scale sends a workspace's API messages one after another. A request that arrives while another is being sent waits up to 1 second, then answers
429 RATE_LIMITEDwithdetails.reason: "send_in_progress"andRetry-After: 1— retry it (with the same Idempotency-Key). - What a success means.
200with"status": "accepted"means WhatsApp accepted the message and returned its id (wamid). Delivery and reading come later — through webhooks orGET /messages/{id}. - It is a normal conversation message. The message appears in the Scale Inbox, and one message is taken from the workspace's allowance.
Text
{
"to": "201012345678",
"type": "text",
"text": { "body": "Your appointment is confirmed for Thursday at 4 pm." }
}text.body is required, at most 4,000 characters.
Template
{
"to": "201012345678",
"type": "template",
"template": {
"name": "order_shipped",
"language": "ar",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Ahmed" },
{ "type": "text", "text": "#123" }
]
}
]
}
}template.name and template.language are required; template.components is optional. Details in Templates.
Image
{
"to": "201012345678",
"type": "image",
"image": { "link": "https://cdn.example.com/products/sku-778.jpg", "caption": "Your new bag" }
}Video
{
"to": "201012345678",
"type": "video",
"video": { "link": "https://cdn.example.com/howto/setup.mp4", "caption": "How to set it up" }
}Audio
{
"to": "201012345678",
"type": "audio",
"audio": { "link": "https://cdn.example.com/voice/welcome.ogg" }
}Document
{
"to": "201012345678",
"type": "document",
"document": {
"link": "https://cdn.example.com/invoices/INV-2026-0042.pdf",
"filename": "INV-2026-0042.pdf",
"caption": "Your invoice"
}
}| Type | Required | Optional | Limits |
|---|---|---|---|
text | text.body | — | 4,000 characters |
template | template.name, template.language | template.components | up to 20 components |
image | image.link | image.caption | caption up to 1,000 characters |
video | video.link | video.caption | caption up to 1,000 characters |
audio | audio.link | — | audio takes no caption |
document | document.link | document.filename, document.caption | caption up to 1,000 characters |
Every type answers with the same success shape shown in Quick start. A message that could not be sent answers with an error (see Errors); META_API_ERROR is the one to read carefully.
Templates
A template is a message Meta has approved in advance for your WhatsApp Business Account. It is the only kind of message WhatsApp lets you send outside the 24-hour window — a first contact, an order update, a reminder.
name— the approved template's name exactly as Meta has it: lowercase letters, digits and underscores (order_shipped).language— the WhatsApp language code of the approved translation:"ar","en","en_US". The form{ "code": "en_US" }is accepted too.components— the variable parts, passed to Meta exactly as you send them. Each component is an object whosetypeisheader,body,buttonorfooter; itsparametersfollow Meta's template message format. Leavecomponentsout for a template with no variables.- The template must be approved for the WhatsApp Business Account connected to the workspace. Meta checks the name, language and parameters: a template that does not exist, is not approved, or gets the wrong number of parameters is refused by Meta —
502 META_API_ERROR,details.outcome: "refused", nothing sent and nothing charged. - A template sent through the API takes one message from the workspace allowance, like any API message. Meta's own WhatsApp charges are separate and billed by Meta to the connected WhatsApp Business Account.
{
"to": "201012345678",
"type": "template",
"template": {
"name": "new_collection",
"language": { "code": "en_US" },
"components": [
{ "type": "header", "parameters": [ { "type": "image", "image": { "link": "https://cdn.example.com/banner.jpg" } } ] },
{ "type": "body", "parameters": [ { "type": "text", "text": "Sara" } ] }
]
}
}Media messages
- You host the file. Scale does not upload or store media you send:
linkmust be a publichttps://URL, and Meta downloads the file from it. Anything behind a login, a VPN or an expiring signature that expires too soon will fail. - Scale checks only that
linkis a validhttps://URL. The file's type and size are checked by WhatsApp; a file it cannot use is refused (502 META_API_ERROR) or reported later asmessage.failed. captionis accepted for image, video and document (up to 1,000 characters); audio takes none.document.filenameis the name the customer sees.- Media is free-form: only inside the 24-hour window.
Media your CUSTOMERS send you arrives differently — see message.received: images, audio and documents are stored by Scale, and the webhook carries a link to Scale's copy.
Idempotency
Networks fail. A request can reach Scale, send the message, and lose its answer on the way back — and if your system simply tries again, the customer gets the message twice. The Idempotency-Key header prevents that.
Idempotency-Key: order-123-message-1- Optional, but use it for every send. Without it every request is a new message, and a retry after a timeout is a duplicate.
- 1–150 visible ASCII characters (otherwise
400 INVALID_MESSAGE). Make it describe the message, not the attempt:order-123-message-1, not a random value per try. Keys are per workspace. - Same key, same request → the ORIGINAL answer: the same
msg_…,"replayed": true, and no second message or charge — even hours later. - Same key, different request →
409 IDEMPOTENCY_CONFLICT,details.message_idnaming the original. Nothing is sent. - The request is compared AFTER validation, so the same message written with different JSON spacing or key order is still the same request.
- Same key while the first request is still being sent →
429 RATE_LIMITED(send_in_progress). Wait a second and retry with the same key: you get the original message back. - After a refusal (
META_API_ERROR,outcome: "refused") nothing was sent, so retrying the same key tries again. - After an unknown outcome (
outcome: "unknown") the same key always returns the same unknown answer and is never sent again — see below.
Timeouts and unknown outcomes
If your HTTP call times out, retry with the SAME key: if Scale finished, you get its answer; if the send is still running you get send_in_progress; retry again shortly.
Sometimes Scale itself cannot know: WhatsApp did not answer in time, answered with a server error, or answered without a message id. The message may or may not have reached the customer. Scale then answers 502 META_API_ERROR with details.outcome: "unknown", "retry_safe": false and "charged": false. It will not charge it and will not send it again, because WhatsApp offers no way to send "only if not already sent".
Message status
curl https://getscale.me/wp-json/scale/v1/messages/msg_Q2w3E4r5T6y7U8i9O0p1A2s3 \
-H "Authorization: Bearer $SCALE_API_KEY"{
"success": true,
"message": {
"id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"wamid": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"type": "text",
"to": "201012345678",
"status": "delivered",
"failure": null,
"created_at": "2026-09-24T10:00:00Z"
}
}| status | Meaning |
|---|---|
pending | Recorded; the call to WhatsApp is in progress. |
sent | WhatsApp accepted it (one tick). |
delivered | It reached the customer's phone. |
read | The customer opened it (only if they allow read receipts). |
failed | Not delivered. failure.stage is send (WhatsApp refused the request; failure.meta_code is its error code; nothing was charged) or delivery (accepted, then failed; failure.message is WhatsApp's reason). |
unknown | Scale could not tell whether WhatsApp sent it — see Idempotency. A send still pending after 5 minutes also reads unknown. |
{
"success": true,
"message": {
"id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"wamid": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"type": "text",
"to": "201012345678",
"status": "failed",
"failure": { "stage": "delivery", "meta_code": null, "message": "Message undeliverable" },
"created_at": "2026-09-24T10:00:00Z"
}
}- Status only moves forward (
sent→delivered→read); a late or repeated update never moves it back.readcan arrive withoutdeliveredhaving been reported. - It is the same state the Scale Inbox shows — Scale updates both from WhatsApp's signed status webhook.
- Prefer webhooks to polling; if you poll, do it sparingly —
GETrequests share the 300-per-minute limit. - An id from another workspace, an unknown id and a malformed id all answer the same
404 MESSAGE_NOT_FOUND.
WhatsApp connection status
curl https://getscale.me/wp-json/scale/v1/whatsapp/status \
-H "Authorization: Bearer $SCALE_API_KEY"{
"success": true,
"connected": true,
"status": "connected",
"reason": null,
"provider": "meta",
"phone_number": "+201000000001",
"display_name": "Nile Leather House",
"waba_id": "123456789012345",
"phone_number_id": "109876543210987",
"connected_at": "2026-09-20T08:15:00Z",
"health": { "checked": true, "checked_at": "2026-09-24T09:58:03Z" },
"can_send": true,
"blockers": []
}| Field | Meaning |
|---|---|
status | connected · error (connected, but Meta refused the stored token — reconnect) · connecting (a connection is being set up) · disconnected (it was connected and has been disconnected) · pending (never connected). |
connected | true only when status is connected. |
reason | Why it is not usable: token_invalid or provider_unsupported (connected through Twilio, which the API does not support); otherwise null. |
provider | meta, twilio, or null when no number is connected. |
phone_number, display_name, waba_id, phone_number_id, connected_at | The connected number, its verified display name and Meta ids; null when not connected. |
health | Whether Scale checked the connection with Meta and when. Checks are cached for a few minutes; a check Meta could not answer says nothing either way. When Meta refused the token, health.meta_code holds Meta's error code. |
can_send | true when nothing blocks a send — the 24-hour window and rate limits depend on the request and are not included. |
blockers | What stops sending: whatsapp_not_connected, token_invalid, provider_unsupported, subscription_required, message_limit_reached. |
The endpoint only reads — it never changes the connection or the dashboard.
Webhooks
Scale can POST an event to your system whenever something happens in the workspace. Configure it in Settings → Developer & API → Webhook: your https:// URL (on the public internet) and the events you want (all seven by default). Scale shows the signing secret (whsec_…) ONCE, when the webhook is created and each time you rotate it; afterwards only its last four characters. "Send test event" delivers a webhook.test event so you can check your endpoint.
Every event is a JSON POST with the same envelope:
{
"event": "message.delivered",
"id": "evt_H7j8K9l0Z1x2C3v4B5n6M7q8",
"created_at": "2026-09-24T10:00:05Z",
"workspace_id": "ws_90408561-0487-485d-b513-0053582f3d55",
"data": { }
}id is the event's identity and never changes, even when the event is re-delivered — de-duplicate on it. workspace_id is the workspace's public id. Events are sent for WhatsApp conversations only.
message.received
A customer sent a message to the workspace's WhatsApp number (text, image, audio or document).
{
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAEhgUM0E5",
"from": "201012345678",
"type": "text",
"text": { "body": "Is size 42 available?" },
"media": null,
"contact": { "name": "Ahmed" },
"timestamp": "2026-09-24T10:02:11Z"
}{
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAEhgUM0F0",
"from": "201012345678",
"type": "image",
"text": null,
"media": { "type": "image", "url": "https://getscale.me/wp-content/uploads/scale-ai/img-7f3a.jpg", "mime": "image/jpeg", "caption": "This one" },
"contact": { "name": "Ahmed" },
"timestamp": "2026-09-24T10:03:40Z"
}typeistext,image,audioordocument. For media,textisnullandmediaholds a link to the copy Scale stored, its type, MIME type (when known) and the customer's caption. Other kinds of inbound message carry nomediain this version.- Do: store the message against the customer (
from), reply throughPOST /messages— replying within 24 hours needs no template. - The customer's message also lands in the Scale Inbox, counts as an incoming message against the allowance, and — if the workspace's AI assistant is switched on — may be answered by it.
message.sent
WhatsApp accepted a message the workspace sent — from the API, from the dashboard, from the AI assistant or from a campaign.
{
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"api_message_id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"to": "201012345678",
"type": "text",
"source": "api",
"status": "sent",
"text": { "body": "Hello from our store! Your order #123 is confirmed." },
"template": null,
"timestamp": "2026-09-24T10:00:01Z"
}source:api(sent through the API),ai(the AI assistant) orscale(a person in the dashboard, or a campaign).api_message_idis yourmsg_…id for API sends, otherwisenull.typeistext,templateor the media type; for a template,templateholds itsnameandlanguage.- Do: match API sends to your records by
api_message_id; use the others to mirror the conversation in your system.
message.delivered
{
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"api_message_id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"to": "201012345678",
"status": "delivered",
"timestamp": "2026-09-24T10:00:05Z"
}The message reached the customer's phone. Do: mark it delivered.
message.read
{
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"api_message_id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"to": "201012345678",
"status": "read",
"timestamp": "2026-09-24T10:07:30Z"
}The customer opened it — only if they allow read receipts, so never wait for it.
message.failed
{
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
"api_message_id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"to": "201012345678",
"status": "failed",
"timestamp": "2026-09-24T10:00:06Z",
"error": { "message": "Message undeliverable" }
}WhatsApp accepted the message and then could not deliver it. error.message is WhatsApp's reason. Do: flag it for a person — do not blindly resend.
Status events only move forward: a status that would go back (delivered after read) or repeat is not sent, and no second message.sent is sent for a message.
whatsapp.connected
{
"phone_number": "+201000000001",
"phone_number_id": "109876543210987",
"waba_id": "123456789012345",
"timestamp": "2026-09-24T10:04:12Z"
}A WhatsApp number was connected to the workspace — from the dashboard or through hosted onboarding. Do: enable WhatsApp features in your system.
whatsapp.disconnected
{
"phone_number_id": "109876543210987",
"timestamp": "2026-09-24T12:00:00Z"
}The number was disconnected in Scale. Sends answer 403 WHATSAPP_NOT_CONNECTED until it is connected again. Do: pause sending and tell your users.
webhook.test
{ "message": "This is a test event from Scale.", "timestamp": "2026-09-24T09:00:00Z" }Sent only when you press "Send test event". It is signed like every other event.
Webhook security
| Header | Value |
|---|---|
X-Scale-Event | The event name, e.g. message.delivered. |
X-Scale-Event-Id | The event id (evt_…) — the same as id in the body. |
X-Scale-Timestamp | Unix time in seconds when THIS delivery attempt was signed. |
X-Scale-Signature | sha256= followed by the hex HMAC-SHA256. |
X-Scale-Delivery-Attempt | 1 for the first attempt, 2 for the first retry, … |
The signature is HMAC-SHA256(secret, timestamp + "." + raw body), hex-encoded and prefixed with sha256=. To verify an event:
- Read the RAW request body, byte for byte — before any JSON parsing or re-encoding.
- Compute
sha256=+ hex HMAC-SHA256 ofX-Scale-Timestamp+.+ raw body, with your webhook secret as the key. - Compare with
X-Scale-Signaturein constant time. - Reject a timestamp more than 5 minutes from your clock (replay protection).
- De-duplicate on
X-Scale-Event-Id: the same event can arrive more than once.
<?php
$secret = getenv('SCALE_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // the RAW body
$ts = $_SERVER['HTTP_X_SCALE_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SCALE_SIGNATURE'] ?? '';
$fresh = ctype_digit($ts) && abs(time() - (int) $ts) <= 300;
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!$fresh || !hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// Seen $event['id'] before? Answer 200 and stop.
// Otherwise record it, answer 200 quickly, and do the work afterwards.
http_response_code(200);const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/scale/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.SCALE_WEBHOOK_SECRET; // whsec_…
const ts = req.get('X-Scale-Timestamp') || '';
const sig = req.get('X-Scale-Signature') || '';
const raw = req.body.toString('utf8'); // the RAW body
const fresh = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(ts + '.' + raw).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(sig);
if (!fresh || a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
// De-duplicate on event.id, answer quickly, process afterwards.
res.sendStatus(200);
});Webhook retries and delivery
- Success is any
2xxanswer within 5 seconds. Answer quickly and do slow work afterwards. - Anything else — a
3xxthat does not end in a2xx, any4xxor5xx, a timeout or a connection error — is a failed attempt, and the event is retried after 1, 2, 5, 15, 30, 60 and 120 minutes. - After 8 attempts in total the event is marked failed for good and not sent again. The dashboard's Webhook card shows recent deliveries and their result.
- A retry sends the SAME event: the same
X-Scale-Event-Idand a byte-identical body. OnlyX-Scale-Timestamp,X-Scale-SignatureandX-Scale-Delivery-Attemptare new — so verify the signature per attempt, and de-duplicate on the event id. - Ordering: events about the SAME message are delivered in the order they happened —
message.sent, thenmessage.delivered, thenmessage.read; a later one waits while an earlier one is being retried (an earlier one that fails for good releases the next). Events about DIFFERENT messages are not ordered relative to each other. - Delivery is asynchronous and at least once. Scale never calls your endpoint from inside another request, and one slow or failing endpoint never delays other businesses' webhooks.
- Changing the webhook URL applies to events still waiting; deleting the webhook ends delivery, and events not yet delivered are dropped.
Usage & billing
The API has no quota, credits or package of its own. Every API message uses the same workspace message allowance as the Inbox, the AI assistant and campaigns.
- One message is used when WhatsApp ACCEPTS an API message (any type, templates included).
- Nothing is used when a message is refused before sending, refused by WhatsApp (
outcome: "refused"), or endsunknown. - Incoming customer messages, dashboard replies, AI replies and campaign messages (one per recipient) use the same allowance.
- When the allowance is used up, sends answer
403 MESSAGE_LIMIT_REACHED; without an active subscription,403 SUBSCRIPTION_REQUIRED. - Meta's WhatsApp conversation charges are separate: Meta bills the WhatsApp Business Account.
curl https://getscale.me/wp-json/scale/v1/usage \
-H "Authorization: Bearer $SCALE_API_KEY"{
"success": true,
"period": { "basis": "subscription_cycle", "starts_at": "2026-09-01T00:00:00Z", "ends_at": "2026-10-01T00:00:00Z" },
"messages": { "limit": 10000, "used": 812, "remaining": 9188, "unlimited": false },
"breakdown": { "dashboard": 120, "ai": 390, "api": 214, "incoming": 88, "campaigns": 0, "total": 812 },
"shared_allowance": true,
"can_send": true
}| Field | Meaning |
|---|---|
period | basis is subscription_cycle (the current billing cycle) or last_30_days; starts_at / ends_at in UTC. |
messages | The ONE allowance: limit, used, remaining. limit and remaining are null when unlimited is true. |
breakdown | Where the used messages came from in the same period: dashboard (people in Scale), ai, api, incoming (customers' messages), campaigns, and their total. |
shared_allowance | Always true — the API shares the workspace allowance. |
can_send | Whether the subscription and allowance allow sending right now. |
Rate limits
| Requests | Limit |
|---|---|
POST /messages | 60 per minute per workspace |
| Every other API request | 300 per minute per workspace |
- Limits belong to the WORKSPACE: all its API keys share them.
- Each minute is a fixed window on the clock; the limit resets at the start of the next minute.
- Every answer reports
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(the Unix time the window resets). - Over the limit:
429 RATE_LIMITEDwith aRetry-Afterheader in seconds. A refused request does nothing — no message, no charge. - A different 429 —
details.reason: "send_in_progress",Retry-After: 1— means another message of the workspace was being sent at that moment. Retry with the same Idempotency-Key.
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790161260
{
"success": false,
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests for this workspace. Retry after the number of seconds in Retry-After.",
"details": { "limit": 60, "retry_after": 37 }
},
"request_id": "req_k2V9pQ7sL1mN4xR8tY3wZ6aB"
}Errors
Read error.code — it is stable. error.message is for people and may change; error.details adds facts where there are any.
| HTTP | error.code | Meaning | What to do |
|---|---|---|---|
| 401 | INVALID_API_KEY | The key is missing, malformed or unknown — or a key was sent in the URL. | Send Authorization: Bearer sk_live_… in the header only. |
| 401 | API_KEY_REVOKED | The key was revoked in the dashboard. | Use another active key. |
| 403 | API_ACCESS_DISABLED | Public API access is not enabled for this workspace. Nothing was done. | Contact Scale to enable it. Keys and settings are kept. |
| 404 | WORKSPACE_NOT_FOUND | The key's workspace was archived or deleted. | Contact the workspace owner. |
| 403 | WHATSAPP_NOT_CONNECTED | No WhatsApp number connected (details.reason: not_connected), or it is connected through Twilio (provider_unsupported). | Connect WhatsApp (dashboard or hosted onboarding). |
| 403 | SUBSCRIPTION_REQUIRED | No active subscription; details.reason e.g. not_subscribed, plan_expired, suspended. | Renew the subscription. |
| 403 | MESSAGE_LIMIT_REACHED | The workspace's message allowance is used up. | Top up or wait for the next cycle. |
| 400 | INVALID_MESSAGE | The body is not a JSON object, or the Idempotency-Key is invalid. | Fix the request. |
| 422 | INVALID_MESSAGE | A field is missing or invalid (type, text, template, media link, caption length). The message says which. | Fix the request. |
| 422 | INVALID_PHONE_NUMBER | to is not a usable international number, or belongs to a non-WhatsApp conversation in the workspace. | Send the full number with country code. |
| 422 | OUTSIDE_MESSAGING_WINDOW | Text or media outside the 24-hour window. | Send an approved template. |
| 409 | IDEMPOTENCY_CONFLICT | This Idempotency-Key was used for a different request (details.message_id). | Use a new key for a new message. |
| 429 | RATE_LIMITED | Rate limit reached, or details.reason: send_in_progress. | Wait Retry-After seconds, retry with the same key. |
| 502 | META_API_ERROR | details.outcome: "refused" — WhatsApp refused it (meta_code, meta_message); nothing sent, nothing charged. details.outcome: "unknown" — WhatsApp did not confirm; not charged, never re-sent (retry_safe: false). | Refused: fix and retry. Unknown: check the Inbox before sending again with a new key. |
| 404 | MESSAGE_NOT_FOUND | No such message in this workspace. | Check the msg_… id. |
| 404 | ONBOARDING_SESSION_NOT_FOUND | No such onboarding session in this workspace. | Check the onb_… id. |
| 409 | WHATSAPP_ALREADY_CONNECTED | A working WhatsApp connection exists. | Disconnect in Scale before connecting another number. |
| 422 | INVALID_RETURN_URL | return_url is not registered exactly, or not a valid https:// URL. | Register it in Developer & API → Onboarding return URLs. |
| 503 | ONBOARDING_UNAVAILABLE | Hosted onboarding is not available right now. | Connect from the dashboard, or try later. |
| 500 | INTERNAL_ERROR | Something failed on Scale's side. Nothing was sent. | Retry later; quote the request_id if it persists. |
{
"success": false,
"error": {
"code": "META_API_ERROR",
"message": "WhatsApp refused the message. Nothing was sent and nothing was charged.",
"details": {
"message_id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"outcome": "refused",
"meta_code": 132001,
"meta_message": "Template name does not exist in the translation",
"charged": false
}
},
"request_id": "req_k2V9pQ7sL1mN4xR8tY3wZ6aB"
}{
"success": false,
"error": {
"code": "META_API_ERROR",
"message": "WhatsApp did not confirm this message. It may or may not have been delivered, so it will NOT be sent again and was not charged. Check the conversation before sending a new one.",
"details": {
"message_id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
"outcome": "unknown",
"charged": false,
"retry_safe": false,
"replayed": false
}
},
"request_id": "req_k2V9pQ7sL1mN4xR8tY3wZ6aB"
}Production integration checklist
- Public API access is enabled for the workspace.
- The API key is created, stored only on your server, and never in a URL, a browser or an app.
- WhatsApp is connected —
GET /whatsapp/statusshows"can_send": true. - The webhook is configured on an
https://URL and its secret is stored securely. - Every webhook is verified: signature, timestamp freshness, constant-time comparison.
- Webhook events are de-duplicated on the event id, and your endpoint answers
2xxwithin 5 seconds. - Every send carries a meaningful Idempotency-Key, reused on retries.
429is handled withRetry-After;send_in_progressis retried with the same key.META_API_ERRORis handled:refusedis fixed and retried,unknowngoes to a person.- Text and media are only sent inside the 24-hour window; templates are used to start conversations.
- Message status is followed by webhooks (or occasional
GET /messages/{id}). - Retry behaviour has been tested against your staging endpoint (answer 500 once, then 200).
Complete example: a CRM
A CRM that sends an order confirmation and keeps the conversation in sync.
- The owner creates an API key in Scale and saves it in the CRM's server settings; the CRM saves its webhook URL and secret in Scale.
- The CRM checks
GET /whatsapp/status. Ifcan_sendis false because WhatsApp is not connected, it callsPOST /whatsapp/onboarding/sessionand shows the owner theonboarding_url;whatsapp.connectedarrives when it is done. - An order is placed. The customer has not written in the last 24 hours, so the CRM sends the approved template
order_confirmationwithIdempotency-Key: order-5001-confirmation, and stores the returnedmsg_…on the order. message.sent, thenmessage.delivered, thenmessage.readarrive; the CRM matches them byapi_message_idand updates the order timeline.- The customer replies —
message.receivedarrives withfromand the text. The CRM attaches it to the customer. - Within 24 hours of that reply, the CRM answers with a plain text message.
curl -X POST https://getscale.me/wp-json/scale/v1/messages \
-H "Authorization: Bearer $SCALE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-5001-confirmation" \
-d '{
"to": "201012345678",
"type": "template",
"template": {
"name": "order_confirmation",
"language": "en",
"components": [ { "type": "body", "parameters": [ { "type": "text", "text": "#5001" } ] } ]
}
}'{
"event": "message.received",
"id": "evt_P9o8I7u6Y5t4R3e2W1q0A9s8",
"created_at": "2026-09-24T11:20:00Z",
"workspace_id": "ws_90408561-0487-485d-b513-0053582f3d55",
"data": {
"message_id": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAEhgUQkE3",
"from": "201012345678",
"type": "text",
"text": { "body": "Thanks! Can you deliver on Saturday?" },
"media": null,
"contact": { "name": "Ahmed" },
"timestamp": "2026-09-24T11:20:00Z"
}
}curl -X POST https://getscale.me/wp-json/scale/v1/messages \
-H "Authorization: Bearer $SCALE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-5001-reply-1" \
-d '{"to":"201012345678","type":"text","text":{"body":"Yes — Saturday between 10 am and 2 pm."}}'