The walpio API sends WhatsApp messages from your own number, tells you what happened to them, manages your templates and text rules, records your customers' consent, and posts signed callbacks to your systems. walpio.com/docs shows this same file.

  • Base URL https://api.walpio.com. Every path below starts with /v1, so the send endpoint is https://api.walpio.com/v1/messages. If your system asks for a base address and adds /messages itself, give it https://api.walpio.com/v1. Code written for Meta's Cloud API can keep Meta's path — see Moving from the Cloud API.
  • Authentication Authorization: Bearer wp_live_… with an API key from the dashboard (API keys). A 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 20 keys that are not revoked (expired ones count until they are revoked). A key can be limited to what your system needs and to the addresses it calls from — see API keys: scopes and addresses.
  • Bodies are JSON, up to 512 KB. Answers are JSON and never cached.
  • Field names are snake_case (phone_number_id, client_reference) everywhere except three places, which use camelCase: the text-rule endpoints, in requests and answers (templateName, templateLanguage, waAccountId, matchCount, lastMatchedAt, createdAt, updatedAt, pairId, pairedWith); the consent request (optedIn — its answer says opted_in); and the system object of an inbound callback (newWaId, previousUserId, newUserId). Both spellings are stable: /v1 does not rename fields, so write each one exactly as shown here.
  • Errors all have one shape — see Errors for every code:
{ "error": { "code": "no_matching_template", "message": "plain words", "retryable": false,
             "request_id": "8c1f0e7d4a2b3c4d-DXB" } }

Build on code and retryable, never on the wording of message.

Not connected to a number yet? Connect your WhatsApp number explains what to do at Meta first.

Your first message

In a new workspace, a plain text to a customer is refused with 422 no_matching_template — on purpose. WhatsApp lets a business send free-form text only within 24 hours of the customer's last message; outside that window only a template Meta has approved may go. A new workspace has no text rules yet, and walpio never wraps unmatched text in a catch-all template unless the workspace chooses that. So the first message that works is one of these:

A. Text a customer who wrote to you in the last 24 hours. From your own phone, send any WhatsApp message to your business number. Then send a text to your own number:

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971501234567", "type": "text", "text": { "body": "Hello from walpio" } }
HTTP/1.1 200 OK
Content-Type: application/json

{ "messaging_product": "whatsapp",
  "contacts": [{ "input": "971501234567", "wa_id": "971501234567" }],
  "messages": [{ "id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSMEY1QTJCN0I4" }],
  "walpio": { "id": "0b6f4c2e-7d1a-4c8e-9f3b-2a5d6e7f8091",
              "via": "text", "status": "accepted" } }

B. Send an approved template by name. A template works at any time, whether or not the customer wrote. GET /v1/templates lists yours with their status, language and wording; any one whose status is approved will do. Fill its variables with params:

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971501234567", "type": "template",
  "template": { "name": "order_update", "language": "en", "params": ["#42"] } }
HTTP/1.1 200 OK
Content-Type: application/json

{ "messaging_product": "whatsapp",
  "contacts": [{ "input": "971501234567", "wa_id": "971501234567" }],
  "messages": [{ "id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSNTc3QjE2RDJF" }],
  "walpio": { "id": "5d0c8e1b-3f2a-4b6c-8d7e-9f0a1b2c3d4e",
              "via": "template", "status": "accepted",
              "template": { "name": "order_update", "language": "en" },
              "category": "UTILITY" } }

If your account has Meta's sample template hello_world (language en_US, no variables), "template": { "name": "hello_world", "language": "en_US" } works as it is.

Why the plain text is refused, and what the refusal looks like:

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971509876543", "type": "text", "text": { "body": "Your order #42 is on the way." } }
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{ "error": { "code": "no_matching_template",
             "message": "the customer has not written to this number in the last 24 hours, so WhatsApp only allows an approved template — and no text rule recognises this wording. Add a text rule for it (or send type=template).",
             "retryable": false, "request_id": "…" } }

Nothing was sent, and the refusal is recorded in the dashboard's Activity, so whoever looks after your wording can see it. To keep sending your own text outside the window, add a text rule that recognises the wording and names the approved template to send instead.

The same in your language

Each example reads the key from the WALPIO_API_KEY environment variable and prints walpio's id and status for the message, or the error's HTTP status, code and message.

A text (to a customer who wrote in the last 24 hours):

cURL

curl -X POST https://api.walpio.com/v1/messages \
  -H "Authorization: Bearer $WALPIO_API_KEY" \
  -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 by name (to any customer, at any time):

cURL

curl -X POST https://api.walpio.com/v1/messages \
  -H "Authorization: Bearer $WALPIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-42-ready" \
  -d '{ "to": "+971501234567", "type": "template",
        "template": { "name": "order_update", "language": "en", "params": ["#42"] } }'

What you can send and receive today

  • Send: text and template messages — templates with their body, header and button values through params (header and button values), or through Meta's own components. Inside the customer's 24-hour window, media too: images, documents (an invoice PDF), video, audio and stickers, by a link or by a file you upload first; and interactive messages, locations, contacts and reactions: reply buttons, lists, a URL button, a location request, a Flow. And read receipts and the typing indicator for a customer's message. Catalogue and carousel messages (product, product_list, carousel) cannot be sent through walpio today; another type is refused with 400 invalid_request.
  • Receive: every message a customer sends reaches your callback URL as message.inbound, with its readable text and Meta's whole message object (message): a photo's or file's media id, a button's payload, the list row or reply button they chose, a location's coordinates, shared contact cards, a Flow's answers, the ad that brought them. The photo, video, voice message or document itself is fetched through walpio — walpio keeps no file.
  • Track: a message.status callback for every status Meta reports (sent, delivered, read, failed), GET /v1/messages/{id} at any time, and GET /v1/messages to list and search them — by status, customer, number, your own reference or time. walpio's own final failures do not produce a callback (see The answer).
  • Manage: templates (list, create, delete), text rules, and your customers' consent.

Send a message

POST /v1/messages

The body follows the Cloud API's message shapes — text, template, media, interactive, location, contacts and reaction — so a body written for Meta's API is accepted as it is. walpio is not a drop-in replacement for the rest of Meta's API: see What you can send and receive today and Moving from the Cloud API.

FieldRequiredNotes
toyesThe customer: an international number (a leading +, spaces, dashes, dots and brackets are fine; a leading 00 is dropped; a trunk (0) right after the country code, as in +971 (0) 50 …, is dropped; Arabic-Indic and full-width digits are read as digits), or a business-scoped user id such as AE.1234… for a customer who hides their number. 5–160 characters. National numbers (05…) are refused, never guessed.
typenotext (the default), template, a media type — image, document, video, audio, sticker (see Send media) — or interactive, location, contacts, reaction (see Interactive messages, locations, contacts and reactions).
text.bodyfor textThe message, 1–4,096 characters, not only spaces.
text.preview_urlnotrue asks WhatsApp to show a preview of the first link in the text. Passed to Meta as given when the text goes as text; a text that goes through a template has no preview.
contextno{ "message_id": "wamid…" } — the message this one replies to, shown to the customer as a quoted reply (a text, a template, media or any other message). Passed to Meta as given; up to 256 characters.
template.namefor templateThe approved template's name.
template.languagefor template{ "code": "en" } or "en" — or "auto": the template in the customer's language (set by you, or read from their messages), else in the workspace's default language (dashboard, Settings → Sending), else in the template's first approved language. The answer names the language chosen and adds "language_auto" to transformations; 422 template_not_approved when walpio knows no approved language of the template on the number.
template.paramsnowalpio's shortcut for the body's variables: an array for {{1}}, {{2}} … (in order), or an object for named variables ({ "customer_name": "Aisha" }). Up to 50 values of up to 1,024 characters each; runs of spaces and line breaks are collapsed (Meta refuses them in a variable). For a template with a header or buttons that take a value, send { "body": <the values, as above>, "header": …, "buttons": [{ "index": 0, "sub_type": "url", "value": "…" }] } — see Header and button values.
template.componentsnoMeta's own components array, passed to Meta untouched (up to 20). Use it for what params does not cover — a currency or date value, a carousel, a limited-time offer. When both are sent, components wins and params is ignored.
image, document, video, audio, stickerfor that typeThe file, as Meta takes it — see Send media.
interactive, location, contacts, reactionfor that typeThe message, as Meta takes it — see Interactive messages, locations, contacts and reactions.
fromnoThe number to send from: its phone_number_id, or walpio's number id. Without it, walpio sends from the workspace's oldest active number.
biz_opaque_callback_datanoYour own reference, up to 512 characters. It comes back as client_reference in status callbacks and in the message status.
ttlnoHow long the message is worth sending, in seconds (1 to 2,592,000 — 30 days). When walpio has to send it again — a queued retry, or the one re-send of a lost answer — and this time has passed, it does not: the message fails with walpio:expired (or, after a lost answer, stays unknown). For "your technician is 10 minutes away", send "ttl": 600. Without it the message never expires. Not part of what makes two requests the same.
messaging_product, recipient_typenoAccepted for Meta compatibility ("whatsapp", "individual"); walpio sets them itself.

Any other field is ignored.

A template can be sent at any time. When walpio knows the template on the sending number and its status is not approved, the send is refused with 422 template_not_approved; a template walpio has not read from Meta yet is passed to Meta, which decides. Marketing templates are not sent to a customer who turned off marketing from your business (marketing_opted_out) or whom Meta's per-user marketing limit refused in the last 24 hours (marketing_paused). Both are about the customer, not the number: a stop or a limit Meta reported on one of your numbers holds marketing from all of them. walpio learns of them from Meta's status callbacks and from Meta's answer to a send (codes 131050 and 131049), including on the very first message to a customer.

Header and button values

A template whose header has a variable, is a file or a location, or whose buttons take a value in every message — a link that ends in a variable (a payment or tracking link), a copy code, a quick reply's payload — gets those values in params beside the body's:

params fieldWhat it holds
bodyThe body's values, as above: an array, or an object for named variables.
headerA text header's value: "Aisha" (or { "type": "text", "text": "Aisha" }). A file: `{ "type": "image" \"video" \"document", "id" or "link", "filename"? } (filename for a document). A location: { "type": "location", "latitude", "longitude", "name"?, "address"? }`.
buttonsOne entry per button that takes a value: { "index", "sub_type", "value" }. index is the button's place in the template, from 0. sub_type is url — the end of the link, which walpio percent-encodes as Meta requires (an escape you already made, such as %20, is kept as it is); copy_code — the code the button copies, up to 20 characters; or quick_reply — the payload sent back to you when the customer taps it. Each button once, up to 10.

For a template with named variables, walpio gives a header's and a link's value its variable's name itself once it has read the template from Meta (or send "parameter_name" with it). Buttons may be listed in any order: the same values in another order are the same request. { "header": "…" } on its own is still a body variable called header — this form starts with a body array or object, a buttons array, or a header object. Meta checks the values against the template and refuses a send that does not fit it (422 meta_rejected, with Meta's code — 132000 for a value too many or too few); a dry run shows the components walpio would send.

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{
  "to": "971509876543",
  "type": "template",
  "template": {
    "name": "payment_due",
    "language": "en",
    "params": {
      "body": ["INV-2042", "980.00"],
      "buttons": [{ "index": 0, "sub_type": "url", "value": "pay_7Hq2x" }]
    }
  }
}
HTTP/1.1 200 OK

{
  "messaging_product": "whatsapp",
  "contacts": [{ "input": "971509876543", "wa_id": "971509876543" }],
  "messages": [{ "id": "wamid.HBgLOTcxNTA5ODc2NTQzFQIAERgSNEU1RjYxQjM2QTc0Qjg1QzlEAA==" }],
  "walpio": {
    "id": "5c1f0e3a-7b2d-4e8f-a1c6-9d3b2e7f4a10",
    "via": "template",
    "status": "accepted",
    "template": { "name": "payment_due", "language": "en" },
    "category": "UTILITY"
  }
}

Send media

