Overview

Base URL https://api.walpio.com. Every request and answer body is JSON. Authenticate with an API key from your dashboard:

Authorization: Bearer wp_live_…

An API key belongs to one workspace, is shown once when it is created, can be given an expiry date and can be revoked at any time. A workspace can hold up to 20 active keys.

Errors

Every error answers with the same shape:

{ "error": { "code": "machine_code", "message": "plain words", "retryable": false } }

retryable says whether repeating the same request later can succeed without anyone acting. Build on code and retryable, not on the wording of message.

StatusMeaning
400Malformed request.
401Missing, wrong or expired API key.
403Not allowed.
404Not found.
409The same Idempotency-Key is still in flight — wait and repeat it.
413Body too large.
422Refused: fix the request, or the recipient cannot get this message.
429Rate or plan limit. Honour the Retry-After header.
502Meta is unavailable. Retry.
503The WhatsApp number needs attention (for example, its credentials).

Send a message — POST /v1/messages

The body follows Meta's Cloud API message shape; fields walpio does not use are ignored. A plain text:

curl -X POST https://api.walpio.com/v1/messages \
  -H "Authorization: Bearer wp_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-42-shipped" \
  -d '{ "to": "+971501234567", "type": "text",
        "text": { "body": "Your order #42 is on the way." } }'

An approved template, with its values:

{ "to": "971501234567", "type": "template",
  "template": { "name": "order_update", "language": { "code": "en" },
                "params": ["#42", "Thursday"] } }
FieldNotes
toAn international number (a leading + and spaces are fine), or a business-scoped user id (AE.123…) for a customer who hides their number. National numbers (05…) are refused, never guessed.
typetext or template.
text.bodyUp to 4,096 characters.
template.language{ "code": "en" } or "en".
template.componentsRaw Cloud API components, passed through to Meta.
template.paramsA walpio shortcut: the body values as an array ({{1}}, {{2}} …) or an object for named variables.
fromThe phone_number_id (or walpio number id) to send from, when the workspace has several numbers.
biz_opaque_callback_dataYour own reference, up to 512 characters. It comes back in status callbacks.

Safe retries

Send an Idempotency-Key header (up to 200 characters) and the send is safe to retry: the same key replays the first answer instead of messaging the customer again. The same key with a different body is refused with 422 idempotency_key_reused. Without a key, an identical request (same number, recipient and content) inside the workspace's dedupe window — 120 seconds by default — is also treated as a retry.

How a plain text is delivered

WhatsApp only lets a business send free-form text within 24 hours of the customer's last message. For every plain-text send, walpio decides:

SituationWhat walpio sends
The customer wrote to this number in the last 24 hoursThe text, as written.
Otherwise, and a text rule recognises the wordingThe rule's approved template, with the values filled in.
Otherwise, and the workspace is set to fallbackThe approved fallback template for the text's language (Arabic script → Arabic).
Otherwise422 no_matching_template — nothing is sent.

walpio treats the window as closed a little early (after 23.5 hours) to leave a safety margin. By default a workspace refuses text that no rule matches; the fallback is an explicit per-workspace choice with a cost: Meta treats a template whose content is unclear — such as a body that is only one variable — as marketing, charges it at the marketing rate, and can restrict accounts that send utility content through it. Text rules pointing at specific approved templates avoid that.

The answer

{ "messaging_product": "whatsapp",
  "messages": [{ "id": "wamid.HBgM…" }],
  "walpio": { "id": "7b1c…", "via": "template", "status": "accepted",
              "template": { "name": "invoice_issued", "language": "en" },
              "transformations": ["matched_text_rule"] } }
  • 200 — sent to Meta. walpio.status is accepted, or held when Meta holds the message for a quality check. Accepted is not delivered: delivery arrives as a status.
  • 202 — accepted but not yet sent. status is queued (Meta asked to slow down; walpio retries it), unknown (Meta's answer was lost; the status callback settles it), or an earlier identical request is still in flight.

Refusals worth handling in your code:

CodeMeaning
contact_opted_outThe customer opted out (for example by replying STOP). Nothing is sent to them.
marketing_opted_outThe customer turned off marketing messages from this business in WhatsApp.
marketing_pausedMeta's per-user marketing limit was hit; walpio holds marketing to that customer for 24 hours.
template_not_approvedThe template is not approved in that language.
text_too_long_for_templateThe text is longer than the fallback template leaves room for.
recipient_unavailableMeta can't deliver to this recipient (for example, the number isn't on WhatsApp).
rate_limitedThe per-minute send rate is used up. Retry after Retry-After.
monthly_limit_reachedThis month's plan allowance is used up (only when a hard cap is set).
account_unavailableMeta refused because of the sending number's account; it needs attention.

