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 ishttps://api.walpio.com/v1/messages. If your system asks for a base address and adds/messagesitself, give ithttps://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 saysopted_in); and thesystemobject of an inbound callback (newWaId,previousUserId,newUserId). Both spellings are stable:/v1does 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." } }'Node.js
// Node.js 18 or later (built-in fetch); save as send.mjs and run: node send.mjs
const res = await fetch('https://api.walpio.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WALPIO_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-42-shipped',
},
body: JSON.stringify({ to: '+971501234567', type: 'text', text: { body: 'Your order #42 is on the way.' } }),
});
const answer = await res.json();
if (res.ok) console.log(answer.walpio.id, answer.walpio.status);
else console.log(res.status, answer.error.code, answer.error.message);Python
# Python 3.8 or later, standard library only
import json, os, urllib.error, urllib.request
request = urllib.request.Request(
"https://api.walpio.com/v1/messages",
method="POST",
headers={
"Authorization": f"Bearer {os.environ['WALPIO_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "order-42-shipped",
},
data=json.dumps({"to": "+971501234567", "type": "text",
"text": {"body": "Your order #42 is on the way."}}).encode(),
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
answer = json.load(response)
print(answer["walpio"]["id"], answer["walpio"]["status"])
except urllib.error.HTTPError as refused:
error = json.load(refused)["error"]
print(refused.code, error["code"], error["message"])PHP
<?php
// PHP 7.4 or later, with the curl extension
$ch = curl_init('https://api.walpio.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WALPIO_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-42-shipped',
],
CURLOPT_POSTFIELDS => json_encode([
'to' => '+971501234567',
'type' => 'text',
'text' => ['body' => 'Your order #42 is on the way.'],
]),
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$answer = json_decode($raw, true);
if ($status < 400) {
echo $answer['walpio']['id'], ' ', $answer['walpio']['status'], "\n";
} else {
echo $status, ' ', $answer['error']['code'], ' ', $answer['error']['message'], "\n";
}C#
// .NET 6 or later: a console app's Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("WALPIO_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.walpio.com/v1/messages")
{
Content = JsonContent.Create(new
{
to = "+971501234567",
type = "text",
text = new { body = "Your order #42 is on the way." },
}),
};
request.Headers.Add("Idempotency-Key", "order-42-shipped");
using var response = await http.SendAsync(request);
using var answer = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (response.IsSuccessStatusCode)
{
var walpio = answer.RootElement.GetProperty("walpio");
Console.WriteLine($"{walpio.GetProperty("id")} {walpio.GetProperty("status")}");
}
else
{
var error = answer.RootElement.GetProperty("error");
Console.WriteLine($"{(int)response.StatusCode} {error.GetProperty("code")} {error.GetProperty("message")}");
}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"] } }'Node.js
// Node.js 18 or later (built-in fetch); save as send-template.mjs and run: node send-template.mjs
const res = await fetch('https://api.walpio.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.WALPIO_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-42-ready',
},
body: JSON.stringify({
to: '+971501234567',
type: 'template',
template: { name: 'order_update', language: 'en', params: ['#42'] },
}),
});
const answer = await res.json();
if (res.ok) console.log(answer.walpio.id, answer.walpio.status);
else console.log(res.status, answer.error.code, answer.error.message);Python
# Python 3.8 or later, standard library only
import json, os, urllib.error, urllib.request
request = urllib.request.Request(
"https://api.walpio.com/v1/messages",
method="POST",
headers={
"Authorization": f"Bearer {os.environ['WALPIO_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": "order-42-ready",
},
data=json.dumps({"to": "+971501234567", "type": "template",
"template": {"name": "order_update", "language": "en",
"params": ["#42"]}}).encode(),
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
answer = json.load(response)
print(answer["walpio"]["id"], answer["walpio"]["status"])
except urllib.error.HTTPError as refused:
error = json.load(refused)["error"]
print(refused.code, error["code"], error["message"])PHP
<?php
// PHP 7.4 or later, with the curl extension
$ch = curl_init('https://api.walpio.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WALPIO_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-42-ready',
],
CURLOPT_POSTFIELDS => json_encode([
'to' => '+971501234567',
'type' => 'template',
'template' => ['name' => 'order_update', 'language' => 'en', 'params' => ['#42']],
]),
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$answer = json_decode($raw, true);
if ($status < 400) {
echo $answer['walpio']['id'], ' ', $answer['walpio']['status'], "\n";
} else {
echo $status, ' ', $answer['error']['code'], ' ', $answer['error']['message'], "\n";
}C#
// .NET 6 or later: a console app's Program.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("WALPIO_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.walpio.com/v1/messages")
{
Content = JsonContent.Create(new
{
to = "+971501234567",
type = "template",
template = new { name = "order_update", language = "en", @params = new[] { "#42" } },
}),
};
request.Headers.Add("Idempotency-Key", "order-42-ready");
using var response = await http.SendAsync(request);
using var answer = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (response.IsSuccessStatusCode)
{
var walpio = answer.RootElement.GetProperty("walpio");
Console.WriteLine($"{walpio.GetProperty("id")} {walpio.GetProperty("status")}");
}
else
{
var error = answer.RootElement.GetProperty("error");
Console.WriteLine($"{(int)response.StatusCode} {error.GetProperty("code")} {error.GetProperty("message")}");
}What you can send and receive today
- Send:
textandtemplatemessages — templates with their body, header and button values throughparams(header and button values), or through Meta's owncomponents. 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; anothertypeis refused with400 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.statuscallback for every status Meta reports (sent,delivered,read,failed),GET /v1/messages/{id}at any time, andGET /v1/messagesto 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.
| Field | Required | Notes |
|---|---|---|
to | yes | The 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. |
type | no | text (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.body | for text | The message, 1–4,096 characters, not only spaces. |
text.preview_url | no | true 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. |
context | no | { "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.name | for template | The approved template's name. |
template.language | for 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.params | no | walpio'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.components | no | Meta'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, sticker | for that type | The file, as Meta takes it — see Send media. |
interactive, location, contacts, reaction | for that type | The message, as Meta takes it — see Interactive messages, locations, contacts and reactions. |
from | no | The 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_data | no | Your own reference, up to 512 characters. It comes back as client_reference in status callbacks and in the message status. |
ttl | no | How 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_type | no | Accepted 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 field | What it holds | ||
|---|---|---|---|
body | The body's values, as above: an array, or an object for named variables. | ||
header | A 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"? }`. |
buttons | One 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.
| type | Object | Notes |
|---|---|---|
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.
| type | Object | Meta'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.
| Field | Required | Notes |
|---|---|---|
file | yes | The 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. |
type | no | The file's MIME type, when the file part does not say it (application/pdf). |
from | no | The number the file is for — its phone_number_id or walpio's number id; without it, the oldest active number. |
messaging_product | no | Accepted 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:
| Situation | What walpio sends |
|---|---|
| The customer wrote to this number in the last 24 hours | The text, as written. |
| Otherwise, and a text rule recognises the wording | The rule's approved template, with the values from the text filled in. |
| Otherwise, and the workspace is set to the fallback | The approved fallback template for the text's language (Arabic script → the Arabic one), with the whole text as its one value. |
| Otherwise | 422 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_progresswithRetry-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
| HTTP | walpio.status | Meaning |
|---|---|---|
200 | accepted | Meta accepted the message. Accepted is not delivered: delivery arrives as a status callback. |
200 | held | Meta accepted it and is holding it for a quality check before delivering it. |
202 | queued | Meta 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. |
202 | unknown | Meta'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. |
202 | sending | Meta 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:
| Header | Meaning |
|---|---|
RateLimit-Limit | The workspace's sends per rolling minute. |
RateLimit-Remaining | How many more it can send now, this one counted. 0: the next is refused with 429 rate_limited. |
RateLimit-Reset | Seconds until the oldest send of the last minute leaves it (and a slot frees); 0 when there is none. |
Walpio-Monthly-Limit | The workspace's monthly allowance — only when it has one. |
Walpio-Monthly-Remaining | What 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" } }| Field | Notes |
|---|---|
messaging_product | Always whatsapp. |
contacts | Meta'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].id | Meta'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.id | walpio'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.via | text (sent as written) or template. |
walpio.status | See the table above. |
walpio.template | The template that was sent, { name, language } — for a template send, a text rule or the fallback. |
walpio.transformations | What 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.deduplicated | true when this answer replays an earlier request (Idempotency-Key, or an identical request inside the dedupe window). |
walpio.rule | The name of the text rule that turned a plain text into its template. Absent when no rule did (and on a replay). |
walpio.category | The 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.note | A 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
failedin walpio, but no status callback is posted for it — status callbacks come only from Meta's reports. The same holds for anunknownmessage that no report ever settles, and for asendingmessage that Meta refused after walpio had answered. PollGET /v1/messages/{id}for messages that stayqueued,sendingorunknown— or list them all at once withGET /v1/messages?status=queued,sending,unknown(see List messages). - if you store
messages[0].idas "the WhatsApp id", queued messages will not match thewa_message_idof their later callbacks. Match onwalpio.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 }| Field | Notes |
|---|---|
would | send; 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, route | How 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, to | The 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). |
window | The 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. |
params | The template's body values as they would be sent: in order (an array), or by name (an object). null for a text. |
transformations | As in a send's answer. |
payload | The exact body walpio would post to Meta. |
retry_of | When 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. |
replay | For would: "replay": { message_id, status } of the earlier request. |
refusal | For 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:
| Status | Meaning |
|---|---|
sending | walpio is sending it to Meta right now, or is still waiting for Meta's answer. |
queued | Meta throttled or failed for a moment; walpio will send it again. |
accepted | Meta accepted it. |
held | Meta accepted it and is holding it for a quality check. |
unknown | Meta's answer was lost; it may have been delivered. |
sent | Meta sent it (from Meta's status report). |
delivered | It reached the customer's phone. |
read | The customer read it (only if they allow read receipts). |
failed | It was not delivered. error says why. |
received | A 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 }| Answer | When |
|---|---|
200 { "success": true } | WhatsApp has it. |
400 invalid_request | The body is not JSON, or typing is not true or false. |
404 not_found | No message from a customer with this id in the workspace (your own messages cannot be marked read). |
422 no_wa_account | The number the customer wrote to is no longer active. |
422 meta_rejected, 502 meta_unavailable, 503 account_unavailable | Meta 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" } ] } }| Field | Notes |
|---|---|
direction | out (you sent it) or in (the customer sent it). |
to, from | For 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. |
type | For 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_reference | Your biz_opaque_callback_data, or null. |
inbound | For 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. |
events | Meta'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 }| Parameter | Notes |
|---|---|
status | One status, or several separated by commas (queued,sending,unknown) — see Message statuses. |
direction | out (you sent them) or in (your customers sent them). |
from | One of your numbers — its phone_number_id or walpio's number id — including a disconnected one. |
to | The 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_reference | Your biz_opaque_callback_data, exactly (up to 512 characters). |
since, until | Only 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. |
limit | Messages per page, 1–100; 50 when not given. |
cursor | The 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 }| Field | Notes |
|---|---|
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). |
from | The number to send from, as in a send; without it, the oldest active number. |
items | 1 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 header | The 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 }| Field | Notes |
|---|---|
batch.status | queued, running, done (every item settled) or cancelled (in the dashboard). |
batch.pending, sent, failed, skipped | How many items are in each state; they add up to total. |
items[].status | pending (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_status | The 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.
| Query | Notes |
|---|---|
from | Optional: 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"| Answer | When |
|---|---|
200 | The file. |
404 not_found | No such media on the number — expired at Meta, another number's, or not Meta's id. |
413 payload_too_large | Meta says the file is larger than 100 MB. |
422 no_wa_account | from is not one of your active numbers (or you have none). |
502 meta_unavailable, 503 account_unavailable | Meta 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.
| Field | Notes |
|---|---|
name | Lower-case letters, digits and underscores. |
language | Meta's language code, e.g. en, ar, en_US. |
category | UTILITY, MARKETING or AUTHENTICATION. Meta decides the final category: a template it finds promotional is approved as MARKETING. |
components | Meta'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_inputs | Instead 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_format | POSITIONAL (the default: {{1}}, {{2}} …) or NAMED ({{customer_name}}). |
from | The 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}} = dueHow 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__,constructorandprototypecannot 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 with422 invalid_rulewhen 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 anhttp(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 with422 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 asACME: {{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).
| Field | Notes |
|---|---|
name | Your label for the rule (1–120 characters). |
pattern | Your 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, templateLanguage | The 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). |
params | Which 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. |
header | Optional, 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. |
buttons | Optional: [{ "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. |
pairedWith | Optional: 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). |
waAccountId | Optional: the rule applies to one number only (walpio's number id or its phone_number_id). Without it, every number of the workspace. |
enabled | Optional, 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 field | Meaning |
|---|---|
reason | wording: 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. |
expected | For 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. |
found | For wording: the text's words there, as the text has them; "" when it has none there. Otherwise null. |
at | For 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. |
message | The 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" }Contacts and consent
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).
| Field | Notes |
|---|---|
to | The customer, as in a send. |
optedIn | true records an opt-in and clears any opt-out; false records an opt-out. |
source | Where it came from, e.g. "signup form" (2–120 characters). |
evidence | Proof, 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. |
from | Optional: 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, ornull;language_sourcesays where it came from —apiordashboard(set by you) orinferred(from the script of their messages).opted_out/opted_in:{ at, source }ornull.sourceiskeywordwhen 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), ornull.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).nullwhen 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 promotionsandإيقاف العروض الترويجية— 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.
| Scope | Lets the key |
|---|---|
messages:send | send 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:read | read and list messages and batches — GET /v1/messages/{id}, GET /v1/messages, GET /v1/messages/batch/{id} |
numbers:read | list the workspace's numbers — GET /v1/numbers |
templates:read | list templates — GET /v1/templates |
templates:write | create and delete templates — POST /v1/templates, DELETE /v1/templates/{name} |
text-rules:read | list text rules and test a text — GET /v1/text-rules, POST /v1/text-rules/test |
text-rules:write | create and delete text rules — POST /v1/text-rules, DELETE /v1/text-rules/{id} |
contacts:read | read a contact's consent — GET /v1/contacts/{to} |
contacts:write | record 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 withGET /v1/messages/{id}.meta_code— present when Meta refused: Meta's own numeric error code.required_scope— present oninsufficient_scope: the scope the key would need for this route (fullfor a route only a full-access key may use).request_id— always the last field: the id of this request, also sent as theX-Request-Idheader 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_keyHTTP/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.
| Code | HTTP | retryable | Retry-After | Meaning, and what to do |
|---|---|---|---|---|
invalid_request | 400 | no | — | 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. |
unauthorized | 401 | no | — | 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_suspended | 403 | no | — | The workspace is suspended. Contact walpio. |
insufficient_scope | 403 | no | — | 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_allowed | 403 | no | — | 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_found | 404 | no | — | 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_progress | 409 | yes | 2 | A 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_large | 413 | no | — | 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_recipient | 422 | no | — | 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_account | 422 | no | — | 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_reused | 422 | no | — | This Idempotency-Key was already used for a different request. Use a new key for a new message. |
window_closed | 422 | no | — | 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_out | 422 | no | — | 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_template | 422 | no | — | 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_fallback | 422 | no | — | The same, in a workspace set to the fallback template, but the number has no approved fallback template. |
text_too_long_for_template | 422 | no | — | Outside the window, the text is longer than the fallback template leaves room for. Shorten it, or add a text rule. |
template_not_approved | 422 | no | — | 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_out | 422 | no | — | 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_paused | 422 | no | — | 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_unavailable | 422 | no | — | 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_rejected | 422 | no | — | 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_rejected | 502 | yes | — | DELETE /v1/templates/{name} only: Meta could not be reached. Try again. |
monthly_limit_reached | 429 | no | to the 1st | The 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_limited | 429 | yes | 10 | The 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_throttled | 429 | yes | 30 | Meta 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_unavailable | 502 | yes | — | Meta failed or could not be reached, and walpio did not queue a retry itself. Safe to repeat with the same Idempotency-Key. |
account_unavailable | 503 | no | — | 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_error | 503 | no | — | walpio cannot use the number's stored credentials. The number has to be reconnected. |
template_rejected | 422 | no | — | POST /v1/templates: walpio's checks or Meta refused the draft; message says why. |
template_rejected | 502 | yes | — | POST /v1/templates: Meta could not be reached. Try again. |
invalid_rule | 422 | no | — | POST /v1/text-rules: the rule does not fit its template; message lists every problem. |
evidence_required | 422 | no | — | 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_batch | 422 | no | — | 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_allowance | 422 | no | — | 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_unavailable | 503 | no | — | 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. |
internal | 500 | yes | — | 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:
v1is the hex HMAC-SHA256 of"<t>.<raw body>", keyed with the whole secret, includingwhsec_. (Libraries built for the "Standard Webhooks" convention strip that prefix and decode the rest — that does not work here.) Reject atmore than 5 minutes old. - De-duplicate by the body's
id: a delivery can arrive more than once. - Answer with any
2xxwithin 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"Node.js
import crypto from 'node:crypto';
function verifyWalpio(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
const want = Buffer.from(expected, 'hex');
return given.length === want.length && crypto.timingSafeEqual(given, want);
}Python
import hashlib, hmac, time
def verify_walpio(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))PHP
<?php
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_WALPIO_SIGNATURE'] ?? '';
function verifyWalpio(string $rawBody, string $header, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key] = $value;
}
$t = (int) ($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}C#
using System.Linq;
using System.Security.Cryptography;
using System.Text;
static bool VerifyWalpio(string rawBody, string header, string secret)
{
var parts = header.Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var ts) || !long.TryParse(ts, out var t)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
byte[] given;
try { given = Convert.FromHexString(parts.GetValueOrDefault("v1", "")); }
catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(given, expected);
}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_internationalorservice(a free-form message inside the customer's 24-hour window).type:regular(charged),free_customer_service, orfree_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", andfree_customer_servicethen 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" } }| Field | Notes | ||
|---|---|---|---|
from | The customer: their phone digits, or their business-scoped user id when Meta withholds the number. Send your reply to this. | ||
phone, user_id, username | The customer's phone digits, business-scoped user id and WhatsApp username, each when Meta shares it (else null). | ||
phone_number_id, to | Which of your numbers they wrote to. | ||
type | Meta's message type: text, image, audio, video, document, sticker, location, contacts, button, interactive, reaction, order, system, unsupported… | ||
text | The 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. | ||
message | Meta'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_id | When the customer replied to one of your messages: that message's wamid. | ||
opt | Only 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). | ||
system | Only 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:
- The path. Meta's own path is the send:
POST /{version}/{phone_number_id}/messageswith the basehttps://api.walpio.com(any version, such asv25.0), orPOST /v1/{phone_number_id}/messageswith the basehttps://api.walpio.com/v1. The number in the path is the sending number — one of your workspace's, by itsphone_number_id(otherwise422 no_wa_account); afromin the body must name the same one. Everything else isPOST /v1/messages: the same body, answer, headers,Idempotency-Keyand?dry_run=true. - 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_closedinstead of letting Meta fail them). Three calls have walpio's own path: uploading a file isPOST /v1/media(the same multipart form;fromnames the number), fetching a customer's file is one call,GET /v1/media/{id}, that answers the file itself, and a read receipt isPOST /v1/messages/{id}/read— a{ "status": "read" }body on the send path is refused. - Fields.
text.preview_urlandcontext(quoting the message you reply to) work as at Meta.messaging_productandrecipient_typeare accepted;biz_opaque_callback_dataworks as at Meta. - The answer.
contacts[]is Meta's when Meta answered within the request (not on a202or a replay).messages[0].idis Meta's wamid on a200but walpio's own id on a202. Meta'smessage_status(a message held for a quality check) iswalpio.status: "held". - Errors. walpio answers
{ "error": { "code", "message", "retryable" } }with a textcode; when Meta refused, its numeric code is inmeta_code. SendWalpio-Error-Format: metaand every error of the client API comes in Meta's shape instead (see below). - 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 code | Meta code |
|---|---|
invalid_request, invalid_batch, invalid_recipient, payload_too_large | 100 |
not_found, no_wa_account | 100 (subcode 33) |
unauthorized, account_credentials_error | 190 |
tenant_suspended, insufficient_scope, ip_not_allowed | 10 |
idempotency_in_progress | 2 |
monthly_limit_reached, over_monthly_allowance | 4 |
contact_opted_out, recipient_unavailable | 131026 |
no_matching_template, outside_window_no_fallback | 131047 |
text_too_long_for_template | 132005 |
template_not_approved | 132001 |
marketing_opted_out | 131050 |
marketing_paused | 131049 |
rate_limited, meta_throttled | 130429 |
meta_unavailable, queue_unavailable | 131016 |
account_unavailable | 131031 |
internal | 131000 |
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
| Limit | Value |
|---|---|
| Send rate | Per 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 messages | Your 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 window | 0–3,600 seconds (120 by default), set in the dashboard. |
| API keys | 20 per workspace that are not revoked, each with an optional expiry. |
| Request body | 512 KB. |
| Message lists | GET /v1/messages: 60 pages a minute per workspace, up to 100 messages a page. |
| Broadcasts | In 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
/v1only grows. Within/v1walpio 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 messagestatus, atype), new error codes. Build for that: ignore fields and events you do not know, treat an unknown errorcodeby its HTTP status andretryable, and do not depend on the order of fields — except that an error'scodealways comes first and itsrequest_idlast.- 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 aDeprecationheader and aSunsetheader 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.