Images, documents (an invoice PDF), video, audio and stickers. WhatsApp lets a business send them only inside the customer's 24-hour window — within 24 hours of the customer's last message to that number — so walpio sends them while a plain text would still go as text (23.5 hours, the same safety margin) and otherwise refuses with 422 window_closed: nothing is stored or sent, and an approved template (with a media header, if it has one) is the way to reach the customer. Everything else is as for a text: an Idempotency-Key makes it safe to retry, an identical request inside the dedupe window is sent once, an opted-out customer is refused, and it counts against the per-minute rate and the month's allowance.

typeObjectNotes
image{ "link" or "id", "caption"? }JPEG or PNG, up to 5 MB.
document{ "link" or "id", "caption"?, "filename"? }PDF, Word, Excel, PowerPoint or plain text, up to 100 MB. filename is the name the customer sees.
video{ "link" or "id", "caption"? }MP4 or 3GP (H.264 and AAC), up to 16 MB.
audio{ "link" or "id", "voice"? }AAC, AMR, MP3, M4A or OGG (Opus), up to 16 MB. "voice": true sends it as a voice message.
sticker{ "link" or "id" }WebP: up to 100 KB, or 500 KB animated.

Give the file by exactly one of link — a public http or https URL Meta fetches itself (Meta caches what it fetched for 10 minutes) — or id, what Upload a file answered, for a file uploaded to the same number you send from. caption is up to 1,024 characters, on an image, a document or a video only. walpio checks these rules before anything is stored; the file's type and size are Meta's to check when you send by link or id (a file of the wrong type answers 422 meta_rejected with Meta's code 131053). The message log keeps the caption (or the file name, or [image]) as the message's preview — never the link, which can carry a signature.

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{
  "to": "971501234567",
  "type": "document",
  "document": {
    "link": "https://files.example.com/invoices/INV-2026-0042.pdf",
    "filename": "INV-2026-0042.pdf",
    "caption": "Your invoice for the September service"
  }
}
HTTP/1.1 200 OK

{
  "messaging_product": "whatsapp",
  "contacts": [{ "input": "971501234567", "wa_id": "971501234567" }],
  "messages": [{ "id": "wamid.HBgLOTcxNTAxMjM0NTY3FQIAERgSQzVGMjY1RkY2OTk3NUI5MEI0AA==" }],
  "walpio": { "id": "0b7d6c1e-3f7a-4c2e-9a51-6d8e2f4b1c90", "via": "document", "status": "accepted" }
}

Interactive messages, locations, contacts and reactions

Like media, these go only inside the customer's 24-hour window (else 422 window_closed), under the same rules as a text, and exactly as Meta takes them — a body written for the Cloud API is accepted unchanged. walpio checks Meta's limits first and answers 400 invalid_request naming the field; nothing is stored or sent.

typeObjectMeta's limits walpio checks
interactive{ "type": "button", "header"?, "body": { "text" }, "footer"?, "action": { "buttons": [{ "type": "reply", "reply": { "id", "title" } }] } }1–3 reply buttons, each with its own id (up to 256 characters) and title (up to 20); body up to 1,024.
interactive{ "type": "list", "header"?, "body", "footer"?, "action": { "button", "sections": [{ "title", "rows": [{ "id", "title", "description"? }] }] } }button up to 20; 1–10 sections, each with a title (up to 24); 1–10 rows in all, each with its own id (up to 200), a title (up to 24) and an optional description (up to 72); body up to 4,096; a text header only.
interactive{ "type": "cta_url", "header"?, "body", "footer"?, "action": { "name": "cta_url", "parameters": { "display_text", "url" } } }display_text up to 20; an http or https URL; body up to 1,024.
interactive{ "type": "location_request_message", "body", "action": { "name": "send_location" } }Body up to 1,024. The customer's answer arrives as a location message.
interactive{ "type": "flow", "header"?, "body", "footer"?, "action": { "name": "flow", "parameters": { "flow_message_version": "3", "flow_id" or "flow_name", "flow_cta", "flow_token"?, "mode"?, "flow_action"?, "flow_action_payload"? } } }A Flow you built at Meta, named by exactly one of flow_id and flow_name. walpio passes it on; the customer's answers come back in the message.inbound callback (interactive.nfm_reply).
location{ "latitude", "longitude", "name"?, "address"? }Degrees as numbers or decimal text, within ±90 and ±180.
contacts[{ "name": { "formatted_name", … }, "phones"?, "emails"?, "addresses"?, "org"?, "urls"?, "birthday"? }]1–257 cards, each with name.formatted_name; birthday as YYYY-MM-DD.
reaction{ "message_id", "emoji" }One emoji, on a message of the conversation (Meta ignores a reaction to a message more than 30 days old).

header (on buttons, a URL button or a Flow) is { "type": "text", "text" } (up to 60 characters) or an image, video or document by id or link — { "type": "image", "image": { "link": "…" } }; footer is { "text" } (up to 60). The message log keeps the body's text (or the location's name, [contact card] — never a card's name or number — or the emoji) as the message's preview.

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{
  "to": "971501234567",
  "type": "interactive",
  "interactive": {
    "type": "button",
    "body": { "text": "Your technician can come tomorrow at 10:00. Does that suit you?" },
    "action": {
      "buttons": [
        { "type": "reply", "reply": { "id": "visit-confirm", "title": "Yes, 10:00" } },
        { "type": "reply", "reply": { "id": "visit-change", "title": "Another time" } }
      ]
    }
  }
}
HTTP/1.1 200 OK

{
  "messaging_product": "whatsapp",
  "contacts": [{ "input": "971501234567", "wa_id": "971501234567" }],
  "messages": [{ "id": "wamid.HBgLOTcxNTAxMjM0NTY3FQIAERgSMUQ0NDk3QkU3OEYwQzY3QjI1AA==" }],
  "walpio": { "id": "7c1e9b2a-5d3f-4e8a-b6c0-2f9d8e7a1b3c", "via": "interactive", "status": "accepted" }
}

The customer's tap comes back as a message.inbound callback whose message.interactive is { "type": "button_reply", "button_reply": { "id": "visit-confirm", "title": "Yes, 10:00" } }.

Upload a file

POST /v1/media — a file for a media send, as Meta takes it: multipart/form-data with the file in a file field. walpio checks its type and size against Meta's (the table above), passes it to Meta and keeps nothing; Meta keeps it for 30 days. The answer is Meta's own: { "id": "…" }. Send it as "id" from the same number.

FieldRequiredNotes
fileyesThe file, with its type (Content-Type of the part). Up to Meta's size for its type, and at most 16 MB through walpio: a larger document goes by link.
typenoThe file's MIME type, when the file part does not say it (application/pdf).
fromnoThe number the file is for — its phone_number_id or walpio's number id; without it, the oldest active number.
messaging_productnoAccepted for Meta compatibility (whatsapp).
curl https://api.walpio.com/v1/media \
  -H "Authorization: Bearer $WALPIO_KEY" \
  -F "file=@INV-2026-0042.pdf;type=application/pdf"
# → {"id":"1037543291543636"}

A type Meta does not take answers 400 invalid_request; a file over its size (or over 16 MB) 413 payload_too_large; Meta's own refusal, as for a send (meta_rejected, account_unavailable, meta_unavailable, with meta_code). Uploading needs the messages:send scope.

How a plain text is delivered

WhatsApp only lets a business send free-form text within 24 hours of the customer's last message to that number. For each plain text, 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 from the text filled in.
Otherwise, and the workspace is set to the fallbackThe approved fallback template for the text's language (Arabic script → the Arabic one), with the whole text as its one value.
Otherwise422 no_matching_template — nothing is sent.

walpio treats the window as closed after 23.5 hours, not 24, 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.

What walpio keeps of your text

Meta gets your text exactly as you sent it. walpio keeps the first 160 characters for the message log, and a copy for the conversation preview in the Inbox and, when it refuses a text for its wording or template, in the workspace's Activity. Those last two copies leave out the secret part of every link: the part after /pay/, /reset/, /invite/ and similar, a token=, code=, key= or sig= value, and anything that looks like a random token — https://app.example.com/pay/[redacted].

Safe retries

Send an Idempotency-Key header (1–200 characters) and a send is safe to repeat:

  • the same key with the same body replays the first answer instead of messaging the customer again (the answer carries "deduplicated": true);
  • the same key while the first request is still being sent answers 409 idempotency_in_progress with Retry-After: 2 — repeat it then;
  • the same key with a different body is refused with 422 idempotency_key_reused;
  • if the first attempt failed in a way a retry can fix (retryable: true), the same key tries again on the same message record.

Without a key, walpio treats an identical request as your retry: the same sending number, the same recipient, the same content and the same biz_opaque_callback_data (a request without one is identical only to another without one), within the workspace's dedupe window — 120 seconds by default, 0–3,600 as set in the dashboard — and only if the earlier one did not fail. "The same content" is the same text, or the same template name, language and values; the order in which your JSON lists an object's keys does not matter. Identical requests that arrive at the same moment are sent once: the first is sent, and the others get its answer — 202 with "status": "sending" while it is still on its way to Meta. Two different business events that happen to have the same wording need different references (or keys), or the second is taken for a retry of the first.

The same rule — the order of an object's keys does not matter — applies when walpio compares a repeated Idempotency-Key's body with the first one's.

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json
Idempotency-Key: order-44-ready

{ "to": "+971501234567", "type": "template",
  "template": { "name": "order_update", "language": "en", "params": ["#44"] } }

The second time this arrives, walpio answers what it answered the first time:

HTTP/1.1 200 OK
Content-Type: application/json

{ "messaging_product": "whatsapp",
  "messages": [{ "id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSQzRENUU2RjcwOA" }],
  "walpio": { "id": "8e2d4f6a-1b3c-4d5e-8f7a-9b0c1d2e3f4a",
              "via": "template", "status": "accepted",
              "template": { "name": "order_update", "language": "en" },
              "deduplicated": true } }

The answer

HTTPwalpio.statusMeaning
200acceptedMeta accepted the message. Accepted is not delivered: delivery arrives as a status callback.
200heldMeta accepted it and is holding it for a quality check before delivering it.
202queuedMeta asked walpio to slow down, or failed for a moment. walpio sends it again itself (up to 6 attempts) — you do not have to. Before each attempt it checks again that it still may: if meanwhile the customer opted out, a marketing message's customer stopped marketing or reached Meta's marketing limit, the number was disconnected or held, or the workspace was suspended — or its ttl has passed — the message fails with the reason and nothing more reaches Meta.
202unknownMeta's answer was lost (Meta took longer than 6 seconds, the connection failed, or Meta's front door answered 502, 503 or 504 without a Meta error code). The message may have been delivered; walpio settles it from Meta's status reports when it can. After a timeout or a lost connection it is never re-sent. After a front-door 5xx walpio sends it once more if no status for it has arrived within 15 minutes (and not at all after two hours, or once its ttl has passed), and never a third time.
202sendingMeta had not answered within walpio's answer time (about 3.5 seconds after your request reached walpio): walpio keeps waiting for Meta's answer — up to 6 seconds of Meta's time — and records it, so the message's status and its callbacks follow. Or, with "deduplicated": true, an identical request without an Idempotency-Key is still being sent, and this answer is that request's.

A replayed answer ("deduplicated": true) carries the message's status at that moment, so a 200 can also say sent, delivered or read.

walpio answers a send within about 4 seconds whatever Meta does, so a client with a timeout of 10 seconds or more never has to give up on a send it cannot see. Every answer to POST /v1/messages carries a Server-Timing header — walpio;dur=<ms> for walpio's own work and meta;dur=<ms> for its wait for Meta (absent when Meta was not called) — to show where the time went.

To pace yourself, read the limit headers on every answer to POST /v1/messages:

HeaderMeaning
RateLimit-LimitThe workspace's sends per rolling minute.
RateLimit-RemainingHow many more it can send now, this one counted. 0: the next is refused with 429 rate_limited.
RateLimit-ResetSeconds until the oldest send of the last minute leaves it (and a slot frees); 0 when there is none.
Walpio-Monthly-LimitThe workspace's monthly allowance — only when it has one.
Walpio-Monthly-RemainingWhat is left of it this month (UTC), this one counted; 0 once it is used (in warn mode sending goes on).

