Meta's official API
bZapper has two rails. On the unofficial one, a number is a session paired by QR code. On the official one, messages go out through the WhatsApp Cloud API, from the number that belongs to your customer's business account at Meta.
You pick the rail when creating the project, with one button. After that, the promise
is that nothing changes: POST /messages/text is POST /messages/text, the SDK is the
same, the webhooks are the same, the inbox is the same.
This page is about what only exists when Meta is the carrier.
What changes, in one table
| Unofficial rail | Official rail | |
|---|---|---|
| How a number joins | QR code in the panel | One Meta window, once |
| Starting a cold conversation | Free (with block risk) | Approved template only |
| Buttons and lists | Fall back to a text menu | Native (within Meta's limits) |
| Poll | Yes | Does not exist in the Cloud API |
| Groups | Yes | Do not exist in the Cloud API |
| Campaigns | Text with variations, 7-day ramp-up | Approved template, no ramp-up |
| Scheduling | Yes | Yes — the 24h window is evaluated when it goes out |
| Who charges for the conversation | Nobody | Meta, on your customer's card |
| Ban risk | Real | Practically nil |
What does not exist on a rail does not pretend to: in an official project, the QR-number
routes (creating and pairing an instance, groups, presence, editing or deleting a message,
blocking, labels, calls) answer 409 official_rail_unsupported; in a QR project,
/official/* and /templates answer 409 official_project_required. The full matrix,
route by route, is in the x-rails field of the API reference.
The billing deserves one more sentence: Meta charges the conversations directly to your customer, on the card they register in their own business account. bZapper neither intermediates nor adds a margin to that. Your bZapper subscription is one thing; Meta's per-conversation fee is another.
Connecting the account
Once, in the panel, under Numbers. Your customer signs in with their Facebook account, picks (or creates) the business account, enters the number, confirms the code and authorises. It is the only moment they leave bZapper.
After that, one step remains that Meta does not allow through the API: attaching a card to the business account. Without it, Meta refuses the sends. The panel explains it with the exact link, and when the card lands we detect it ourselves — the step closes without anyone clicking anything.
Through the API, GET /official/status tells the same story, live:
{ "connected": true, "can_send": true, "display_number": "+55 11 3333-3333",
"number_verified": true, "name_status": "APPROVED", "business_review": "APPROVED",
"quality_rating": "GREEN", "messaging_limit": "TIER_1K", "live": true }
can_send is the only question that matters. live: false means we could not reach Meta
right now and the values are the last known ones — the difference between "this is how it
is" and "we could not check" matters when you automate on top of it.
Several numbers in one project
Meta allows several numbers on one business account, and bZapper follows:
GET /official/numbers lists them all. Each has an instance_id — the same value you
pass in a send to choose which number the message goes out from, exactly as on the
unofficial rail.
Without instance_id, conversation affinity applies: it goes out from the number already
talking to that contact. With no prior conversation, from the project's default number.
To connect one more number from the same business account there is nothing to authorize
again — the authorization belongs to the account, not to the number.
GET /official/numbers/available lists the numbers the account holds at Meta that are not
in the project yet; POST /official/numbers with the chosen phone_number_id connects it.
We check at Meta that the number really belongs to that account before saving it, so a
wrong id fails there, not on the first send.
curl -X POST https://api.bzapper.com.br/official/numbers \
-H "Authorization: Bearer $BZAPPER_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone_number_id": "106540352242922" }'
PUT /official/numbers/{id}/default changes the default number, and
DELETE /official/numbers/{id} disconnects one — its conversation history stays.
The 24-hour window
This is the rule that defines the official rail, and it is worth understanding once.
You may send free text to someone for 24 hours counted from the last message that person sent you. Outside that window, Meta refuses any free text. Only a template it has approved gets through.
This is not a bZapper limitation and there is no way around it: it is how the Cloud API is designed. What bZapper does is keep it from catching you by surprise.
The same endpoint works on both sides
Configure a fallback template on the project and you are done:
# once
curl -X PUT https://api.bzapper.com.br/templates/fallback \
-H "Authorization: Bearer $BZAPPER_KEY" \
-H "Content-Type: application/json" \
-d '{"template_id":"..."}'
From then on, the same POST /messages/text works inside and outside the window. When it
is closed, the message goes out as the template — and the response says what happened:
{
"message_id": "0f2c…",
"status": "queued",
"delivery": {
"via": "template_fallback",
"template": "reengajamento",
"note": "A janela de 24 horas com este contato estava fechada: a mensagem saiu pelo template de alternativa configurado no projeto."
}
}
The delivery field appears only when there was a detour. The platform decides for you,
but it does not hide what it decided — and note is a sentence ready to show to a person.
With no fallback configured, a send outside the window fails with 422
official_needs_template, telling you what to set up. We prefer that to handing back
Meta's 131047, which is what you would have without us.
Templates
A template is a message model approved by Meta. Approval is asynchronous and can take up to 24 hours; Meta rejects with a terse reason and caps how many you may submit.
That is why bZapper validates before sending — and it is where this rail earns its keep.
Creating one
You describe the message in a simple shape. We build the component format Meta expects, with its examples nested three levels deep and the rules it does not document alongside.
curl -X POST https://api.bzapper.com.br/templates \
-H "Authorization: Bearer $BZAPPER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "pedido_confirmado",
"language": "pt_BR",
"category": "UTILITY",
"header": { "type": "text", "text": "Pedido {{1}}", "example": "10432" },
"body": {
"text": "Olá {{1}}, seu pedido {{2}} foi confirmado e já está em separação.",
"example": ["Ana", "10432"]
},
"footer": "Loja do Berni",
"buttons": [{ "type": "quick_reply", "text": "Acompanhar" }]
}'
An image, video or document header is also reviewed by Meta, from a sample file. You
do not need to host it anywhere: upload it with POST /templates/media (multipart, field
file — JPEG, PNG, MP4 or PDF up to 10 MB) and use the returned url as
header.media_url. It is only the sample Meta reviews, not the media that goes out on each
send.
curl -X POST https://api.bzapper.com.br/templates/media \
-H "Authorization: Bearer $BZAPPER_KEY" \
-F "[email protected]"
# { "url": "https://…/banner.jpg" } → "header": { "type": "image", "media_url": "https://…/banner.jpg" }
The rules that get you rejected (and that we block first)
Each of these comes back as 422 with a sentence saying what to fix, instead of becoming a Meta rejection 24 hours later:
- the body cannot start with
{{1}}— it needs text before it; - the body cannot end with a variable — it needs text after it;
- two variables side by side (
{{1}} {{2}}) do not pass; - numbering is
{{1}},{{2}},{{3}}— no gaps, starting at 1; - every variable needs an example (the most common rejection of all);
- the footer takes no variables, and is capped at 60 characters;
- a text header takes one variable, and is capped at 60 characters;
- at most 10 buttons, of which up to 2 links and 1 phone.
The category sets the price
MARKETING, UTILITY or AUTHENTICATION is not an administrative label: it is what
defines what the conversation costs and what Meta accepts in the content.
UTILITY is cheaper, but only covers what the customer already expects — a
confirmation, an order update, a reminder. Write "enjoy our discount" in a UTILITY template
and Meta reclassifies it on its own to MARKETING, and the price changes without you
noticing.
The creation response carries an advice field for exactly this kind of warning: things
that did not block the submission, but change the outcome.
Following the decision
You do not have to poll. The decision arrives by webhook:
| Event | When |
|---|---|
template.approved | Meta approved it; you can send. |
template.rejected | Rejected. The payload carries rejected_reason. |
template.paused | Paused for low quality — it stops sending. |
template.categorized | Meta reclassified it. The conversation price changes. |
Sending by name
curl -X POST https://api.bzapper.com.br/messages/template \
-H "Authorization: Bearer $BZAPPER_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+5511999999999", "template": "pedido_confirmado",
"variables": ["Ana", "10432"], "header_variables": ["10432"] }'
The id Meta gave the template never appears. language may be omitted when the name
exists in a single language; if it exists in more than one, we answer 409
template_ambiguous rather than choosing for you — sending in the wrong language is
worse than asking.
The number of variables must match the approved template. We check before spending
the call: a mismatch is the number one cause of Meta error 132000.
Verification code (OTP)
In the AUTHENTICATION category Meta writes the text itself and adds the copy-code button.
You only set the validity:
{ "name": "codigo_acesso", "language": "pt_BR",
"category": "AUTHENTICATION", "code_expiry_minutes": 10 }
And you send with code instead of variables:
{ "to": "+5511999999999", "template": "codigo_acesso", "code": "738291" }
This is how you send an OTP outside the 24-hour window — which is the normal case for an access code, since the person has almost never written to you first.
Already have templates at Meta?
POST /templates/sync imports the ones that exist there and not here — the ones you
created in WhatsApp Manager before you met us. Without it they would exist for Meta and be
invisible in the panel, and sending by name would say they do not exist.
Type parity
The 13 supportable types work the same on both rails, with the same request body: text, image, video, audio, document, sticker, location, contact, reaction, buttons, list, OTP and template.
Three differences worth noting:
Buttons and lists are better here. On the unofficial rail they are unstable and bZapper always falls back to a numbered text menu. On the Cloud API they are native and they render. Above Meta's limits (more than 3 buttons, a title over 20 characters, more than 10 list rows) we fall back to the same text menu — the message arrives, it just does not arrive clickable.
Poll does not exist. It is the only parity gap, and it is permanent: the Cloud API has
no such type. Sending a poll on an official project fails with 422
official_poll_unsupported and points at the alternative, which is /messages/list.
The destination is always a phone number. The @lid belongs to multi-device WhatsApp:
the Cloud API neither addresses by it nor has the LID→phone map. A @lid in to fails
with 422 official_lid_unsupported — on the unofficial rail the same to works (see
Contacts with @lid).
When Meta refuses
bZapper turns its code into an instruction, keeping the number at the end for when you need to open a ticket with them:
| What Meta says | What you read |
|---|---|
131047 | The 24-hour window closed: use a template. |
131026 | WhatsApp could not deliver to this number. |
131049 | Meta chose not to deliver (contact saturated with marketing). |
131042 | Account eligibility — almost always the missing card. |
132000 | The number of variables does not match the template. |
132015 | Meta paused the template for low quality. |
130429 | The number hit its daily cap of started conversations. |
Two of these change the account state on their own: a refused eligibility marks the account as awaiting payment again (and the panel shows the step, with the instruction), and a policy block suspends it.
Business profile
The number's business card — what your customer sees when opening the chat: the short line under the name, description, address, e-mail, sector, up to two websites and the photo. All of it is edited from here, without opening WhatsApp Manager.
curl https://api.bzapper.com.br/official/profile -H "Authorization: Bearer $BZAPPER_KEY"
curl -X PUT https://api.bzapper.com.br/official/profile \
-H "Authorization: Bearer $BZAPPER_KEY" \
-H "Content-Type: application/json" \
-d '{ "about": "Open 8am to 6pm", "description": "Specialty coffee shop.",
"email": "[email protected]", "vertical": "RETAIL",
"websites": ["https://shop.com"] }'
At Meta an empty field does not mean "unchanged" — it means "clear it". Read it with
GET, change what you need and send everything back on PUT.
We check Meta's limits before sending — 139 characters for the short line, 512 for the description, 256 for the address, at most 2 websites and a closed list of sectors. When something overflows, the 422 names the field and by how much, instead of Meta's "Invalid parameter".
- Photo:
POST /official/profile/photo(multipart, fieldfile, JPEG or PNG up to 5 MB; Meta recommends 640×640). WhatsApp Business only has the round photo — there is no cover image. - Display name: the name change itself is requested in WhatsApp Manager and reviewed
by Meta. Once approved, the half that is ours remains:
POST /official/profile/reregister. Until the number is re-registered it keeps showing the old name — the step everybody forgets.
The profile belongs to the number, not to the account. With nothing in the path, the
routes above act on the default number; for another number use
/official/numbers/{id}/profile, /official/numbers/{id}/profile/photo and
/official/numbers/{id}/profile/reregister.
Quality and limit
Meta assigns each number a quality (GREEN, YELLOW, RED) and a daily cap on
started conversations (TIER_250, TIER_1K, …). The cap rises on its own with volume
and quality — and quality falls when people block or report you.
Both appear in the panel and in GET /official/status, updated by Meta's webhooks. You
never have to open WhatsApp Manager to see them.
An approved template is not a licence to blast. Quality at RED drops the cap, and a
dropped cap is dropped delivery. What protects the number is the same as on the other rail:
sending to people who want to hear from you. See
sending best practices.