Integration guides and API reference for WasapFlow Bridge
npm install github:kobaranteguh/wasapflow-bridge-node
pip install git+https://github.com/kobaranteguh/wasapflow-bridge-python.git
composer require kobaranteguh/wasapflow-bridge-php
http://partner.wasapflow.com/bridge/v1
Version: 2.9.2
Last Updated: 1 September 2026
Base URL: https://officialapi.wasapflow.com/bridge/v1
Auth Header: x-partner-key: wf_your_key
⚠️ Meta pricing change — effective 1 October 2026. Service messages (non-template replies inside the 24-hour customer service window) and utility templates sent inside that window become billable. How you send does not change — service messages still need no template and no Meta pre-approval. Only the billing changes. See the Changelog for what to do now.
What's new in 2.9.0: 32 new endpoints. Bridge previously exposed 13 of Meta's ~38 Cloud API surfaces — the rest were never missing, just never asked for, which meant emailing us and having us run the call for you. Now open: QR codes & short links, conversational automation (ice breakers, commands, welcome message), blocking, commerce settings, call & storage settings, phone number status, WABA details, audit log, assigned users, schedules, Flows (read-only), phone number lifecycle, calling and groups. Meta errors now carry
meta_code,meta_subcodeanddetailsinstead of just message text. Irreversible actions need{"confirm": true}.What's new in 2.2.0: Every message status webhook now carries a
pricingobject so you can attribute Meta's cost per message, plus aconversationobject. Every API response carries notice headers, and every webhook carries ametaobject pointing at the changelog. All additive — no existing field changed.2.1.0: Template lifecycle + account webhooks —
template.status_updated(approved/rejected),template.quality_updated,template.category_updated,waba.account_updated,waba.review_updated, andcontact.synced(Coexistence contact sync). See Webhook Event Payloads.2.0.0: Coexistence message sync —
message.echo(messages your client sends from the WhatsApp Business App) andmessage.history(past conversations backfilled after onboarding).
Your system connects directly to Meta's WhatsApp Cloud API — through WasapFlow's infrastructure.
Your System → Bridge API (officialapi.wasapflow.com) → Meta
You do not need a Meta Tech Provider account. You do not need a Meta App. Your clients register their WhatsApp Business Account (WABA) through WasapFlow's Embedded Signup flow, and you manage those WABAs directly via this API.
WasapFlow Bridge currently supports Coexistence mode for Embedded Signup.
Coexistence mode means your client can connect a number that is already active in the WhatsApp Business App while continuing to use that same Business App number. WasapFlow connects the number to WhatsApp Cloud API for automation, templates, webhooks, and API sending, without asking the client to stop using the WhatsApp Business App.
Use Coexistence when:
Do not use Coexistence for:
For Embedded Signup, set:
{
"connection_mode": "coexistence"
}
If you self-host the Meta popup, launch it with:
{
"setup": {},
"featureType": "whatsapp_business_app_onboarding",
"sessionInfoVersion": "3",
"version": "v4"
}
If you use the WasapFlow hosted popup (/bridge/connect), this is already handled for you.
| Credential | Location in Dashboard |
|---|---|
| Partner Key | Dashboard → API Credentials → Partner Key (👁️ eye icon to reveal, 📋 copy button) |
| Webhook Secret | Settings → Webhook Security → Webhook Secret (👁️ eye icon to reveal, 📋 copy button) |
| Base URL | Dashboard → API Credentials → Base URL |
Include your Partner Key in every request:
x-partner-key: wf_your_partner_key
For WABA-scoped endpoints, also include the WABA ID:
x-partner-key: wf_your_partner_key
x-waba-id: 123456789
Every Bridge API response is JSON with a top-level success field:
Success response:
{ "success": true, ...endpoint-specific fields }
HTTP status: 200 OK (or 201 Created for resource creation)
Error response:
{
"success": false,
"error": {
"code": "WABA_NOT_REGISTERED",
"message": "WABA not registered with your partner account"
}
}
HTTP status: appropriate 4xx or 5xx based on the error category.
The Bridge API follows standard REST semantics — your fetch wrappers, monitoring tools, and retry middleware can rely on res.ok / 4xx-5xx detection.
| HTTP Status | Category | Example error codes |
|---|---|---|
400 Bad Request |
Client sent malformed/missing data | MISSING_FIELDS, MISSING_WABA, INVALID_PAYLOAD, CODE_EXCHANGE_FAILED |
401 Unauthorized |
Missing or invalid x-partner-key |
UNAUTHORIZED, INVALID_PARTNER_KEY |
402 Payment Required |
Billing issue blocks the request | TRIAL_EXPIRED, PAYMENT_FAILED, SUBSCRIPTION_CANCELLED |
403 Forbidden |
Authenticated but not allowed | WABA_LIMIT_REACHED, TOO_MANY_CONTACTS, PARTNER_SUSPENDED |
404 Not Found |
Resource doesn't exist | WABA_NOT_REGISTERED, NOT_FOUND, NO_ACTIVE_WABA, MEDIA_NOT_FOUND |
409 Conflict |
State conflict | CANNOT_CANCEL (broadcast already started) |
429 Too Many Requests |
Rate / quota exceeded | RATE_LIMIT_EXCEEDED, QUOTA_EXCEEDED |
500 Internal Server Error |
WasapFlow-side failure | SERVER_ERROR, INTERNAL_ERROR |
502 Bad Gateway |
Upstream Meta API error | META_ERROR (check meta_code and meta_message) |
503 Service Unavailable |
Platform not configured | PLATFORM_NOT_CONFIGURED, NOT_CONFIGURED |
const res = await fetch(url, opts);
if (!res.ok) {
// 4xx / 5xx — parse error body
const { error } = await res.json();
throw new BridgeError(error.code, error.message, res.status);
}
const body = await res.json();
// Defensive check (legacy / unknown error codes still set success=false)
if (body.success === false) {
throw new BridgeError(body.error.code, body.error.message, res.status);
}
return body;
Migration note (May 2026): Before v1.4.5 the Bridge API returned
200 OKeven for failed responses. From v1.4.5 onwards, failed responses return appropriate 4xx/5xx codes. Your existing code checkingbody.success === falsecontinues to work, but new code can now rely on HTTP status checks as well.
Meta changes its platform on a fixed calendar — rates can move only on 1 January, 1 April, 1 July, or 1 October. When something is coming that affects you, we tell you through every response, so you never have to poll a page or monitor Meta's docs yourself.
Every API response carries these headers:
| Header | Value |
|---|---|
X-Bridge-Api-Version |
Current Bridge API version, e.g. 2.2.0 |
X-Bridge-Changelog |
URL of the changelog |
X-Bridge-Notice |
Comma-separated notice IDs (header absent when there are none) |
X-Bridge-Notice-Level |
Highest severity: info, action_required, or breaking |
Every webhook carries the same information in a meta object — see Webhook Envelope.
const res = await fetch(url, opts);
const level = res.headers.get('X-Bridge-Notice-Level');
if (level === 'action_required' || level === 'breaking') {
logger.warn('Bridge platform notice', {
ids: res.headers.get('X-Bridge-Notice'),
level,
changelog: res.headers.get('X-Bridge-Changelog')
});
}
Notice IDs are stable forever, so you can suppress ones you have already acted on. Headers are purely additive — response bodies are unchanged, so strict body parsers and schema validators are unaffected.
Full detail for every notice, plus the Meta platform calendar: Changelog (mirrored at github.com/kobaranteguh/changelog).
There is no SDK to install. Every endpoint is plain REST over HTTPS with two
headers, so use whatever HTTP client your language already has — fetch, axios,
Guzzle, requests, httpx, curl. Nothing to add to package.json.
curl -X POST https://officialapi.wasapflow.com/bridge/v1/clients/register -H "x-partner-key: wf_your_key" -H "Content-Type: application/json" -d '{
"waba_id": "123456789",
"phone_number_id": "987654321",
"access_token": "EAAxxxxxxxx",
"display_name": "My Client Business"
}'
Then send a message. Note x-waba-id — it picks which of your clients' numbers
sends, and every endpoint below takes it:
curl -X POST https://officialapi.wasapflow.com/bridge/v1/messages/send -H "x-partner-key: wf_your_key" -H "x-waba-id: 123456789" -H "Content-Type: application/json" -d '{ "to": "60123456789", "text": "Hello!" }'
The same two calls in JavaScript:
const BASE = 'https://officialapi.wasapflow.com/bridge/v1';
async function bridge(path, { method = 'GET', wabaId, body } = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
'x-partner-key': process.env.WF_PARTNER_KEY,
...(wabaId ? { 'x-waba-id': wabaId } : {}),
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
// Bridge maps error codes onto HTTP status, so res.ok is meaningful.
if (!data.success) throw new Error(`${data.error.code}: ${data.error.message}`);
return data;
}
await bridge('/clients/register', {
method: 'POST',
body: {
waba_id: '123456789',
phone_number_id: '987654321',
access_token: 'EAAxxxxxxxx',
display_name: 'My Client Business',
},
});
await bridge('/messages/send', {
method: 'POST',
wabaId: '123456789',
body: { to: '60123456789', text: 'Hello!' },
});
That helper is the whole integration surface. Copy it, and every one of the 68 endpoints in this document works through it.
Every field is
snake_case— requests and responses alike:waba_id,phone_number_id,access_token,display_name,registered_at. There is no camelCase form of this API anywhere. Earlier versions of this page showed camelCase in one response example; that was wrong and has been corrected.
POST /clients/register-from-code
x-partner-key: wf_xxx
Content-Type: application/json
Use this after your client completes the Meta Embedded Signup flow. Send the code from FB.login — WasapFlow exchanges it server-side and registers the WABA. Your app never sees the Meta access token.
For Coexistence onboarding, include "connection_mode": "coexistence". This tells WasapFlow that the client is connecting an existing WhatsApp Business App number and intends to keep the Business App usable.
{
"code": "AQB8x...",
"display_name": "My Client Business",
"connection_mode": "coexistence"
}
Response:
{
"success": true,
"client": {
"waba_id": "123456789",
"phone_number_id": "987654321",
"display_name": "My Client Business",
"connection_mode": "coexistence",
"status": "active",
"quality_rating": "GREEN",
"registered_at": "2026-05-15T10:00:00.000Z"
}
}
Error responses:
| Error Code | Meaning |
|---|---|
MISSING_FIELDS |
code not provided |
PLATFORM_NOT_CONFIGURED |
Meta App credentials not set up on WasapFlow platform |
CODE_EXCHANGE_FAILED |
Code expired or invalid — client needs to redo Embedded Signup |
DISCOVERY_FAILED |
Could not find WABA/Phone from the signup — client may not have completed the flow |
WABA_LIMIT_REACHED |
Partner exceeded max WABAs on their plan |
POST /connect/session
x-partner-key: wf_xxx
Content-Type: application/json
{
"display_name": "USRAA Skin Centre",
"state": "your-own-client-id-123"
}
Server-to-server. Exchanges your partner key for a short-lived link you can safely open in your client's browser.
Response:
{
"success": true,
"connect_url": "https://officialapi.wasapflow.com/bridge/connect?token=93d29ead…",
"token": "93d29ead…",
"expires_in": 1800
}
Send your client to connect_url. Nothing else is needed — the page handles the
Meta popup, the Coexistence extras and the code exchange.
| Field | Notes |
|---|---|
display_name |
Optional. Your client's business name, shown during onboarding |
state |
Optional, max 255 chars. Opaque to us. Returned to you unchanged when the connection completes, so you can match it to your own client record |
expires_in |
Seconds. 30 minutes. The link may be reloaded within that window |
Why this exists — please migrate. The older form,
/bridge/connect?partner_key=wf_live_…, puts your full API credential in a
browser URL. From there it reaches the address bar, browser history, the
Referer header sent to other hosts, and our own access logs in plain text.
Anyone who reads any one of those gets complete control of your partner account —
every WABA, every send, every client.
The old form still works and we have not set a removal date, so nothing breaks
today. But a partner_key that has been through a browser should be treated as
exposed. Rotation is not self-serve — ask us and we will rotate it and send
you the new key. (An earlier version of this page said to rotate it in the
partner portal. There is no such control; that was our mistake.)
Rotating revokes any connect session that has not been completed yet, so migrate and deploy first, then ask for the rotation. It does not touch WABAs that are already connected: each one holds its own Meta token, so messaging is unaffected.
The popup posts to window.opener on every outcome, and state is on all
three:
type |
When | Extra fields |
|---|---|---|
WASAPFLOW_CONNECT_SUCCESS |
Account registered | waba_id, phone_number_id, display_name, quality_rating, connection_mode |
WASAPFLOW_CONNECT_CANCEL |
User dismissed Meta's dialog | reason |
WASAPFLOW_CONNECT_ERROR |
Registration was rejected | message, code |
window.addEventListener('message', (e) => {
if (e.origin !== 'https://officialapi.wasapflow.com') return;
// Ignore results meant for a different client of yours — a popup left open
// for one must never report against another.
if (e.data?.state && e.data.state !== myClientId) return;
switch (e.data?.type) {
case 'WASAPFLOW_CONNECT_SUCCESS': /* e.data.waba_id */ break;
case 'WASAPFLOW_CONNECT_CANCEL': /* let them retry */ break;
case 'WASAPFLOW_CONNECT_ERROR': /* show e.data.message */ break;
}
});
state is also in the /clients/register-from-code response body.
Fixed in 2.6.0. Before this, only
WASAPFLOW_CONNECT_SUCCESSwas ever posted. A cancelled or failed onboarding showed a message inside the popup and told the opener nothing at all — so an integration waiting on an event sat in a "connecting…" state until the user closed the window by hand. If you wrote cancel or error handlers against earlier versions, they never fired. They will now.
Before 2.6.0 the connect page also accepted a
statequery parameter and silently discarded it. If you were passing one and wondering why it never came back, that is why.
expires_in is 1800 seconds, and that clock governs opening the link: after
it, the page answers 410 and you must mint a new one.
Finishing is more forgiving. An onboarding that opened in time may complete up to
30 minutes past expiry, because Meta's own flow — portfolio selection, number
verification, waiting for a code — can easily outlast the window, and throwing
that work away at the last step helps nobody. The code Meta hands back is
itself short-lived and single-use.
Past that, /clients/register-from-code answers 401 and the customer has to
start again.
GET /onboarding/events?limit=50
x-partner-key: wf_xxx
What actually happened inside Meta's dialog during your customers' onboarding attempts — including the ones that failed.
{
"success": true,
"count": 2,
"events": [
{
"state": "your-client-id-123",
"current_step": "PHONE_NUMBER_VERIFICATION",
"error_code": "2655122",
"error_message": "This phone number is already registered to a WhatsApp account…",
"meta_session_id": "01a01c9b-fff7-7d34-abb7-2747f9de7b9f",
"waba_id": null,
"phone_number_id": null,
"created_at": "2026-08-20T09:00:15.106Z"
}
]
}
meta_session_id is the one to keep. It is Meta's own trace id, and it is
the first thing Direct Support asks for. Quote it when you escalate to us.
Onboarding runs in your customer's browser against Meta — our server only sees the very end of it. Before 2.8.0 a failure inside that dialog left no record anywhere, so neither of us could say what went wrong. This is that record.
The same information also reaches your window live, on
WASAPFLOW_CONNECT_ERROR (meta_session_id, current_step) and
WASAPFLOW_CONNECT_CANCEL (current_step).
When you onboard in coexistence mode, we now ask Meta to send the customer's
contacts and chat history. The result is on the registration response:
{
"success": true,
"client": { … },
"coexistence_sync": {
"smb_app_state_sync": "requested",
"history": "requested"
}
}
Check this field. Meta documents a 24 hour window from onboarding to trigger the sync — "otherwise they must be offboarded and they must complete the flow again." If either value starts with
failed:, tell us the same day while the window is still open.
Contacts then arrive as contact.synced and past conversations as
message.history on your webhook.
GET /embedded-signup/config
x-partner-key: wf_xxx
Returns the Meta App ID and Config ID needed for your frontend FB.init() and FB.login(). No secrets are exposed.
The response also includes the recommended Coexistence extras. If you are self-hosting the Facebook SDK popup, pass these values into FB.login. If you use WasapFlow hosted popup, you do not need to handle these extras yourself.
Response:
{
"success": true,
"app_id": "123456789012345",
"config_id": "987654321098765",
"connection_mode": "coexistence",
"extras": {
"setup": {},
"featureType": "whatsapp_business_app_onboarding",
"sessionInfoVersion": "3",
"version": "v4"
}
}
Also available in your Partner Dashboard → Settings page.
POST /clients/register
x-partner-key: wf_xxx
Content-Type: application/json
Use
register-from-codeinstead when possible. This endpoint requires you to handle the Meta access token yourself. Performs an upsert — safe to call again if credentials change.
{
"waba_id": "123456789",
"phone_number_id": "987654321",
"access_token": "EAAxxxxxxxx",
"display_name": "My Client Business"
}
Response:
{
"success": true,
"client": {
"waba_id": "123456789",
"phone_number_id": "987654321",
"display_name": "My Client Business",
"status": "active",
"tier": "STANDARD",
"quality_rating": "GREEN",
"registered_at": "2026-05-14T10:00:00.000Z"
}
}
GET /clients?limit=100&offset=0
x-partner-key: wf_xxx
| Parameter | Default | Max |
|---|---|---|
limit |
100 |
500 |
offset |
0 |
— |
phone_numberis the number Meta holds forphone_number_id. Map by this field, never bydisplay_name— that label is free text you set once, and it does not follow the number if the number changes.
A WABA with more than one number. Each number now gets its own client row, so
/clientslists them separately under the samewaba_id. Where a WABA has two or more,x-waba-idalone no longer identifies a number, and every endpoint will refuse the call:{ "success": false, "error": { "code": "META_ERROR", "message": "WABA 123 has 2 registered numbers — send x-phone-number-id to choose one: ..." } }Add the header
x-phone-number-id: <id>to pick one. This is deliberate: choosing for you would send your messages from the wrong number, and the only person who would notice is the customer receiving them.WABAs with a single number are unaffected — no header needed.
Billing does not change. Slots are counted per WABA, not per number, so a second number on an existing WABA costs nothing extra.
Response:
{
"success": true,
"clients": [
{
"waba_id": "123456789",
"phone_number_id": "987654321",
"phone_number": "+60 11-5440 0067",
"display_name": "My Client Business",
"status": "active",
"tier": "STANDARD",
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_2K",
"throughput_level": "STANDARD",
"registered_at": "2026-05-14T10:00:00.000Z",
"last_activity_at": "2026-05-14T12:00:00.000Z"
}
],
"total": 1,
"limit": 100,
"offset": 0
}
DELETE /clients/:wabaId
x-partner-key: wf_xxx
Response:
{ "success": true }
The WABA is marked as
deleted— messaging stops immediately. This action cannot be undone via API; contact support to restore.
POST /clients/:wabaId/refresh
x-partner-key: wf_xxx
Content-Type: application/json
Fetches latest quality rating, messaging limit and throughput from Meta. Optionally update the stored access token in the same call.
Body (optional):
{ "access_token": "EAAxxxxxxxx" }
Response:
{
"success": true,
"quality_rating": "GREEN",
"throughput_level": "STANDARD",
"messaging_limit_tier": "TIER_2K",
"token_updated": false
}
Messaging limit values:
TIER_250,TIER_1K,TIER_2K,TIER_10K,TIER_100K,UNLIMITED
POST /clients/:wabaId/resubscribe-webhook
x-partner-key: wf_xxx
Re-subscribes a WABA to WasapFlow's Meta webhook endpoint. Use this when webhook events stop arriving without re-doing the Embedded Signup flow.
Response:
{ "success": true, "message": "Webhook resubscribed successfully" }
Safe to call at any time — does not affect messaging or WABA configuration.
All message endpoints require both headers:
x-partner-key: wf_xxx
x-waba-id: 123456789
Phone number format: Always use international format without
+. Example:60123456789(not+60123456789).
All successful message sends return the Meta wamid, under two keys holding the same value — read whichever you prefer:
{
"success": true,
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA",
"messages": [ { "id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA" } ]
}
message_id has been there since 1.0. messages[0].id was added in 2.5.0
because it is the shape Meta's own Cloud API returns, and partners arriving
from Meta's documentation look for it there first.
Store this id. Every delivery callback (message.sent, message.delivered,
message.read, message.failed) is keyed on it. Without it you cannot tell
which message a status belongs to, and a message.failed — the only way you
learn a send did not arrive — has nothing to attach to.
POST /messages/send
{
"to": "60123456789",
"text": "Hello from my system!",
"preview_url": false
}
to, user_id or recipient (2.5.0)Every send endpoint takes either a phone number or a BSUID:
| Field | Value | Use when |
|---|---|---|
to |
Phone number or BSUID | The normal case. A BSUID here is detected and routed correctly — you do not have to branch |
user_id |
BSUID, e.g. MY.2026206508304015 |
You want an explicit field rather than relying on us detecting the shape |
recipient |
BSUID | Alias of user_id, kept from 2.4.0 |
All three of these deliver to the same person:
{ "to": "MY.2026206508304015", "text": "Terima kasih!" }
{ "user_id": "MY.2026206508304015", "text": "Terima kasih!" }
{ "recipient": "MY.2026206508304015", "text": "Terima kasih!" }
When a phone number and a BSUID are both supplied, the phone number wins. That is Meta's own precedence rule. Existing integrations that send a phone number in to are unaffected, byte for byte.
Supplying neither returns MISSING_FIELDS.
Fixed in 2.5.0 — if you are on 2.4.0, this affects you. In 2.4.0 a BSUID placed in
towas forwarded to Meta's phone number field. Meta stripped it to digits, soMY.1777397056778201became1777397056778201— a number belonging to nobody — and the send failed with131026 Message undeliverableafter we had already returned2xx. Onlyrecipientworked. From 2.5.0 all three field names work, and a BSUID is never treated as a phone number. Click-to-WhatsApp ad leads are the common case here: those users often have no phone number at all.
Response shape differs. Sending to a BSUID returns
contacts[0].user_idand nowa_id. If you readwa_idfrom a send response, it will beundefinedfor BSUID sends — readuser_idinstead.
Not accepted for authentication templates. Meta rejects BSUID recipients for one-tap, zero-tap and copy-code authentication templates with error
131062. This is permanent: OTP flows always need a real phone number.
POST /messages/template
Simple template (body params only):
{
"to": "60123456789",
"template": {
"name": "order_confirmed",
"language": "en_US",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "John Doe" },
{ "type": "text", "text": "RM150.00" }
]
}
]
}
}
Template with header image:
{
"to": "60123456789",
"template": {
"name": "promo_with_image",
"language": "ms",
"components": [
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "link": "https://yourserver.com/promo.jpg" } }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "Ahmad" },
{ "type": "text", "text": "30%" }
]
}
]
}
}
Template with call-to-action buttons:
{
"to": "60123456789",
"template": {
"name": "order_with_tracking",
"language": "en_US",
"components": [
{
"type": "body",
"parameters": [
{ "type": "text", "text": "ORD-12345" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [
{ "type": "text", "text": "ORD-12345" }
]
}
]
}
}
Template categories:
MARKETING,UTILITY,AUTHENTICATION
Templates must be approved in Meta Business Manager before use.
Validation, and what Bridge does not check. Bridge now rejects, before calling Meta: a
typeoutsideimage | video | audio | document | sticker, amediaobject with neitheridnorlink(or both), andfilenameon animage. These come back asINVALID_FIELDwith the offendingfieldnamed.
voiceis an inbound webhook type only. Send voice notes asaudio.Everything else in the
mediaobject is passed to Meta untouched. Meta does not publish a full per-type key matrix, so Bridge does not guess one — rejecting keys we cannot verify would break payloads that work today. If Meta returnsmeta_code 100naming a key, that key is not valid for that type.Sizes (
/media/uploadenforces these after download, before Meta):
Type MIME Max image image/jpeg,image/png5 MB video video/mp4,video/3gpp16 MB audio audio/aac,amr,mpeg,mp4,ogg16 MB document pdf, doc(x), xls(x), ppt(x), text/plain100 MB sticker image/webp100 KB static, 500 KB animated Oversize returns
MEDIA_TOO_LARGEwithsize_bytesandlimit_bytes. An unsupportedmime_typeis refused before the file is fetched at all.URL lifetime.
/media/uploadfetches your URL once, synchronously, during the request, with a 30-second timeout. One-time links are fine — delete them as soon asmedia_idcomes back.Filename comes from the URL path, not from any field you send:
/media/a1b2c3?token=…becomesa1b2c3, with no extension. Harmless for images; document recipients will see a file with no extension. Put a real name in the path if it matters.
POST /messages/media
Send by URL:
{
"to": "60123456789",
"type": "image",
"media": {
"link": "https://yourserver.com/image.jpg",
"caption": "Your receipt"
}
}
Send by media_id (from /media/upload):
{
"to": "60123456789",
"type": "image",
"media": {
"id": "1234567890",
"caption": "Your receipt"
}
}
Supported types and size limits:
| Type | Formats | Max Size |
|---|---|---|
image |
JPG, PNG | 5 MB |
document |
PDF, DOCX, XLSX, etc | 100 MB |
audio |
MP3, OGG, AAC | 16 MB |
video |
MP4, 3GP | 16 MB |
POST /messages/interactive
Reply buttons (max 3):
{
"to": "60123456789",
"interactive": {
"type": "button",
"body": { "text": "Choose an option:" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "confirm", "title": "Confirm Order" } },
{ "type": "reply", "reply": { "id": "cancel", "title": "Cancel" } }
]
}
}
}
List message (for menus, max 10 items per section):
{
"to": "60123456789",
"interactive": {
"type": "list",
"body": { "text": "Select a department:" },
"action": {
"button": "View Options",
"sections": [
{
"title": "Support",
"rows": [
{ "id": "billing", "title": "Billing", "description": "Payment & invoices" },
{ "id": "technical", "title": "Technical", "description": "Product issues" }
]
}
]
}
}
}
If your client has a WhatsApp Business catalogue connected, you can send product
messages through the same /messages/interactive endpoint. We pass the
interactive object to Meta unchanged, so every interactive type Meta supports
works here — we neither add nor filter anything.
catalog_id and product_retailer_id must come from you. We do not store client
catalogues, do not inject ids, and do not validate them; a wrong id comes back as
Meta's own error, unaltered.
Single product:
{
"to": "60123456789",
"interactive": {
"type": "product",
"body": { "text": "The one you asked about" },
"action": {
"catalog_id": "230574128435254",
"product_retailer_id": "SKU-001"
}
}
}
Multiple products (product_list) — sections of product_retailer_ids, and
catalogue (catalog_message) — opens the client's full catalogue: both follow
Meta's own request shape. Send it exactly as Meta documents it.
We do not manage catalogues. There is no
/catalogor/productsendpoint. Bridge sends messages that reference a catalogue and relays the orders that come back from it. To read products, stock or prices, use Meta's Catalog API directly with the client's token.
POST /messages/location
{
"to": "60123456789",
"latitude": 3.1390,
"longitude": 101.6869,
"name": "Kuala Lumpur City Centre",
"address": "Jalan Ampang, KL"
}
POST /messages/reaction
{
"to": "60123456789",
"message_id": "wamid.xxx",
"emoji": "👍"
}
POST /messages/read
{
"message_id": "wamid.xxx"
}
context)Any message can be sent as a reply to an earlier one, showing the quoted bubble
above it in WhatsApp. Add context to the body of any send endpoint:
{
"to": "60123456789",
"text": "Yes, that one is in stock.",
"context": { "message_id": "wamid.HBgMNjAxMTY0NjI1MTA3FQIAERgS..." }
}
Works on /messages/send, /messages/template, /messages/media,
/messages/interactive and /messages/location.
Where the wamid comes from — either source is valid:
| Source | Field |
|---|---|
| A message you sent | message_id in the send response |
| A message you received | data.message_id on the inbound webhook |
Three accepted shapes. Use whichever fits your code; they are equivalent:
{ "context": { "message_id": "wamid..." } }
{ "context_message_id": "wamid..." }
{ "reply_to": "wamid..." }
If both context and an alias are given, context wins.
⚠️ A
contextwe cannot parse returns400 INVALID_PAYLOAD— it is not sent untagged. Emptycontext: {}, a blankreply_to, or a non-stringmessage_idall fail loudly at the call site. Do not retry these; correct the body. Before 2.9.1 acontextwas discarded silently and the send returned200, which is exactly the failure this guard exists to prevent.
Meta ignores a
contextpointing at a message it cannot find — the message sends without a quote and no error is raised. If a tag does not appear, check the wamid is the exact string you were given, unmodified.
GET /templates
x-partner-key: wf_xxx
x-waba-id: 123456789
Response:
{
"success": true,
"templates": [
{
"id": "123456789012345",
"name": "order_confirmed",
"language": "en_US",
"category": "UTILITY",
"status": "APPROVED",
"components": [
{ "type": "BODY", "text": "Hello {{1}}..." }
]
}
],
"total": 1
}
| Field | Type | Note |
|---|---|---|
id |
string | Meta template id (numeric string) |
name |
string | Template name (unique per WABA) |
language |
string | e.g. en_US, ms, id |
category |
string | MARKETING / UTILITY / AUTHENTICATION |
status |
string | APPROVED / PENDING / REJECTED / PAUSED / DISABLED / IN_APPEAL |
components |
array | Meta component objects (BODY, HEADER, FOOTER, BUTTONS) |
Quality + rejection reason: Not returned by this endpoint. For rejection reason, subscribe to webhook
template.status_updated(carriesreason). For quality changes, subscribe totemplate.quality_updated(carriesprevious_quality+new_quality).
Wrapping convention: All list endpoints wrap items under a key named after the resource (lowercase plural) — never a bare array.
/clients→clients[],/templates→templates[],/broadcasts→broadcasts[],/events→events[].
POST /templates
x-partner-key: wf_xxx
x-waba-id: 123456789
Positional variables {{1}}, {{2}} (default):
{
"name": "order_confirmed",
"language": "en_US",
"category": "UTILITY",
"parameter_format": "POSITIONAL",
"components": [
{
"type": "BODY",
"text": "Hello {{1}}, your order {{2}} has been confirmed.",
"example": {
"body_text": [["Ali", "ORD123"]]
}
}
]
}
Named variables {{customer_name}}:
{
"name": "welcome_named",
"language": "en_US",
"category": "UTILITY",
"parameter_format": "NAMED",
"components": [
{
"type": "BODY",
"text": "Hello {{customer_name}}, order {{order_id}} ready.",
"example": {
"body_text_named_params": [
{ "param_name": "customer_name", "example": "Ali" },
{ "param_name": "order_id", "example": "ORD123" }
]
}
}
]
}
Document (PDF) header — requires header_handle from /templates/upload-header:
{
"name": "repair_ticket",
"language": "en_US",
"category": "UTILITY",
"components": [
{
"type": "HEADER",
"format": "DOCUMENT",
"example": { "header_handle": ["4::aW1hZ2..."] }
},
{
"type": "BODY",
"text": "Your repair ticket {{1}} is ready.",
"example": { "body_text": [["TKT123"]] }
}
]
}
Categories:
MARKETING,UTILITY,AUTHENTICATIONparameter_formatis optional (defaultPOSITIONAL). Set toNAMEDfor{{name}}style — must be at TOP level, not inside the component. Variable templates must includeexamplevalues — Meta rejects approval otherwise. Template approval may take up to 24 hours. Status changes will appear in Meta Business Manager.
POST /templates/upload-header
x-partner-key: wf_xxx
x-waba-id: 123456789
Content-Type: application/json
{
"url": "https://your-cdn.com/sample.pdf",
"mime_type": "application/pdf"
}
Response:
{
"success": true,
"header_handle": "4::aW1hZ2UvanBlZw==:..."
}
Why a separate endpoint? Meta requires a sample file when creating a template with a media header (IMAGE / VIDEO / DOCUMENT). That sample is uploaded via Meta's Resumable Upload API against the platform
app_id, returning aheader_handlestring — different from the numericmedia_idreturned by/media/upload.
Field Endpoint When Bound to header_handle(string)POST /templates/upload-headerTemplate approval (one-time) app_idmedia_id(numeric)POST /media/uploadSending approved template (every send) phone_number_id, expires 30 daysUse the returned
header_handleincomponents[].example.header_handle[]when callingPOST /templates.Limits: image 5 MB, video 16 MB, document 100 MB. Source
urlmust be HTTPS and publicly reachable.
DELETE /templates/:name
x-partner-key: wf_xxx
x-waba-id: 123456789
GET /profile
x-partner-key: wf_xxx
x-waba-id: 123456789
PUT /profile
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"about": "We sell quality products",
"address": "Kuala Lumpur, Malaysia",
"email": "[email protected]",
"websites": ["https://mybusiness.com"]
}
GET /contacts/:phone
x-partner-key: wf_xxx
x-waba-id: 123456789
Response:
{ "phone": "60123456789", "exists": true, "waId": "60123456789" }
GET /contacts/media/:mediaId
x-partner-key: wf_xxx
x-waba-id: 123456789
Returns the media file as binary stream. Use the media_id from the message.received webhook payload.
Note: Media IDs from incoming messages expire after 30 days. Download and store them on your own server if needed long-term.
Two media paths, and they behave in opposite ways. The names look alike, so it is worth reading once:
What happens POST /messages/mediawithmedia.linkPassthrough. The URL goes to Meta untouched and Meta fetches it. Every size, format and host rule is Meta's. POST /media/uploadWe fetch it. Bridge downloads the file and uploads it to Meta, returning a media_idyou can reuse.Use
/media/uploadfor anything sent repeatedly — a catalogue photo, a logo — so the file is not re-fetched on every send.Rate limits (2.7.0): both media endpoints now count against your account's
rate_limit_per_seclike every other endpoint. This closed a gap rather than tightening anything: they previously bypassed the limiter entirely. The limit is the same one already applied elsewhere (200/s by default) and normal use will not reach it. Downloads are also logged now — they were invisible before, so we could not see the traffic at all.
This endpoint is URL-based, not a multipart file receiver. Host the file at a publicly reachable HTTPS URL — Bridge downloads it and uploads it to Meta on your WABA's behalf.
POST /media/upload
x-partner-key: wf_xxx
x-waba-id: 123456789
Content-Type: application/json
{
"url": "https://your-server.com/files/receipt.png",
"mime_type": "image/png"
}
| Field | Required | Description |
|---|---|---|
url |
✅ | Public HTTPS URL of the file. Bridge fetches it server-side (30s timeout). |
mime_type |
✅ | Full MIME type, e.g. image/png, image/jpeg, application/pdf, video/mp4. |
Response:
{ "success": true, "media_id": "1234567890", "waba_id": "123456789" }
Use the returned media_id when sending: pass it to POST /messages/media as media.id.
Limits: image 5 MB · audio/video 16 MB · document 100 MB.
Media IDs are bound to the WABA's phone number and expire after ~30 days on Meta's side — re-upload if stale.
⚠️ Sending
multipart/form-datato this endpoint returns400 UNSUPPORTED_CONTENT_TYPE. (Historical note: docs previously showed a multipart request shape — that was never how this endpoint worked.)
GET /analytics?days=7
x-partner-key: wf_xxx
x-waba-id: 123456789
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
days |
integer | 7 |
90 |
Number of past days to retrieve |
Response:
{
"success": true,
"waba_id": "123456789",
"period_days": 7,
"meta_analytics": {
"sent": 1200,
"delivered": 1180,
"read": 950,
"data_points": [
{ "start": 1735689600, "end": 1735775999, "sent": 40, "delivered": 39, "read": 31 }
]
},
"daily_logs": [
{ "date": "2026-05-14", "sent": 40, "failed": 1 }
]
}
Send a single template message to a large list of recipients.
POST /broadcasts
x-partner-key: wf_xxx
x-waba-id: 123456789
Content-Type: application/json
{
"name": "Raya Promo 2026",
"template_name": "raya_promo",
"template_language": "ms",
"template_components": [
{
"type": "body",
"parameters": [{ "type": "text", "text": "Ahmad" }]
}
],
"contacts": ["60123456789", "60198765432"],
"scheduled_at": null
}
scheduled_at— ISO 8601 datetime string to schedule, ornullto start immediately.
Maximum 10,000 contacts per broadcast.
Response:
{
"success": true,
"broadcast_id": 42,
"status": "pending",
"total": 2
}
GET /broadcasts?limit=20&offset=0
x-partner-key: wf_xxx
x-waba-id: 123456789
| Parameter | Default | Max |
|---|---|---|
limit |
20 |
100 |
offset |
0 |
— |
Response:
{
"success": true,
"broadcasts": [
{
"id": 42,
"waba_id": "123456789",
"name": "Raya Promo 2026",
"template_name": "raya_promo",
"template_language": "ms",
"total": 500,
"sent": 498,
"delivered": 480,
"failed": 2,
"status": "completed",
"scheduled_at": null,
"started_at": "2026-05-14T10:00:00.000Z",
"completed_at": "2026-05-14T10:05:00.000Z",
"created_at": "2026-05-14T09:58:00.000Z"
}
]
}
Broadcast status values:
| Status | Meaning |
|---|---|
pending |
Queued, not started yet |
scheduled |
Waiting for scheduled_at datetime |
processing |
Currently sending |
completed |
All messages attempted |
cancelled |
Cancelled before completion |
GET /broadcasts/:broadcastId
x-partner-key: wf_xxx
x-waba-id: 123456789
Response:
{
"success": true,
"broadcast": {
"id": 42,
"name": "Raya Promo 2026",
"total": 500,
"sent": 498,
"delivered": 480,
"failed": 2,
"status": "completed",
"error_log": null,
"started_at": "2026-05-14T10:00:00.000Z",
"completed_at": "2026-05-14T10:05:00.000Z"
},
"progress": 100
}
POST /broadcasts/:broadcastId/cancel
x-partner-key: wf_xxx
x-waba-id: 123456789
Response:
{ "success": true }
Can only cancel broadcasts with status
pendingorscheduled. Already-started broadcasts cannot be cancelled.
A QR code carries a pre-filled message. A customer scans it and their WhatsApp opens on a new chat with that text already typed — they only have to hit send.
Updating the message does not change the image or the link. Posters, name cards and shopfront stickers you have already printed keep working. That is why creating and updating are separate calls here.
GET /qr-codes
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"success": true,
"qr_codes": [
{
"code": "3JJPQ7BQO4RJB1",
"prefilled_message": "Hi, I want to order",
"deep_link_url": "https://wa.me/message/3JJPQ7BQO4RJB1",
"qr_image_url": "https://scontent.xx.fbcdn.net/..."
}
]
}
POST /qr-codes
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"prefilled_message": "Hi, I want to order",
"image_format": "PNG"
}
| Field | Required | Notes |
|---|---|---|
prefilled_message |
Yes | The text that appears already typed in the customer's chat |
image_format |
No | PNG (default) or SVG |
The response contains code, deep_link_url and qr_image_url. Save the code — it is the only way to update or delete this QR code later.
qr_image_urlis a Meta CDN link and it expires. Download the image and host it yourself if you are printing it.
PUT /qr-codes/3JJPQ7BQO4RJB1
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "prefilled_message": "Hi, I want the Raya promo" }
The image and the short link stay exactly the same.
DELETE /qr-codes/3JJPQ7BQO4RJB1
x-partner-key: wf_xxx
x-waba-id: 123456789
Ice breakers and commands are the tappable shortcuts a customer sees in the chat. Until now these could only be set by hand in WhatsApp Manager, one number at a time.
prompts) — up to 4 tappable questions shown on the first chat only. Good for "Check my order", "Opening hours"./ menu available at any time in the thread.messages webhook when a customer opens the chat for the first time, so you can greet them.GET /conversational-automation
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"success": true,
"enable_welcome_message": true,
"commands": [
{ "command_name": "tickets", "command_description": "Book flight tickets" }
],
"prompts": ["Book a flight", "Plan a trip"]
}
POST /conversational-automation
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"enable_welcome_message": true,
"prompts": ["Book a flight", "Plan a trip"],
"commands": [
{ "command_name": "tickets", "command_description": "Book flight tickets" }
]
}
Send only the keys you want to change. Omitted keys are left untouched.
An ice breaker is dismissed automatically if the customer arrives through a
wa.melink that already carries pre-filled text — including one of your own QR codes. The two features do not stack.
Block spam and abuse without touching your own send logic. A blocked user's messages stop reaching your webhook, and your attempts to message them return an error.
Meta's limits — these are the ones partners hit:
GET /blocked?limit=50
x-partner-key: wf_xxx
x-waba-id: 123456789
Supports limit, after and before for cursor pagination; the cursors come back in paging.cursors.
POST /blocked
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "users": ["+60123456789", "+60198887777"] }
{
"success": true,
"blocked": [{ "input": "+60123456789", "wa_id": "60123456789" }],
"failed": [
{
"input": "+60198887777",
"wa_id": "60198887777",
"errors": [{ "code": 131047, "message": "Re-engagement required",
"error_data": { "details": "User has not messaged in the last 24 hours" } }]
}
]
}
⚠️
success: truedoes not mean every number was blocked. Meta reports failures per number. Always read thefailedarray — a partially successful request still returns 200.
DELETE /blocked
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "users": ["+60123456789"] }
Returns unblocked and failed in the same shape.
Controls whether your catalog and cart are visible to customers on this number.
GET /commerce-settings
x-partner-key: wf_xxx
x-waba-id: 123456789
POST /commerce-settings
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "is_catalog_visible": true, "is_cart_enabled": false }
Send either key or both. Useful when you sell through a catalog but take payment on your own site — show the catalog, hide the cart.
The settings node holds two unrelated things: call settings and No-Storage mode. Bridge returns the whole node rather than a hand-picked list of fields, because Meta adds keys here without notice and a whitelist would hide new ones from you.
GET /settings
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"success": true,
"settings": {
"calling": { "status": "DISABLED", "call_icon_visibility": "DEFAULT" },
"storage_configuration": { "status": "disabled" }
}
}
POST /settings
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"calling": {
"status": "ENABLED",
"call_icon_visibility": "DEFAULT",
"callback_permission_status": "ENABLED"
}
}
Calling must be ENABLED here before any /calls request will work.
Answers the three questions partners used to email us about: is this number an Official Business Account, is two-step verification on, and has the display name passed review.
GET /phone-status
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"success": true,
"phone": {
"display_phone_number": "+60 12-345 6789",
"verified_name": "My Business",
"status": "CONNECTED",
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_1K",
"is_official_business_account": false,
"is_pin_enabled": true,
"name_status": "APPROVED",
"code_verification_status": "VERIFIED",
"platform_type": "CLOUD_API",
"is_on_biz_app": true
}
}
platform_typereadsCLOUD_APIfor Coexistence numbers too.is_on_biz_appis what tells you it is Coexistence — notplatform_type.
GET /waba
x-partner-key: wf_xxx
x-waba-id: 123456789
Returns name, currency, timezone_id, account_review_status, business_verification_status, owner_business_info, message_template_namespace, health_status, ownership_type and is_enabled_for_insights.
health_status is the useful one when sends start failing — it reports can_send_message and names the entity that is blocking.
primary_funding_idis deliberately absent. It needs a billing permission the onboarding token never holds, and Meta rejects the entire request with code 10 when one requested field is not permitted — one unreachable field would wipe out the other eleven.
GET /waba/activities?limit=50&since=1750000000&until=1755000000
x-partner-key: wf_xxx
x-waba-id: 123456789
Who changed what on this WABA and when. This is what to reach for when a client asks why a template disappeared or why their messaging limit changed. limit is capped at 200.
GET /waba/assigned-users
x-partner-key: wf_xxx
x-waba-id: 123456789
Which Business Manager users have access to this WABA, and their tasks. Meta requires a business id for this call — Bridge fills in the one recorded at registration, so you do not need to know it. Override with ?business_id= if you need a different one.
GET /waba/solutions
x-partner-key: wf_xxx
x-waba-id: 123456789
GET /schedules
x-partner-key: wf_xxx
x-waba-id: 123456789
Scheduled changes queued against this WABA — for example a pending messaging-limit change.
List and inspect the WhatsApp Flows on this WABA.
GET /flows
x-partner-key: wf_xxx
x-waba-id: 123456789
GET /flows/1234567890
x-partner-key: wf_xxx
x-waba-id: 123456789
Returns id, name, status, categories, validation_errors, json_version and health_status.
Read-only on purpose. Creating a Flow needs an endpoint you host plus an encryption key pair registered with Meta — that is a separate integration, not a proxy call. Exposing half of it would leave you thinking Flows were fully supported when they are not. Talk to us if you need the write path.
Everything in this section changes the number itself, not just its metadata. A deregistered number stops receiving messages immediately, and repeatedly requesting verification codes gets the number blocked from requesting more.
Actions that cannot be undone require "confirm": true in the body, and return 400 CONFIRMATION_REQUIRED without it. That flag exists because these calls end up inside your scripts, where one mis-targeted loop could disconnect every number you manage.
POST /phone/request-code
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "code_method": "SMS", "language": "en" }
code_method is SMS or VOICE.
POST /phone/verify-code
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "code": "123456" }
POST /phone/two-step
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "pin": "123456" }
Exactly 6 digits.
This changed in 2.9.0 and it matters. Bridge needs this PIN to re-register a number after a migration or a recovery. Previously we only held one platform PIN, so a partner who changed the PIN in WhatsApp Manager broke our re-registration silently. Setting it through this endpoint stores your PIN encrypted against the client, and
/phone/registerreads it before falling back to the platform PIN. If you have ever changed a PIN outside Bridge, set it here once.
POST /phone/register
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "pin": "123456" }
pin is optional — Bridge uses your stored PIN, then the platform PIN, in that order.
Bringing a second number online. A WABA can hold several numbers, but Bridge stores one client row per WABA, so only one of them is the client. Every provisioning endpoint below —
/phone-status,/phone/request-code,/phone/verify-code,/phone/two-step,/phone/register,/phone/deregister— accepts an optionalphone_number_idto target another number under the same WABA. Omit it and you get the client's own number, exactly as before.{ "phone_number_id": "1377823848737261", "pin": "123456" }The number must belong to your WABA; Bridge checks with Meta and returns
does not belong to WABAotherwise. UseGET /clients(phone_number) and your WhatsApp Manager to find the id.Embedded Signup will not get a new number registered — Meta's dialog only lists numbers already live on Cloud API, so it returns the one that already works. A number sitting at
status: PENDINGwithplatform_type: NOT_APPLICABLEhas never been registered; the sequence is/phone/two-step(set the PIN) then/phone/register, both withphone_number_id.
/phone/two-stepstores the PIN against the client row only when the target is the client's own number, so a sibling's PIN cannot overwrite the one re-registration depends on. Keep a sibling's PIN yourself.
POST /phone/deregister
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "confirm": true }
⚠️ The number stops receiving messages the moment this succeeds. Re-registering needs the PIN and, depending on how the number was onboarded, a fresh verification code.
WhatsApp Business Calling. Bridge passes your request body to Meta unchanged apart from messaging_product — every call action carries an SDP offer or answer produced by your WebRTC stack, and Bridge cannot validate or reshape SDP without being a media server, which it is not.
Before this works:
POST /settings with calling.status: "ENABLED".Without both, Meta rejects the request with its own error, which we pass through untouched.
POST /calls
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"action": "connect",
"to": "+60123456789",
"session": { "sdp_type": "offer", "sdp": "<RFC 4566 SDP>" }
}
action must be one of connect, pre_accept, accept, reject, terminate. Terminating takes call_id instead of to.
Counts against your rate_limit_per_sec.
⚠️ Groups must be enabled on your number by Meta. If it is not, Meta returns
131000 "Something went wrong"— an unhelpful message that says nothing about the real cause. We verified this against a live WABA while building these endpoints. That is not a Bridge bug, and Bridge passes Meta's status through unchanged. Contact Meta to have Groups enabled.
You cannot add participants directly — that is Meta's design, not our limitation. You create a group, get an invite link, send the link, and people join themselves. You can remove participants.
GET /groups
x-partner-key: wf_xxx
x-waba-id: 123456789
POST /groups
x-partner-key: wf_xxx
x-waba-id: 123456789
{
"subject": "New Purchase Inquiry",
"description": "Options for current year models",
"join_approval_mode": "auto_approve"
}
subject max 128 characters. join_approval_mode is auto_approve (default) or approval_required. The invite link arrives on the group_lifecycle_update webhook.
GET /groups/{group_id}
x-partner-key: wf_xxx
x-waba-id: 123456789
Returns subject, description, participants, join_approval_mode.
DELETE /groups/{group_id}/participants
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "participants": ["+60123456789"] }
Maximum 8 per request. A removed participant can no longer rejoin through the invite link.
DELETE /groups/{group_id}
x-partner-key: wf_xxx
x-waba-id: 123456789
{ "confirm": true }
| Base URL | Webhook URL | |
|---|---|---|
| Whose server | WasapFlow | Your server |
| Direction | Your system → WasapFlow | WasapFlow → Your system |
| Purpose | Send messages, manage WABAs | Receive incoming messages & delivery status |
Base URL is fixed: https://officialapi.wasapflow.com/bridge/v1
Webhook URL is your own endpoint — build it, then set it in Settings → Webhook URL.
If you do not set a Webhook URL, you can still send messages but you will not receive incoming messages or delivery receipts.
app.post('/webhook/wasapflow', express.json(), (req, res) => {
res.status(200).send('OK'); // always respond immediately
const { event, waba_id, data } = req.body;
// handle events below...
});
Go to Settings → Webhook URL → enter your full public URL → Save.
Your Webhook Secret is in Settings → Webhook Security — use it to verify requests.
Every webhook request includes an x-wasapflow-signature header. Always verify before processing:
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-wasapflow-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WF_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (sig !== expected) return res.status(401).send('Unauthorized');
res.status(200).send('OK'); // respond immediately — then process
const { event, waba_id, phone_number_id, timestamp, data } = JSON.parse(req.body);
// handle event...
});
Always respond
200 OKfirst, then process asynchronously. Your endpoint must respond within 10 seconds or the attempt is counted as failed and will be retried.
Every webhook POST has this top-level structure:
{
"event": "message.received",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": { ... },
"meta": {
"api_version": "2.2.0",
"changelog": "https://partner.wasapflow.com/bridge/docs?tab=changelog",
"notices": [
{
"id": "meta-service-message-billing-2026-10-01",
"severity": "action_required",
"effective": "2026-10-01"
}
]
}
}
The meta object (added in 2.2.0) tells you the current API version and any Meta platform change coming your way. notices is an empty array when there is nothing pending. Notice IDs are stable forever, so you can suppress ones you have already handled.
The
metaobject is inside the signed body. If you verify signatures, no change is needed — the signature is computed over the complete body as sent.
Additional headers sent with every webhook:
| Header | Value |
|---|---|
x-wasapflow-signature |
sha256=<hmac> |
x-wasapflow-event |
Event name, e.g. message.received |
x-wasapflow-waba-id |
WABA ID |
message.received — Inbound message from end user{
"event": "message.received",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"from": "60123456789",
"bsuid": "MY.2035200694071263",
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA",
"type": "text",
"text": "Hello, I need help!",
"contact_name": "Ahmad",
"timestamp": 1715695200,
"raw": { ... }
}
}
from— Phone number in international format without+, e.g.60123456789. Passed through from Metamessage.from.bsuid— 🆔 Business-Scoped User ID (added by Meta April 2026). A separate field fromfrom— always sent alongside, not as a replacement. Format observed:CC.<numeric_id>(e.g.MY.2035200694071263,SG.1234567890123). Unique per business-user pair — same user gets a different BSUID across different businesses. May benullfor legacy webhooks or users not yet on a BSUID-aware client. Bridge does not validate the format — passed through verbatim from Metacontacts[0].user_id.type— Message type:text,image,document,audio,video,location,button,interactive,sticker,reaction,unknowntext— Populated for text messages only. For media messages, usedata.raw.messageto get the full media object includingidfor downloading.
raw.message carries everything Meta sentThe flat fields above are a convenience layer. data.raw holds Meta's own
objects, unmodified — we never strip anything out of it:
"raw": {
"message": { … }, // Meta's message object, exactly as received
"contacts": [ … ],
"metadata": { … }
}
So anything Meta emits reaches you, including message types that have no flat field of their own:
| What you need | Where it is |
|---|---|
Cart / order — catalog_id, product_items[] (product_retailer_id, quantity, item_price) |
raw.message.order |
Product enquiry — interactive carrying catalog_id + product_retailer_id |
raw.message.interactive |
| Reply context — the message id the customer replied to | raw.message.context |
Media object — id, mime_type, sha256 |
raw.message.<type> |
| Button / list reply ids | raw.message.interactive or raw.message.button |
Trap: for
orderandinteractivemessages the flattextisnull. Switch on the flattype, then readraw.message. Code that only readstextwill treat a cart as an empty message.
🆔 BSUID & WhatsApp Usernames (June 2026+) — detection guidance:
data.from and data.bsuid are two separate fields in the same payload. Bridge does not replace from with a BSUID — you receive both. Detect BSUID presence with a null-check, not by regex-matching from:
// ✅ Correct — presence check on the dedicated field
const hasBsuid = payload.data.bsuid != null;
// ❌ Wrong — don't regex-match data.from; it's always the phone
if (/^[A-Z]{2}\.\d+$/.test(payload.data.from)) { ... }
Recommendation: Persist BOTH from (phone) AND bsuid against each contact. Use bsuid as your stable customer key — phone may change/disappear when the user adopts a WhatsApp username; BSUID won't. The same BSUID also appears as recipient_bsuid on message.sent/delivered/read/failed/echo, and as contact_bsuid on message.history.
Meta states that supporting BSUID is required for all partners. The part that catches most integrations is not parsing it — it is what happens downstream.
Once a customer adopts a username, Meta may omit the phone number from the webhook entirely: wa_id, from and recipient_id are "omitted entirely" when the user has a username and there has been no interaction in the last 30 days. You get a BSUID and nothing else.
A BSUID sends WhatsApp messages fine. A courier cannot call it.
Check your phone normaliser today. Nearly every integration has one shaped like this:
let n = phone.replace(/\D/g, '');
if (n.startsWith('0')) n = '6' + n;
if (!n.startsWith('60')) n = '60' + n;
Give it MY.2035200694071263 and it returns 602035200694071263 — a value that looks like a Malaysian number, passes naive validation, and reaches your courier. No exception, no log line. If your normaliser strips non-digits before validating, you have this bug right now.
Guard it explicitly, and return null rather than trying to clean the value:
// 2-letter country code, dot, alphanumerics.
// Parent BSUIDs insert ENT: US.ENT.11815799212886844830
const BSUID = /^(?:whatsapp:)?[A-Z]{2}\.(?:ENT\.)?[A-Za-z0-9]{1,128}$/;
function toPhone(value) {
if (!value) return null;
if (BSUID.test(String(value).trim())) return null; // never normalise a BSUID
if (/[A-Za-z]/.test(value)) return null; // letters mean it is not a phone
// ...your existing normalisation...
}
Split the field. Most schemas keep one phone on the contact and use it for everything. It now has to be two:
| Role | Contents | Used for |
|---|---|---|
| Canonical id | Phone or BSUID | Sending WhatsApp, matching a contact back |
| Reachable phone | Always a real phone, may be empty | Courier, invoices, voice calls |
Ask for the phone in your order flow. If your flow collects name, address and postcode but never a phone — because the WhatsApp number was always assumed to be the phone — it now produces orders that cannot be delivered. When you already hold a plausible number, ask the customer to confirm it rather than retype it; that keeps the friction to a one-word answer. This is worth doing before usernames even arrive, since customers regularly order from a work WhatsApp or on behalf of someone else.
Pushing to WooCommerce or an ERP: send the real phone as billing.phone, and keep the canonical id in a separate meta field for matching webhooks back. Leave billing.phone empty rather than filling it with an id — an empty field makes a human ask; a fake number sends a courier to a dead end.
Full guidance, including what Meta requires that Bridge does not yet expose: Changelog 2.3.0.
Handling different message types:
const { event, data } = JSON.parse(req.body);
if (event === 'message.received') {
switch (data.type) {
case 'text':
console.log(`Text from ${data.from}: ${data.text}`);
break;
case 'image':
case 'document':
case 'audio':
case 'video':
const mediaId = data.raw.message[data.type]?.id;
// Download: GET /contacts/media/:mediaId
console.log(`Media (${data.type}) ID: ${mediaId}`);
break;
case 'button':
// Quick reply button tapped
const buttonId = data.raw.message.button?.payload;
console.log(`Button tapped: ${buttonId}`);
break;
case 'interactive':
// List reply selected
const listId = data.raw.message.interactive?.list_reply?.id;
console.log(`List option selected: ${listId}`);
break;
}
}
message.sent — Message accepted by Meta{
"event": "message.sent",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA",
"status": "sent",
"recipient": "60123456789",
"recipient_bsuid": "MY.2035200694071263",
"timestamp": 1715695200,
"errors": null
}
}
message.delivered — Message delivered to phone{
"event": "message.delivered",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695260,
"data": {
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA",
"status": "delivered",
"recipient": "60123456789",
"recipient_bsuid": "MY.2035200694071263",
"timestamp": 1715695260,
"errors": null,
"pricing": {
"billable": false,
"pricing_model": "PMP",
"type": "free_customer_service",
"category": "service"
},
"conversation": {
"id": "b0d4a1f2c3e4",
"origin": "service"
}
}
}
pricing and conversation (added in 2.2.0)Meta charges per delivered message under per-message pricing (PMP). These two objects pass through exactly what Meta reports, so you can attribute cost per message without a separate analytics call.
| Field | Values | Meaning |
|---|---|---|
pricing.billable |
true / false |
Whether Meta charges for this message |
pricing.pricing_model |
PMP |
Per-message pricing |
pricing.type |
regular, free_customer_service, free_group_customer_service, free_entry_point |
See below |
pricing.category |
service, utility, marketing, authentication, referral_conversion |
Which rate applies |
conversation.id |
string | Meta's conversation ID |
conversation.origin |
same values as category |
What opened the conversation |
Both are null when Meta does not supply them — commonly on read receipts. Treat them as optional and never assume they are present.
Why pricing.type matters right now:
regular — billable today.free_customer_service — a service message inside Meta's free tier of 1,000 delivered service messages per business phone number per month, or (until 30 September 2026) a utility template sent inside an open customer service window. Not billed.regular with category: service — a service message after the phone number's free tier is used that month. Billed at the market's service rate (equal to utility/authentication). From 1 October 2026.regular with category: utility — from 1 October 2026 this includes utility templates sent inside an open window, which were free_customer_service before. Billed.free_group_customer_service / category: group_service — group service messages inside the same free tier (one unit per delivered recipient).Meta decides which side of the free tier a message lands on and tells you in this object. Count from it — do not infer billing from message text or your own window logic. Only delivered statuses consume the tier.
Payment method. Meta: "If you do not have a payment method for your WhatsApp Business account, Meta will deliver service messages within the shared free tier but not deliver them after the free tier has been used." A number with no payment method stops delivering replies at message 1,001, silently. Check Billing Hub for every client before 1 October.
free_entry_point — inside the 72-hour free entry point window. Stays free.To project your 1 October exposure, use GET /usage/whatsapp?month=YYYY-MM (below) or count delivered messages with category: service over a full month; everything past 1,000 per phone number is billable at the market's service rate. Rates effective 1 October 2026 are published on Meta's pricing page (Malaysia: USD 0.014 per service message).
GET /usage/whatsapp?month=2026-10
x-partner-key: wf_xxx
Optional filters: waba_id, phone_number_id. month defaults to the current month (Asia/Kuala_Lumpur; Meta resets at 12am WABA time). Counts include statuses delivered and read (a read message was delivered).
Add include=meta to also return Meta's own delivered counts for the month, per business phone number and pricing_type, from pricing_analytics — one Graph call per WABA. Use it to reconcile: if Bridge's phones[].service_free is far below meta.wabas[].phones[].service_free, webhooks were missed on the way to Bridge, not by you.
"meta": { "month": "2026-10", "source": "meta pricing_analytics VOLUME (delivered)", "cost_available": false,
"wabas": [ { "waba_id": "895343399927202", "ok": true,
"phones": [ { "display_phone_number": "601171306595", "service_free": 2293, "service_paid": 0, "utility": 909, "authentication": 95, "fep": 0,
"free_tier_remaining": 0, "estimated_cost_usd_from_meta_volume": 6.90 } ] } ] }
Meta does not return COST for these WABAs. Every WABA onboarded through a partner app gets "COST is not shown for businesses who bill through a partner (i.e. BSP)" from Meta, even though the merchant pays Meta directly. The merchant's actual invoice is only visible in their own Billing Hub. Everything Bridge shows as cost is an estimate from counts × Meta's published rate card, and is labelled as such.
{ "success": true, "month": "2026-10", "free_tier_per_phone": 1000, "rate_card_effective": "2026-10-01",
"phones": [ { "phone_number_id": "1352430714615301", "waba_id": "895343399927202",
"service_sent": 3450, "service_free": 1000, "service_paid": 2450,
"utility": 120, "marketing": 40, "authentication": 12, "fep": 300,
"estimated_cost_usd": 36.16, "actual_meta_cost_usd": null,
"free_tier_remaining": 0,
"warnings": [ "Meta is billing service messages on this number: the free tier for this month is used up.",
"WhatsApp billing method required. ..." ] } ],
"totals": { "service_free": 1000, "service_paid": 2450, "estimated_cost_usd": 36.16 } }
service_free and service_paid are what Meta marked (free_customer_service vs regular) — Bridge does not compute the tier itself. estimated_cost_usd is billable rows × Meta's 1 Oct 2026 USD rate card by recipient market; actual_meta_cost_usd is never estimated and stays null until a Meta invoice or analytics figure is imported for that number and month.
message.read — Message opened by recipient{
"event": "message.read",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695320,
"data": {
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA",
"status": "read",
"recipient": "60123456789",
"recipient_bsuid": "MY.2035200694071263",
"timestamp": 1715695320,
"errors": null
}
}
message.failed — Delivery failed{
"event": "message.failed",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgASGBQzQUVEMUFBQkVBRUQ5NTJERTA",
"status": "failed",
"recipient": "60123456789",
"recipient_bsuid": "MY.2035200694071263",
"timestamp": 1715695200,
"errors": [
{
"code": 131047,
"title": "Re-engagement message",
"message": "More than 24 hours have passed since the customer last replied"
}
]
}
}
Common Meta error codes:
131047— 24-hour window expired (use a template message instead)131026— Recipient is not a WhatsApp user132001— Template not found or not approved131000— Generic message send failure
message.echo — Outbound message sent from the WhatsApp Business App (Coexistence) 🆕Coexistence only. Fired when your client (or their staff) types a reply manually from the WhatsApp Business App on their phone — not through the API. Lets your inbox stay in sync with messages sent outside your platform. Only delivered for WABAs registered with
connection_mode: "coexistence".
{
"event": "message.echo",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"direction": "outbound",
"source": "business_app",
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgARGBI5QUVE...",
"from": "60111111111",
"recipient": "60123456789",
"recipient_bsuid": null,
"type": "text",
"text": "Okay boss, sudah siap ✅",
"timestamp": 1715695200,
"raw": { ... }
}
}
direction— Always"outbound"(business → customer).source— Always"business_app". Use this to distinguish manual Business-App replies from API-sent messages (which arrive asmessage.sent).from— The business's own number.recipient— The customer.text— Populated for text echoes. For media, readdata.raw.echofor the media object +id.Tip: Store these as outbound messages in your inbox so the conversation mirrors WhatsApp exactly, regardless of whether the reply came from your platform or the Business App.
message.history — Historical message synced after onboarding (Coexistence) 🆕Coexistence only, one-time. When a client onboards via the WhatsApp Business App, Meta replays their existing conversation history so you can backfill past chats. Delivered as a stream of individual messages (one webhook per message) shortly after registration. Not replayed for WABAs already onboarded before this feature.
{
"event": "message.history",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"history": true,
"direction": "outbound",
"status": "read",
"thread_id": "60123456789",
"contact_wa_id": "60123456789",
"contact_bsuid": "MY.2035200694071263",
"contact_username": "@alice",
"message_id": "wamid.HBgNNjAxMjM0NTY3ODkVAgARGBI...",
"from": "60111111111",
"type": "text",
"text": "Terima kasih, order saya dah sampai",
"timestamp": 1504902988,
"phase": 1,
"progress": 30,
"raw": { ... }
}
}
history— Alwaystrue. Use this flag to route historical messages into a backfill path (don't trigger auto-replies/notifications on them).direction—"outbound"if the business sent it (from_me), else"inbound".status— Original delivery status from history (sent,delivered,read).phase/progress— Meta's sync progress (progress0→100). Lets you show a "syncing history…" indicator.timestamp— Original message time (not sync time) — use it to order backfilled messages correctly.Note: Media in history may use
type: "media_placeholder", and very old media may no longer be downloadable from Meta.
waba.quality_updated — Phone number quality rating changed{
"event": "waba.quality_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"phone_number_id": "987654321",
"quality_rating": "YELLOW",
"previous_rating": "GREEN",
"raw": { ... }
}
}
Ratings:
GREEN(good),YELLOW(medium — watch your opt-out rate),RED(high opt-out — messaging may be restricted)
waba.tier_updated — Messaging limit tier changed{
"event": "waba.tier_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"tier": "TIER_2K",
"max_daily_per_phone": 2000,
"max_phone_numbers": 25,
"raw": { ... }
}
}
Tier values:
TIER_250,TIER_1K,TIER_2K,TIER_10K,TIER_100K,UNLIMITEDDelivery fix (21 Sep 2026). Meta sends template and account-level webhooks (
template.*,waba.account_updated,waba.review_updated,waba.tier_updated,waba.quality_updated,waba.alert,waba.pricing_tier_updated) to the app's main callback URL, never to a WABA's override URL. Before this date Bridge only listened for them on the override URL, so none of these events reached partners. They are delivered from 21 September 2026. Events from before that date were not replayed; useGET /templatesfor current template status.
waba.alert — Meta sent a business alert{
"event": "waba.alert",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"alert": {
"type": "BUSINESS_ACCOUNT_RESTRICTION",
"details": "Your account has been flagged due to policy violation."
},
"raw": { ... }
}
}
Alert types and content come directly from Meta. Check Meta's Business Manager for full details. Common alerts include policy violations, account restrictions, and phone number quality warnings.
template.status_updated — Template approved / rejected by Meta 🆕Fired when a template you created (
POST /templates) changes review status. Essential if you manage templates programmatically — don't send a template until its status isAPPROVED.
{
"event": "template.status_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"template_id": 12345678,
"template_name": "order_confirmation",
"language": "ms",
"status": "APPROVED",
"category": "MARKETING",
"reason": null,
"raw": { ... }
}
}
status—APPROVED,REJECTED,PENDING,PAUSED,DISABLED.reason— populated on rejection (e.g. policy violation).
template.quality_updated — Template quality score changed 🆕{
"event": "template.quality_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"template_id": 12345678,
"template_name": "order_confirmation",
"language": "ms",
"previous_quality": "GREEN",
"new_quality": "YELLOW",
"raw": { ... }
}
}
Quality:
GREEN(good),YELLOW(watch),RED(high block rate — template may be paused). Act before a template gets disabled.
template.category_updated — Meta re-categorised a template 🆕{
"event": "template.category_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"template_id": 12345678,
"template_name": "order_confirmation",
"language": "en_US",
"previous_category": "MARKETING",
"new_category": "UTILITY",
"correct_category": "MARKETING",
"appeal_status": "ELIGIBLE",
"raw": { ... }
}
}
Meta may re-categorise templates (affects pricing).
appeal_statustells you whether you can appeal the change.
waba.account_updated — WABA account status changed 🆕{
"event": "waba.account_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"phone_number": "60123456789",
"event": "VERIFIED_ACCOUNT",
"raw": { ... }
}
}
eventexamples:VERIFIED_ACCOUNT,DISABLED_UPDATE,ACCOUNT_RESTRICTION. Watch this to detect when a client's WABA is verified, disabled, or restricted.
waba.review_updated — Business verification review decision 🆕{
"event": "waba.review_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"decision": "APPROVED",
"raw": { ... }
}
}
decision—APPROVEDorREJECTED. Result of Meta's business verification review.
contact.synced — Contact added/edited in the WhatsApp Business App (Coexistence) 🆕Coexistence only. Fired when your client adds, edits, or removes a contact directly in the WhatsApp Business App. Keeps your contact list in sync with their phone. One event per contact.
{
"event": "contact.synced",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"action": "add",
"type": "contact",
"full_name": "Ahmad Razak",
"first_name": "Ahmad",
"phone_number": "60123456789",
"version": 1,
"timestamp": 1715695200,
"raw": { ... }
}
}
action—add,update, orremove. Upsert/delete the contact in your CRM byphone_number.
waba.pricing_tier_updated — Volume pricing tier reached 🆕{
"event": "waba.pricing_tier_updated",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"waba_id": "123456789",
"pricing_category": "UTILITY",
"tier": "25000001:50000000",
"region": "Malaysia",
"effective_month": "2026-09",
"tier_update_time": 1743451903,
"raw": { ... }
}
}
Not the same as
waba.tier_updated. That one is the messaging tier — how many unique recipients per 24 hours. This one is about price: reaching a new volume tier unlocks a lower rate for utility and authentication messages in that market.
tier—"<lower>:<upper>". Subtract your current volume from<upper>to see how many more messages reach the next tier.<upper>may be the stringMAX.pricing_category—UTILITYorAUTHENTICATIONonly. Service messages have no volume tiers, so this never fires for them.Meta may send more than one webhook for the same tier change — use the one with the smallest
tier_update_time.
waba.offboarded / waba.partner_removed / waba.reconnected — Meta connection state 🆕Fired when Meta changes WasapFlow's access to a client's number. The same state is on GET /clients as connection_state (connected, offboarded, partner_removed) with connection_state_at and connection_state_reason, and the dashboard shows it instead of ACTIVE.
| Webhook | Meta event | What it means | Sends fail with |
|---|---|---|---|
waba.offboarded |
ACCOUNT_OFFBOARDED |
Coexistence number: the WhatsApp Business app was reinstalled, re-registered or moved to another device. Meta normally reconnects it automatically within minutes. | 133010 |
waba.partner_removed |
PARTNER_REMOVED, PARTNER_APP_UNINSTALLED |
WasapFlow no longer has access to the WABA — the business disconnected in the app, opted out of reconnection, or Meta removed access. | 100 / subcode 33 |
waba.reconnected |
ACCOUNT_RECONNECTED, PARTNER_ADDED, PARTNER_APP_INSTALLED |
Access restored — Meta's automatic reconnection, or the business onboarded again. | — |
{
"event": "waba.partner_removed",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1789738513,
"data": {
"waba_id": "123456789",
"phone_number_id": "987654321",
"state": "partner_removed",
"previous_state": "offboarded",
"meta_event": "PARTNER_REMOVED",
"reason": null,
"initiated_by": null,
"occurred_at": "2026-09-18T13:35:13.000Z",
"action_required": "WasapFlow no longer has access to this WABA ...",
"raw": { ... }
}
}
waba.offboarded— pause sends for this number; they fail untilwaba.reconnected. If it is followed bywaba.partner_removedinstead, the reconnection did not happen.waba.partner_removed— only the business can fix this, by completing onboarding again and selecting the same WABA and phone number. Re-onboarding updates the existing client record (sameid), so do not delete the client first.reason/initiated_bycome from Meta'sdisconnection_infowhen Meta includes it (for examplePRIMARY_INACTIVITY/SYSTEMafter ~14 days of the phone being inactive). Each webhook is sent once per change of state; repeated Meta events do not produce repeated webhooks.
standby.* — Meta Business Agent is handling the conversation 🆕When a business enables Meta Business Agent on a phone number, Meta's AI agent and your app coexist on that number. Only one is the active handler at a time. While your app is the passive listener, Meta stops sending message.received and sends standby events instead.
| Event | Fired when |
|---|---|
standby.message_received |
A user sent a message while your app is not the active handler |
standby.message_echo |
Meta Business Agent sent a message on the client's behalf |
standby.message_status |
Delivery/read status for a Business Agent message (carries pricing) |
{
"event": "standby.message_received",
"waba_id": "123456789",
"phone_number_id": "987654321",
"timestamp": 1715695200,
"data": {
"standby": true,
"active_handler": "meta_business_agent",
"message_id": "wamid....",
"from": "60123456789",
"contact_name": "Aiman",
"contact_wa_id": "60123456789",
"type": "text",
"text": "Ada saiz S tak?",
"timestamp": 1715695200,
"raw": { ... }
}
}
⚠️ Sending a service message while in standby makes your app the active handler. Per Meta's documentation this is the intended way to escalate to a human agent — but it also means an automated reply will silently take control away from the agent. If you run a bot, gate it on
standby !== true:if (data.standby) return saveContextOnly(data); // observe, don't reply
standby.message_echo carries template and flow — the full unhydrated definitions of what the agent sent, when applicable. standby.message_status carries the same pricing and conversation objects as regular status events, so Business Agent traffic appears in your cost reporting alongside your own.
Prerequisite: the business must enable Meta Business Agent and grant standby permission. Until then these events never fire and nothing changes for you.
This is the single most confusing failure mode on the platform, and it is not a failure at all.
When Meta Business Agent takes over, your client sees their automation stop replying. Nothing is broken, nothing errors, and nothing appears in your logs — because the messages never reach you. Your client will open a support ticket saying "the bot is dead", and without a banner you will have no way to explain it.
You must surface this in your UI. Build a persistent warning banner — in the app header and in the inbox for the affected number — that appears when a standby.* event arrives and disappears when normal message.received events resume.
Use a warning style (yellow/amber), not an error style (red). Nothing has failed; your client simply needs to know who is answering and what their options are.
Recommended wording:
🤖 Meta Business Agent is answering your customers — not your AI. To take back control, turn it off in WhatsApp Manager → Account tools → Business Agent. Manual chat still works normally.
Implementation shape:
// On any standby.* event — raise the banner (idempotent, once per number)
if (event.startsWith('standby.')) {
await alerts.raise({
clientId,
phoneNumberId: payload.phone_number_id,
type: 'meta_agent_active',
severity: 'warning'
});
return saveContextOnly(data); // observe — never auto-reply
}
// On a normal inbound message — we are the active handler again, clear it
if (event === 'message.received') {
await alerts.clear({ clientId, type: 'meta_agent_active' });
}
Standby events can arrive many times a minute, so make the raise idempotent — create the alert once and update it, rather than emitting a notification per event.
WasapFlow's own product implements exactly this pattern; we are asking you to mirror it so your clients get the same clarity.
If your endpoint was down or returned errors, failed events are stored and can be retrieved or replayed.
GET /webhooks/failed?limit=50
x-partner-key: wf_xxx
Response:
{
"success": true,
"count": 2,
"events": [
{
"id": 1,
"waba_id": "123456789",
"event": "message.received",
"payload": { "event": "message.received", "data": { ... } },
"attempts": 3,
"last_error": "ECONNREFUSED",
"status": "failed",
"created_at": "2026-05-15T10:00:00.000Z"
}
]
}
POST /webhooks/retry/:eventId
x-partner-key: wf_xxx
Re-sends the stored event to your current webhook URL. On success, status changes to delivered.
POST /webhooks/retry-all
x-partner-key: wf_xxx
Replays up to 100 failed events in chronological order.
Response:
{ "success": true, "replayed": 8, "failed": 1, "total": 9 }
Retention: 30 days. Failed events are purged automatically 30 days after they were created, by a nightly job. Replay before then —
created_aton each event tells you its deadline.The one clock that does run is Meta's: any
media_idinside a stored payload (includingmessage.historymedia) expires on Meta's side about 30 days after it was issued, regardless of when you replay. Text survives; media does not. If a backlog contains media you need, replay and download it inside that window.
| Attempt | Delay after failure |
|---|---|
| 1st retry | 2 seconds |
| 2nd retry | 4 seconds |
| 3rd retry | 8 seconds |
After 3 failed attempts, the event is dropped and logged. Ensure your endpoint responds 200 OK within 10 seconds.
The access_token (EAAxxxxxxxx) stored per WABA is a permanent system user token from Meta. It does not expire on its own, but can be invalidated if:
Signs your token is invalid:
500 META_ERROR responses when sending messagesHow to fix:
POST /clients/:wabaId/refresh with { "access_token": "new_token" } — or re-call POST /clients/register (safe upsert)There is no automatic token expiry webhook from Meta. Build a periodic health check in your system (e.g. call /refresh weekly) to catch invalid tokens early.
| HTTP | Code | Meaning | Action |
|---|---|---|---|
400 |
INVALID_REQUEST |
Missing or invalid field | Check request body |
400 |
MISSING_FIELDS |
Required fields not provided | Check required params |
400 |
TEMPLATE_NOT_FOUND |
Template name/language wrong | Verify in Meta Business Manager |
400 |
PHONE_NOT_ON_WHATSAPP |
Recipient not on WhatsApp | Check with /contacts/:phone first |
400 |
CANNOT_CANCEL |
Broadcast already started | Cannot cancel in-progress broadcast |
400 |
TOO_MANY_CONTACTS |
Over 10,000 contacts | Split into multiple broadcasts |
401 |
INVALID_KEY |
Partner key invalid | Check x-partner-key header |
402 |
PAYMENT_REQUIRED |
Subscription unpaid | Pay invoice in Billing page |
403 |
SUBSCRIPTION_INACTIVE |
Subscription cancelled | Resubscribe in Billing page |
404 |
WABA_NOT_FOUND |
WABA not registered | Register WABA first |
404 |
WABA_NOT_REGISTERED |
WABA not found for this partner | Check x-waba-id header |
429 |
RATE_LIMITED |
Too many requests | Slow down — check Retry-After header |
500 |
META_ERROR |
Meta rejected request | Check meta_error_code in response |
500 |
SERVER_ERROR |
Internal error | Retry — contact support if persists |
PAYMENT_ACTION_REQUIRED (402)Your bank requires 3D Secure authentication before the charge can complete. The card was not declined — do not ask the client to replace it.
{ "success": false,
"error": {
"code": "PAYMENT_ACTION_REQUIRED",
"message": "Your bank requires 3D Secure authentication for this payment...",
"payment_url": "https://invoice.stripe.com/i/acct_.../live_..."
} }
Open payment_url — a Stripe-hosted page where the payment can be authenticated,
or a different card entered. The WABA activates automatically once the invoice is
paid; there is no need to reconnect.
Do not retry this call. Retrying creates nothing new and changes nothing — the invoice is already waiting.
There is no WasapFlow SDK, and you do not need one. Bridge is plain REST: two headers, JSON in, JSON out. Below is the same request in four languages — each is complete, with nothing to install beyond what the language already ships or what your project almost certainly already has.
The two headers, everywhere:
| Header | Value | When |
|---|---|---|
x-partner-key |
wf_your_key |
Every request |
x-waba-id |
The client's WABA id | Every request that acts on a specific client's number |
cURL
curl -X POST https://officialapi.wasapflow.com/bridge/v1/messages/send -H "x-partner-key: $WF_PARTNER_KEY" -H "x-waba-id: 123456789" -H "Content-Type: application/json" -d '{ "to": "60123456789", "text": "Hello!" }'
JavaScript / TypeScript — fetch is built in to Node 18+, Deno, Bun and browsers.
const res = await fetch('https://officialapi.wasapflow.com/bridge/v1/messages/send', {
method: 'POST',
headers: {
'x-partner-key': process.env.WF_PARTNER_KEY,
'x-waba-id': '123456789',
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '60123456789', text: 'Hello!' }),
});
const data = await res.json();
if (!data.success) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data.message_id);
Python — requests or httpx, either is fine.
import os, requests
res = requests.post(
'https://officialapi.wasapflow.com/bridge/v1/messages/send',
headers={
'x-partner-key': os.environ['WF_PARTNER_KEY'],
'x-waba-id': '123456789',
},
json={'to': '60123456789', 'text': 'Hello!'},
timeout=15,
)
data = res.json()
if not data.get('success'):
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data['message_id'])
PHP — Guzzle, which most PHP projects already have.
use GuzzleHttp\Client;
$http = new Client(['base_uri' => 'https://officialapi.wasapflow.com/bridge/v1/']);
$res = $http->post('messages/send', [
'headers' => [
'x-partner-key' => getenv('WF_PARTNER_KEY'),
'x-waba-id' => '123456789',
],
'json' => ['to' => '60123456789', 'text' => 'Hello!'],
'http_errors' => false,
]);
$data = json_decode((string) $res->getBody(), true);
if (empty($data['success'])) {
throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
}
echo $data['message_id'];
Every endpoint in this document differs only in path, method and body. One function covers all 68 — that is the entire abstraction worth having:
async function bridge(path, { method = 'GET', wabaId, body } = {}) {
const res = await fetch('https://officialapi.wasapflow.com/bridge/v1' + path, {
method,
headers: {
'x-partner-key': process.env.WF_PARTNER_KEY,
...(wabaId ? { 'x-waba-id': wabaId } : {}),
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!data.success) {
const e = new Error(`${data.error.code}: ${data.error.message}`);
e.code = data.error.code;
e.metaCode = data.error.meta_code; // branch on this, never on message text
e.status = res.status;
throw e;
}
return data;
}
Two things that wrapper should do, and most hand-rolled ones forget:
error.meta_code, never on error.message. Meta rewords messages
without warning. The code is stable.X-Bridge-Notice-Level tells you when a Meta change
needs your attention before it takes effect. See Change Notices.GET /health
No authentication required. Use for uptime monitoring.
Response:
{ "status": "ok", "service": "wasapflow-bridge", "timestamp": "2026-05-15T00:00:00.000Z" }
| Setting | Default |
|---|---|
| Requests/second | 200 |
| Response on limit | 429 Too Many Requests with Retry-After header |
Contact support to increase your limit.
| Item | Details |
|---|---|
| Hosting | DigitalOcean (Singapore region) |
| Token Encryption | AES-256-CBC, encrypted at rest with random IV |
| Webhook Signing | HMAC-SHA256 |
| API Version (Meta) | v24.0 (auto-updated by WasapFlow) |
| Failed Events Retention | 30 days |
Bridge calls Meta on Graph API v26.0, and all 32 webhook fields deliver v26.0 payloads. Both moved from v24.0 on 21 August 2026.
| Version | Controls | |
|---|---|---|
| Outbound calls | v26.0 | The requests Bridge makes to Meta on your behalf |
| Webhook fields | v26.0 | The shape of the payloads Meta sends Bridge |
This does not change your integration. Bridge normalises every Meta webhook
into its own event envelope (message.received, message.delivered, …) and its
own response shapes, so Meta's version has never been visible to you. It is
documented here because the version appears in error traces and support tickets.
Response shapes were compared across v24, v25 and v26 for every edge Bridge uses before the switch — all identical. v26's breaking changes are confined to Marketing API, Ads, Commerce Order Management and Rights Manager; none touch WhatsApp messaging.
Meta supports each Graph version for roughly two years. v26.0 was released 29 July 2026 and has no announced end date. We track this and move ahead of the deadline; you do not need to.
WasapFlow Bridge proxies the most commonly used Meta WhatsApp Cloud API endpoints.
Coexistence sync (Supported since 2.0.0): For WABAs onboarded via the WhatsApp Business App (connection_mode: "coexistence"), Bridge forwards messages your client sends manually from the Business App (message.echo) and replays their past conversation history once after onboarding (message.history). See Webhook Event Payloads.
As of 2.9.0 Bridge exposes 68 partner endpoints across 24 Meta surfaces. Every one was tested live against a real WABA before release, not copied from documentation.
Still not available through Bridge:
| Feature | Status | Why |
|---|---|---|
| Flows — create / update / publish | Not planned as a proxy | Needs an endpoint you host plus an encryption key pair. Talk to us. |
| Marketing Messages Lite (MM Lite) | Blocked | Requires Solution Partner tier, which WasapFlow does not yet hold |
| Extended Credits | Blocked | Solution Partner tier |
| Pre-Verified Phone Numbers | Blocked | Solution Partner tier |
| Migration Intent (move a number between BSPs) | Roadmap | |
| Business Encryption (Flows key management) | Roadmap | Pairs with the Flows write path |
| Business Compliance Information | Not planned | India-only requirement |
| Payments | Not planned | India & Brazil only |
| Bot Details | Unavailable | Meta returns "nonexisting field" on v24.0 |
| Message History Events | Unavailable | Meta returns "unknown path" on v24.0 |
The last two are documented by Meta but do not resolve on the current Graph version. We verified both directly rather than assume — an endpoint that always errors is worse than one that does not exist, because it looks like our bug.
Meta API evolves regularly. WasapFlow tracks the latest changes and updates Bridge endpoints accordingly. If you need a specific Meta endpoint that isn't available, contact support — we may be able to add it.
WasapFlow Bridge API Reference v2.9.2 — for registered partners only.