A plain text that a text rule turned into a template:

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971509876543", "type": "text",
  "text": { "body": "Your invoice INV-2041 for AED 1,250.00 is due on 30 September." } }
HTTP/1.1 200 OK
Content-Type: application/json

{ "messaging_product": "whatsapp",
  "contacts": [{ "input": "971509876543", "wa_id": "971509876543" }],
  "messages": [{ "id": "wamid.HBgMOTcxNTA5ODc2NTQzFQIAERgSRjhBOUIwQzFEMg" }],
  "walpio": { "id": "2c4e6a8b-0d1f-4a3b-9c5d-7e9f1a3b5c7d",
              "via": "template", "status": "accepted",
              "template": { "name": "invoice_issued", "language": "en" },
              "transformations": ["matched_text_rule"],
              "rule": "Invoice issued", "category": "UTILITY" } }
FieldNotes
messaging_productAlways whatsapp.
contactsMeta's own [{ input, wa_id }] — user_id in place of wa_id for a business-scoped user id — when Meta answered within this request. input is the recipient as walpio sent it; wa_id is the number as WhatsApp knows it, which can differ. Absent on a 202 and on a replay.
messages[0].idMeta's message id (wamid…) when Meta has answered — on a 200. On a 202 no wamid exists yet, and this holds walpio's id instead.
walpio.idwalpio's id for the message. Store this one: it is on every answer, in every status callback (message_id) and in GET /v1/messages/{id}.
walpio.viatext (sent as written) or template.
walpio.statusSee the table above.
walpio.templateThe template that was sent, { name, language } — for a template send, a text rule or the fallback.
walpio.transformationsWhat walpio did to a plain text: matched_text_rule, wrapped_in_template (the fallback), whitespace_collapsed (the fallback's value had its spaces and line breaks collapsed). Absent when nothing was changed.
walpio.deduplicatedtrue when this answer replays an earlier request (Idempotency-Key, or an identical request inside the dedupe window).
walpio.ruleThe name of the text rule that turned a plain text into its template. Absent when no rule did (and on a replay).
walpio.categoryThe category of the template sent — UTILITY, MARKETING or AUTHENTICATION — as walpio last read it from Meta. Meta can change a template's category, and with it the price and the marketing opt-outs that apply. Absent for plain text (and on a replay).
walpio.noteA sentence saying what a 202 means for this message (queued, answer lost, still waiting for Meta) or that Meta holds it for a quality check. Absent when there is nothing to add. Read it as text: its wording can change.

A queued message — messages[0].id is walpio's id, the same as walpio.id:

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971509876543", "type": "template",
  "template": { "name": "order_update", "language": "en", "params": ["#43"] } }
HTTP/1.1 202 Accepted
Content-Type: application/json

{ "messaging_product": "whatsapp",
  "messages": [{ "id": "9a7c5e3b-1d2f-4e6a-8b0c-2d4f6a8c0e1b" }],
  "walpio": { "id": "9a7c5e3b-1d2f-4e6a-8b0c-2d4f6a8c0e1b",
              "via": "template", "status": "queued",
              "template": { "name": "order_update", "language": "en" },
              "category": "UTILITY",
              "note": "Meta asked walpio to slow down …; walpio will retry it" } }

Two things to know about a 202:

  • a message walpio queued and then finally could not send reaches failed in walpio, but no status callback is posted for it — status callbacks come only from Meta's reports. The same holds for an unknown message that no report ever settles, and for a sending message that Meta refused after walpio had answered. Poll GET /v1/messages/{id} for messages that stay queued, sending or unknown — or list them all at once with GET /v1/messages?status=queued,sending,unknown (see List messages).
  • if you store messages[0].id as "the WhatsApp id", queued messages will not match the wa_message_id of their later callbacks. Match on walpio.id / message_id.

Dry run

Add ?dry_run=true to POST /v1/messages and walpio answers what the send would do — and does none of it: no message is recorded or sent to Meta, nothing counts against the per-minute rate or the month, no Activity entry is written and no text rule's count moves. Check your wordings after you change them (each notification's text against its rule), or a template's values, before a customer sees anything:

POST /v1/messages?dry_run=true
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971509876543", "type": "text",
  "text": { "body": "Your invoice INV-2042 for AED 980.00 is due on 15 October." } }
HTTP/1.1 200 OK
Content-Type: application/json

{ "dry_run": true,
  "would": "send",
  "via": "template",
  "route": "text_rule",
  "from": "106540352242922",
  "to": "971509876543",
  "window": { "open": false, "plain_text_until": null },
  "rule": { "id": "6c8e0a2c-4e6a-4b8c-9e0a-2c4e6a8c0e2a", "name": "Invoice issued" },
  "template": { "name": "invoice_issued", "language": "en", "category": "UTILITY", "status": "approved" },
  "params": ["INV-2042", "980.00", "15 October"],
  "transformations": ["matched_text_rule"],
  "payload": { "messaging_product": "whatsapp", "recipient_type": "individual", "to": "971509876543",
               "type": "template",
               "template": { "name": "invoice_issued", "language": { "code": "en" },
                             "components": [{ "type": "body", "parameters": [
                               { "type": "text", "text": "INV-2042" },
                               { "type": "text", "text": "980.00" },
                               { "type": "text", "text": "15 October" } ] }] } },
  "retry_of": null,
  "replay": null,
  "refusal": null }
FieldNotes
wouldsend; replay — a real send would get an earlier request's answer instead (the same Idempotency-Key, or an identical request inside the dedupe window); or refuse.
via, routeHow it would go: via is text, template or the message's own type (image, interactive…); route is text (as written), text_rule, fallback, template (by name), or for any other type that type (sent as given). null when it would not be sent.
from, toThe sending number's phone_number_id, and the recipient as walpio reads it; null when walpio cannot tell (no such number, a recipient that is not a number).
windowThe customer's window on that number: open, and plain_text_until (until when a plain text goes as text; null when it is closed). null without a number and a recipient.
rule{ id, name } — the text rule that recognises the text, or null.
template{ name, language, category, status } — status is approved, or null when walpio has not read the template from Meta yet (Meta then decides). null for a text sent as written.
paramsThe template's body values as they would be sent: in order (an array), or by name (an object). null for a text.
transformationsAs in a send's answer.
payloadThe exact body walpio would post to Meta.
retry_ofWhen this Idempotency-Key's first attempt failed in a way a retry can fix: the message a real send would try again on. Otherwise null.
replayFor would: "replay": { message_id, status } of the earlier request.
refusalFor would: "refuse" — and for the replay of a failed request — { code, message, retryable, http_status }: exactly what the real send would answer (the codes of Every error code).

A dry run answers 200 whenever the body is a valid send, refusals included; a body that is not a valid send gets the send's own 400, and dry_run other than true or false is a 400 invalid_request. It reads everything walpio decides with — the key, the rate, the month, the number's hold — but it cannot tell what Meta itself will answer (a recipient not on WhatsApp, a template Meta paused a moment ago). An Idempotency-Key on a dry run is looked up, never taken.

Message statuses

A message's status — in answers, in GET /v1/messages/{id} and in callbacks — is one of:

StatusMeaning
sendingwalpio is sending it to Meta right now, or is still waiting for Meta's answer.
queuedMeta throttled or failed for a moment; walpio will send it again.
acceptedMeta accepted it.
heldMeta accepted it and is holding it for a quality check.
unknownMeta's answer was lost; it may have been delivered.
sentMeta sent it (from Meta's status report).
deliveredIt reached the customer's phone.
readThe customer read it (only if they allow read receipts).
failedIt was not delivered. error says why.
receivedA message the customer sent you.

A status only moves forward (sent → delivered → read), and failed is final.

Read receipts and the typing indicator

POST /v1/messages/{id}/read — tell WhatsApp you have read a message the customer sent: they see blue ticks on it and on every earlier message. { "typing": true } also shows them "typing…" until you answer or 25 seconds pass — show it only when an answer is coming. {id} is the message's wamid (from the message.inbound callback) or walpio's id. Meta takes it for a message received in the last 30 days (older: 422 meta_rejected, Meta's code 131009). It is not a message: nothing is recorded and it counts against no limit. Needs the messages:send scope.

POST /v1/messages/wamid.w1dx.in.1/read
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "typing": true }
HTTP/1.1 200 OK

{ "success": true }
AnswerWhen
200 { "success": true }WhatsApp has it.
400 invalid_requestThe body is not JSON, or typing is not true or false.
404 not_foundNo message from a customer with this id in the workspace (your own messages cannot be marked read).
422 no_wa_accountThe number the customer wrote to is no longer active.
422 meta_rejected, 502 meta_unavailable, 503 account_unavailableMeta refused (a message older than 30 days), could not be reached, or refused the number's token.

The dashboard can do it for you: with Read receipts on (a workspace setting an admin chooses in Settings → Sending; off by default), opening a conversation with unread messages sends the read receipt for the newest message the customer sent.

Message status

GET /v1/messages/{id} — {id} is walpio's id or Meta's wamid. Any message of the workspace, sent or received.

GET /v1/messages/{id}
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "message": {
    "id": "5d0c8e1b-3f2a-4b6c-8d7e-9f0a1b2c3d4e",
    "wa_message_id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSNTc3QjE2RDJF",
    "direction": "out",
    "to": "971501234567",
    "from": "106540352242922",
    "type": "template",
    "template": { "name": "order_update", "language": "en" },
    "status": "delivered",
    "error": null,
    "pricing": { "category": "utility", "billable": true, "type": "regular" },
    "client_reference": "order-42",
    "inbound": null,
    "created_at": "2026-09-25T10:00:00.000Z",
    "updated_at": "2026-09-25T10:00:03.000Z",
    "events": [
      { "status": "sent", "detail": null, "at": "2026-09-25T10:00:01.000Z" },
      { "status": "delivered", "detail": null, "at": "2026-09-25T10:00:03.000Z" } ] } }
FieldNotes
directionout (you sent it) or in (the customer sent it).
to, fromFor out: to is the customer, from the sending number's phone_number_id. For in: from is the customer, to the number's display phone.
typeFor out, text, template or the media type you sent (image, document…); for in, Meta's type (text, image, button, location…).
template{ name, language }, or null.
error{ code, message } when it failed — and also while it is queued or unknown, with the reason — else null. code is Meta's error code as text (for example "131026"), or walpio's own (network, walpio:credentials, walpio:number_disabled, walpio:number_held, http:<status>) — including, for a queued message walpio did not send again, walpio:opted_out, walpio:marketing_opted_out, walpio:marketing_paused, walpio:workspace_inactive and, past its ttl, walpio:expired.
pricing{ category, billable, type } as Meta last reported them, or null — see Pricing fields.
client_referenceYour biz_opaque_callback_data, or null.
inboundFor a message the customer sent (in), the identifiers Meta gave with it — each only when there is one: media { id, mime_type } (the file: Fetch a customer's media), button { payload } (a template's quick-reply button), interactive { type, id } (the reply button or list row they chose; for a Flow's answer { type: "nfm_reply", name }), reaction { message_id } (the message they reacted to), referral { source_type, source_id } (the Click-to-WhatsApp ad that brought them), context { message_id } (the message they replied to). null when there are none, and for your own messages. walpio keeps no content here — no caption, coordinates, contact card or form answer: those reach you once, in the message.inbound callback.
eventsMeta's status reports in the order they happened: { status, detail, at }. walpio's own steps (queued, a final failed of its own) are not listed here — they show only in status and error.

List messages

GET /v1/messages — the workspace's messages, sent and received, newest first, a page at a time. Each one is exactly the record GET /v1/messages/{id} gives. Use it to reconcile without callbacks — every message that failed since yesterday (?status=failed&since=2026-09-24T00:00:00Z), the ones still waiting (?status=queued,sending,unknown), or the message your own reference names:

GET /v1/messages?client_reference=order-42
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "messages": [
    { "id": "5d0c8e1b-3f2a-4b6c-8d7e-9f0a1b2c3d4e",
      "wa_message_id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSNTc3QjE2RDJF",
      "direction": "out",
      "to": "971501234567",
      "from": "106540352242922",
      "type": "template",
      "template": { "name": "order_update", "language": "en" },
      "status": "delivered",
      "error": null,
      "pricing": { "category": "utility", "billable": true, "type": "regular" },
      "client_reference": "order-42",
      "inbound": null,
      "created_at": "2026-09-25T10:00:00.000Z",
      "updated_at": "2026-09-25T10:00:03.000Z",
      "events": [
        { "status": "sent", "detail": null, "at": "2026-09-25T10:00:01.000Z" },
        { "status": "delivered", "detail": null, "at": "2026-09-25T10:00:03.000Z" } ] } ],
  "next_cursor": null }
