العربيةStart Free
Start Free Trial →العربية
Docs
Scale API

Scale API Integration Guide

Connect your CRM, ERP, store, booking system or application to Scale WhatsApp: authentication, onboarding, sending messages and templates, message status, signed webhooks, usage, rate limits and errors — with complete examples.

SC By Scale Team Updated 24 Sep 2026 24 min read
Who can do this
Developers

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.

In one sentence: your server calls the Scale API with a Scale API key, and Scale talks to WhatsApp for you. You never need a Meta access token, app secret or WhatsApp Business password.

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.
How it fits together
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 API

Your 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 needWhere it comes from
A Scale workspace with an active subscriptionYour Scale account. API messages use the same message allowance as the rest of the workspace.
Public API access for the workspaceEnabled 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 keyDashboard → Settings → Developer & API → API keys → Create API key. The key is shown once.
A connected WhatsApp numberDashboard → 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 only. Webhook URLs, onboarding return URLs and media links must all be https:// addresses on the public internet.

Base URL

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:

Header
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 with 401 INVALID_API_KEY even 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.
Your first call
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:

Error envelope
{
  "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

  1. Create an API key — Settings → Developer & API → API keys. Copy it into your server's secret storage.
  2. Connect WhatsApp — Settings → Channels in the dashboard, or send your customer through hosted onboarding (next section). Check it with GET /whatsapp/status: "can_send": true means you are ready.
  3. Configure a webhook — Settings → Developer & API → Webhook: your https:// URL. Copy the signing secret (whsec_…) shown once, and press "Send test event".
  4. Send a first message — POST /messages with an Idempotency-Key.
  5. Receive webhook events — message.sent, then message.delivered and message.read; message.received when the customer answers.
  6. Check a message's status — GET /messages/{id} with the msg_… id you got back.
Step 2 — is WhatsApp ready?
curl https://getscale.me/wp-json/scale/v1/whatsapp/status \
  -H "Authorization: Bearer $SCALE_API_KEY"
Step 4 — send a text
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."}}'
Response — 200
{
  "success": true,
  "message": {
    "id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
    "wamid": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
    "status": "accepted",
    "type": "text",
    "to": "201012345678",
    "created_at": "2026-09-24T10:00:00Z"
  },
  "replayed": false
}
Step 6 — its status
curl https://getscale.me/wp-json/scale/v1/messages/msg_Q2w3E4r5T6y7U8i9O0p1A2s3 \
  -H "Authorization: Bearer $SCALE_API_KEY"
Free-form text needs an open conversation. WhatsApp only allows text and media within 24 hours of the customer's last message to you. To start a conversation, send an approved template (see Templates).

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.

  1. (Optional) Register your return URL: Settings → Developer & API → Onboarding return URLs.
  2. Your server calls POST /whatsapp/onboarding/session.
  3. Send the person to the onboarding_url from the answer (a browser redirect or a link).
  4. They complete Meta's Embedded Signup on the Scale page (entering their 6-digit WhatsApp PIN if the number asks for one).
  5. Scale sends their browser to your return_url with the result — and, if you configured a webhook, sends whatsapp.connected.
  6. Your server confirms with GET /whatsapp/onboarding/session/{id} or GET /whatsapp/status.

Start a session

POST /whatsapp/onboarding/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"}'
FieldRequiredMeaning
return_urlNoWhere 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.
stateNoYour own reference, up to 200 visible ASCII characters. Returned to you on the return URL.
Response — 201
{
  "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_UNAVAILABLE means onboarding is not available on this Scale installation right now.

Return URLs

RuleDetail
HTTPShttps:// only, a public host name, at most 500 characters.
Exact matchThe return_url in the request must be character-for-character one of the registered URLs.
Not allowedCredentials (user:pass@), a #fragment, or a query that already contains session_id, status, state or error — Scale adds those.
How manyUp 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):

The redirect
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_required
Treat the redirect as a hint, not proof. Anyone can type a URL. Confirm the result from your server with GET /whatsapp/onboarding/session/{id} before you rely on it.

Check a session

GET /whatsapp/onboarding/session/{id}
curl https://getscale.me/wp-json/scale/v1/whatsapp/onboarding/session/onb_Z1x2C3v4B5n6M7a8S9d0F1g2 \
  -H "Authorization: Bearer $SCALE_API_KEY"
Response — 200
{
  "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"
  }
}
statusMeaning
createdSession created, link not opened yet.
openedThe link was opened; Meta's signup is in progress.
connectingScale is finishing the connection with Meta.
pin_requiredThe number needs its 6-digit WhatsApp PIN; the page asks for it.
connectedDone. whatsapp holds the phone number ID and WABA ID.
failedIt did not connect; error holds the reason code.
cancelledThe person cancelled, or a newer session replaced this one (error: superseded).
expiredThe 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.

