Integration guides and API reference for WasapFlow Bridge
Current API Version: 2.9.2 Last Updated: 1 September 2026
This changelog covers two kinds of change:
You are responsible for the second category as much as we are. Bridge is a transparent relay: when Meta changes a rate or a rule, that change reaches your clients through us, but the commercial impact lands on you. This page is how we tell you ahead of time.
You do not need to poll this page. Every Bridge API response carries notice headers:
| Header | Meaning |
|---|---|
X-Bridge-Api-Version |
Current Bridge API version |
X-Bridge-Changelog |
URL of this page |
X-Bridge-Notice |
Comma-separated IDs of active notices (absent when there are none) |
X-Bridge-Notice-Level |
Highest severity among active notices: info, action_required, or breaking |
Every webhook also carries the same information in a meta object:
{
"event": "message.delivered",
"waba_id": "...",
"phone_number_id": "...",
"timestamp": 1786160000,
"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"
}
]
}
}
Recommended handling: log X-Bridge-Notice-Level and alert your team when it is action_required or breaking. Notice IDs are stable forever, so you can suppress ones you have already acted on.
The
metaobject is inside the signed body. If you verify webhook signatures, no change is needed — the signature is computed over the complete body as sent.
Docs mirror on GitHub: github.com/kobaranteguh — api, guide, and changelog repos are updated in the same pass as this dashboard, so both are always in sync.
meta-service-message-billing-2026-10-01 — action requiredEffective 1 October 2026. Meta will begin charging for two message types that are free today.
| What | Free since | Charged from |
|---|---|---|
| Service messages — any non-template reply inside the 24-hour customer service window | 1 Nov 2024 | 1 Oct 2026 |
| Utility templates delivered inside an open customer service window | 1 Jul 2025 | 1 Oct 2026 |
Key facts, confirmed against Meta's documentation:
What you should do now: estimate your exposure. Every message.sent / message.delivered webhook now includes a pricing object (see 2.2.0 below). Messages with pricing.type of free_customer_service are exactly the messages that become billable on 1 October. Count them for a month and you have your projection; multiply by the rate when Meta publishes it.
Meta reference: Upcoming pricing updates
meta-business-agent-token-billing-2026-08-01 — infoEffective 1 August 2026. Meta launched its own AI agent, Meta Business Agent, as a new message category alongside service. It is billed per token at USD $2.00 per 1M tokens (roughly 4–5 US cents per message), as a single charge covering both AI processing and message delivery. Meta will not deliver Business Agent messages unless a credit line is attached.
This does not change anything about messages sent through Bridge. Meta's own documentation is explicit that any non-template message not powered by Meta Business Agent — including messages from a human agent or a third-party AI solution — is a service message. Your traffic through Bridge is service traffic.
It matters to you for two reasons:
Meta reference: Conversations 2026 announcement
If connecting a WABA ever told you "your card cannot be charged, please update your payment method", your card may never have been the problem.
Stripe's real error, sitting in our own logs, said so:
Payment for this subscription requires additional user action, but the
requested payment behavior doesn't save the invoice or PaymentIntent.
Retry the request with payment_behavior=allow_incomplete...
Stripe told us the fix in the error text. We showed you "card declined" instead. One partner saved five cards across four attempts — two different cards — chasing a fault that did not exist. Malaysian cards almost all require 3D Secure under the Bank Negara mandate, so this blocked our largest market by design.
Three places carried the same wrong assumption — that needs authentication and card refused are the same event:
| Was | Now | |
|---|---|---|
| Subscription creation | error_if_incomplete — Stripe throws rather than returning an invoice when the bank wants 3DS |
allow_incomplete — the off-session charge is tried first, authentication requested only when the bank demands it |
invoice.payment_action_required |
Treated as payment_failed; its own comment read "block immediately, same as payment_failed" |
Sending still stops (the invoice is genuinely unpaid), but we record the payment link instead of a card failure that never happened |
A WABA in billing_failed |
Nothing ever moved it back. Not one query in the codebase | invoice.paid revives it automatically and logs which |
That third row was its own bug: paying after a failed charge did not get your WABA back. It could not, because no code path existed to restore 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_..."
} }
Still HTTP 402 — the payment genuinely has not happened. What changes is what
you do about it: authenticate, not replace your card. Open payment_url,
confirm with your bank (or enter a different card on that page), and the WABA
activates by itself when the invoice is paid.
PAYMENT_FAILED still exists and still means what it says: the card was
actually refused.
Contact us. We will send you a payment link for it, and it will come back on its own once paid — no need to reconnect or re-onboard.
Sending context did nothing. You got a 200, the message arrived, and the
reply tag was gone — no error, no warning, nothing in your logs to search for.
The cause: every send endpoint builds Meta's payload from scratch and copies
only the fields it knows about. context was not one of them, so a partner
sending Meta's correct shape had it discarded before the request ever left
us. One partner spent time trying other field names — context_message_id,
reply_to, quoted — before asking. None of them worked either, because the
problem was never the name.
Now supported on all five send paths: /messages/send,
/messages/template, /messages/media, /messages/interactive,
/messages/location.
POST /bridge/v1/messages/send
{
"to": "60123456789",
"text": "Yes, that one is in stock.",
"context": { "message_id": "wamid.HBgMNjAxMTY0NjI1MTA3FQIAERgS..." }
}
Three shapes are accepted, because all three are reasonable guesses and rejecting two of them just repeats the same failure in a different costume:
| Field | Example |
|---|---|
context (Meta's own shape) |
{ "context": { "message_id": "wamid..." } } |
context_message_id |
{ "context_message_id": "wamid..." } |
reply_to |
{ "reply_to": "wamid..." } |
The wamid is the message_id from a send response, or from an inbound webhook
(data.message_id). If both context and an alias are present, context wins.
context is now an error, not a silent sendA context you supplied but we cannot parse returns 400 INVALID_PAYLOAD
instead of sending the message untagged:
{ "success": false,
"error": { "code": "INVALID_PAYLOAD",
"message": "context must be {\"message_id\": \"wamid...\"} — or send context_message_id / reply_to as a string." } }
This is deliberate and it is the actual lesson of the bug. The original failure
was not a wrong tag — it was a failure that looked like a success. An empty
context: {}, a blank reply_to, or a non-string message_id now tells you so
at the call site rather than in a customer's chat thread three weeks later.
Do not "fix" INVALID_PAYLOAD inside a retry wrapper. It will not succeed on
the second attempt; the body needs correcting.
Sends without context behave exactly as before. If you never used quoted
replies, this release is a no-op for you. If you built a workaround for the
missing tag, you can remove it.
32 new endpoints. Bridge exposed 13 of Meta's ~38 Cloud API surfaces. The other 25 were never missing — they were simply never asked for, so a partner who needed one emailed us and we ran the call on their behalf. That is not a gateway, that is a help desk. Anything reachable with the token we already hold should be reachable by the person who owns the WABA.
This audit started as a question about which Meta endpoints we cover. It ended as a list of things partners had been working around.
Every endpoint below was probed live against a real WABA before it was written, not copied out of Meta's reference. Two documented APIs failed that test and are deliberately not exposed:
| API | What Meta actually returns on v24.0 |
|---|---|
| Bot Details | (#100) Tried accessing nonexisting field (bot_details) |
| Message History Events | (#2500) Unknown path components: /message_history_events |
An endpoint that always errors is worse than one that does not exist, because it looks like our bug and you will spend an afternoon proving it isn't.
GET, POST /qr-codes · PUT, DELETE /qr-codes/{code}
A QR code carries a pre-filled message: the customer scans, their WhatsApp opens with the text already typed. Updating that text does not change the image or the link, so posters and stickers already in the wild keep working.
GET, POST /conversational-automation
Ice breakers (up to 4 tappable questions on the first chat), / commands, and
the welcome message. Previously WhatsApp Manager only — which meant a partner
running hundreds of numbers could not do it at all.
GET, POST, DELETE /blocked
Meta's limits are the part worth reading: you can only block someone who messaged
you in the last 24 hours, 1,000 per request, 64,000 total. Failures come back
per number — a 200 with a populated failed array is a partial success,
not a success. Read that array.
GET, POST /commerce-settings
Show or hide the catalog and the cart per number. This is the question one of you asked us in July and we answered "not possible". It was possible; we just hadn't exposed it.
GET, POST /settings
Calling configuration and No-Storage mode. We return the whole node rather than a chosen list of fields, because Meta adds keys here without notice and a whitelist would hide the new ones from you.
GET /phone-status
Official Business Account, two-step verification state, display-name review
status, quality, throughput and tier in one call. Note platform_type reads
CLOUD_API for Coexistence numbers too — is_on_biz_app is the discriminator.
GET /waba · GET /waba/activities · GET /waba/assigned-users · GET /waba/solutions · GET /schedules
/waba/activities is the one to reach for when a client asks why a template
disappeared or why their messaging limit moved.
primary_funding_id is deliberately absent from /waba. It needs a billing
permission the onboarding token never holds, and Meta rejects the entire
request with code 10 when a single requested field is not permitted — one
unreachable field would have wiped out the other eleven. Each field name in that
list was tested individually.
GET /flows · GET /flows/{id}
List and inspect. Creating a Flow needs an endpoint you host plus a registered encryption key pair; that is a separate integration, not a proxy call, and shipping half of it would leave you believing Flows were fully supported.
POST /phone/request-code · /phone/verify-code · /phone/two-step · /phone/register · /phone/deregister
Irreversible actions now require {"confirm": true} and return
400 CONFIRMATION_REQUIRED without it. Not security theatre — these calls end up
inside your scripts, and one mis-targeted loop could disconnect every number you
manage. The flag forces the intent to be written at the call site.
Bridge needs the two-step PIN to re-register a number after a migration or recovery, and we only ever held one platform PIN from our environment. A partner who changed the PIN in WhatsApp Manager broke our re-registration and nothing said so — it failed later, during a recovery, when it mattered most.
POST /phone/two-step now stores your PIN encrypted against the client, and
/phone/register reads it before falling back to the platform PIN. If you have
ever changed a PIN outside Bridge, set it here once.
POST /calls — actions connect, pre_accept, accept, reject, terminate.
A deliberate passthrough. Every call action carries an SDP offer or answer
generated by your WebRTC stack, and Bridge cannot validate or reshape SDP without
being a media server, which it is not. Enable calling first via POST /settings
with calling.status: "ENABLED".
GET, POST /groups · GET, DELETE /groups/{id} · DELETE /groups/{id}/participants
Groups must be enabled on your number by Meta. If it is not, Meta returns
131000 "Something went wrong" — a message that says nothing about the actual
cause. We confirmed this against a live WABA while building these endpoints, so
it is documented here rather than left for you to discover. Bridge passes Meta's
status through unchanged; 131000 here is not a Bridge fault.
You cannot add participants directly — Meta's design, not our limitation. Create the group, send the invite link, people join themselves. You can remove them.
Error responses from these endpoints include meta_code, meta_subcode and
details alongside message. Previously you had to infer from message text
alone, and that text is what separates "retry this" from "never retry this".
{
"success": false,
"error": {
"code": "META_ERROR",
"message": "(#131000) Something went wrong",
"meta_code": 131000,
"meta_subcode": null,
"details": null
}
}
Bridge called Meta on v24.0. It now calls v26.0, the current version.
Why it was worth doing carefully rather than quickly: the version was written directly into the URL in 125 places across Bridge and the CRM, while 19 other places read it from an environment variable. Those two groups were never in sync. Setting the environment variable would have moved only 19 of them — messages, templates, profile and media would have stayed on the old version while registration and sync jumped to the new one. Half the integration speaking two dialects at once, with nothing to tell us.
All 144 call sites now read one constant. Raising the version is a one-line edit.
Before switching we compared responses across v24, v25 and v26 for every edge Bridge uses — all identical. v26's breaking changes are confined to Marketing API, Ads, Commerce Order Management and Rights Manager; none touch WhatsApp messaging. v27 does not exist yet.
Nothing changes for you. Request and response shapes are the same. This is recorded because the version now appears in error traces and support logs.
All 32 webhook fields moved from v24.0 to v26.0 at the same time. Three
(business_username_updates, standby, messaging_handovers) were already on
v26.0 because those fields did not exist in v24 — Meta forces the earliest version
that has them.
A webhook field's version controls the shape of the payload Meta sends you, and
Meta has changed webhook payloads across versions before — v24 itself dropped the
conversation object from status webhooks. So this was done with a recorded
rollback of the previous subscription, and verified end-to-end rather than assumed:
| Check | Result |
|---|---|
| Subscription still active, callback unchanged | Yes — 32/32 fields on v26.0 |
| Webhooks still arriving | 995 processed in the first 5 minutes |
| Payloads still parsing (not just arriving) | 58 messages written to the database |
| New unrecognised field names | None |
| New parse errors | None |
The third row is the one that matters. A payload that changed shape still arrives with a 200 — it fails silently inside, which is why "webhooks are coming through" is not evidence on its own.
Nothing changes for you. Bridge normalises every webhook into its own event
envelope (message.received, message.delivered, …), so your integration never
saw Meta's raw field versions and does not see this change either.
The Node.js, Python and PHP client libraries are gone from this documentation.
Nobody was using them. Checking a month of access logs, every partner call
already arrives from an ordinary HTTP client — Guzzle, node fetch, axios,
httpx, Deno. Not one request came from a WasapFlow SDK. They were documentation
we maintained and nobody installed.
They were also a liability. The SDKs wrapped 13 endpoints; 2.9.0 brought the total to 68. A client library that lags the API teaches you the API is smaller than it is — the "not supported by Bridge" workarounds we asked you to search for in (g) exist partly because of this.
Nothing to migrate. If you somehow have one installed it will keep working, but it is unsupported and does not know about the 32 new endpoints. Replace it with the wrapper function in the guide — it is about twenty lines and covers every endpoint.
The guide showed POST /clients/register returning camelCase
(wabaId, phoneNumberId, displayName, registeredAt). It does not. That
was the old SDK's shape, not the API's, and anyone who wrote against that example
was reading fields that are never present.
The API returns snake_case everywhere, requests and responses alike:
{
"success": true,
"client": {
"id": 42,
"waba_id": "123456789",
"phone_number_id": "987654321",
"display_name": "My Client Business",
"quality_rating": "GREEN",
"tier": "TIER_1K",
"messaging_limit_tier": "TIER_1K",
"status": "active",
"registered_at": "2026-05-12T10:00:00Z"
}
}
If your registration code reads client.wabaId and silently stores undefined,
this is why. There is no camelCase form of this API anywhere.
All 32 endpoints are logged to your usage records under their own endpoint names,
visible via GET /usage. They are not billed as messages. /calls counts against
your rate_limit_per_sec; the rest do not.
Two requirements in Meta's own documentation that we had never implemented. Both were found by re-reading the docs rather than the code, after a client number failed onboarding four times in a row and we could not say why.
Meta's implementation guide lists a WA_EMBEDDED_SIGNUP message listener as a
required step, and the Coexistence guide is blunter still: "You must use
Embedded Signup with session logging." Our connect page did not have one.
The cost was not theoretical. Onboarding runs in the customer's browser against Meta; our server is only involved at the very end. Without that listener, an onboarding that failed inside Meta's dialog left no trace at all — we saw the page open and never learned whether it succeeded, was abandoned, or died halfway. One partner tried ten times over two weeks before anyone noticed.
Now recorded, and readable:
GET /onboarding/events
x-partner-key: wf_xxx
Each event carries current_step, error_code, error_message and Meta's own
meta_session_id — the id Meta Direct Support asks for first.
The popup also posts richer messages now: WASAPFLOW_CONNECT_ERROR carries
meta_session_id and current_step, and abandonment reports the step it was
abandoned at.
After a Coexistence onboarding, Meta does not send the customer's contacts and chat history on its own. You have to ask:
POST /{phone_number_id}/smb_app_data { "sync_type": "smb_app_state_sync" }
POST /{phone_number_id}/smb_app_data { "sync_type": "history" }
And the window is hard: "you have 24 hours to synchronize their messaging history, otherwise they must be offboarded and they must complete the flow again."
We never called it. The evidence was in our own logs: 49 Coexistence clients,
26,877 message.echo events — and message.history delivered twice,
contacts once. Echo is automatic; history and contacts must be requested,
and nobody was requesting them.
Registration now triggers both, and the result comes back on the
/clients/register-from-code response as coexistence_sync so you can see
whether it worked while the customer is still in front of you.
For clients onboarded before 20 August 2026, we are not going to guess. Their sync was never triggered — that part is certain. Whether it can still be triggered now is not: a test call against an older client came back with a generic permission error rather than anything about the window, so we cannot yet say whether the cause is the elapsed time, a token scope, or something else. We are checking with Meta.
You will get a definite answer rather than an assumption. If you would like your existing Coexistence clients looked at individually in the meantime, ask and we will go through them with you.
All of this came from one partner asking six questions. Nothing here is new capability — it is capability that already worked and was never written down, plus one gap we found while checking.
POST /messages/interactive sends your interactive object to Meta unchanged,
so product, product_list and catalog_message have always worked. Nobody
knew, because catalog_id and product_retailer_id appeared nowhere in these
docs. They do now.
catalog_id and product_retailer_id come from you. We do not store client
catalogues, inject ids, or validate them.
There is no catalogue management API and none is planned — no /catalog, no
/products. Bridge sends messages that reference a catalogue and relays the
orders that come back. To read products or stock, use Meta's Catalog API directly.
raw.message carries everythingThe flat fields on message.received are a convenience layer. data.raw.message
is Meta's own message object with nothing removed, which is where these live:
| Cart / order | raw.message.order |
| Product enquiry | raw.message.interactive |
| Reply context | raw.message.context |
Watch the trap: for order and interactive, the flat text is null.
Integrations that only read text see a cart as an empty message.
POST /messages/media with media.link is a passthrough: Meta fetches your
URL, and every limit is Meta's. POST /media/upload is the reverse — we
fetch the file and upload it to Meta, returning a reusable media_id. Similar
names, opposite mechanics. Use the second for anything sent repeatedly.
GET /media/{id} and POST /media/upload bypassed the rate limiter completely.
They now count against your account's rate_limit_per_sec like every other
endpoint.
This closes a gap rather than tightening a limit. It is the same limit
already applied everywhere else (200/s by default), and real usage is nowhere
near it — /media/upload averages under 6 calls a day across all partners.
Media downloads were also not logged at all, so the traffic was invisible to us.
They now appear as /media/download in your usage. We added the logging before
the limit deliberately: setting a threshold on traffic you cannot measure is a
guess.
Three fixes, all from one partner's integration questions. Every one of them was something we had got wrong rather than something they had.
The connect popup only ever posted WASAPFLOW_CONNECT_SUCCESS. If the customer
dismissed Meta's dialog, or registration was rejected, the popup showed a message
to the customer and told your window nothing.
So an integration waiting on an event stayed in "connecting…" until the customer closed the popup by hand, with no way to know whether they had given up or it had broken. Cancel and error handlers written against earlier versions never fired.
Two new messages, both carrying state like the success one:
type |
Extra fields |
|---|---|
WASAPFLOW_CONNECT_CANCEL |
reason |
WASAPFLOW_CONNECT_ERROR |
message, code |
Keep any popup.closed polling you added to work around this — a customer can
still close the window before reaching either path.
The connect token was checked twice: when the page opened, and again when registration completed. A customer who opened the popup at minute 29 and worked through Meta's flow — portfolio, number, verification code — arrived at the final call after expiry and was rejected, losing everything they had just done. With the bug above, that rejection was also silent.
Completion is now allowed up to 30 minutes past expiry. Opening is unchanged and
still strict: that is the moment a leaked link could be reused, and it is the one
worth guarding. Meta's code is short-lived and single-use, so accepting it a
little late costs nothing.
Connect sessions reference the partner, not the key, so rotating a key left every unfinished session working for up to 30 more minutes. Since a rotation almost always means the old key is considered exposed, that defeated the point.
Rotation now deletes unconsumed, unexpired sessions and reports how many. WABAs already connected are untouched — each holds its own Meta token, so messaging does not stop.
Until now the way to start onboarding was:
https://officialapi.wasapflow.com/bridge/connect?partner_key=wf_live_…
That is your full API credential, the same key that authorises every send,
every WABA and every client you manage — sitting in a URL. From there it reaches
your client's address bar, their browser history, the Referer header their
browser sends to other hosts, and our own nginx access logs in plain text.
We found it in our logs while investigating an unrelated onboarding failure. No misuse has been detected, and no partner did anything wrong — this was our design.
The new way is server-to-server:
POST /connect/session
x-partner-key: wf_xxx
{ "display_name": "Client Name", "state": "your-own-client-id-123" }
{
"success": true,
"connect_url": "https://officialapi.wasapflow.com/bridge/connect?token=93d29ead…",
"expires_in": 1800
}
Send your client to connect_url. The token is scoped to that one onboarding and
expires in 30 minutes; it cannot be used to call any other endpoint.
The old form still works and has no removal date — nothing breaks today. But please migrate, and once you have, ask us to rotate the key: a credential that has been through a browser should be treated as exposed.
Correction (2.6.1): this originally said to rotate the key "in the partner portal". There is no self-serve rotation — it is a support request. Sorry for sending anyone looking for a button that does not exist.
state is now returned to youPOST /connect/session accepts an optional state (max 255 characters, opaque to
us). It comes back untouched when the connection completes, both in the
postMessage payload and in the /clients/register-from-code response body, so
you can match the finished connection to your own client record.
Before 2.6.0 the connect page accepted a state query parameter and discarded it
without telling anyone. If you passed one and it never came back, that is why.
to was sent to Meta as a phone numberIf you are on 2.4.0 and reply to username-only customers, this affects you and you should upgrade.
2.4.0 added recipient for BSUID sends. It did not stop to from being forwarded to Meta's phone number field, and nothing checked whether the value in to was actually a phone number.
So a partner who put a BSUID in to — the most natural thing to do, since our own inbound webhook carries bsuid and no number at all — got this:
we sent "MY.1777397056778201"
Meta received "1777397056778201"
The market prefix was dropped and the remaining digits reached Meta as a phone number belonging to nobody. Meta answered 131026 Message undeliverable — after we had already returned 2xx. The send looked successful and the message never arrived.
Click-to-WhatsApp ads are where this bites hardest: those leads frequently have a username and no phone number, so every one of them was unreachable through the API while the same reply sent from the WhatsApp Business app went through normally.
From 2.5.0 all three field names work, and a BSUID is never treated as a phone number:
{ "to": "MY.1777397056778201", "text": "..." }
{ "user_id": "MY.1777397056778201", "text": "..." }
{ "recipient": "MY.1777397056778201", "text": "..." }
user_id is new in 2.5.0, for integrations that prefer an explicit field over us detecting the shape of to. recipient still works.
Sending to a phone number is unchanged, byte for byte. When a phone number and a BSUID are both supplied, the phone number wins — Meta's own precedence rule.
Send responses have always carried the Meta message id as message_id. Partners arriving from Meta's Cloud API documentation look for messages[0].id instead, find nothing, and conclude no id is returned. Both keys are now present and hold the same value:
{
"success": true,
"message_id": "wamid.HBg...",
"messages": [ { "id": "wamid.HBg..." } ]
}
Nothing was removed. If you already read message_id, keep reading it.
Store the id: every message.sent / message.delivered / message.read / message.failed callback is keyed on it. Without it a message.failed — the only way you learn a send did not arrive — cannot be matched to anything.
Meta refuses one-tap, zero-tap and copy-code authentication templates addressed to a BSUID (error 131062), and this is permanent: OTP always needs a real phone number. That used to surface as a generic META_ERROR. It now returns:
HTTP 400
{
"success": false,
"error": {
"code": "AUTH_TEMPLATE_NEEDS_PHONE",
"meta_code": 131062,
"retryable": false
}
}
Do not retry it. Collect a phone number instead.
recipient2.3.0 told you Meta had made BSUID support mandatory, and admitted Bridge could not yet send to one. That gap is closed.
Every send endpoint now accepts recipient (a BSUID) as an alternative to to (a phone number):
POST /messages/send
{
"recipient": "MY.2026206508304015",
"text": "Terima kasih! Order anda dalam proses."
}
Applies to /messages/send, /messages/template, /messages/media, /messages/interactive, /messages/location, /messages/reaction, and broadcasts.
Nothing changes for existing integrations. When both are supplied, to wins — that is Meta's own precedence rule, not ours. Code that sends to behaves exactly as before; you only reach for recipient when you have no phone number to use.
If neither is supplied you now get a clearer error:
{ "success": false, "error": { "code": "MISSING_FIELDS",
"message": "Either \"to\" (phone number) or \"recipient\" (BSUID) is required" } }
Broadcasts accept the same on each contact. A contact may stay a bare phone string, or become an object:
{ "contacts": [
"60123456789",
{ "recipient": "MY.2026206508304015", "params": ["Aiman"] }
] }
A contact carrying neither is skipped rather than sent with an empty recipient.
Verified against Meta, not just documented. We sent a live message to a BSUID on Graph API v24.0 and Meta accepted it:
{ "contacts": [{ "input": "MY.2026206508304015", "user_id": "MY.2026206508304015" }],
"messages": [{ "id": "wamid.HBgTTVkuMjAyNjIwNjUwODMwNDAxNRUU..." }] }
One response-shape difference worth knowing: when you send to a BSUID, Meta's response carries contacts[0].user_id and no wa_id. Code that reads wa_id from a send response to record the recipient will get undefined. Read user_id when you sent to a recipient.
BSUID recipients are rejected for:
Meta returns error 131062 — "Business-scoped User ID (BSUID) recipients are not supported for this message".
This is permanent, not a rollout gap. OTP flows will always need a real phone number. If you verify users by sending a code over WhatsApp, keep collecting a phone number for that path — a BSUID cannot carry it.
user_id_update webhook — Meta regenerates a BSUID when a user changes their phone number. Until we relay it, a stored BSUID can go stale.US.ENT.…) — relevant only to multi-portfolio businesses.No endpoint changed in this release. It documents a requirement Meta has already made mandatory, and a data-model trap that will silently corrupt delivery data in most integrations built on this platform — including ours, until we fixed it.
If you only read one part of this release, read this.
WhatsApp is rolling out usernames. A customer can now be reachable as @aiman instead of by phone number, and they can hide their phone number from businesses they talk to. Meta still has to identify that person to you somehow — so it gives you a BSUID, a stable id scoped to your business:
MY.2026206508304015
│ └─ up to 128 alphanumeric characters
└──── ISO country code, then a dot
Multi-portfolio businesses also see a parent form with ENT inserted: US.ENT.11815799212886844830.
Four things worth knowing:
recipient: "<BSUID>" in place of to: "<phone>". We tested this against a live number on Graph API v24.0 and Meta accepted it — no version bump needed.billing.phone, and — critically — it will survive a naive phone normaliser and come out the other side looking like a real number.When do you get one instead of a phone number? Bridge already sends bsuid alongside from on every inbound message, so today you get both. You start receiving the BSUID without a phone number when a customer has adopted a username and there has been no interaction with your number in the last 30 days. Per Meta, wa_id, from and recipient_id are then "omitted entirely".
So the change arrives gradually, customer by customer — not on a single date.
Most inboxes fall back to showing the phone number when a contact has no name. That fallback breaks in a way users notice immediately.
If your code does something like contact.name || ('+' + contact.phone), a BSUID contact renders as:
+MY.2026206508304015
Support staff read that as a broken system and raise a ticket. It is worth checking how often you would hit this: in our own fleet, 45% of contacts have no profile name, so this is not an edge case.
A display order that holds up:
1. profile name (Meta still sends contacts[0].profile.name)
2. collected phone number (once your order flow has asked for it)
3. "WhatsApp customer · 304015" <- last 6 characters of the BSUID
Putting the collected phone second matters: as soon as your order flow captures a real number, that contact stops showing an id and starts showing something a human recognises.
Use 6 characters, not 4. We checked this against real data before deciding. In our largest workspace, 924 contacts with BSUIDs already produce 41 collisions on the last 4 characters — different people rendering identically in the list. Extrapolated to that workspace's 2,422 nameless contacts, 4 characters would give roughly 293 collisions; 6 characters brings it to about 3.
Also show the full BSUID in the contact detail view, not just the fragment. The fragment is for scanning a list; the full value is for when someone needs to be sure two rows are different people.
Meta states plainly: "Supporting business-scoped user IDs (BSUID) is required for all partners and directly-integrated businesses on the WhatsApp Business Platform."
Bridge has been forwarding bsuid on inbound messages and recipient_bsuid on status events since 2.0. What changes now is what you must do with it.
The problem. Once a customer adopts a WhatsApp username, Meta may omit their phone number from the webhook entirely. Per Meta's docs, 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 receive a BSUID like MY.2035200694071263 and nothing else.
A BSUID is a valid identifier for sending WhatsApp messages. It is not a phone number. A courier cannot call it, and WooCommerce cannot use it as billing.phone.
The trap. Almost every integration has a phone normaliser shaped like this:
let n = phone.replace(/\D/g, '');
if (n.startsWith('0')) n = '6' + n;
if (!n.startsWith('60')) n = '60' + n;
Feed it a BSUID and you get 602035200694071263 — a value that looks like a Malaysian phone number, passes naive validation, and flows all the way to your courier. It does not throw. Nothing appears in your logs. Your seller ships to a customer nobody can reach.
Check your own normaliser now. If it strips non-digits before validating, you have this bug.
1. Split one field into two. Most schemas store a single phone on the contact and use it for everything. Separate the roles:
| 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 |
2. Reject BSUID in phone validation — do not "clean" it. Return null and treat it as we have no number. No number beats a fake one.
// A BSUID is: 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...
}
3. Ask the customer for a phone number in your order flow. If your flow collects name, address and postcode but not a phone — because the WhatsApp number was always assumed to be the phone — it now collects an order that cannot be delivered.
Keep the friction low. When you already hold a plausible number, ask them to confirm it rather than retype it:
"Boleh sahkan no 012-345 6789 ni untuk kurier WhatsApp atau call ya?"
Only when you genuinely have nothing should you ask them to type it in:
"Boleh bagi no telefon untuk kurier WhatsApp atau call masa hantar ya?"
Worth doing even before usernames arrive: customers routinely order from a work WhatsApp, or on behalf of someone else. The delivery number is frequently not the WhatsApp number, and you have probably been shipping with the wrong one already.
4. If you push 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 to the contact. Leave billing.phone empty rather than filling it with an id — an empty field prompts a human to ask; a fake number sends a courier to a dead end.
5. Meta's REQUEST_CONTACT_INFO template button (available since early July 2026) is the supported way to ask a customer to share their phone number. Parse the resulting contacts webhook and read the phone from the shared vCard.
These are on our roadmap; you may need them sooner depending on your integration:
recipient (BSUID) in place of to (phone). Bridge send endpoints currently accept to only. Until we ship this, you cannot reply to a customer who has no phone number on file.user_id_update webhook — Meta regenerates a BSUID when the user changes their phone number. Miss it and your stored id goes stale.US.ENT.…) — only relevant for multi-portfolio businesses.Nothing is broken today. Across our own fleet we have recorded zero BSUID-only messages so far, and usernames are not yet live in Malaysia. This is preventive work — and it is far cheaper now than during a support queue.
Meta reference: Business-scoped user IDs
pricing on message status webhooksmessage.sent, message.delivered, message.read, and message.failed now include the per-message pricing data Meta reports, so you can attribute cost per message without a separate analytics call.
{
"event": "message.delivered",
"data": {
"message_id": "wamid....",
"status": "delivered",
"recipient": "60123456789",
"timestamp": 1786160000,
"pricing": {
"billable": false,
"pricing_model": "PMP",
"type": "free_customer_service",
"category": "service"
},
"conversation": {
"id": "...",
"origin": "service"
}
}
}
| Field | Values | Notes |
|---|---|---|
pricing.billable |
true / false |
Whether Meta charges for this message |
pricing.pricing_model |
PMP |
Per-message pricing |
pricing.type |
regular, free_customer_service, free_entry_point |
free_customer_service becomes regular on 1 Oct 2026 |
pricing.category |
service, utility, marketing, authentication, referral_conversion |
Which rate applies |
conversation.origin |
same values as category | What opened the conversation |
pricing is null when Meta does not supply it (commonly on read receipts). Treat it as optional.
This is additive. Existing fields are unchanged and no field was removed.
X-Bridge-Api-Version, X-Bridge-Changelog, X-Bridge-Notice, X-Bridge-Notice-Level. See How you are notified. Headers only — response bodies are unchanged, so strict body parsers and schema validators are unaffected.
meta object on every webhookCarries api_version, changelog, and active notices. Additive; existing fields unchanged.
When a business enables Meta Business Agent on a phone number, Meta's agent and your app coexist on the same number and only one is the active handler at a time. While your app is the passive listener, Meta stops sending the normal messages field and sends standby instead. Bridge now relays all three standby sub-types:
| Event | Fired when |
|---|---|
standby.message_received |
A user sends 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 Meta Business Agent message (carries pricing) |
Every standby payload includes standby: true and identifies the current handler, so you can branch cleanly:
if (event.startsWith('standby.')) {
// Meta Business Agent is handling this conversation.
// Store for context — do NOT auto-reply.
return saveContextOnly(data);
}
⚠️ 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 seize control from the agent. If you run a bot, gate it on
standby !== true.
standby.message_status events carry the same pricing object as regular status events and are recorded in the same ledger, so Business Agent traffic shows up in your cost reporting too.
This is the 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 errors, and nothing appears in your logs — the messages never reach you. Your client will report "the bot is dead" and you will have nothing to point at.
Please build a persistent warning banner in your app header and in the inbox for the affected number. Raise it on any standby.* event; clear it when normal message.received events resume. Use a warning style (yellow/amber), not an error style — nothing has broken.
Suggested 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.
Make the raise idempotent — standby events can arrive many times a minute. Full implementation shape in the API Reference.
WasapFlow's own product ships this exact banner. We are asking you to mirror it so your clients get the same explanation instead of a mystery.
| Event | Fired when |
|---|---|
waba.offboarded |
A Coexistence client's WhatsApp Business App was offboarded (device change, reinstall, re-registration) |
waba.reconnected |
Meta finished reonboarding it automatically in the background |
Between these two events the number may not send. Treat waba.offboarded as a soft pause rather than a disconnection — Meta usually reconnects within a few minutes without any action from you.
waba.pricing_tier_updatedFires when a WABA reaches a new volume pricing tier for a market–category pair, which changes the rate for utility and authentication messages.
{
"event": "waba.pricing_tier_updated",
"data": {
"waba_id": "123456789",
"pricing_category": "UTILITY",
"tier": "25000001:50000000",
"region": "Malaysia",
"effective_month": "2026-09"
}
}
Do not confuse this with
waba.tier_updated, which is the messaging tier (how many unique recipients per 24 hours). This one is about price. Service messages have no volume tiers — this event will never fire for them.
Meta may send more than one webhook describing the same tier change; use the one with the smallest tier_update_time.
message_status on template sendsPOST /messages/template now returns message_status when Meta is pacing the template — holding delivery back while it tests quality on a small audience first.
{ "success": true, "message_id": "wamid....", "message_status": "held_for_quality_assessment" }
The field is only present when pacing applies, so its absence means normal delivery. This matters for broadcast reporting: HTTP 200 means Meta accepted the request, not that the message went out. When message_status is present, wait for the message.sent / message.delivered webhook before counting it as delivered.
template.status_updated — template approved, rejected, or paused by Metatemplate.quality_updated — template quality rating changedtemplate.category_updated — Meta recategorised a templatewaba.account_updated — WABA account-level changewaba.review_updated — account review status changedcontact.synced — Coexistence contact sync from the WhatsApp Business Appmessage.echo — messages your client sends from the WhatsApp Business App directlymessage.history — past conversations backfilled after Coexistence onboardingPreviously, when the same WABA was registered under more than one partner, only the first partner received webhooks. All partners owning a phone_number_id now receive events, each signed with their own webhook secret.
Before 1.4.5 the Bridge API returned 200 OK even for failures. Failed responses now return an appropriate 4xx/5xx status. Existing code checking body.success === false continues to work unchanged; new code can rely on res.ok.
Not yet implemented. Listed so you can plan; each will move to a released version with its own notice when it ships.
Bridge currently calls Meta on v24.0 (released 8 Oct 2025, supported until 18 Feb 2028). Meta's current version is v26.0. Everything Bridge relies on today works identically on v24.0 — we verified pricing_analytics returns the same data on both. We will move when a feature we need requires it, and will announce the change here first.
Meta's Direct Send lets you send utility messages without creating a template first — Meta auto-generates the matching template behind the scenes, with content PII-redacted and language auto-detected. Utility became generally available on 31 July 2026; authentication is still in beta. This would remove template pre-approval from utility notification flows entirely.
Cloud API Groups are now available to businesses with an Official Business Account. Sending uses the same Messages endpoint with recipient_type: "group", and responses carry a group_id.
Meta's Calling API opens a 24-hour customer service window when a user calls the business, not only when they message. Bridge does not surface call events today, which means a window can be open without your system knowing.
messaging_handovers)The companion signal to standby webhooks. Where standby.* carries the content you observe while passive, messaging_handovers carries the event telling you control changed hands between Meta Business Agent and your app.
This is more reliable than inferring handover state from traffic. Inference has a real gap: if a conversation is handed back to you but the customer does not message again, you cannot tell you have control until their next message arrives.
We have subscribed to the field, but Meta's public documentation does not yet specify its payload shape for WhatsApp. Rather than ship a parser built on assumption, we log the first real payload and will write the parser from actual data. It will appear here with its event names once confirmed.
Worth copying: whatever your webhook handler looks like, it is almost certainly a chain of
if (field === ...)checks — which means any field Meta adds falls through silently. No error, no log, nothing. Add a fallback branch that logs unrecognised fields once, with the full payload. It costs a few lines and it is how you find out about a new Meta field in days rather than months.
Meta may change rates only on the first day of a quarter — 1 January, 1 April, 1 July, 1 October — with minimum advance notice of 1 month for a rate card update, 3 months for a pricing model add-on, and 6 months for a pricing model change.
| Date | Change |
|---|---|
| 1 Nov 2024 | Service conversations became free |
| 1 Jul 2025 | Conversation-based pricing replaced by per-message pricing (PMP); utility templates in an open window became free |
| 1 Jan 2026 | Rate updates: India, France, Egypt, North America |
| 1 Apr 2026 | 8 new billing currencies including MYR (Malaysia), SGD, SAR, AED |
| 1 Jul 2026 | Rate updates: Hong Kong, Hungary, Italy, Poland, Qatar, Romania, Singapore, Spain, UK. Several markets moved off regional rates onto market-specific rate cards with market-specific volume tiers |
| 1 Aug 2026 | Meta Business Agent token billing begins |
| 1 Sep 2026 | Meta publishes the rates effective 1 October |
| 1 Oct 2026 | Service messages and in-window utility templates become billable |
Current rate cards, including MYR: Pricing on the WhatsApp Business Platform