ParameterNotes
statusOne status, or several separated by commas (queued,sending,unknown) — see Message statuses.
directionout (you sent them) or in (your customers sent them).
fromOne of your numbers — its phone_number_id or walpio's number id — including a disconnected one.
toThe customer: the messages to them and from them. A number, written as in a send, or a business-scoped user id — matched as the message was recorded.
client_referenceYour biz_opaque_callback_data, exactly (up to 512 characters).
since, untilOnly messages created at or after since and before until: ISO 8601 (2026-09-25T10:00:00Z; a date alone is midnight UTC) or milliseconds since 1970.
limitMessages per page, 1–100; 50 when not given.
cursorThe previous page's next_cursor, exactly as it came, with the same filters.

Filters combine: a message is listed when it matches all of them. A parameter walpio cannot read — an unknown status, a limit of 0, a since after until, a cursor it did not give — is refused with 400 invalid_request, and message names it. A from that is none of your numbers answers 422 no_wa_account; a to that is not a number 422 invalid_recipient.

Pages. next_cursor is null on the last page. A page can hold fewer than limit messages — even none — and still carry a next_cursor: status, direction and to are looked up directly, but from, client_reference, and several statuses next to another filter are checked message by message, through at most 1,000 messages per page. Keep following next_cursor until it is null; since and until make that shorter. A message created while you page is not added to the pages behind your cursor: it is at the top of the next list.

Rate. A workspace can list 60 pages a minute (message lists and batch reads together); the next in that minute answers 429 rate_limited with Retry-After: 10.

Send a batch

POST /v1/messages/batch — one approved template to up to 1,000 customers, each with its own values (and, if you like, its own Idempotency-Key and reference). walpio stores the batch, answers 202 at once and sends it through its queue: every item goes through the same send path as POST /v1/messages — opt-outs, marketing holds, the per-minute rate (an item over it waits its turn), the month — and each item's result is kept. The dashboard lists a batch under Broadcasts. Batches are sent now: walpio does not schedule them.

POST /v1/messages/batch
Authorization: Bearer wp_live_…
Content-Type: application/json
Idempotency-Key: ready-orders-2026-09-26

{ "template": { "name": "order_update", "language": "en" },
  "items": [
    { "to": "+971501234567", "params": ["#50"], "biz_opaque_callback_data": "order-50" },
    { "to": "+971509876543", "params": ["#51"], "idempotency_key": "order-51-ready" } ] }
HTTP/1.1 202 Accepted
Content-Type: application/json

{ "batch": { "id": "e4f6a8c0-2b3d-4e5f-9a7b-1c3d5e7f9a0b",
             "status": "queued",
             "from": "106540352242922",
             "template": { "name": "order_update", "language": "en" },
             "total": 2, "pending": 2, "sent": 0, "failed": 0, "skipped": 0,
             "created_at": "2026-09-26T08:00:00.000Z", "finished_at": null },
  "replayed": false }
FieldNotes
template{ name, language } — language as "en" or { "code": "en" }. It must be approved on the sending number and read by walpio from Meta (otherwise 422 template_not_approved).
fromThe number to send from, as in a send; without it, the oldest active number.
items1 to 1,000. Each: to (as in a send; each customer once per batch); params (this customer's body values, an array or an object) or components (Meta's own, for header and button values; they win over params); idempotency_key (1–200 characters, optional); biz_opaque_callback_data (optional, as in a send).
Idempotency-Key headerThe batch's own key (1–200 characters). The same key and body again answers the first batch (200, "replayed": true) and sends nothing; the same key with another body is 422 idempotency_key_reused. Without a key, the same batch created again within 10 minutes is taken for your retry. A batch's key shares its space with the dashboard's broadcasts.

Nothing is sent twice. Each item is sent with its own idempotency_key — in the same key space as a send's Idempotency-Key, so a key one of your sends already used replays that send instead of messaging the customer again (and a key used for a different message fails the item with idempotency_key_reused) — or, without one, a key of the batch's own for that item.

The request is checked whole. One item that cannot be sent as it is — a recipient that is not a number, a customer twice, the same idempotency_key twice — and nothing is stored: 422 invalid_batch, with error.items naming each one (index, code, message). In block mode, a batch with more items than are left of the month's allowance is refused whole (422 over_monthly_allowance).

GET /v1/messages/batch/{id} — the batch's progress, and each item's result in the order you sent them (?status= one of pending, sent, failed, skipped; ?limit= 1–1,000, 100 when not given; ?cursor= the previous page's next_cursor):

GET /v1/messages/batch/{id}
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "batch": { "id": "e4f6a8c0-2b3d-4e5f-9a7b-1c3d5e7f9a0b",
             "status": "done",
             "from": "106540352242922",
             "template": { "name": "order_update", "language": "en" },
             "total": 2, "pending": 0, "sent": 2, "failed": 0, "skipped": 0,
             "created_at": "2026-09-26T08:00:00.000Z", "finished_at": "2026-09-26T08:00:02.000Z" },
  "items": [
    { "index": 0, "to": "971501234567", "status": "sent",
      "message_id": "f5a7c9e1-3b4d-4f6a-8b9c-2d4e6f8a0b1c", "message_status": "accepted",
      "error": null, "idempotency_key": null, "client_reference": "order-50" },
    { "index": 1, "to": "971509876543", "status": "sent",
      "message_id": "a6b8d0f2-4c5e-4a7b-9c0d-3e5f7a9b1c2d", "message_status": "accepted",
      "error": null, "idempotency_key": "order-51-ready", "client_reference": null } ],
  "next_cursor": null }
FieldNotes
batch.statusqueued, running, done (every item settled) or cancelled (in the dashboard).
batch.pending, sent, failed, skippedHow many items are in each state; they add up to total.
items[].statuspending (not sent yet), sent (handed to Meta — or a replay of an earlier send with its key), failed, or skipped (the customer opted out, the batch was cancelled, the number was disconnected).
items[].message_id, message_statusThe message walpio recorded for the item — look it up, or find it in List messages — and its status now; null when none was recorded.
items[].error{ code, message } for a failed or skipped item: code is what the send answered (contact_opted_out, template_not_approved, recipient_unavailable…), or null when walpio skipped the item itself.

Another workspace's batch — or a broadcast made in the dashboard — answers 404 not_found. Reading a batch counts with the message lists: 60 a minute per workspace.

Fetch a customer's media

GET /v1/media/{id} — the photo, video, voice message, document or sticker a customer sent, by the media id in its message.inbound callback (message.image.id…) or in the message record's inbound.media.id. walpio asks Meta for the file with your number's token and streams it to you as it arrives; it keeps nothing. Meta keeps a customer's media for 7 days (an upload for 30): after that the answer is 404 not_found, so fetch what you need to keep when it arrives.

QueryNotes
fromOptional: the number the media belongs to — its phone_number_id or walpio's number id. walpio finds the number itself for media a customer sent; name it for a file you uploaded.

The answer is the file: Content-Type its type (one of Meta's media types — anything else is application/octet-stream), Content-Length, and Content-Disposition: attachment — a download, never a page. Up to 100 MB (Meta's own limit). Only media of your own numbers can be fetched: walpio asks Meta for the number's media only, so another number's id answers 404. Needs the messages:read scope. A workspace can fetch 60 files a minute (429 rate_limited after, Retry-After: 10).

curl -o leak.jpg https://api.walpio.com/v1/media/1003383421387256 \
  -H "Authorization: Bearer $WALPIO_KEY"
AnswerWhen
200The file.
404 not_foundNo such media on the number — expired at Meta, another number's, or not Meta's id.
413 payload_too_largeMeta says the file is larger than 100 MB.
422 no_wa_accountfrom is not one of your active numbers (or you have none).
502 meta_unavailable, 503 account_unavailableMeta could not be reached, or refused the number's token.

Numbers

GET /v1/numbers — every WhatsApp number connected to the workspace, oldest first, with what walpio last read from Meta (it checks each number daily).

GET /v1/numbers
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "numbers": [
    { "id": "4f1e3d2c-6b5a-4c9d-8e7f-0a1b2c3d4e5f",
      "phone_number_id": "106540352242922",
      "waba_id": "102290129340398",
      "display_phone": "+971 4 123 4567",
      "verified_name": "Acme Services",
      "label": "Main line",
      "status": "active",
      "health": "ok",
      "health_error": null,
      "quality_rating": "GREEN",
      "messaging_limit_tier": "TIER_2K",
      "name_status": "APPROVED",
      "last_checked_at": "2026-09-25T09:58:00.000Z" } ] }

status is active or disabled (walpio does not send from a disabled number). health is ok, error (with health_error in plain words — the number needs attention) or unknown. quality_rating, messaging_limit_tier and name_status (the display name's review) are Meta's values.

Templates

Templates belong to the WhatsApp Business Account at Meta, so every number of the same account lists the same templates. walpio reads them from Meta when a number is connected, then on a schedule and whenever Meta reports a change.

List templates

GET /v1/templates — every template of every number, sorted by name. Add ?from=<phone_number_id> (or walpio's number id) for one number's.

GET /v1/templates
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "templates": [
    { "id": "7b3d5f7a-9c1e-4a2b-8d4f-6a8c0e2a4c6e",
      "name": "invoice_issued",
      "language": "en",
      "category": "UTILITY",
      "status": "approved",
      "rejected_reason": null,
      "quality_score": "GREEN",
      "parameter_format": "POSITIONAL",
      "is_fallback": false,
      "phone_number_id": "106540352242922",
      "components": [{ "type": "BODY", "text": "Invoice {{1}} for AED {{2}} is due on {{3}}. Reply here with any questions." }],
      "updated_at": "2026-09-25T09:58:00.000Z" },
    { "id": "1a3c5e7b-9d1f-4b3d-8e5f-7a9c1e3b5d7f",
      "name": "order_update",
      "language": "en",
      "category": "UTILITY",
      "status": "approved",
      "rejected_reason": null,
      "quality_score": "GREEN",
      "parameter_format": "POSITIONAL",
      "is_fallback": false,
      "phone_number_id": "106540352242922",
      "components": [{ "type": "BODY", "text": "Your order {{1}} is ready. Reply here with any questions." }],
      "updated_at": "2026-09-25T09:58:00.000Z" },
    { "id": "3e5a7c9d-1b2f-4d6e-8a0c-2e4f6a8b0d1c",
      "name": "payment_due",
      "language": "en",
      "category": "UTILITY",
      "status": "approved",
      "rejected_reason": null,
      "quality_score": "GREEN",
      "parameter_format": "POSITIONAL",
      "is_fallback": false,
      "phone_number_id": "106540352242922",
      "components": [{ "type": "BODY", "text": "Invoice {{1}} for AED {{2}} is ready to pay. Reply here with any questions." },
                     { "type": "BUTTONS", "buttons": [{ "type": "URL", "text": "Pay now", "url": "https://pay.example.com/i/{{1}}" }] }],
      "updated_at": "2026-09-25T09:58:00.000Z" } ] }

status is Meta's, in lower case: approved, pending, rejected, paused, disabled, in_appeal, pending_deletion, deleted, archived. components is the definition as Meta returns it; rejected_reason, quality_score and parameter_format can be null.

Create a template

POST /v1/templates — creates the template at Meta and submits it for review. The answer's status is normally pending; approval arrives as a template.status callback.

FieldNotes
nameLower-case letters, digits and underscores.
languageMeta's language code, e.g. en, ar, en_US.
categoryUTILITY, MARKETING or AUTHENTICATION. Meta decides the final category: a template it finds promotional is approved as MARKETING.
componentsMeta's components array (1–10), passed to Meta as given. walpio checks Meta's published rules first and answers 422 template_rejected with every problem: a link button that ends in a variable needs an example of its end, a copy-code button an example code (up to 20 characters), quick replies sit together, and an image, video or document header needs a sample's handle in example.header_handle (Meta's Resumable Upload API gives it; the dashboard's template builder uploads the file for you).
library_template_name, library_template_button_inputsInstead of components: a template from Meta's Template Library, UTILITY only — the library template's name, and for one with a link or phone button [{ "type": "URL", "url": { "base_url": "https://…", "url_suffix_example": "https://…" } }, { "type": "PHONE_NUMBER", "phone_number": "+…" }]. Meta writes its content; an unchanged library template needs no review.
parameter_formatPOSITIONAL (the default: {{1}}, {{2}} …) or NAMED ({{customer_name}}).
fromThe number whose account gets the template; without it, the oldest active number.
POST /v1/templates
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "name": "delivery_window", "language": "en", "category": "UTILITY",
  "parameter_format": "NAMED",
  "components": [{ "type": "BODY",
    "text": "Hi {{customer_name}}, your technician arrives between {{window}} today. Reply here to change it.",
    "example": { "body_text_named_params": [
      { "param_name": "customer_name", "example": "Aisha" },
      { "param_name": "window", "example": "2 and 4 pm" } ] } }] }
