The Scale Developer Platform lets your software connect your customers' WhatsApp numbers through Meta's official onboarding, then send and receive their WhatsApp messages over one REST API with one API key — for OTP and verification codes, order and appointment notifications, alerts, and any messaging your product needs.
- Base URL:
https://<your Scale site>/wp-json/scale/v1 - Price: a flat subscription — EGP 1,200 a month or EGP 12,000 a year, including two WhatsApp numbers for the whole account; each additional number is its own subscription at EGP 600 a month or EGP 6,000 a year (§8). Scale charges nothing per message and deducts no message credits. WhatsApp's own conversation charges follow each business's billing arrangement with Meta.
- Transport only: Scale's AI never replies on, or acts on, a Developer customer's number.
1. Getting started — from sign-up to your first message
Eight steps, about fifteen minutes, plus the time Meta takes to verify a number.
1.1 Create your Developer account
Open the Developer sign-up page: https://<your Scale site>/signup/?plan=developer for the monthly plan, or ?plan=developer_annual for the annual one. Enter your name, email, password and company, then open the verification link Scale emails you. A Developer account is separate from a Scale AI account: it has no AI assistant, no inbox and no message allowance.
1.2 Subscribe
Your account starts in setup mode, and everything in this guide up to the first real message works in it: your API key, your webhooks (one for the account, and one per number if you like), customers, connecting your two included numbers, templates, and Test integration (POST /test/webhook, POST /test/messages). What setup mode does not do is send: until your first payment is confirmed by Scale's billing, every real message answers 403 SUBSCRIPTION_INACTIVE with details.mode setup, and buying an additional number answers 402 PAYMENT_REQUIRED (§8). Pay at checkout after verification, or later from the Developer Dashboard (Billing); the confirmed payment opens sending at once — you never need to contact Scale.
1.3 Create your API key
In the Developer Dashboard, open API key and press Create API key. The key (sk_live_…) is shown once: store it in your server's secret store at once (§10). Check it:
curl -s https://scale.example/wp-json/scale/v1/account \
-H "Authorization: Bearer $SCALE_KEY"
1.4 Set your webhook endpoint
In the Developer Dashboard, open Webhooks, enter your endpoint's https:// URL and press Create endpoint. The signing secret (whsec_…) is shown once. Press Verify endpoint: your endpoint must answer the signed webhook.verification event with a 2xx whose body contains its data.challenge. Then send a labelled test event — Send a test now in the dashboard, or POST /test/webhook with your key (§6) — and check that your endpoint verifies its signature (§7). A number can also have its own endpoint and secret (§7).
1.5 Create a customer
A customer is one business you serve. Keep your own id for it in external_ref.
curl -s -X POST https://scale.example/wp-json/scale/v1/customers \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-d '{"name":"Cairo Clinic","external_ref":"crm-1"}'
The answer carries the customer's customer_id (ws_…). Store it next to your own record.
1.6 Connect the customer's WhatsApp number
Create an onboarding link and open it yourself, or send it to the business:
curl -s -X POST https://scale.example/wp-json/scale/v1/customers/ws_XXXX/onboarding-links \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-d '{"label":"Front desk","state":"crm-1"}'
The link opens Scale's hosted page, which runs Meta's official Embedded Signup: the business signs in to Facebook, chooses or creates its WhatsApp Business Account, and verifies the number with the code Meta sends. If the number has two-step verification, the page asks for its 6-digit PIN. Scale never sees the business's Facebook password, and you never handle its Meta token.
When the number is connected, your endpoint receives number.connected and onboarding.completed, and GET /numbers lists the number with its number_id (num_…).
1.7 Send a test message
Until your subscription is active, POST /test/messages takes this same body and answers what the send would do (would_send, blockers) without sending it. A business may start a conversation only with an approved template. List the customer's templates with GET /templates, then send one from the number:
curl -s -X POST https://scale.example/wp-json/scale/v1/messages \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-crm-1" \
-d '{"number_id":"num_XXXX","to":"201012345678","type":"template","template":{"name":"hello_world","language":"en_US"}}'
Your endpoint receives message.sent, then message.delivered and message.read as WhatsApp reports them.
1.8 Receive the reply
When the person answers, your endpoint receives message.received. For the next 24 hours you may reply with any message type, such as text, from the same number_id.
2. Concepts and identifiers
Your Developer account dev_… (one primary API key)
└─ Customer (one per business you serve) ws_… customer_id
└─ WhatsApp number num_… number_id
- Developer account (
developer_id,dev_…). Your Scale account. It holds the subscription, the API key and the webhook endpoint. - Customer (
customer_id,ws_…). One per business you serve. You may store your own id for it asexternal_ref. Your end customers never log in to Scale. - Number (
number_id,num_…). One WhatsApp number of a customer.- A customer may hold several numbers, all on one WhatsApp Business Account (WABA).
- A number keeps its
number_idfor as long as it stays connected, including through a reconnect.
- Stable identifiers.
developer_id,customer_idandnumber_idare Scale's own ids. They never change and are never reused for something else; store them in your system. - Meta's ids are data, never an address.
phone_number_idandwaba_idare returned so you can match Meta's dashboards.- The API never accepts them in place of
number_idorcustomer_id.
- Isolation. Every customer, number, message, template and link belongs to exactly one Developer account. An id that belongs to another account answers
404, exactly like an id that does not exist. - Transport only. What you send is sent; what arrives is stored and delivered to your webhook. No AI reply, assistant, follow-up or automation ever runs on a Developer customer's number.
3. Authentication and your API key
Authorization: Bearer sk_live_…
- One active key per Developer account. It reaches all of your customers and their numbers, and nothing else.
- Where keys are managed. Keys are issued, rotated and revoked in the Developer Dashboard only. The API cannot create keys.
- Rotation. Issuing a new key stops the previous one at once.
- Revocation. Revoke key in the dashboard stops the key at once; every request with it is refused until you create a new one.
- Shown once. The key appears once, when issued. Scale stores only its SHA-256 and shows only its prefix afterwards.
- Header only. Send the key in the
Authorizationheader. A key in the URL (?api_key=…) is refused, and you should revoke any key sent that way. - Workspace keys are different. A Scale AI workspace key (the single-workspace Public API) still works exactly as before. It cannot call the Developer routes below (
403 PRODUCT_NOT_ENTITLED), and a Developer key cannot call the workspace-only routes/whatsapp/statusand/whatsapp/onboarding/session.
4. Requests, responses and pagination
Requests. Send JSON with Content-Type: application/json. Ids go in the path or the body as documented; unknown fields are ignored.
Success:
{ "success": true, "...": "..." }
Failure:
{
"success": false,
"error": { "code": "NUMBER_NOT_FOUND", "message": "Number not found.", "details": {} },
"request_id": "req_…"
}
Headers on every answer:
X-Scale-Request-Id— quote it to support.Cache-Control: no-store.X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(a UNIX time).Retry-After(seconds) on a429.
Timestamps are ISO-8601 UTC (2026-11-10T12:00:00Z).
Not yours means not found. An id that is not yours answers 404, exactly like one that does not exist.
Pagination. Lists that can grow are paged with a cursor:
GET /customers— oldest first;limit1–100 (default 50); pass the answer'snext_cursorasafterfor the next page.GET /messages— newest first;limit1–100 (default 50);next_cursorthe same way.next_cursorisnullon the last page.GET /numbers,GET /templatesand a customer's onboarding links return everything at once;GET /usagetakes a date range.
Idempotency. POST /messages accepts an Idempotency-Key header so a retry never sends twice (§6).
5. Rate limits
| Bucket | Default | Applies to |
|---|---|---|
| Per key | 300 requests / 60 s | every request |
| Per Developer account | 600 sends / 60 s | POST /messages |
| Per customer | 60 sends / 60 s | POST /messages for that customer |
| Per customer, number changes | 60 / 10 min | creating onboarding links, disconnecting numbers |
| Per Developer account, account changes | 60 / 10 min | creating customers (POST /customers, and in the dashboard), and in the dashboard: issuing or revoking the key, saving, rotating or removing the webhook |
| Per Developer account, test events | 10 / 10 min | the dashboard's "Send a test event" |
When you exceed one, you get 429 RATE_LIMITED; retry after Retry-After. WhatsApp may also slow a number down. Scale then answers 429 with details.reason = "sender_paced".
6. Endpoints
GET /account
Your account, its subscription and its key.
{
"success": true,
"developer_id": "dev_…",
"name": "Acme Integrations",
"subscription": {
"status": "active",
"plan": "developer",
"period_end": "2026-12-10T12:00:00Z",
"grace_ends_at": "2026-12-13T12:00:00Z",
"can_send": true,
"first_payment_confirmed": true
},
"migration": null,
"customers": { "used": 2, "limit": null },
"numbers": { "included": 2, "additional": 1, "capacity": 3, "connected": 2, "reserved": 1, "available": 0, "max": null, "over_max": false, "uncovered": 0 },
"entitlement": {
"mode": "active",
"can": { "configure": true, "test": true, "send": true },
"checks": { "account_exists": true, "email_verified": true, "api_key_valid": true, "payment_approved": true, "payment_awaiting_review": false, "subscription_active": true },
"subscription_status": "active",
"next_step": null
},
"api_key": { "prefix": "sk_live_AbC123", "created_at": "…", "last_used_at": "…" }
}
customers.limit is null: Scale sets no limit on how many customers you have. If a Scale admin agrees a limit with you, it appears here as a number.
**numbers** — the WhatsApp numbers your account may connect, across all your customers: included (2, with your subscription), additional (additional-number subscriptions that cover a number now), capacity (the two together), connected, reserved (live onboarding links — each holds one number), available (what you can add now), max (a maximum Scale set for your account, or null for none), over_max, and uncovered (connected numbers no subscription covers — they cannot send). See §8.
**subscription.status values:**
nonepending_paymentactivepast_due— inside the 3-day grace; sending still works.expiredcancelledsuspended
**can_send** says whether messages can be sent right now.
**first_payment_confirmed** is false until your first subscription payment is confirmed: your account is in setup mode (§8).
**entitlement** says what your account may do now, from the same facts the server's gates check: mode (setup — never paid; active; paused — the period and its grace ended; suspended; receive_only — your deletion is pending), can (configure, test, send), each fact in checks, and next_step (create_api_key, activate_subscription, wait_payment_review, renew_subscription, contact_scale, move_numbers, or null). It is a reading, not a permission: a send is still refused or allowed by the send itself, and a valid key alone never sends. Each number has its own reading in GET /numbers.
**migration** is null, or the 14-day migration period your numbers are in (§8.1).
Customers
**GET /customers?limit=50&after=ws_…** — your customers, oldest first, in pages.
limitis between 1 and 100.- To get the next page, pass the previous
next_cursorasafter.
{
"success": true,
"customers": [
{
"customer_id": "ws_…",
"name": "Cairo Clinic",
"external_ref": "crm-1",
"created_at": "…",
"numbers": [ { "...": "a number, see /numbers" } ]
}
],
"next_cursor": null
}
**POST /customers** with {"name": "Cairo Clinic", "external_ref": "crm-1"} returns 201 {"customer": {…}}.
nameis 1–120 characters.external_refis optional: 1–190 visible ASCII characters, unique among your customers.- It works in setup mode, before your first payment.
**GET /customers/{customer_id}** returns {"customer": {…}}.
Connecting a number — onboarding links
A link opens Scale's hosted page, where the business connects its WhatsApp number through Meta's official Embedded Signup. You open it yourself, or send it to your customer.
**POST /customers/{customer_id}/onboarding-links**
Request fields (all optional):
| Field | Meaning |
|---|---|
label | The number's name, e.g. "Front desk". |
state | Up to 200 visible ASCII characters. It is returned on the link, for your own correlation. |
expires_in | Seconds until the link can no longer be opened: 600–604800. Default 259200 (72 h). |
Not supported yet:
intentother than"add". A link always adds a number. To replace one, disconnect it, then add the new one.return_url.
Both answer 422 INVALID_REQUEST rather than being ignored.
Each live link holds one of your account's numbers — while it is created, opened, connecting or waiting for a PIN, until it expires. A new link is refused with 402 NUMBER_SUBSCRIPTION_REQUIRED when your connected numbers and live links already fill your allowance (2 included plus your additional numbers), and with 409 NUMBER_LIMIT_REACHED at the maximum Scale set for your account. A link that expires, is revoked, is cancelled or fails frees its number at once.
Answer: 201. link.url appears only in this answer; Scale cannot show it again.
{
"success": true,
"link": {
"link_id": "onb_…",
"customer_id": "ws_…",
"status": "created",
"error": null,
"label": "Front desk",
"state": "crm-1",
"created_via": "api",
"created_at": "…",
"link_expires_at": "…",
"opened_at": null,
"completed_at": null,
"number": null,
"url": "https://<your Scale site>/?scale_onboarding=1#t=…"
}
}
How a link behaves:
- It can be opened once. After it is opened, the business has 30 minutes to finish.
- Statuses:
created,opened,connecting,pin_required,connected,failed,cancelled,expired,revoked. - When
connected,link.numberis the number it connected, including itsnumber_id. - It works in setup mode, within your two included numbers; a third number needs an additional-number subscription, which needs your first payment (§8).
Related routes:
GET /customers/{customer_id}/onboarding-linksreturns{"links": […]}.GET /onboarding-links/{link_id}returns{"link": {…}}, without the URL.POST /onboarding-links/{link_id}/revokereturns{"link": {…, "status": "revoked"}}. A finished link cannot be revoked (409 ONBOARDING_LINK_FINISHED).
Numbers
**GET /numbers?customer_id=ws_…** — your connected numbers; customer_id is optional.
{
"success": true,
"numbers": [
{
"number_id": "num_…",
"customer_id": "ws_…",
"display_phone_number": "+201001234567",
"label": "Front desk",
"phone_number_id": "1234567890",
"waba_id": "9876543210",
"verified_name": "Cairo Clinic",
"status": "connected",
"role": "primary",
"connected_at": "…",
"entitlement": {
"can_send": false,
"checks": { "number_configured": true, "number_connected": true, "number_eligible": true, "api_access": true, "templates_approved": 3 },
"coverage": "included",
"blockers": [ { "code": "SUBSCRIPTION_INACTIVE", "mode": "setup" } ],
"webhook": "number"
}
}
]
}
**GET /numbers/{number_id}** returns {"number": {…}}.
**entitlement is this number's own reading: whether it** can send now (can_send), each fact (checks), its place in your allowance (coverage: included, covered or uncovered), what stops it (blockers, each with the code a send would answer), and where its events go (webhook: number — its own endpoint, account, or none). One number's allowance or connection never stands for another's.
**POST /numbers/{number_id}/disconnect** stops Scale routing and sending for the number. The number is not deleted at Meta, and the conversation history is kept. It returns {"disconnected": {"number_id": "num_…", "customer_id": "ws_…"}}.
Additional numbers — GET /number-subscriptions
Your subscription includes two WhatsApp numbers for the whole account. Each further number has its own subscription (EGP 600 a month or EGP 6,000 a year), bought from the Developer Dashboard (Billing) and paid at Scale's checkout; only the confirmed payment activates it. This route is read-only.
{
"success": true,
"numbers": { "included": 2, "additional": 1, "capacity": 3, "connected": 3, "reserved": 0, "available": 0, "max": null, "over_max": false, "uncovered": 0 },
"prices": [
{ "key": "developer_number", "name": "Additional WhatsApp number", "price": 600, "currency": "EGP", "interval": "month" },
{ "key": "developer_number_annual", "name": "Additional WhatsApp number (Annual)", "price": 6000, "currency": "EGP", "interval": "year" }
],
"subscriptions": [
{
"subscription_id": "nsl_…",
"status": "active",
"covers_number": true,
"interval": "month",
"price": 600,
"currency": "EGP",
"period_start": "2026-11-01T10:00:00Z",
"period_end": "2026-12-01T10:00:00Z",
"grace_ends_at": "2026-12-04T10:00:00Z",
"payment": "paid",
"number": { "number_id": "num_…", "display_phone_number": "+201001234567" },
"created_at": "…",
"cancelled_at": null
}
],
"coverage": [
{ "number_id": "num_…", "role": "included", "subscription_id": null },
{ "number_id": "num_…", "role": "included", "subscription_id": null },
{ "number_id": "num_…", "role": "covered", "subscription_id": "nsl_…" }
]
}
status is pending_payment, active, cancelled (will not renew; it covers its number until its period ends), past_due (the 3-day grace; it still covers), expired (it no longer covers) or void (withdrawn). coverage[].role is included (one of your first two numbers), covered or uncovered (it cannot send until a subscription covers it). Your numbers count in the order they were first connected; disconnecting one frees its place. While your main subscription is suspended or has ended (past its grace), every number pauses — included and covered alike — and an additional number cannot be bought or renewed until the main subscription is renewed; a payment already made is still applied. payment is unpaid, awaiting_confirmation, paid, complimentary (a period Scale granted at no charge — no invoice) or refunded (its payment was refunded and nothing paid is left). A refund takes back exactly the period that payment bought: the number stays connected and keeps receiving, and sends again once a new payment covers it. Scale emails the account owner before an additional number's period ends (7 and 2 days before, at the end, and once its sending pauses).
Templates
A customer's message templates, as WhatsApp approved them on its WABA. A number's templates are its customer's. Templates are created and approved in Meta's WhatsApp Manager; Scale reads them.
**GET /templates?number_id=num_…** (or customer_id=ws_…)
- Optional:
status=approved, andrefresh=1to re-read them from Meta. - Scale reads them from Meta on the first request, when you pass
refresh=1, and when a send names a template it has not seen approved. It re-reads at most once a minute per customer.
{
"success": true,
"customer_id": "ws_…",
"templates": [
{
"name": "appointment_reminder",
"language": "ar",
"category": "UTILITY",
"status": "approved",
"quality": "GREEN",
"rejection_reason": null,
"components": [
{ "type": "BODY", "text": "مرحباً {{1}}، موعدك {{2}}." }
],
"parameters": { "body": 2, "header": 0 },
"synced_at": "…"
}
],
"sync": { "attempted": false, "ok": null, "error": null, "throttled": false }
}
**GET /templates/{name}?number_id=…&language=ar** returns {"customer_id": …, "templates": [ … ]} (404 TEMPLATE_NOT_FOUND if none).
Sending — POST /messages
The body is a message plus **number_id, the number to send from** (required). customer_id is optional; if you send it, it must be that number's customer.
Optional header: Idempotency-Key, 1–150 visible ASCII characters.
- It is unique across your whole account.
- Retrying with the same key returns the first answer (
"replayed": true) and never sends twice. - The same key with a different body or number answers
409 IDEMPOTENCY_CONFLICT.
Message types:
type | Fields |
|---|---|
text | text.body (≤ 4000) |
image | image.link (public https://), image.caption? (≤ 1000) |
video | video.link, video.caption? |
audio | audio.link |
document | document.link, document.filename?, document.caption? |
template | template.name, template.language (e.g. ar, en_US), template.components? (Meta's format, at most 20) |
A body for each type:
{ "number_id": "num_XXXX", "to": "201012345678", "type": "text", "text": { "body": "Your order 1001 has shipped." } }
{ "number_id": "num_XXXX", "to": "201012345678", "type": "image", "image": { "link": "https://cdn.example.com/receipt-1001.png", "caption": "Your receipt" } }
{ "number_id": "num_XXXX", "to": "201012345678", "type": "video", "video": { "link": "https://cdn.example.com/how-to.mp4", "caption": "How to set up your device" } }
{ "number_id": "num_XXXX", "to": "201012345678", "type": "audio", "audio": { "link": "https://cdn.example.com/voice-note.ogg" } }
{ "number_id": "num_XXXX", "to": "201012345678", "type": "document", "document": { "link": "https://cdn.example.com/invoice-1001.pdf", "filename": "invoice-1001.pdf", "caption": "Invoice 1001" } }
{ "number_id": "num_XXXX", "to": "201012345678", "type": "template", "template": { "name": "order_update", "language": "en_US", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "Mona" }, { "type": "text", "text": "1001" } ] } ] } }
Rules that apply to every send:
tois a full international number, e.g.201012345678.- WhatsApp allows free-form messages (every type except
template) only within 24 hours of the customer's last message to that number. Outside that window, send an approved template. - A template must exist and be approved for the customer, and the body must get exactly one parameter per variable. Otherwise the answer is
422 TEMPLATE_NOT_FOUND,TEMPLATE_NOT_APPROVEDorTEMPLATE_PARAMETER_MISMATCH, and nothing is sent. - Media links must be public
https://URLs that WhatsApp can download.
Answer: 200. status: "accepted" means WhatsApp accepted the message. Delivery follows; read it with GET /messages/{message_id} or wait for the webhook.
{
"success": true,
"message": {
"id": "msg_…",
"wamid": "wamid.…",
"status": "accepted",
"type": "text",
"to": "201012345678",
"created_at": "…",
"number_id": "num_…",
"customer_id": "ws_…",
"phone_number_id": "1234567890"
},
"replayed": false
}
When WhatsApp refuses, you get 502 META_API_ERROR. Nothing was sent.
**details fields:**
message_id.outcome: "refused".meta_code— Meta's error code.meta_subcode.meta_message— Meta's own sentence, at most 300 characters.reason— Scale's reading:window_expired,recipient,template,rate_limited,connection,account,media,network,unsupportedorunknown.number_id.charged: false.
When WhatsApp does not confirm (a timeout, a 5xx), outcome is "unknown". The message may or may not have been delivered. Scale never re-sends it, and retrying with the same Idempotency-Key returns this same answer.
Reading messages
**GET /messages/{message_id}** — one message you sent, from any of your customers.
{
"success": true,
"message": {
"id": "msg_…",
"wamid": "wamid.…",
"type": "text",
"to": "201012345678",
"status": "delivered",
"failure": null,
"created_at": "…",
"number_id": "num_…",
"customer_id": "ws_…",
"phone_number_id": "1234567890"
}
}
**status values:** pending, sent, delivered, read, failed, unknown.
- When
failed,failure.stageissend(WhatsApp refused it) ordelivery(WhatsApp accepted it, then could not deliver).
**GET /messages?number_id=…&customer_id=…&limit=50&after=msg_…** — your sends, newest first.
- The filters are optional;
limitis between 1 and 100. - The answer is
{"messages": […], "next_cursor": "msg_…" | null}.
Messages your customers' numbers receive are delivered to your webhook (message.received); they are not listed here.
Usage — GET /usage
Counts only. A Developer account has no message allowance and no per-message charge, so there is no limit and no remaining figure.
Query: from and to are UTC dates YYYY-MM-DD, at most 92 days apart. The default is the last 30 days. Optional filters: customer_id, number_id.
dayshas every UTC day of the range, in order, including days with nothing (all zero). Its figures add up tototals.sent,failedandunknownare your sends by their state;receivedis every message your customers' numbers received.
{
"success": true,
"period": { "from": "2026-10-12", "to": "2026-11-10", "timezone": "UTC" },
"totals": { "sent": 120, "failed": 2, "unknown": 0, "pending": 0, "received": 340 },
"days": [
{ "day": "2026-10-12", "sent": 4, "failed": 0, "unknown": 0, "pending": 0, "received": 11 },
{ "day": "2026-10-13", "sent": 0, "failed": 0, "unknown": 0, "pending": 0, "received": 0 }
],
"numbers": [
{
"number_id": "num_…",
"customer_id": "ws_…",
"phone_number_id": "1234567890",
"sent": 120,
"failed": 2,
"unknown": 0,
"pending": 0,
"received": 340
}
]
}
Test integration — POST /test/webhook, POST /test/messages
Both work in setup mode and after it, with your Developer key. Nothing reaches WhatsApp: no message is sent, no contact is created, nothing is charged or queued. WhatsApp has no sandbox for numbers connected to Scale (Meta's test number exists only in a developer's own Meta app), so these simulate instead.
**POST /test/webhook** with {"number_id": "num_…", "event": "message.received"} (both optional) delivers a signed test event now to the endpoint that number's events go to — its own, or your account's — and answers what your endpoint said.
eventisping(the default),message.received,message.deliveredormessage.failed; it arrives astest.webhook,test.message.received,test.message.deliveredortest.message.failed.- The data is fictional (the sender is
15550100001), the envelope says"livemode": falseand"test": true, and the request carriesX-Scale-Test: 1. - It is signed exactly like a real delivery, so your signature check is what is tested.
{
"success": true,
"test": true,
"livemode": false,
"destination": { "kind": "number", "number_id": "num_…", "host": "hooks.example.com" },
"result": { "delivered": true, "event_id": "evt_test_…", "event": "test.message.received", "http_status": 200, "duration_ms": 184, "error": null, "response_excerpt": "ok" },
"sent_to_whatsapp": false,
"request_id": "req_…"
}
**POST /test/messages** takes the body of POST /messages and runs the same checks — the number, the body, the template (against the templates Scale has synced; GET /templates?refresh=1 refreshes them), the subscription, the number's allowance and the 24-hour window — then stops. would_send says whether the real send would go; blockers lists every reason it would not, each with the error code the real send answers.
{
"success": true,
"test": true,
"livemode": false,
"simulated": true,
"sent_to_whatsapp": false,
"would_send": false,
"blockers": [ { "code": "SUBSCRIPTION_INACTIVE", "message": "Your account is in setup mode: …", "details": { "status": "pending_payment", "mode": "setup" } } ],
"number": { "number_id": "num_…", "customer_id": "ws_…", "phone_number_id": "1234567890" },
"message": { "type": "text", "template": null },
"idempotency_key": null,
"request_id": "req_…"
}
An account may run 30 test deliveries and 60 dry runs per 10 minutes. The dashboard's Test integration tab runs them for you and lists your recent API requests with their request_id.
7. Webhooks — events pushed to your server
Your account endpoint receives the events of every number that has no endpoint of its own; a number may have its own (see Endpoints per number below).
- You set it, and see its deliveries, in the Developer Dashboard (Webhooks).
- The API key cannot change it, so a leaked key cannot redirect your events.
- The endpoint's secret (
whsec_…) is shown once, when the endpoint is created or the secret is rotated.
Events
| Event | When | data |
|---|---|---|
message.received | Someone wrote to one of your numbers: text, image, audio or voice note, video, document, location, a button or list reply, or an order. Stickers, contact cards and reactions are not delivered. | message_id (WhatsApp's id), from, type, text or media, contact.name, timestamp |
message.sent | WhatsApp accepted a message sent from one of your numbers. | message_id, api_message_id (msg_…), to, type, source, status: "sent", text / template, timestamp |
message.delivered, message.read, message.failed | WhatsApp reported progress. | message_id, api_message_id, to, status, error (failed only), timestamp |
number.connected | A number was connected (an onboarding link, or a reconnect). | number (as in GET /numbers/{number_id}), timestamp |
number.disconnected | A number stopped routing. | number_id, phone_number_id, reason, timestamp |
onboarding.completed, onboarding.failed | An onboarding link finished. | link_id, then number_id (completed) or error (failed), timestamp |
webhook.test | You pressed "Send test event". | message, timestamp |
For number.disconnected, reason is one of:
developer— you, through the API or the dashboard;admin— Scale;released— anything else, for example the number was connected elsewhere.
You may limit the endpoint to some of these events. webhook.test is always sent.
Envelope
{
"event": "message.received",
"id": "evt_…",
"api_version": "2026-10",
"created_at": "2026-11-10T12:00:00Z",
"developer_id": "dev_…",
"customer_id": "ws_…",
"number_id": "num_…",
"phone_number_id": "1234567890",
"data": { "…": "…" }
}
number_id and phone_number_id are null for an event about no particular number (onboarding.failed, webhook.test).
Delivery
Request: POST with a JSON body.
Headers:
| Header | Value |
|---|---|
X-Scale-Event | The event type. |
X-Scale-Event-Id | evt_… |
X-Scale-Timestamp | UNIX seconds of this attempt. |
X-Scale-Signature | sha256= + HMAC-SHA256 of "<timestamp>.<raw body>" with your secret. |
X-Scale-Delivery-Attempt | The attempt number. |
X-Scale-Signature-Previous | During the 24 hours after a secret rotation only: the same signature, made with the previous secret. |
Retries:
- Answer
2xxwithin 5 seconds. - Anything else is retried: 1, 2, 5, 15 and 30 minutes, then 1 and 2 hours later, up to 8 attempts.
- The body and
idare identical on every attempt.
At least once — handle duplicates. The same event can arrive more than once (a retry after a timeout, for example). Deduplicate on id (X-Scale-Event-Id): store each id you have processed, and answer 200 without processing it again.
Order. Events about the same message arrive in order. Events about different messages may not.
Rules for your endpoint:
- It must be
https://. Redirects are followed only on the same host and only tohttps://. - Your endpoint's first 200 characters of answer appear in your dashboard (tags stripped). Do not echo secrets.
- Answer quickly: record the event, answer
200, then do slow work in your own queue.
Events keep flowing while the subscription is inactive (sending is what stops — §8). They stop only if the Developer account itself is closed.
Verifying a delivery
Compute the signature over the raw body, exactly as received, before parsing it. Accept either signature header: during a rotation, whichever secret you hold matches one of them. Refuse an old timestamp to stop replays.
<?php
// scale-webhook-verify
function scale_webhook_is_valid( string $secret, string $raw_body, array $headers, int $tolerance = 300, ?int $now = null ): bool {
$headers = array_change_key_case( $headers, CASE_LOWER );
$ts = (string) ( $headers['x-scale-timestamp'] ?? '' );
$now = $now ?? time();
if ( ! ctype_digit( $ts ) || abs( $now - (int) $ts ) > $tolerance ) {
return false; // missing, or too old: refuse a replay
}
$expected = 'sha256=' . hash_hmac( 'sha256', $ts . '.' . $raw_body, $secret );
foreach ( [ 'x-scale-signature', 'x-scale-signature-previous' ] as $name ) {
if ( isset( $headers[ $name ] ) && hash_equals( $expected, (string) $headers[ $name ] ) ) {
return true;
}
}
return false;
}
// In your endpoint:
// $raw = file_get_contents( 'php://input' );
// if ( ! scale_webhook_is_valid( getenv( 'SCALE_WEBHOOK_SECRET' ), $raw, getallheaders() ) ) { http_response_code( 400 ); exit; }
// $event = json_decode( $raw, true ); // then deduplicate on $event['id'] and answer 200 quickly
// scale-webhook-verify
const crypto = require('crypto');
function scaleWebhookIsValid(secret, rawBody, headers, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) {
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]));
const ts = h['x-scale-timestamp'] || '';
if (!/^\d+$/.test(ts) || Math.abs(now - Number(ts)) > toleranceSeconds) return false; // too old: refuse a replay
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex'));
return ['x-scale-signature', 'x-scale-signature-previous'].some((name) => {
const got = Buffer.from(h[name] || '');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
module.exports = { scaleWebhookIsValid };
// In Express: app.post('/scale', express.raw({ type: 'application/json' }), (req, res) => {
// if (!scaleWebhookIsValid(process.env.SCALE_WEBHOOK_SECRET, req.body.toString('utf8'), req.headers)) return res.sendStatus(400);
// const event = JSON.parse(req.body); /* deduplicate on event.id */ res.sendStatus(200);
// });
Endpoints per number
Meta delivers your numbers' webhooks to Scale's callback, never to yours: Scale's app has one callback URL, and Scale verifies Meta's X-Hub-Signature-256 over the raw body. Meta can send a WABA's or a phone number's events to another URL, but Scale does not point that at your server — Meta signs with Scale's app secret, which you could not verify, and Scale would stop seeing the messages and statuses your sends and its gates depend on. Instead, Scale forwards each number's events to that number's own endpoint:
- Set it in the dashboard (Webhooks → Endpoints per number → Configure): its own URL, its own events and its own
whsec_…secret, shown once. - One event goes to one endpoint: the number's own, or — when it has none — your account endpoint. Another number's events never reach it, and it is never signed with another secret.
- Each endpoint has its own status: verification, last delivery and its HTTP status, last error, and how many events are waiting to retry.
- Rotate a number's secret, end the rotation's overlap at once (End the overlap now), or remove the endpoint (its secret is deleted; the number's next events go to your account endpoint).
- Scale checks that a number's events arrive under the WhatsApp Business Account it was connected under; a number that arrives under another is not accepted.
- The API key cannot read or change any endpoint.
Verification and test events
| Event | When | data |
|---|---|---|
webhook.verification | You pressed Verify endpoint. Answer 2xx with the challenge in the body. | challenge, message |
test.webhook, test.message.received, test.message.delivered, test.message.failed | A test (POST /test/webhook, or the dashboard). Fictional data. | As the real event, with "livemode": false and "test": true in the envelope |
Every one of them carries X-Scale-Test: 1, is signed like a real delivery, and is delivered at once — never queued or retried. Never treat one as a customer's message.
8. The subscription, payment and sending
| When | What happens |
|---|---|
| Signed up, first payment not confirmed (setup mode) | Everything but sending works: your API key, webhooks, customers, connecting your two included numbers, templates, and the tests (POST /test/webhook, POST /test/messages). Every real send — an OTP, an authentication, marketing or service message, a template or free text — is refused: 403 SUBSCRIPTION_INACTIVE, with details.mode setup. Buying an additional number is refused: 402 PAYMENT_REQUIRED, with details.subscription_status. |
| Paid | Sending works from every number that is connected and covered — your two included numbers, and each additional number while its own subscription runs. |
| Period ended, within 3 calendar days | Everything still works (past_due). Renew. |
| After the 3 days | Every send from every number of every customer is refused: 403 SUBSCRIPTION_INACTIVE, with details.status, details.grace_ends_at and details.migration. The 14-day migration period starts (§8.1). |
| Scale suspended the account | The same as after the 3 days (details.status is suspended). The 14-day migration period starts (§8.1). |
| You confirmed your account's deletion | The account is receive-only for 14 calendar days before the deletion runs: sending, creating a customer and creating an onboarding link are refused with 403 ACCOUNT_RECEIVE_ONLY (details.migration), and a link created earlier can no longer connect a number. Reads and disconnecting a number keep working, and incoming messages keep reaching your webhook. Cancelling the deletion (from the link in Scale's email) ends it at once. |
| A third number, and every one after it | Needs its own additional-number subscription (EGP 600 a month or EGP 6,000 a year). Without one, a new onboarding link is refused: 402 NUMBER_SUBSCRIPTION_REQUIRED. |
| An additional-number subscription ends (after its own 3-day grace) | That number stays connected and keeps receiving; only its sends are refused: 403 NUMBER_SUBSCRIPTION_INACTIVE. Renew it in the dashboard (Billing). |
| At the maximum Scale set for your account | New links and new numbers are refused: 409 NUMBER_LIMIT_REACHED. Nothing connected is affected. |
Renewing restores sending at once, with the same ids. No reconnection is needed.
How a payment is confirmed. You pay by InstaPay: at checkout Scale shows the InstaPay number and the exact amount; you transfer it and upload the transfer receipt with the sender and reference. The payment then waits for review — a person at Scale checks the transfer against the receipt, and nothing is activated until they approve it. An approved payment activates exactly what it bought (the main subscription's period, or that additional number's period). A rejected payment activates nothing; the dashboard (Billing → Payments) shows each payment as awaiting review, approved, rejected, cancelled or refunded, with what to do next. Returning from the payment page proves nothing on its own. The first approved payment opens sending at once — no request to Scale is needed. While a main-subscription payment is awaiting review, a second one cannot be started.
8.1 The 14-day migration period
When sending stops for your whole account — the 3-day grace ended, Scale suspended the account, or you confirmed the account's deletion — your numbers enter a 14-calendar-day migration period, so you can move them without losing what your customers receive:
- Nothing is deleted or disconnected. Every number stays connected to the same customer, with the same ids.
- Incoming messages are still received, stored and delivered to your webhook, with the usual retries, until the deadline.
- Every send is refused, on every route and every retry.
- Reads (
/account,/customers,/numbers,/messages,/usage,/templates) andPOST /numbers/{number_id}/disconnectkeep working with the same API key. - No AI reply, automation or ad conversion runs on a Developer number, during this period or ever (§2).
GET /accountreturnsmigration, and every refused request carries the same object indetails.migration:
"migration": {
"reason": "subscription",
"started_at": "2026-12-13T12:00:00Z",
"deadline": "2026-12-27T12:00:00Z",
"days_remaining": 9,
"inbound_forwarding": "on",
"deletion_at": null
}
reason is subscription (the grace ended), suspended or deletion. deletion_at is when a pending deletion runs — 14 days after you confirmed it, never earlier — or null. inbound_forwarding becomes stopped after the deadline.
The deadline is fixed when the period starts: 14 days after the earliest of the end of the grace, the start of a suspension and the confirmation of a deletion. Retried deliveries, repeated requests, and a deletion requested while a period is already running never move it.
How the period ends:
- You renew (or Scale lifts the suspension): sending resumes at once and the period ends. Nothing needs reconnecting.
- You cancel a pending deletion: the account leaves receive-only mode at once (sending still needs an active subscription).
- The deadline passes after a lapse or a suspension: incoming messages are still received and stored, but no longer delivered to your webhook. Nothing is deleted or disconnected, and renewing restores everything.
- The deadline passes on a pending deletion: the deletion runs — your customers, their numbers' connections to Scale, their conversations, your API key and your webhook endpoint are removed. Billing records are kept as the law requires. Never before the deadline.
Moving a number. Scale does not transfer numbers for you. Each number belongs to your customer's WhatsApp Business Account: to move it, connect it to its new provider following that provider's and Meta's instructions, then disconnect it here with POST /numbers/{number_id}/disconnect (the dashboard's Numbers screen lists every number with its customer and its ids). Ask the new provider whether the number must be disconnected from Scale first.
What you pay. EGP 1,200 a month or EGP 12,000 a year, for the whole account — every customer, with two WhatsApp numbers included. Each additional number: EGP 600 a month or EGP 6,000 a year, its own subscription, shown separately on your invoices — for example 3 numbers cost EGP 1,800 a month and 5 numbers EGP 3,000 a month (EGP 18,000 and EGP 30,000 a year). Scale charges nothing per message and deducts no message credits; GET /usage only counts, and Scale sets no message limit. WhatsApp's own conversation charges are Meta's, billed to each business under its own WhatsApp Business Account arrangement.
9. Errors and troubleshooting
| Code | HTTP | When |
|---|---|---|
INVALID_API_KEY | 401 | No key, a malformed key, or a key in the URL. |
API_KEY_REVOKED | 401 | Revoked, or replaced by a newer key. |
PAYMENT_REQUIRED | 402 | Buying an additional number before your first subscription payment is confirmed (§8). Your two included numbers can be connected before it. |
NUMBER_SUBSCRIPTION_REQUIRED | 402 | Your numbers and live links fill your allowance: add an additional number in the dashboard (Billing), or let a link expire (§8). |
PRODUCT_NOT_ENTITLED | 403 | A workspace key on a Developer route, or a Developer key on a workspace-only route. |
SUBSCRIPTION_INACTIVE | 403 | Sending in setup mode (before your first payment), after the grace, or while Scale has suspended the account — details.mode says which (setup, paused, suspended), with details.status and details.migration (§8, §8.1). |
ACCOUNT_RECEIVE_ONLY | 403 | Your account's deletion is pending: sending, new customers and new onboarding links are refused until it runs or you cancel it (details.migration, §8.1). Incoming messages keep reaching your webhook. |
NUMBER_SUBSCRIPTION_INACTIVE | 403 | This number's additional-number subscription ended (details.number_id, details.subscription_id). Renew it; nothing was disconnected. |
WHATSAPP_NOT_CONNECTED | 403 | The number cannot send right now (details.reason). Reconnect it with a new onboarding link. |
API_ACCESS_DISABLED | 403 | Scale switched the API off for this customer (its routes and its webhook events). Contact Scale. |
INVALID_REQUEST | 422 | A missing or malformed field (details.field). |
INVALID_MESSAGE | 400 / 422 | The message body is invalid. |
INVALID_PHONE_NUMBER | 422 | to is not a full international number. |
INVALID_CUSTOMER | 422 | A bad customer name or external_ref. |
CUSTOMER_NOT_FOUND | 404 | Not one of your customers. |
NUMBER_NOT_FOUND | 404 | Not one of your connected numbers. A Meta Phone Number ID is never a number_id. |
ONBOARDING_LINK_NOT_FOUND | 404 | Not one of your links. |
MESSAGE_NOT_FOUND | 404 | Not one of your messages. |
WEBHOOK_NOT_FOUND | 404 | A test or check found no endpoint: neither the number nor your account has one. |
TEMPLATE_NOT_FOUND | 404 / 422 | The template is unknown for this customer. |
CUSTOMER_NUMBER_MISMATCH | 422 | customer_id and number_id disagree. |
CUSTOMER_REF_IN_USE | 409 | Another of your customers has this external_ref. |
CUSTOMER_LIMIT_REACHED | 409 | A customer limit a Scale admin set on your account (there is none by default). Contact Scale to raise it. |
NUMBER_LIMIT_REACHED | 409 | The maximum number of WhatsApp numbers Scale set for your account. Contact Scale to raise it. |
ONBOARDING_LINK_FINISHED | 409 | The link has finished and cannot be revoked. |
IDEMPOTENCY_CONFLICT | 409 | The Idempotency-Key was used for a different request. |
OUTSIDE_MESSAGING_WINDOW | 422 | A free-form message outside the 24-hour window. Send a template. |
TEMPLATE_NOT_APPROVED | 422 | The template is not approved by WhatsApp. |
TEMPLATE_PARAMETER_MISMATCH | 422 | The wrong number of body parameters (details.expected, details.given). |
RATE_LIMITED | 429 | A rate limit (see §5). |
META_API_ERROR | 502 | WhatsApp refused the message, or did not confirm it (details.outcome). |
INTERNAL_ERROR | 500 | Scale's fault. Quote request_id. |
Troubleshooting
- **Every call answers
401.** Send the key asAuthorization: Bearer …, never in the URL. If you rotated the key, the old one stopped at once — use the new one. - **
403 SUBSCRIPTION_INACTIVEwithdetails.modesetup.** Your first payment is not confirmed yet, so nothing is sent. Everything else works; check your sends withPOST /test/messages. Pay from the Developer Dashboard (Billing);GET /accountshowsfirst_payment_confirmed. An InstaPay payment you already made may still be awaiting review — Billing → Payments shows its state. - **
403 SUBSCRIPTION_INACTIVEwithdetails.migration.** The grace ended (or Scale suspended the account). Incoming messages reach your webhook untildetails.migration.deadline; renew to send again, or move your numbers before then (§8.1). - **
403 ACCOUNT_RECEIVE_ONLY.** Your account's deletion is pending. Sending and new customers or onboarding links are refused until it runs (details.migration.deletion_at) or you cancel it from the link in Scale's email. - **
404 NUMBER_NOT_FOUNDfor a number you can see in Meta.** You sent Meta'sphone_number_id. Send Scale'snumber_id(num_…) fromGET /numbers. - **
422 OUTSIDE_MESSAGING_WINDOW.** The person has not written to that number in the last 24 hours. Send an approved template. - **
422 TEMPLATE_PARAMETER_MISMATCH.** The body needs one parameter per{{n}}variable;GET /templatesshowsparameters.body. - **
429 RATE_LIMITED.** WaitRetry-Afterseconds. Withdetails.reason = "sender_paced", WhatsApp is slowing that number down: spread your sends. - **
502 META_API_ERROR.** Readdetails.reasonanddetails.meta_code.outcome: "unknown"means WhatsApp did not confirm: do not send again with a new key; retry with the sameIdempotency-Keyto read the same answer. - No webhook arrives. The endpoint must be
https://, answer2xxwithin 5 seconds, and not redirect to another host. The Developer Dashboard (Webhooks) shows each delivery, its attempts and your endpoint's answer. - Signatures never match. Hash the raw body exactly as received, before any JSON parsing; use the timestamp header as sent; keep your server's clock right (the samples allow 300 seconds).
- **A number shows
WHATSAPP_NOT_CONNECTED.** Its connection needs renewing at Meta: create a new onboarding link for the customer.
10. Security best practices
- Keep the key on your server. Never put it in a browser, a mobile app, a public repository or a URL. Load it from a secret manager or an environment variable.
- Rotate on any doubt. Rotating in the dashboard stops the old key at once; revoking stops all access until you create a new one.
- Verify every webhook. Check the signature over the raw body and refuse old timestamps; never trust an unsigned request that looks like Scale.
- Store the webhook secret like the key. Rotate it in the dashboard if it may have leaked; for 24 hours both secrets sign, so you can switch without missing a delivery.
- One secret per endpoint. Verify each delivery with the secret of the endpoint that received it — a number's own endpoint has its own secret — and never take a test event (
X-Scale-Test: 1,"test": true) for a customer's message. - Deduplicate events on
id, and make your handlers safe to run twice. - **Use an
Idempotency-Key** on every send you might retry. - Keep your own tenants apart. One key reaches all of your customers. Scale keeps your account apart from every other Developer's, but inside your account your application must map each of your users to their own
customer_idand never let one user name another's customer or number. - Treat onboarding links as secrets. A link works once and expires; send it only to the business it is for, and revoke links you no longer need.
- Least data. Send only what the message needs; never put passwords or full card numbers in a message.
- One-time codes are not kept. For an AUTHENTICATION template send, Scale records "Verification code sent" and keeps neither the code nor the template's parameter values, and the
message.sentevent carries no code. Keep codes out of what Scale stores as you send it: theIdempotency-Key,external_refand an onboarding link'sstate.
11. Examples
cURL
# Your customers and their numbers
curl -s https://scale.example/wp-json/scale/v1/numbers \
-H "Authorization: Bearer $SCALE_KEY"
# A customer, then a link to connect its number
curl -s -X POST https://scale.example/wp-json/scale/v1/customers \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-d '{"name":"Cairo Clinic","external_ref":"crm-1"}'
curl -s -X POST https://scale.example/wp-json/scale/v1/customers/ws_XXXX/onboarding-links \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-d '{"label":"Front desk","state":"crm-1"}'
# Send a text from one number (safe to retry with the same Idempotency-Key)
curl -s -X POST https://scale.example/wp-json/scale/v1/messages \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-confirmation" \
-d '{"number_id":"num_XXXX","to":"201012345678","type":"text","text":{"body":"Your order shipped."}}'
# Send an approved template
curl -s -X POST https://scale.example/wp-json/scale/v1/messages \
-H "Authorization: Bearer $SCALE_KEY" -H "Content-Type: application/json" \
-d '{"number_id":"num_XXXX","to":"201012345678","type":"template","template":{"name":"appointment_reminder","language":"ar","components":[{"type":"body","parameters":[{"type":"text","text":"Mona"},{"type":"text","text":"5 pm"}]}]}}'
# Where does it stand?
curl -s https://scale.example/wp-json/scale/v1/messages/msg_XXXX \
-H "Authorization: Bearer $SCALE_KEY"
PHP
<?php
function scale_api( string $method, string $path, ?array $body = null, array $headers = [] ): array {
$ch = curl_init( 'https://scale.example/wp-json/scale/v1' . $path );
$h = array_merge( [ 'Authorization: Bearer ' . getenv( 'SCALE_KEY' ), 'Content-Type: application/json' ], $headers );
curl_setopt_array( $ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $h,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
] );
if ( $body !== null ) {
curl_setopt( $ch, CURLOPT_POSTFIELDS, json_encode( $body ) );
}
$raw = curl_exec( $ch );
$status = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
$json = json_decode( (string) $raw, true );
if ( $status >= 400 ) {
throw new RuntimeException( ( $json['error']['code'] ?? 'HTTP_' . $status ) . ': ' . ( $json['error']['message'] ?? '' ) . ' (' . ( $json['request_id'] ?? '' ) . ')' );
}
return $json;
}
// Send from a customer's number; the Idempotency-Key makes a retry safe.
$sent = scale_api( 'POST', '/messages', [
'number_id' => 'num_XXXX',
'to' => '201012345678',
'type' => 'text',
'text' => [ 'body' => 'Your order shipped.' ],
], [ 'Idempotency-Key: order-1001-confirmation' ] );
echo $sent['message']['id'], ' from ', $sent['message']['number_id'], PHP_EOL;
// Connect a new customer's number: create the customer, then a link to send them.
$customer = scale_api( 'POST', '/customers', [ 'name' => 'Cairo Clinic', 'external_ref' => 'crm-1' ] );
$link = scale_api( 'POST', '/customers/' . $customer['customer']['customer_id'] . '/onboarding-links', [ 'label' => 'Front desk' ] );
echo $link['link']['url'], PHP_EOL;
Node.js (18+)
const BASE = 'https://scale.example/wp-json/scale/v1';
async function scaleApi(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${process.env.SCALE_KEY}`,
'Content-Type': 'application/json',
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) {
const e = json.error || {};
throw new Error(`${e.code || res.status}: ${e.message || ''} (${json.request_id || ''})`);
}
return json;
}
async function main() {
// Every connected number, with its customer.
const { numbers } = await scaleApi('GET', '/numbers');
for (const n of numbers) console.log(n.number_id, n.customer_id, n.display_phone_number);
// Send an approved template from the first number.
const sent = await scaleApi('POST', '/messages', {
number_id: numbers[0].number_id,
to: '201012345678',
type: 'template',
template: {
name: 'appointment_reminder',
language: 'ar',
components: [{ type: 'body', parameters: [{ type: 'text', text: 'Mona' }, { type: 'text', text: '5 pm' }] }],
},
}, { 'Idempotency-Key': 'reminder-77' });
console.log(sent.message.id, sent.message.status);
// Usage for the last 30 days — counts only.
const usage = await scaleApi('GET', '/usage');
console.log(usage.totals);
}
main().catch((err) => { console.error(err.message); process.exit(1); });
An OTP flow, end to end (Node.js)
// Send a one-time code with an approved AUTHENTICATION template (Meta's category for codes),
// then follow it on your webhook. A template with a copy-code button takes the code twice:
// in the body and in the button (Meta's format).
const crypto = require('crypto');
async function sendOtp(scaleApi, numberId, phone) {
const code = String(crypto.randomInt(100000, 1000000));
// Store a hash of the code with a short expiry in your own database before sending it.
const sent = await scaleApi('POST', '/messages', {
number_id: numberId,
to: phone,
type: 'template',
template: {
name: 'otp_code',
language: 'en_US',
components: [
{ type: 'body', parameters: [{ type: 'text', text: code }] },
{ type: 'button', sub_type: 'url', index: '0', parameters: [{ type: 'text', text: code }] },
],
},
}, { 'Idempotency-Key': `otp-${phone}-${Math.floor(Date.now() / 60000)}` });
return sent.message.id; // match it to message.delivered / message.failed events by data.api_message_id
}
module.exports = { sendOtp };