العربيةGet API Access
Get API Access →العربية
Skip to content
Developer documentation

Scale Developer API — guide and reference

From sign-up to your first message: the account and first payment, connecting customers' numbers, the API key, every endpoint and message type, webhooks, errors, security and code examples.

On this page11 sections

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 as external_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_id for as long as it stays connected, including through a reconnect.
  • Stable identifiers. developer_id, customer_id and number_id are 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_id and waba_id are returned so you can match Meta's dashboards.
    • The API never accepts them in place of number_id or customer_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 Authorization header. 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/status and /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-Remaining and X-RateLimit-Reset (a UNIX time).
  • Retry-After (seconds) on a 429.

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; limit 1–100 (default 50); pass the answer's next_cursor as after for the next page.
  • GET /messages — newest first; limit 1–100 (default 50); next_cursor the same way.
  • next_cursor is null on the last page.
  • GET /numbers, GET /templates and a customer's onboarding links return everything at once; GET /usage takes a date range.

Idempotency. POST /messages accepts an Idempotency-Key header so a retry never sends twice (§6).

5. Rate limits

BucketDefaultApplies to
Per key300 requests / 60 severy request
Per Developer account600 sends / 60 sPOST /messages
Per customer60 sends / 60 sPOST /messages for that customer
Per customer, number changes60 / 10 mincreating onboarding links, disconnecting numbers
Per Developer account, account changes60 / 10 mincreating 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 events10 / 10 minthe 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:**

  • none
  • pending_payment
  • active
  • past_due — inside the 3-day grace; sending still works.
  • expired
  • cancelled
  • suspended

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

  • limit is between 1 and 100.
  • To get the next page, pass the previous next_cursor as after.
{
  "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": {…}}.

  • name is 1–120 characters.
  • external_ref is 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": {…}}.

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

FieldMeaning
labelThe number's name, e.g. "Front desk".
stateUp to 200 visible ASCII characters. It is returned on the link, for your own correlation.
expires_inSeconds until the link can no longer be opened: 600–604800. Default 259200 (72 h).