Message status — GET /v1/messages/{id}

id is walpio's id or Meta's wamid. The answer holds the current status, any error, the pricing Meta reported, your reference and the full status timeline (sent, delivered, read, failed).

Numbers — GET /v1/numbers

Every WhatsApp number connected to the workspace, with its health, quality rating, messaging limit and display-name status.

Templates

  • GET /v1/templates[?from=<phone_number_id>] — every template with its status, category, rejection reason, quality and components.
  • POST /v1/templates — create a template and submit it for Meta's review.
  • DELETE /v1/templates/{name}[?language=en] — delete every language, or one.
{ "name": "order_update", "language": "en", "category": "UTILITY",
  "components": [{ "type": "BODY",
                   "text": "Order {{1}} is ready. Reply here with questions.",
                   "example": { "body_text": [["#42"]] } }] }

walpio checks a template before submitting it: a lower-case name, a body of at most 1,024 characters, no variable at the very start or end, variables numbered {{1}}, {{2}} … without gaps, and an example for every variable. Meta decides the final category — a template submitted as utility that Meta finds promotional is approved as marketing.

Text rules — send your own wording as the right template

A text rule recognises a message your systems already send and, outside the 24-hour window, sends the matching approved template instead, with the values filled in.

  • GET /v1/text-rules — list rules.
  • POST /v1/text-rules — create one.
  • DELETE /v1/text-rules/{id} — delete one.
  • POST /v1/text-rules/test with { "text": "…" } — see which rule, if any, a text would match.
{ "name": "Invoice issued",
  "pattern": "Your invoice {{number}} for AED {{amount}} is due on {{due}}.",
  "templateName": "invoice_issued", "templateLanguage": "en",
  "params": ["number", "amount", "due"] }

pattern is your text with {{placeholders}} for the parts that change. params lists which placeholder fills each template variable, in order (or an object for named variables). The rule is checked against the template's variables when you save it.

Callbacks walpio sends you

Set your URL in the dashboard under Settings → Callbacks; the signing secret is shown once. walpio then posts each event to you:

POST <your url>
walpio-event: message.status | message.inbound | template.status
walpio-delivery: <delivery id>
walpio-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
{ "id": "…", "event": "message.status", "created_at": "2026-09-25T10:00:00Z",
  "data": { "message_id": "…", "wa_message_id": "wamid…", "to": "9715…",
            "status": "delivered", "timestamp": "…", "client_reference": "order-42",
            "pricing": { "category": "utility", "billable": true } } }
  • Verify the signature over the raw body, reject a t older than 5 minutes, and de-duplicate by id.
  • Answer with a 2xx within 10 seconds. Redirects are not followed. Failed deliveries are retried after 30 s, 2 m, 8 m, 30 m, 1 h, 2 h and 4 h, and shown in the dashboard.
  • message.inbound carries from (the phone number, or the user id when Meta withholds the number), text, type, profile_name, context_wa_message_id for replies, and opt when the message was STOP or START.

Checking the signature in Node.js:

import crypto from 'node:crypto';

function verifyWalpio(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  const want = Buffer.from(expected, 'hex');
  return given.length === want.length && crypto.timingSafeEqual(given, want);
}

Limits

LimitValue
Send ratePer workspace per minute (60 by default; set by walpio to your plan).
Monthly messagesYour plan allowance. By default sending continues past it and the dashboard warns; a hard cap is available.
Dedupe window0–3,600 seconds (120 by default).
API keys20 active per workspace, optional expiry.
BroadcastsUp to 5,000 recipients per request, sent one by one through the queue.

Meta's own limits still apply: up to 80 messages per second per number, about one message every 6 seconds to the same customer in bursts, and your business portfolio's daily limit of customers reached outside the 24-hour window (shown per number as its messaging limit).

Questions about the API? Email [email protected] or get started.