HTTP/1.1 201 Created
Content-Type: application/json

{ "template": { "id": "3e5a7c9e-1b3d-4f5a-9c7e-1b3d5f7a9c1e", "name": "delivery_window",
                "language": "en", "status": "pending", "category": "UTILITY" } }

walpio checks the draft before Meta sees it, and refuses it with 422 template_rejected when: the name has anything but lower-case letters, digits and underscores; the body (for UTILITY and MARKETING) is empty or longer than 1,024 characters, starts or ends with a variable, or has two variables side by side; positional variables are not numbered {{1}}, {{2}} … without gaps; named variables use anything but lower-case letters, digits and underscores; or a variable has no example (example.body_text for positional, example.body_text_named_params for named). Meta may refuse it for other reasons, which walpio passes on in message.

Delete a template

DELETE /v1/templates/{name} deletes every language of the template at Meta; add ?language=en for one language. ?from= chooses the number, as above. One language can only be deleted once walpio has read it from Meta (otherwise 404: sync the templates in the dashboard first).

DELETE /v1/templates/delivery_window?language=en
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "deleted": { "name": "delivery_window", "language": "en" } }

Without ?language=, deleted.language is "all". Meta does not let a deleted template's name be used again for 30 days. A template belongs to the WhatsApp Business Account, not to one number: deleting it removes it for every number of that account, and walpio marks every one of your numbers' copies deleted at once.

Text rules

A text rule lets your systems keep sending their own wording. It recognises a plain text you already send and, outside the 24-hour window, sends a specific approved template instead with the values taken from the text. Inside the window the text goes as written.

pattern   Your invoice {{number}} for AED {{amount}} is due on {{due}}.
template  invoice_issued (en)   "Invoice {{1}} for AED {{2}} is due on {{3}}. Reply here…"
params    ["number", "amount", "due"]   → {{1}} = number, {{2}} = amount, {{3}} = due

How matching works

  • The whole text must match the pattern — not a part of it.
  • Matching is case-sensitive on every visible character of the wording: letters, digits and punctuation must be the same.
  • What does not matter: any run of spaces, tabs or line breaks matches any other run (a no-break space is a space); spaces at the start and end of the text, and of the pattern (which is trimmed when it is saved); invisible characters — bidi marks such as the right-to-left mark, zero-width spaces and joiners, the byte-order mark (Unicode's "default ignorable" characters) — in the text or in the pattern; the Unicode form (both are compared in NFC, so decomposed text matches); and Arabic diacritics — harakat, tanween, shadda, sukun, the superscript alef — so "ردّ" matches "رد". The letters themselves still count (أ and ا, ة and ه, ى and ي are different wording), and so does the tatweel.
  • Each {{placeholder}} takes the shortest text that lets the rest match, and must take at least one visible character. Its value is sent as written: invisible marks inside it or next to it stay; runs of spaces, tabs, line breaks and control characters become one space, and it is trimmed (Meta refuses line breaks, tabs and long runs of spaces in a template variable).
  • When several rules match, the one with the most fixed wording wins. Of rules with as much, a rule for the sending number comes before a rule for every number, then the rule's name decides (A before B), then its id.
  • Wording that never changes can have a rule too: a pattern with no placeholder and params: [] matches that wording only (with the tolerances above) and sends a template with no variables. Two placeholders cannot sit side by side, each name can be used once, a pattern can have at most 20 placeholders, and a name can be at most 64 characters (__proto__, constructor and prototype cannot be names).
  • Matching takes time in proportion to the length of the text, whatever the pattern: no rule can slow your sends down.
  • Changing one word in your notification breaks its rule. The send is then refused with 422 no_matching_template (and shows in the dashboard's Activity) — update the rule the same day you change the wording. Test the text to see which rule comes closest and the first place your text departs from it.
  • A rule fills the template's body variables, and — when the template needs them — its header and button values too (header, buttons, below): a header with a variable, a file in the header (its link taken from the text, or a fixed file), a location header (fixed), a URL button that ends in a variable (a payment or tracking link — the end is taken from the text and percent-encoded), a copy-code button, and an authentication template's one-time-password button (see A one-time code sent as text). Saving a rule is refused with 422 invalid_rule when the template needs a value the rule does not give, when it gives a value the template does not take (or as another kind), and for what no rule can give: a flow, catalog or multi-product button, a carousel, a limited-time offer. A fixed link, quick replies and a phone button need nothing. For a rule on every number, each number's copy of the template is checked. A text whose value cannot be sent — a file's "link" that is not an http(s) link, a copy code over 20 characters — is not taken by the rule.
  • A rule cannot be a catch-all. A pattern with placeholders and no word of its own — only placeholders, punctuation, numbers and symbols, such as {{body}}! — would send almost any text as its template: the wrapper Meta categorises as marketing and can restrict an account for. Saving one is refused with 422 invalid_rule. (walpio offers a catch-all only as the workspace's opt-in fallback.) A pattern with only one or two words of its own, such as ACME: {{message}}, is saved with a warning.

Create a rule

POST /v1/text-rules — like every text-rule endpoint, it spells its fields in camelCase ("Field names", at the top).

FieldNotes
nameYour label for the rule (1–120 characters).
patternYour wording with {{placeholders}} for the parts that change (3–2,000 characters). A placeholder name starts with a letter of any script or _, then letters, digits or _: {{amount}}, {{رقم_الفاتورة}}.
templateName, templateLanguageThe template to send. It must exist on the number (it may still be in review, but sends through the rule are refused until it is approved).
paramsWhich placeholder fills each template variable: an array in the order of {{1}}, {{2}} …, or an object { "<template variable>": "<placeholder>" } for a template with named variables. It must fill exactly the template's body variables — [] for fixed wording and a template with none.
headerOptional, for a template whose header takes a value: { "placeholder": "name" } for a text header's variable; { "type": "document", "placeholder": "pdf", "filename"?: "…" } for a file whose link is in the text (type image, video or document); { "type": "image", "link": "https://…" } (or an uploaded id) for a fixed file; { "type": "location", "latitude", "longitude", "name"?, "address"? } for a fixed place. null for none.
buttonsOptional: [{ "index": 0, "sub_type": "url", "placeholder": "token" }] — for each button that takes a value, its place in the template (from 0), its kind (url: the end of a link that ends in a variable, or an authentication template's one-time code; copy_code: the code a copy-code button copies) and the placeholder that fills it. [] for none.
pairedWithOptional: the id of a rule in another language to pair this one with. Both wordings need the same placeholders. A customer whose language (see A customer's language) is the other rule's then gets the other rule's template — approved on the number — with the values this wording gave, by placeholder name; transformations adds "paired_language". A customer whose language is not known, or is this rule's own, gets this rule's template, as before. The answer's pairId is shared by the rules of a pair (null: not paired).
waAccountIdOptional: the rule applies to one number only (walpio's number id or its phone_number_id). Without it, every number of the workspace.
enabledOptional, default true.
POST /v1/text-rules
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "name": "Invoice issued",
  "pattern": "Your invoice {{number}} for AED {{amount}} is due on {{due}}.",
  "templateName": "invoice_issued", "templateLanguage": "en",
  "params": ["number", "amount", "due"] }
HTTP/1.1 201 Created
Content-Type: application/json

{ "rule": {
    "id": "6c8e0a2c-4e6a-4b8c-9e0a-2c4e6a8c0e2a",
    "name": "Invoice issued",
    "pattern": "Your invoice {{number}} for AED {{amount}} is due on {{due}}.",
    "placeholders": ["number", "amount", "due"],
    "templateName": "invoice_issued",
    "templateLanguage": "en",
    "params": ["number", "amount", "due"],
    "header": null,
    "buttons": [],
    "pairId": null,
    "waAccountId": null,
    "enabled": true,
    "matchCount": 0,
    "lastMatchedAt": null,
    "createdAt": "2026-09-25T10:05:00.000Z",
    "updatedAt": "2026-09-25T10:05:00.000Z",
    "warnings": [] } }

A rule that does not fit its template is refused with 422 invalid_rule, and message lists every problem (a placeholder that is not in the pattern, a template with a different number of variables, a template that is not on the number, a header or button value it does not give or that the template does not take, a catch-all pattern…). In PATCH (the dashboard and the console), header and buttons replace what the rule had; null or [] clears them.

warnings lists, in plain words, what you should know about a saved rule (empty when nothing): its pattern has only one or two words of its own; Meta categorises its template as MARKETING, so sends through it are priced and limited as marketing and refused to customers who opted out of marketing; or — for a rule saved before these checks — it is a catch-all, or its template needs a value the rule does not give. When Meta re-categorises a rule's template as MARKETING, the workspace's Activity records it for each rule that uses the template.

A one-time code sent as text

When your system makes its own sign-in or payment codes and sends them as plain text, a rule sends the code as your approved authentication template outside the 24-hour window. Meta writes that template's message itself around one value, the code, and its one-time-password button (copy code, one-tap or zero-tap) carries the same code — so the rule fills the body with one placeholder and gives the button that placeholder as "sub_type": "url", the kind Meta sends a code button as:

{ "name": "Sign-in code",
  "pattern": "{{code}} is your verification code. Do not share this code. It expires in 10 minutes.",
  "templateName": "login_code", "templateLanguage": "en",
  "params": ["code"],
  "buttons": [{ "index": 0, "sub_type": "url", "placeholder": "code" }] }

A text 4821 is your verification code. Do not share this code. It expires in 10 minutes. then goes to Meta as the template with 4821 as the body's value and as the button's. The rule may be saved while Meta still reviews the template. It is refused (422 invalid_rule) without the button's value, with the button given as copy_code, with a button value other than the body's placeholder, and when the body is not filled with exactly one value. Meta allows at most 15 characters in a code. Inside the window the text goes as written, as every text does. (Verify a customer makes, sends and checks the code for you instead.)

List rules

GET /v1/text-rules — every rule, sorted by name, and what the workspace does with text that no rule matches (unmatched_text_mode: reject, the default, or fallback). waAccountId is walpio's number id when the rule is for one number.

GET /v1/text-rules
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "rules": [
    { "id": "6c8e0a2c-4e6a-4b8c-9e0a-2c4e6a8c0e2a",
      "name": "Invoice issued",
      "pattern": "Your invoice {{number}} for AED {{amount}} is due on {{due}}.",
      "placeholders": ["number", "amount", "due"],
      "templateName": "invoice_issued",
      "templateLanguage": "en",
      "params": ["number", "amount", "due"],
      "header": null,
      "buttons": [],
      "pairId": null,
      "waAccountId": null,
      "enabled": true,
      "matchCount": 1,
      "lastMatchedAt": "2026-09-25T10:06:00.000Z",
      "createdAt": "2026-09-25T10:05:00.000Z",
      "updatedAt": "2026-09-25T10:05:00.000Z",
      "warnings": [] } ],
  "unmatched_text_mode": "reject" }

Test a text

POST /v1/text-rules/test with { "text": "…" } (and optionally waAccountId) shows which rule a text would use and the values it would send — without sending anything and without checking that the template is approved. Without waAccountId it tests on the number a send without from uses: the oldest active number (the oldest of all when none is active).

POST /v1/text-rules/test
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "text": "Your invoice INV-2041 for AED 1,250.00 is due on 30 September." }
HTTP/1.1 200 OK
Content-Type: application/json

{ "matched": true,
  "rule": { "id": "6c8e0a2c-4e6a-4b8c-9e0a-2c4e6a8c0e2a", "name": "Invoice issued" },
  "template": { "name": "invoice_issued", "language": "en" },
  "values": { "number": "INV-2041", "amount": "1,250.00", "due": "30 September" },
  "params": ["INV-2041", "1,250.00", "30 September"] }

For a rule that gives header or button values, the answer adds them as a send would carry them: header ({ "type": "text", "text" }, a file or a location) and buttons ([{ "index", "sub_type", "value" }]).

When no rule takes the text, the answer is "matched": false with closest: the rule that comes nearest and what differs, so a refused notification can be turned into a rule (or its rule fixed). closest is null when no rule shares at least half of its own words with the text. For the text Your invoice INV-2041 for AED 1,250.00 due on 30 September.:

{ "matched": false,
  "closest": {
    "rule": { "id": "6c8e0a2c-4e6a-4b8c-9e0a-2c4e6a8c0e2a", "name": "Invoice issued" },
    "template": { "name": "invoice_issued", "language": "en" },
    "reason": "wording",
    "expected": "is due on",
    "found": "due on",
    "at": 39,
    "message": "The rule \"Invoice issued\" expects \"is due on\" here; the text says \"due on\"." } }
closest fieldMeaning
reasonwording: the text departs from the rule's wording. value: the wording matches, but a value the rule sends is empty or its params name a placeholder the pattern lacks. switched_off: the wording matches a rule that is off. other_number: the wording matches a rule for another number.
expectedFor wording: up to three of the rule's words where the text departs, as the rule is written; {{name}} when a placeholder has no value; "" when the rule has ended and the text goes on. Otherwise null.
foundFor wording: the text's words there, as the text has them; "" when it has none there. Otherwise null.
atFor wording: where the text departs, counted in UTF-16 code units of the text (in Unicode NFC — the text itself unless it was sent decomposed). Otherwise null.
messageThe difference in one sentence.

Delete a rule

DELETE /v1/text-rules/{id}
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "deleted": "6c8e0a2c-4e6a-4b8c-9e0a-2c4e6a8c0e2a" }

WhatsApp's policy requires a customer's opt-in before you message them, and that you stop when they ask. walpio keeps each customer's opt-out and opt-in, and applies an opt-out to every number of the workspace and to every identifier the customer is known by: their phone number, their business-scoped user id (AE.…, when they hide their number behind a username) and Meta's own form of their number when it differs from the one you sent (Meta reports it for some countries, such as Brazil and Mexico). walpio links them as Meta reveals them — in its answer to a send, in the customer's messages and in the delivery reports of what you sent — so a STOP under one stops sends to the others, a START or an opt-in recorded here clears an opt-out under any of them, and a marketing stop ("Stop promotions", the customer's own marketing setting in WhatsApp, or Meta refusing marketing to them) holds under all of them. The same holds for the 24-hour window: a customer who wrote under one identifier can be sent plain text under another. When Meta reports that a customer changed their number, the new number keeps the old one's opt-out, marketing stop and opt-in.

Record an opt-in or an opt-out

POST /v1/contacts/consent — from your own systems (a sign-up form, app settings, a call centre).

FieldNotes
toThe customer, as in a send.
optedIntrue records an opt-in and clears any opt-out; false records an opt-out.
sourceWhere it came from, e.g. "signup form" (2–120 characters).
evidenceProof, e.g. the form's version or the wording the customer agreed to (up to 300 characters). Required for an opt-in that undoes a STOP the customer texted — without it the answer is 422 evidence_required and the STOP stays. Optional otherwise.
fromOptional: the number the contact is recorded on (the consent itself applies to every number).
POST /v1/contacts/consent
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971501234567", "optedIn": true, "source": "signup form",
  "evidence": "web form v3, WhatsApp updates box ticked" }
