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.
| Status | Meaning |
|---|---|
400 | Malformed request. |
401 | Missing, wrong or expired API key. |
403 | Not allowed. |
404 | Not found. |
409 | The same Idempotency-Key is still in flight — wait and repeat it. |
413 | Body too large. |
422 | Refused: fix the request, or the recipient cannot get this message. |
429 | Rate or plan limit. Honour the Retry-After header. |
502 | Meta is unavailable. Retry. |
503 | The 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"] } }| Field | Notes |
|---|---|
to | An 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. |
type | text or template. |
text.body | Up to 4,096 characters. |
template.language | { "code": "en" } or "en". |
template.components | Raw Cloud API components, passed through to Meta. |
template.params | A walpio shortcut: the body values as an array ({{1}}, {{2}} …) or an object for named variables. |
from | The phone_number_id (or walpio number id) to send from, when the workspace has several numbers. |
biz_opaque_callback_data | Your 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:
| Situation | What walpio sends |
|---|---|
| The customer wrote to this number in the last 24 hours | The text, as written. |
| Otherwise, and a text rule recognises the wording | The rule's approved template, with the values filled in. |
| Otherwise, and the workspace is set to fallback | The approved fallback template for the text's language (Arabic script → Arabic). |
| Otherwise | 422 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.statusisaccepted, orheldwhen Meta holds the message for a quality check. Accepted is not delivered: delivery arrives as a status.202— accepted but not yet sent.statusisqueued(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:
| Code | Meaning |
|---|---|
contact_opted_out | The customer opted out (for example by replying STOP). Nothing is sent to them. |
marketing_opted_out | The customer turned off marketing messages from this business in WhatsApp. |
marketing_paused | Meta's per-user marketing limit was hit; walpio holds marketing to that customer for 24 hours. |
template_not_approved | The template is not approved in that language. |
text_too_long_for_template | The text is longer than the fallback template leaves room for. |
recipient_unavailable | Meta can't deliver to this recipient (for example, the number isn't on WhatsApp). |
rate_limited | The per-minute send rate is used up. Retry after Retry-After. |
monthly_limit_reached | This month's plan allowance is used up (only when a hard cap is set). |
account_unavailable | Meta 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/testwith{ "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
tolder than 5 minutes, and de-duplicate byid. - 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.inboundcarriesfrom(the phone number, or the user id when Meta withholds the number),text,type,profile_name,context_wa_message_idfor replies, andoptwhen 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
| Limit | Value |
|---|---|
| Send rate | Per workspace per minute (60 by default; set by walpio to your plan). |
| Monthly messages | Your plan allowance. By default sending continues past it and the dashboard warns; a hard cap is available. |
| Dedupe window | 0–3,600 seconds (120 by default). |
| API keys | 20 active per workspace, optional expiry. |
| Broadcasts | Up 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.