FieldRule
toThe 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.
typetext, template, image, video, audio or document.
Idempotency-Key headerOptional but strongly recommended — see Idempotency.
  • The 24-hour window. text and media are only accepted within 24 hours of the customer's last message to your number; otherwise the answer is 422 OUTSIDE_MESSAGING_WINDOW with details.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_LIMITED with details.reason: "send_in_progress" and Retry-After: 1 — retry it (with the same Idempotency-Key).
  • What a success means. 200 with "status": "accepted" means WhatsApp accepted the message and returned its id (wamid). Delivery and reading come later — through webhooks or GET /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

Request
{
  "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

Request
{
  "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

Request
{
  "to": "201012345678",
  "type": "image",
  "image": { "link": "https://cdn.example.com/products/sku-778.jpg", "caption": "Your new bag" }
}

Video

Request
{
  "to": "201012345678",
  "type": "video",
  "video": { "link": "https://cdn.example.com/howto/setup.mp4", "caption": "How to set it up" }
}

Audio

Request
{
  "to": "201012345678",
  "type": "audio",
  "audio": { "link": "https://cdn.example.com/voice/welcome.ogg" }
}

Document

Request
{
  "to": "201012345678",
  "type": "document",
  "document": {
    "link": "https://cdn.example.com/invoices/INV-2026-0042.pdf",
    "filename": "INV-2026-0042.pdf",
    "caption": "Your invoice"
  }
}
TypeRequiredOptionalLimits
texttext.body—4,000 characters
templatetemplate.name, template.languagetemplate.componentsup to 20 components
imageimage.linkimage.captioncaption up to 1,000 characters
videovideo.linkvideo.captioncaption up to 1,000 characters
audioaudio.link—audio takes no caption
documentdocument.linkdocument.filename, document.captioncaption 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 whose type is header, body, button or footer; its parameters follow Meta's template message format. Leave components out 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.
A template with a header image and a body variable
{
  "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: link must be a public https:// 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 link is a valid https:// 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 as message.failed.
  • caption is accepted for image, video and document (up to 1,000 characters); audio takes none. document.filename is 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.

Header
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_id naming 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".

Do not resend an unknown message automatically. Look at the conversation in the Scale Inbox (or ask the customer) before sending it again with a NEW key — otherwise the customer may receive it twice.

Message status

GET /messages/{id}
curl https://getscale.me/wp-json/scale/v1/messages/msg_Q2w3E4r5T6y7U8i9O0p1A2s3 \
  -H "Authorization: Bearer $SCALE_API_KEY"
Response — 200
{
  "success": true,
  "message": {
    "id": "msg_Q2w3E4r5T6y7U8i9O0p1A2s3",
    "wamid": "wamid.HBgMMjAxMDEyMzQ1Njc4FQIAERgSQzE5",
    "type": "text",
    "to": "201012345678",
    "status": "delivered",
    "failure": null,
    "created_at": "2026-09-24T10:00:00Z"
  }
}
statusMeaning
pendingRecorded; the call to WhatsApp is in progress.
sentWhatsApp accepted it (one tick).
deliveredIt reached the customer's phone.
readThe customer opened it (only if they allow read receipts).
failedNot 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).
unknownScale could not tell whether WhatsApp sent it — see Idempotency. A send still pending after 5 minutes also reads unknown.
A delivery failure
{
  "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. read can arrive without delivered having 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 — GET requests 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

GET /whatsapp/status
curl https://getscale.me/wp-json/scale/v1/whatsapp/status \
  -H "Authorization: Bearer $SCALE_API_KEY"
Response — 200
{
  "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": []
}
FieldMeaning
statusconnected · 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).
connectedtrue only when status is connected.
reasonWhy it is not usable: token_invalid or provider_unsupported (connected through Twilio, which the API does not support); otherwise null.
providermeta, twilio, or null when no number is connected.
phone_number, display_name, waba_id, phone_number_id, connected_atThe connected number, its verified display name and Meta ids; null when not connected.
healthWhether 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_sendtrue when nothing blocks a send — the 24-hour window and rate limits depend on the request and are not included.
blockersWhat 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:

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

data
{
  "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"
}
data — with media
{
  "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"
}
  • type is text, image, audio or document. For media, text is null and media holds a link to the copy Scale stored, its type, MIME type (when known) and the customer's caption. Other kinds of inbound message carry no media in this version.
  • Do: store the message against the customer (from), reply through POST /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.

data
{
  "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) or scale (a person in the dashboard, or a campaign).
  • api_message_id is your msg_… id for API sends, otherwise null. type is text, template or the media type; for a template, template holds its name and language.
  • Do: match API sends to your records by api_message_id; use the others to mirror the conversation in your system.

message.delivered

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{ "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

HeaderValue
X-Scale-EventThe event name, e.g. message.delivered.
X-Scale-Event-IdThe event id (evt_…) — the same as id in the body.
X-Scale-TimestampUnix time in seconds when THIS delivery attempt was signed.
X-Scale-Signaturesha256= followed by the hex HMAC-SHA256.
X-Scale-Delivery-Attempt1 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:

  1. Read the RAW request body, byte for byte — before any JSON parsing or re-encoding.
  2. Compute sha256= + hex HMAC-SHA256 of X-Scale-Timestamp + . + raw body, with your webhook secret as the key.
  3. Compare with X-Scale-Signature in constant time.
  4. Reject a timestamp more than 5 minutes from your clock (replay protection).
  5. De-duplicate on X-Scale-Event-Id: the same event can arrive more than once.
PHP
<?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);
Node.js (Express)
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);
});
Rotating the secret takes effect at once. After "Rotate secret", every delivery — including retries of events created before the rotation — is signed with the NEW secret. Update your server first (or accept both secrets briefly), then rotate.