HTTP/1.1 200 OK
Content-Type: application/json

{ "to": "971501234567", "opted_in": true, "recorded_at": "2026-09-25T10:07:00.000Z" }

walpio stores the source as api:<API key id>: <source> — <evidence>. Any API key of the workspace can record an opt-in; one that clears a STOP the customer texted needs evidence (an opt-out you recorded yourself, through this call or the dashboard, does not). Send an opt-in only when the customer really gave it.

A customer's language

PATCH /v1/contacts/{to} with { "language": "ar" } — the customer's preferred language, in Meta's code (ar, en, en_US, pt_BR …). A template sent with "language": "auto" goes out in it, and a text rule paired across languages sends its pair's template in it. It is set on every row walpio has for the customer (every number, every identifier) and kept until you change it. null forgets it.

Without it, walpio reads the customer's language from the script of what they write to you: Arabic letters → ar, Latin letters → en (a text with fewer than three letters says nothing). That inferred language follows their latest text; one you set (here or in the dashboard) is never replaced by it. from (optional) is the number to record a customer walpio has not met on.

PATCH /v1/contacts/971501234567
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "language": "ar" }
HTTP/1.1 200 OK
Content-Type: application/json

{ "to": "971501234567", "language": "ar", "language_source": "api" }

Read a contact

GET /v1/contacts/{to} — {to} is a number or a business-scoped user id; the answer covers the customer under every identifier walpio has linked to it. 404 not_found if walpio has never seen the customer.

GET /v1/contacts/971501234567
Authorization: Bearer wp_live_…
HTTP/1.1 200 OK
Content-Type: application/json

{ "to": "971501234567",
  "name": "Aisha Khan",
  "language": "ar",
  "language_source": "api",
  "opted_out": null,
  "opted_in": { "at": "2026-09-25T10:07:00.000Z",
                "source": "api:9d2c41f0-8b3e-4f6a-9c1d-2e3f4a5b6c7d: signup form — web form v3, WhatsApp updates box ticked" },
  "marketing_opted_out": false,
  "window_open_until": "2026-09-26T09:59:00.000Z",
  "plain_text_until": "2026-09-26T09:29:00.000Z" }
  • language: the customer's language, or null; language_source says where it came from — api or dashboard (set by you) or inferred (from the script of their messages).
  • opted_out / opted_in: { at, source } or null. source is keyword when the customer wrote STOP or START.
  • marketing_opted_out: the customer turned off marketing from your business in WhatsApp (Meta tells walpio); utility templates still go.
  • window_open_until: 24 hours after the customer's last message (Meta's window), or null.
  • plain_text_until: until when walpio sends a plain text as text — 23.5 hours after the customer's last message, half an hour before Meta's window closes; after it a plain text goes through its template (see How a plain text is delivered). null when the customer never wrote.

STOP and START

A customer's reply that is exactly one of these words opts them out or back in, on every number of the workspace. The comparison ignores letter case, repeated spaces, punctuation around the reply (. ! ? ؟ ، ۔ , ; :), Arabic diacritics, tatweel, the hamza and the different forms of alef (أ إ آ → ا), ى/ي, ة/ه, and the invisible direction marks a phone may add:

  • opt out: stop, stop all, stopall, unsubscribe, opt out, optout, إلغاء الاشتراك, إلغاء, توقف, وقف, إيقاف
  • opt in: start, subscribe, unstop, اشتراك
  • marketing only: stop promotions and إيقاف العروض الترويجية — WhatsApp Manager's marketing opt-out quick-reply. The customer stops getting marketing templates (marketing_opted_out, as when they turn marketing off in WhatsApp); notifications still go.

A workspace can add its own opt-out and opt-in words in the dashboard (Settings → Opt-out words), with a tester; they are matched the same way. A longer message ("stop the technician, I'm out", "إلغاء الموعد") is not an opt-out. The message.inbound callback carries "opt": "opted_out", "opted_in" or "marketing_opted_out" when a message was one of these. A reaction or Meta's system notice is never an opt-out word.

API keys: scopes and addresses

A key can do everything in its workspace (full access), or only what its scopes allow. Give each system the smallest key it needs: a server that only sends notifications needs messages:send and nothing else — if that key ever leaks, it cannot delete your templates or text rules, or opt a customer back in.

ScopeLets the key
messages:sendsend messages — POST /v1/messages (and ?dry_run=true), Meta's paths POST /{version}/{phone_number_id}/messages and POST /v1/{phone_number_id}/messages, POST /v1/messages/batch
messages:readread and list messages and batches — GET /v1/messages/{id}, GET /v1/messages, GET /v1/messages/batch/{id}
numbers:readlist the workspace's numbers — GET /v1/numbers
templates:readlist templates — GET /v1/templates
templates:writecreate and delete templates — POST /v1/templates, DELETE /v1/templates/{name}
text-rules:readlist text rules and test a text — GET /v1/text-rules, POST /v1/text-rules/test
text-rules:writecreate and delete text rules — POST /v1/text-rules, DELETE /v1/text-rules/{id}
contacts:readread a contact's consent — GET /v1/contacts/{to}
contacts:writerecord opt-ins and opt-outs — POST /v1/contacts/consent

A key without the scope a route needs is answered 403 insufficient_scope, with the scope in required_scope, and nothing is done. Scopes are chosen when a key is made, shown on each key under API keys, and can be changed later by a workspace admin without changing the key itself — the systems that use it keep it. Keys made before scopes existed have full access and keep it until an admin narrows them.

A key can also carry a list of allowed addresses: IPv4 or IPv6 addresses and CIDR ranges (203.0.113.10, 203.0.113.0/24, 2001:db8:1::/48), up to 20. A request from anywhere else is answered 403 ip_not_allowed before anything else is checked. Leave the list empty to allow any address.

Errors

Every error has the same body:

{ "error": { "code": "recipient_unavailable", "message": "Message Undeliverable.", "retryable": false,
             "message_id": "c1e3a5b7-9d0f-4a2c-8e4b-6d8f0a2c4e6b", "meta_code": 131026,
             "request_id": "8c1f0e7d4a2b3c4d-DXB" } }
  • code — stable, machine-readable (the table below).
  • message — plain words for a person; its wording can change.
  • retryable — whether repeating the same request later can succeed without anyone acting.
  • message_id — present when walpio holds a message record for the request (it failed at Meta, or it is still being sent): look it up with GET /v1/messages/{id}.
  • meta_code — present when Meta refused: Meta's own numeric error code.
  • required_scope — present on insufficient_scope: the scope the key would need for this route (full for a route only a full-access key may use).
  • request_id — always the last field: the id of this request, also sent as the X-Request-Id header on every answer (errors and successes alike). Quote it when you ask walpio about a request; it finds the request in walpio's logs.

A Meta refusal, answered as it happens:

POST /v1/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "to": "+971555000111", "type": "template",
  "template": { "name": "order_update", "language": "en", "params": ["#45"] } }
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{ "error": { "code": "recipient_unavailable", "message": "Message Undeliverable.", "retryable": false,
             "message_id": "c1e3a5b7-9d0f-4a2c-8e4b-6d8f0a2c4e6b", "meta_code": 131026,
             "request_id": "…" } }

A key walpio does not know:

GET /v1/numbers
Authorization: Bearer wp_live_not_a_real_key
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{ "error": { "code": "unauthorized", "message": "unknown or revoked API key", "retryable": false, "request_id": "…" } }

Every error code

Retry-After is the header's value in seconds; "—" means the answer has none; "to the 1st" means the seconds until the 1st of next month, 00:00 UTC.

