Skip to main content

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 railOfficial rail
How a number joinsQR code in the panelOne Meta window, once
Starting a cold conversationFree (with block risk)Approved template only
Buttons and listsFall back to a text menuNative (within Meta's limits)
PollYesDoes not exist in the Cloud API
GroupsYesDo not exist in the Cloud API
CampaignsText with variations, 7-day ramp-upApproved template, no ramp-up
SchedulingYesYes — the 24h window is evaluated when it goes out
Who charges for the conversationNobodyMeta, on your customer's card
Ban riskRealPractically 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:

EventWhen
template.approvedMeta approved it; you can send.
template.rejectedRejected. The payload carries rejected_reason.
template.pausedPaused for low quality — it stops sending.
template.categorizedMeta 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 saysWhat you read
131047The 24-hour window closed: use a template.
131026WhatsApp could not deliver to this number.
131049Meta chose not to deliver (contact saturated with marketing).
131042Account eligibility — almost always the missing card.
132000The number of variables does not match the template.
132015Meta paused the template for low quality.
130429The 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"] }'
Send the whole profile

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, field file, 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.

The rule holds on both rails

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.