Not supported yet:

  • intent other 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.number is the number it connected, including its number_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-links returns {"links": […]}.
  • GET /onboarding-links/{link_id} returns {"link": {…}}, without the URL.
  • POST /onboarding-links/{link_id}/revoke returns {"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, and refresh=1 to 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:

typeFields
texttext.body (≤ 4000)
imageimage.link (public https://), image.caption? (≤ 1000)
videovideo.link, video.caption?
audioaudio.link
documentdocument.link, document.filename?, document.caption?
templatetemplate.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:

  • to is 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_APPROVED or TEMPLATE_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, unsupported or unknown.
  • 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.stage is send (WhatsApp refused it) or delivery (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; limit is 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.

  • days has every UTC day of the range, in order, including days with nothing (all zero). Its figures add up to totals.
  • sent, failed and unknown are your sends by their state; received is 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.

  • event is ping (the default), message.received, message.delivered or message.failed; it arrives as test.webhook, test.message.received, test.message.delivered or test.message.failed.
  • The data is fictional (the sender is 15550100001), the envelope says "livemode": false and "test": true, and the request carries X-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

EventWhendata
message.receivedSomeone 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.sentWhatsApp 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.failedWhatsApp reported progress.message_id, api_message_id, to, status, error (failed only), timestamp
number.connectedA number was connected (an onboarding link, or a reconnect).number (as in GET /numbers/{number_id}), timestamp
number.disconnectedA number stopped routing.number_id, phone_number_id, reason, timestamp
onboarding.completed, onboarding.failedAn onboarding link finished.link_id, then number_id (completed) or error (failed), timestamp
webhook.testYou 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:

HeaderValue
X-Scale-EventThe event type.
X-Scale-Event-Idevt_…
X-Scale-TimestampUNIX seconds of this attempt.
X-Scale-Signaturesha256= + HMAC-SHA256 of "<timestamp>.<raw body>" with your secret.
X-Scale-Delivery-AttemptThe attempt number.
X-Scale-Signature-PreviousDuring the 24 hours after a secret rotation only: the same signature, made with the previous secret.

Retries:

  • Answer 2xx within 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 id are 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 to https://.
  • 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

EventWhendata
webhook.verificationYou pressed Verify endpoint. Answer 2xx with the challenge in the body.challenge, message
test.webhook, test.message.received, test.message.delivered, test.message.failedA 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

WhenWhat 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.
PaidSending 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 daysEverything still works (past_due). Renew.
After the 3 daysEvery 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 accountThe same as after the 3 days (details.status is suspended). The 14-day migration period starts (§8.1).
You confirmed your account's deletionThe 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 itNeeds 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 accountNew 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) and POST /numbers/{number_id}/disconnect keep 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 /account returns migration, and every refused request carries the same object in details.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

CodeHTTPWhen
INVALID_API_KEY401No key, a malformed key, or a key in the URL.
API_KEY_REVOKED401Revoked, or replaced by a newer key.
PAYMENT_REQUIRED402Buying an additional number before your first subscription payment is confirmed (§8). Your two included numbers can be connected before it.
NUMBER_SUBSCRIPTION_REQUIRED402Your numbers and live links fill your allowance: add an additional number in the dashboard (Billing), or let a link expire (§8).
PRODUCT_NOT_ENTITLED403A workspace key on a Developer route, or a Developer key on a workspace-only route.
SUBSCRIPTION_INACTIVE403Sending 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_ONLY403Your 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_INACTIVE403This number's additional-number subscription ended (details.number_id, details.subscription_id). Renew it; nothing was disconnected.
WHATSAPP_NOT_CONNECTED403The number cannot send right now (details.reason). Reconnect it with a new onboarding link.
API_ACCESS_DISABLED403Scale switched the API off for this customer (its routes and its webhook events). Contact Scale.
INVALID_REQUEST422A missing or malformed field (details.field).
INVALID_MESSAGE400 / 422The message body is invalid.
INVALID_PHONE_NUMBER422to is not a full international number.
INVALID_CUSTOMER422A bad customer name or external_ref.
CUSTOMER_NOT_FOUND404Not one of your customers.
NUMBER_NOT_FOUND404Not one of your connected numbers. A Meta Phone Number ID is never a number_id.
ONBOARDING_LINK_NOT_FOUND404Not one of your links.
MESSAGE_NOT_FOUND404Not one of your messages.
WEBHOOK_NOT_FOUND404A test or check found no endpoint: neither the number nor your account has one.
TEMPLATE_NOT_FOUND404 / 422The template is unknown for this customer.
CUSTOMER_NUMBER_MISMATCH422customer_id and number_id disagree.
CUSTOMER_REF_IN_USE409Another of your customers has this external_ref.
CUSTOMER_LIMIT_REACHED409A customer limit a Scale admin set on your account (there is none by default). Contact Scale to raise it.
NUMBER_LIMIT_REACHED409The maximum number of WhatsApp numbers Scale set for your account. Contact Scale to raise it.
ONBOARDING_LINK_FINISHED409The link has finished and cannot be revoked.
IDEMPOTENCY_CONFLICT409The Idempotency-Key was used for a different request.
OUTSIDE_MESSAGING_WINDOW422A free-form message outside the 24-hour window. Send a template.
TEMPLATE_NOT_APPROVED422The template is not approved by WhatsApp.
TEMPLATE_PARAMETER_MISMATCH422The wrong number of body parameters (details.expected, details.given).
RATE_LIMITED429A rate limit (see §5).
META_API_ERROR502WhatsApp refused the message, or did not confirm it (details.outcome).
INTERNAL_ERROR500Scale's fault. Quote request_id.

Troubleshooting

  • **Every call answers 401.** Send the key as Authorization: Bearer …, never in the URL. If you rotated the key, the old one stopped at once — use the new one.
  • **403 SUBSCRIPTION_INACTIVE with details.mode setup.** Your first payment is not confirmed yet, so nothing is sent. Everything else works; check your sends with POST /test/messages. Pay from the Developer Dashboard (Billing); GET /account shows first_payment_confirmed. An InstaPay payment you already made may still be awaiting review — Billing → Payments shows its state.
  • **403 SUBSCRIPTION_INACTIVE with details.migration.** The grace ended (or Scale suspended the account). Incoming messages reach your webhook until details.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_FOUND for a number you can see in Meta.** You sent Meta's phone_number_id. Send Scale's number_id (num_…) from GET /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 /templates shows parameters.body.
  • **429 RATE_LIMITED.** Wait Retry-After seconds. With details.reason = "sender_paced", WhatsApp is slowing that number down: spread your sends.
  • **502 META_API_ERROR.** Read details.reason and details.meta_code. outcome: "unknown" means WhatsApp did not confirm: do not send again with a new key; retry with the same Idempotency-Key to read the same answer.
  • No webhook arrives. The endpoint must be https://, answer 2xx within 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_id and 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.sent event carries no code. Keep codes out of what Scale stores as you send it: the Idempotency-Key, external_ref and an onboarding link's state.

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 };
Create a Developer account