CodeHTTPretryableRetry-AfterMeaning, and what to do
invalid_request400no—The body is not a JSON object, a field is missing or has the wrong type or length, or the Idempotency-Key is empty or longer than 200 characters — or a query parameter of GET /v1/messages cannot be read. message names the field. Fix the request.
unauthorized401no—The Authorization header is missing or not Bearer wp_…, or the key is unknown, revoked or expired. Check the key under API keys in the dashboard.
tenant_suspended403no—The workspace is suspended. Contact walpio.
insufficient_scope403no—The key is not allowed to do this: it lacks the scope the route needs, which required_scope names (see API keys: scopes and addresses). Nothing was done. A workspace admin adds the scope to the key under API keys, or you use a key that has it.
ip_not_allowed403no—The key has a list of allowed addresses, and this request came from somewhere else; message names the address walpio saw. Nothing was done. Call from a listed address, or add this one to the key's list.
not_found404no—No such message, template language, text rule or contact in this workspace, or no such media on the number (Meta keeps a customer's media 7 days) — or no such route (check the method and the /v1).
idempotency_in_progress409yes2A request with the same Idempotency-Key is still being sent. Repeat it unchanged after Retry-After; you then get that request's answer.
payload_too_large413no—The body is larger than 512 KB — or, for POST /v1/media, the file is larger than Meta takes for its type, or than walpio's 16 MB for an upload (send a larger document by link); for GET /v1/media/{id}, Meta says the file is larger than 100 MB.
invalid_recipient422no—to is not an international number or a business-scoped user id; message says why (for example a national 05… number). walpio never guesses a country.
no_wa_account422no—The workspace has no active WhatsApp number, or from matches none of its active numbers — for GET /v1/messages, none of its numbers.
idempotency_key_reused422no—This Idempotency-Key was already used for a different request. Use a new key for a new message.
window_closed422no—A media message (or another message WhatsApp allows only inside the customer's 24-hour window) to a customer whose window on the sending number has closed — walpio closes it 30 minutes early — or who never wrote to it. message says when they last wrote. Nothing was stored or sent: send an approved template instead.
contact_opted_out422no—The customer opted out (a STOP, or your consent call). Nothing is sent to them from any number of the workspace until they opt in again.
no_matching_template422no—A plain text outside the 24-hour window that no text rule recognises, in a workspace that refuses unmatched text (the default). Add a text rule for the wording, or send type: "template".
outside_window_no_fallback422no—The same, in a workspace set to the fallback template, but the number has no approved fallback template.
text_too_long_for_template422no—Outside the window, the text is longer than the fallback template leaves room for. Shorten it, or add a text rule.
template_not_approved422no—The template — named in the send, or the one the matching text rule points to — is not approved in that language on this number; message gives its status. Wait for Meta's review, or use an approved one.
marketing_opted_out422no—A marketing template to a customer who turned off marketing from your business in WhatsApp (on any of your numbers). Utility templates still go.
marketing_paused422no—Meta's per-user marketing limit refused marketing to this customer, so walpio holds marketing to them for 24 hours, from every one of your numbers (a retry inside that time can get the account blocked). message gives the time.
recipient_unavailable422no—Meta cannot deliver to this recipient now — for example the number is not on WhatsApp, the 24-hour window has closed at Meta's end, Meta's per-user marketing limit, or this business may not message them (130403). meta_code has Meta's code.
meta_rejected422no—Meta refused the request itself — a template missing in that language, paused or disabled, wrong parameters, an unsupported message. meta_code has Meta's code. Fix the request. When Meta says the template is paused, disabled or missing (132015, 132016, 132001), walpio marks it so on that number and reads the number's templates from Meta again; until they say it is approved, sends of it answer template_not_approved without reaching Meta. A replay of a message walpio settled as failed on its own (for example, its number was disabled before a queued retry) also answers this — except a queued message walpio did not send again because of the customer, whose replay answers the send path's own code (contact_opted_out, marketing_opted_out, marketing_paused).
meta_rejected502yes—DELETE /v1/templates/{name} only: Meta could not be reached. Try again.
monthly_limit_reached429noto the 1stThe workspace's monthly allowance is used up and its limit is set to block. It resets on the 1st of the month (UTC) — Retry-After says when — or when walpio raises the allowance.
rate_limited429yes10The workspace's per-minute send rate is used up — or, for GET /v1/messages, it listed 60 pages in the last minute; for GET /v1/media/{id}, it fetched 60 files. Send again after Retry-After.
meta_throttled429yes30Meta asked walpio to slow down and walpio did not queue the message itself (it does, and answers 202 queued, for the first 6 attempts). Send again after Retry-After.
meta_unavailable502yes—Meta failed or could not be reached, and walpio did not queue a retry itself. Safe to repeat with the same Idempotency-Key.
account_unavailable503no—Meta refused because of the sending number's account — an expired or removed token, a locked or restricted account, a payment problem, a number not registered on the Cloud API. walpio flags the number on the dashboard; someone has to fix it at Meta. After Meta's spam rate limit (131048), utility cap (131064), a policy block (368), a payment problem (131042), a locked account (131031) or a deleted number (33), walpio also holds sends from that number — for an hour, or until a check of the number finds it healthy — and answers every send from it with this code at once, without calling Meta (repeats count against the account); message says why and until when.
account_credentials_error503no—walpio cannot use the number's stored credentials. The number has to be reconnected.
template_rejected422no—POST /v1/templates: walpio's checks or Meta refused the draft; message says why.
template_rejected502yes—POST /v1/templates: Meta could not be reached. Try again.
invalid_rule422no—POST /v1/text-rules: the rule does not fit its template; message lists every problem.
evidence_required422no—POST /v1/contacts/consent: an opt-in for a customer who opted out by texting STOP, without evidence. Send what the customer agreed to, and where.
invalid_batch422no—POST /v1/messages/batch: one or more items cannot be sent as they are, and nothing was stored. error.items lists each: { index, code, message }, where code is invalid_recipient, duplicate_recipient (a customer twice) or duplicate_idempotency_key. Fix them and send the batch again.
over_monthly_allowance422no—POST /v1/messages/batch: the workspace's allowance is set to block, and the batch has more items than are left this month. Send fewer, or wait for the 1st.
queue_unavailable503no—POST /v1/messages/batch: the batch is saved (the answer carries it as batch), but walpio's job queue refused it for now; walpio sends it within the hour. Do not create it again — the same request only answers this batch.
internal500yes—Something failed inside walpio. Safe to repeat with the same Idempotency-Key.

Callbacks

walpio posts events to your own URL. Set it in the dashboard under Settings → Callbacks; it must be a public https address. The signing secret (whsec_…) is shown once, when the URL is first saved, and again whenever you rotate it. After a rotation the old secret keeps signing for 24 hours in a second header, walpio-signature-previous (the same form, checked the same way), so deliveries keep verifying while you deploy the new one: accept a callback when either header verifies with the secret you hold.

POST <your url>
Content-Type: application/json
User-Agent: walpio-webhooks/1
walpio-event: message.status | message.inbound | template.status
walpio-delivery: <the delivery's id — the same as the body's id>
walpio-signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
walpio-signature-previous: t=<unix seconds>,v1=<hex HMAC-SHA256>   (24 hours after a rotation only)
  • Check the signature over the raw body, exactly as received: v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with the whole secret, including whsec_. (Libraries built for the "Standard Webhooks" convention strip that prefix and decode the rest — that does not work here.) Reject a t more than 5 minutes old.
  • De-duplicate by the body's id: a delivery can arrive more than once.
  • Answer with any 2xx within 10 seconds. Redirects are not followed. Anything else is retried after 30 s, 2 min, 8 min, 30 min, 1 h, 2 h and 4 h — 8 attempts in all — and then marked failed. Settings → Callbacks in the dashboard lists the latest deliveries.
  • Every body is { id, event, created_at, data }.

Checking the signature — each function takes the raw body exactly as received, the walpio-signature header and your whole secret (whsec_…), and returns true only for a genuine, fresh callback:

Shell

# For a quick check by hand: body.json holds the raw body exactly as received,
# $SIG the walpio-signature header, $WALPIO_SECRET the whole secret (whsec_…).
t=${SIG#t=}; t=${t%%,*}
v1=${SIG#*,v1=}
expected=$( { printf '%s.' "$t"; cat body.json; } | openssl dgst -sha256 -hmac "$WALPIO_SECRET" | sed 's/^.* //')
[ "$expected" = "$v1" ] && echo "signature valid" || echo "signature NOT valid"

message.status

Meta reported a new status for a message you sent.

{ "id": "f3a5c7e9-1b2d-4f6a-8c0e-2a4c6e8a0c2e",
  "event": "message.status",
  "created_at": "2026-09-25T10:00:03.000Z",
  "data": { "message_id": "5d0c8e1b-3f2a-4b6c-8d7e-9f0a1b2c3d4e",
            "wa_message_id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSNTc3QjE2RDJF",
            "to": "971501234567",
            "status": "delivered",
            "message_status": "delivered",
            "sequence": 17907948032,
            "timestamp": "2026-09-25T10:00:03.000Z",
            "client_reference": "order-42",
            "pricing": { "category": "utility", "billable": true, "type": "regular", "pricing_model": "PMP" } } }

When it failed, data.error holds Meta's code and its words (and there is usually no pricing):

{ "id": "a1c3e5a7-9b1d-4e3f-8a5c-7e9a1c3e5a7b",
  "event": "message.status",
  "created_at": "2026-09-25T10:02:00.000Z",
  "data": { "message_id": "9a7c5e3b-1d2f-4e6a-8b0c-2d4f6a8c0e1b",
            "wa_message_id": "wamid.HBgMOTcxNTA5ODc2NTQzFQIAERgSODhGQzEyQTNCNA",
            "to": "971509876543",
            "status": "failed",
            "message_status": "failed",
            "sequence": 17907949204,
            "timestamp": "2026-09-25T10:02:00.000Z",
            "client_reference": null,
            "error": { "code": 131026, "message": "131026 Message undeliverable Unable to deliver the message." } } }

status is sent, delivered, read or failed. data.message_id is walpio's id — the walpio.id of the send.

Meta's statuses can arrive out of order (a delivered after the read), and so can their callbacks. message_status is the message's status in walpio after this event — the status only moves forward (sent → delivered → read) and failed is final, so a late delivered says "message_status": "read" — and sequence grows along one message's life (Meta's time for the status, then the step): keep the callback with the highest sequence per message_id, or simply use message_status. pricing is { category, billable, type, pricing_model } as Meta reported it (pricing_model is null when Meta did not say).

Pricing fields

Meta says to read type and category together, and that billable will be removed in a future Graph version; walpio passes all of them on while Meta sends them.

  • category: marketing, utility, authentication, authentication_international or service (a free-form message inside the customer's 24-hour window).
  • type: regular (charged), free_customer_service, or free_entry_point (the 72 hours after a customer arrives from a click-to-WhatsApp ad or a Facebook Page button).
  • From 1 October 2026 service messages are charged, after a free allowance of 1,000 delivered service messages per business number per month: a charged service message reports "billable": true, "type": "regular", "category": "service", and free_customer_service then means "inside the month's free allowance". Utility templates sent inside the 24-hour window are charged too.

A send walpio answered 202 (queued for a retry) and then settled failed itself — Meta refused a retry, the retries ran out, or walpio did not send it again (the number was disabled or put on a hold, the customer opted out, the workspace was suspended, its ttl passed) — gets one failed callback from walpio, since Meta sends no status for it: data.error.source is "walpio", wa_message_id is null when Meta never accepted it, and error.code is Meta's code or walpio's own (the walpio: codes listed under error above, such as walpio:number_disabled, walpio:opted_out or walpio:expired). A send refused in its own answer (a 4xx) gets no callback.

message.inbound

A customer wrote to one of your numbers.

{ "id": "b2d4f6a8-0c1e-4a3b-9d5f-7a9b1c3d5e7f",
  "event": "message.inbound",
  "created_at": "2026-09-25T10:03:00.000Z",
  "data": { "wa_message_id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAEhgSQUJDREVGMDEyMw",
            "from": "971501234567",
            "phone": "971501234567",
            "user_id": "AE.1234567890123456789",
            "username": null,
            "phone_number_id": "106540352242922",
            "to": "+971 4 123 4567",
            "type": "text",
            "text": "Can the technician come after 4 pm?",
            "profile_name": "Aisha Khan",
            "context_wa_message_id": null,
            "message": { "from": "971501234567",
                         "from_user_id": "AE.1234567890123456789",
                         "id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAEhgSQUJDREVGMDEyMw",
                         "timestamp": "…",
                         "type": "text",
                         "text": { "body": "Can the technician come after 4 pm?" } },
            "timestamp": "2026-09-25T10:03:00.000Z" } }

A photo, with its media id — fetch the file with GET /v1/media/{id}:

{ "id": "d4f6a8c0-2e4a-4b5c-9e7f-9b1d3f5a7c9e",
  "event": "message.inbound",
  "created_at": "2026-09-25T10:05:00.000Z",
  "data": { "wa_message_id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAEhgSQUJDREVGMDEyNA",
            "from": "971501234567",
            "phone": "971501234567",
            "user_id": "AE.1234567890123456789",
            "username": null,
            "phone_number_id": "106540352242922",
            "to": "+971 4 123 4567",
            "type": "image",
            "text": "The leak under the sink",
            "profile_name": "Aisha Khan",
            "context_wa_message_id": null,
            "message": { "from": "971501234567",
                         "from_user_id": "AE.1234567890123456789",
                         "id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAEhgSQUJDREVGMDEyNA",
                         "timestamp": "…",
                         "type": "image",
                         "image": { "caption": "The leak under the sink",
                                    "mime_type": "image/jpeg",
                                    "sha256": "…",
                                    "id": "1003383421387256" } },
            "timestamp": "2026-09-25T10:05:00.000Z" } }
FieldNotes
fromThe customer: their phone digits, or their business-scoped user id when Meta withholds the number. Send your reply to this.
phone, user_id, usernameThe customer's phone digits, business-scoped user id and WhatsApp username, each when Meta shares it (else null).
phone_number_id, toWhich of your numbers they wrote to.
typeMeta's message type: text, image, audio, video, document, sticker, location, contacts, button, interactive, reaction, order, system, unsupported…
textThe readable part: the text, a caption or file name, a button's title, a location's name, an emoji. For media without a caption it is [image], [document] and so on.
messageMeta's own message object, as Meta sent it: for an image image: { id, mime_type, sha256, caption }, for a document also filename; button: { payload, text }; `interactive: { type: "button_reply" \"list_reply", button_reply \list_reply: { id, title… } }, or a Flow's nfm_reply: { response_json… }; location: { latitude, longitude, name, address }; contacts: [ … ]; reaction: { message_id, emoji }; referral: { source_type, source_id, ctwa_clid… } for a customer who came from a Click-to-WhatsApp ad; context for a reply or a forward. The media object's url is left out: it works only with your number's Meta token, for 5 minutes — [GET /v1/media/{id}`](#fetch-a-customers-media) fetches the file. walpio keeps this object only until the callback is delivered or given up.
context_wa_message_idWhen the customer replied to one of your messages: that message's wamid.
optOnly when the message was an opt-out or opt-in word (see STOP and START): opted_out, opted_in, or marketing_opted_out ("Stop promotions": marketing only).
systemOnly on type: "system" (Meta's notice that the customer changed number or user id): { type, newWaId, previousUserId, newUserId }.

template.status

Meta reported a change to one of your templates.

{ "id": "c3e5a7b9-1d2f-4a4b-8c6d-8e0f2a4b6c8d",
  "event": "template.status",
  "created_at": "2026-09-25T11:00:00.000Z",
  "data": { "waba_id": "102290129340398",
            "name": "delivery_window",
            "language": "en",
            "kind": "status",
            "status": "approved",
            "reason": "NONE",
            "event_at": "2026-09-25T10:59:58.000Z" } }

kind says which field is set: status (Meta's event in lower case — approved, rejected, paused, disabled, flagged, reinstated…, with Meta's reason, which is NONE or null when there is none), quality (quality_score, e.g. GREEN, YELLOW, RED) or category (category, the new category after Meta re-categorised the template).

event_at is Meta's time for the event, when Meta sends one. walpio applies a template's events in that order: a status event older than the one that set the template's current status (a late retry from Meta) changes nothing and is not called back, and the same event delivered twice is called back once — with the same id, which walpio derives from the event.

The test event

Settings → Callbacks → "Send a test" posts a message.status event marked "test": true:

{ "id": "d4f6a8c0-2e3a-4b5c-9d7e-9f1a3b5c7d9e",
  "event": "message.status",
  "created_at": "2026-09-25T12:00:00.000Z",
  "data": { "test": true, "message_id": "test", "wa_message_id": "wamid.TEST",
            "to": "0000000000", "status": "delivered", "timestamp": "2026-09-25T12:00:00.000Z" } }

Moving from the Cloud API

If your system already calls Meta's Cloud API, these are the differences:

  1. The path. Meta's own path is the send: POST /{version}/{phone_number_id}/messages with the base https://api.walpio.com (any version, such as v25.0), or POST /v1/{phone_number_id}/messages with the base https://api.walpio.com/v1. The number in the path is the sending number — one of your workspace's, by its phone_number_id (otherwise 422 no_wa_account); a from in the body must name the same one. Everything else is POST /v1/messages: the same body, answer, headers, Idempotency-Key and ?dry_run=true.
  2. Message types. Every type Meta sends except catalogue and carousel messages — see What you can send and receive today. Media, interactive messages, locations, contacts and reactions go only inside the customer's window (walpio refuses them outside it with 422 window_closed instead of letting Meta fail them). Three calls have walpio's own path: uploading a file is POST /v1/media (the same multipart form; from names the number), fetching a customer's file is one call, GET /v1/media/{id}, that answers the file itself, and a read receipt is POST /v1/messages/{id}/read — a { "status": "read" } body on the send path is refused.
  3. Fields. text.preview_url and context (quoting the message you reply to) work as at Meta. messaging_product and recipient_type are accepted; biz_opaque_callback_data works as at Meta.
  4. The answer. contacts[] is Meta's when Meta answered within the request (not on a 202 or a replay). messages[0].id is Meta's wamid on a 200 but walpio's own id on a 202. Meta's message_status (a message held for a quality check) is walpio.status: "held".
  5. Errors. walpio answers { "error": { "code", "message", "retryable" } } with a text code; when Meta refused, its numeric code is in meta_code. Send Walpio-Error-Format: meta and every error of the client API comes in Meta's shape instead (see below).
  6. Your Meta webhooks move to walpio. Connecting the number points your Meta app's WhatsApp webhooks at walpio — or, if the app already sends them somewhere else, takes this WhatsApp account's messages and statuses through Meta's per-account override. Either way, your existing Meta webhook endpoint stops receiving this number's messages and statuses. walpio posts them to your callback URL instead, in its own format and signed its own way (see Callbacks), so switch your sending and your webhook handling together.

A body written for Meta, sent on Meta's path:

POST /v25.0/106540352242922/messages
Authorization: Bearer wp_live_…
Content-Type: application/json

{ "messaging_product": "whatsapp", "recipient_type": "individual", "to": "971501234567",
  "type": "template",
  "template": { "name": "order_update", "language": { "code": "en" },
                "components": [{ "type": "body", "parameters": [{ "type": "text", "text": "#49" }] }] } }
HTTP/1.1 200 OK
Content-Type: application/json

{ "messaging_product": "whatsapp",
  "contacts": [{ "input": "971501234567", "wa_id": "971501234567" }],
  "messages": [{ "id": "wamid.HBgMOTcxNTAxMjM0NTY3FQIAERgSMTIzNDU2Nzg5MA" }],
  "walpio": { "id": "3b5d7f9a-1c2e-4a4b-8d6f-0a2c4e6a8c0e",
              "via": "template", "status": "accepted",
              "template": { "name": "order_update", "language": "en" },
              "category": "UTILITY" } }

Errors in Meta's shape

With the header Walpio-Error-Format: meta (on any request of the client API), an error comes as Meta's Graph API writes it. The HTTP status and headers — Retry-After included — stay walpio's, and walpio's own code and retryable stay beside Meta's in walpio:

POST /v1/106540352242922/messages
Authorization: Bearer wp_live_…
Content-Type: application/json
Walpio-Error-Format: meta

{ "to": "+971509876543", "type": "text", "text": { "body": "Your order #42 is on the way." } }
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{ "error": { "message": "the customer has not written to this number in the last 24 hours, so WhatsApp only allows an approved template — and no text rule recognises this wording. Add a text rule for it (or send type=template).",
             "type": "OAuthException",
             "code": 131047,
             "error_data": { "messaging_product": "whatsapp",
                             "details": "the customer has not written to this number in the last 24 hours, so WhatsApp only allows an approved template — and no text rule recognises this wording. Add a text rule for it (or send type=template)." },
             "fbtrace_id": "…",
             "walpio": { "code": "no_matching_template", "retryable": false },
             "request_id": "…" } }

code is Meta's own when Meta refused (walpio's meta_code, with walpio's message_id in walpio). For walpio's own refusals it is the Meta code with the same meaning, so code that switches on Meta's numbers takes the same branch:

walpio codeMeta code
invalid_request, invalid_batch, invalid_recipient, payload_too_large100
not_found, no_wa_account100 (subcode 33)
unauthorized, account_credentials_error190
tenant_suspended, insufficient_scope, ip_not_allowed10
idempotency_in_progress2
monthly_limit_reached, over_monthly_allowance4
contact_opted_out, recipient_unavailable131026
no_matching_template, outside_window_no_fallback131047
text_too_long_for_template132005
template_not_approved132001
marketing_opted_out131050
marketing_paused131049
rate_limited, meta_throttled130429
meta_unavailable, queue_unavailable131016
account_unavailable131031
internal131000

Any other code is 100 (an HTTP 4xx) or 131000 (a 5xx). fbtrace_id is walpio's request id (the X-Request-Id header); error_subcode appears only when there is one. Two answers walpio gives before it knows the route keep walpio's shape: no such route (404) and, under /v1, a body over 512 KB (413). Without the header — or with any other value — every error is in walpio's shape, as in Errors.

Limits

LimitValue
Send ratePer workspace, in any rolling minute (60 by default; set by walpio to your plan). A burst larger than that is cut to it: the first ones go out, the rest get 429 rate_limited. A send that failed does not count.
Monthly messagesYour plan's allowance. By default sending continues past it and the dashboard warns; with a hard cap, 429 monthly_limit_reached — exactly at the cap, however many sends arrive together. A message counts once, when walpio hands it to Meta (sent, queued, or with its answer lost); one that ends in a definite failure does not count.
Dedupe window0–3,600 seconds (120 by default), set in the dashboard.
API keys20 per workspace that are not revoked, each with an optional expiry.
Request body512 KB.
Message listsGET /v1/messages: 60 pages a minute per workspace, up to 100 messages a page.
BroadcastsIn the dashboard: up to 5,000 recipients, sent one by one through the queue. Through the API, a batch: up to 1,000 items, each with its own values.

Meta's own limits still apply: up to 80 messages a 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 messaging_limit_tier).

Versioning and changes

  • /v1 only grows. Within /v1 walpio adds and never removes or renames: new endpoints, new optional request fields, new fields in answers and callbacks, new callback events, new values of an existing field (a message status, a type), new error codes. Build for that: ignore fields and events you do not know, treat an unknown error code by its HTTP status and retryable, and do not depend on the order of fields — except that an error's code always comes first and its request_id last.
  • A breaking change — removing or renaming a field, changing its meaning or type, refusing a request that works today — comes only in a new version path (/v2), never in /v1. The old path keeps working for at least 6 months after the new one is published. While it is being retired its answers carry a Deprecation header and a Sunset header with the date it stops.
  • Every change you can notice is announced in walpio's changelog when it ships, marked "API". A fix for a security hole can ship at once; it is announced the same day.
  • Not part of the contract: the wording of message, the order of other fields, and anything this reference does not describe.
  • Every answer carries X-Request-Id (and an error, request_id): quote it when you ask walpio about a request.

Questions about the API? Email hello@walpio.com or get started.