Webhook retries and delivery

  • Success is any 2xx answer within 5 seconds. Answer quickly and do slow work afterwards.
  • Anything else — a 3xx that does not end in a 2xx, any 4xx or 5xx, 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-Id and a byte-identical body. Only X-Scale-Timestamp, X-Scale-Signature and X-Scale-Delivery-Attempt are 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, then message.delivered, then message.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 ends unknown.
  • 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.
GET /usage
curl https://getscale.me/wp-json/scale/v1/usage \
  -H "Authorization: Bearer $SCALE_API_KEY"
Response — 200
{
  "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
}
FieldMeaning
periodbasis is subscription_cycle (the current billing cycle) or last_30_days; starts_at / ends_at in UTC.
messagesThe ONE allowance: limit, used, remaining. limit and remaining are null when unlimited is true.
breakdownWhere the used messages came from in the same period: dashboard (people in Scale), ai, api, incoming (customers' messages), campaigns, and their total.
shared_allowanceAlways true — the API shares the workspace allowance.
can_sendWhether the subscription and allowance allow sending right now.

Rate limits

RequestsLimit
POST /messages60 per minute per workspace
Every other API request300 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-Remaining and X-RateLimit-Reset (the Unix time the window resets).
  • Over the limit: 429 RATE_LIMITED with a Retry-After header 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.
429 — too many requests
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.

HTTPerror.codeMeaningWhat to do
401INVALID_API_KEYThe key is missing, malformed or unknown — or a key was sent in the URL.Send Authorization: Bearer sk_live_… in the header only.
401API_KEY_REVOKEDThe key was revoked in the dashboard.Use another active key.
403API_ACCESS_DISABLEDPublic API access is not enabled for this workspace. Nothing was done.Contact Scale to enable it. Keys and settings are kept.
404WORKSPACE_NOT_FOUNDThe key's workspace was archived or deleted.Contact the workspace owner.
403WHATSAPP_NOT_CONNECTEDNo WhatsApp number connected (details.reason: not_connected), or it is connected through Twilio (provider_unsupported).Connect WhatsApp (dashboard or hosted onboarding).
403SUBSCRIPTION_REQUIREDNo active subscription; details.reason e.g. not_subscribed, plan_expired, suspended.Renew the subscription.
403MESSAGE_LIMIT_REACHEDThe workspace's message allowance is used up.Top up or wait for the next cycle.
400INVALID_MESSAGEThe body is not a JSON object, or the Idempotency-Key is invalid.Fix the request.
422INVALID_MESSAGEA field is missing or invalid (type, text, template, media link, caption length). The message says which.Fix the request.
422INVALID_PHONE_NUMBERto is not a usable international number, or belongs to a non-WhatsApp conversation in the workspace.Send the full number with country code.
422OUTSIDE_MESSAGING_WINDOWText or media outside the 24-hour window.Send an approved template.
409IDEMPOTENCY_CONFLICTThis Idempotency-Key was used for a different request (details.message_id).Use a new key for a new message.
429RATE_LIMITEDRate limit reached, or details.reason: send_in_progress.Wait Retry-After seconds, retry with the same key.
502META_API_ERRORdetails.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.
404MESSAGE_NOT_FOUNDNo such message in this workspace.Check the msg_… id.
404ONBOARDING_SESSION_NOT_FOUNDNo such onboarding session in this workspace.Check the onb_… id.
409WHATSAPP_ALREADY_CONNECTEDA working WhatsApp connection exists.Disconnect in Scale before connecting another number.
422INVALID_RETURN_URLreturn_url is not registered exactly, or not a valid https:// URL.Register it in Developer & API → Onboarding return URLs.
503ONBOARDING_UNAVAILABLEHosted onboarding is not available right now.Connect from the dashboard, or try later.
500INTERNAL_ERRORSomething failed on Scale's side. Nothing was sent.Retry later; quote the request_id if it persists.
A refusal by WhatsApp
{
  "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"
}
An unknown outcome
{
  "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/status shows "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 2xx within 5 seconds.
  • Every send carries a meaningful Idempotency-Key, reused on retries.
  • 429 is handled with Retry-After; send_in_progress is retried with the same key.
  • META_API_ERROR is handled: refused is fixed and retried, unknown goes 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.

  1. 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.
  2. The CRM checks GET /whatsapp/status. If can_send is false because WhatsApp is not connected, it calls POST /whatsapp/onboarding/session and shows the owner the onboarding_url; whatsapp.connected arrives when it is done.
  3. An order is placed. The customer has not written in the last 24 hours, so the CRM sends the approved template order_confirmation with Idempotency-Key: order-5001-confirmation, and stores the returned msg_… on the order.
  4. message.sent, then message.delivered, then message.read arrive; the CRM matches them by api_message_id and updates the order timeline.
  5. The customer replies — message.received arrives with from and the text. The CRM attaches it to the customer.
  6. Within 24 hours of that reply, the CRM answers with a plain text message.
3 — the template
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" } ] } ]
    }
  }'
5 — the reply arrives
{
  "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"
  }
}
6 — the answer, inside the window
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."}}'
Still need help?
Our team answers setup questions within one business day.
Contact support