Broadcast API Guide & Reference
Everything you need to integrate your own system (online store, CRM, internal tooling) with our WhatsApp broadcast platform — from core concepts to the full detail of every endpoint.
Quick Start
Three steps to start sending broadcasts through your own integration.
- Connect your WhatsApp Business number. If you haven't already, connect it from the Numbers & WABA page in the dashboard — this is the source every message you send goes out from.
-
Create an API key.
Open the API Key page in the dashboard and click "Create New Key". The key (prefixed
fvb_...) works across every WABA number your account owns — it's only shown once when created, so store it somewhere safe. -
Make your first call.
Call
GET /api/broadcast/numbersto confirm the key works and get your number'sconnection_id. Or try it interactively from the dashboard's API Playground before writing any code.
Core Concepts
Templates vs. Free-form Text
There are two ways to send a WhatsApp Business message, and this split isn't our choice — it's Meta's rule:
- Templates — a fixed-format message pre-approved by Meta. This is the only way to start a
new conversation with a number that hasn't messaged you before. Used by
POST /sendandPOST /send-to-group. - Free-form text — an unrestricted reply, but only sendable inside the
24-hour session window since the customer last messaged your number. Used by
POST /send-text.
Why does every call need a connection_id?
One API key works across every WhatsApp number your account owns — not just one. Because of
that, every send call must state which number it's sending from via connection_id. Get the full
list from GET /api/broadcast/numbers.
Who Prepares What?
Two things need to exist before you can send, and they're prepared in different places — this trips up new integrators more than anything else:
- The message content (Template) — must be created and Meta-approved through our
dashboard first, not via the API. That's not a design choice on our part —
template approval is a manual review by Meta itself, no platform can make that instant through an API
call. The good news: this is a one-time step per message type, not per send — once
promo_septemberis approved, you can call it through the API as many times as you want. - The recipient list and its data — this can come entirely from your own system, no need
to touch our dashboard at all: pass numbers + data directly to
POST /send. Only if you actually want to reuse contacts already stored and grouped in our dashboard do you reach forPOST /send-to-groupinstead — that's optional, not a requirement.
How Template Variables Work ({{1}}, {{2}}, etc.)
Template variables are positional, not named — Meta has no concept of a "variable name", just a running number. Here's how it works:
- Call
GET /api/broadcast/templates/{name}and read the body text to understand what each number means (e.g."Hi {{1}}, here's your {{2}}% discount!"—{{1}}is the name,{{2}}is the discount). - Fill the
variablesarray in that exact order:["Budi", "20"].
Important for personalized sends (different content per person): one POST /send
call uses one variables array for every number in that
request — not per-recipient. If each recipient needs different content, call POST /send
multiple times,
once per recipient, each with its own numbers (a single number) and
variables — not one call for everyone at once.
Common Scenarios
The three situations most integrations reach for our API — and which endpoint fits each one.
Promo to New Numbers
Your system (e.g. an online store) wants to send an offer to hundreds of numbers from its own database — those numbers may have never messaged your WABA before, so it must use a Template that's already Meta-approved.
POST /sendBroadcast to a Customer Group
Contacts are already stored and grouped in our dashboard (e.g. a "VIP Customers" group) with a
Template already mapped — your system just needs to pass the group_id, no need to
resend the whole number list.
Auto-Reply to Customers
A customer just messaged your number (still inside the 24-hour window) — your system hears about it
through the webhook message.received, then your own
code decides what to reply (e.g. order confirmation) and calls this endpoint to send it.
This isn't an automated auto-reply feature from us — the "what to reply" logic is entirely yours.
(Our own reply-automation features, Keyword Auto-Reply & AI Agent, live in the dashboard but
aren't available through the API yet.)
Authentication
Every request is authenticated via the Authorization header using the Bearer scheme. Paste your
API key (prefixed fvb_...) as-is, no extra quoting:
Required header on every request
Authorization: Bearer fvb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
If the header is missing, the key is wrong, revoked, or expired — every endpoint returns 401
with a clear error message, never a silent failure.
Dashboard vs. API — Which One Should I Use?
Both go through the exact same delivery pipeline behind the scenes — the only difference is who triggers it, and a few dashboard features aren't exposed via the API yet.
- Dashboard is the right fit when you (or your team) manually pick contacts/groups and send a broadcast straight from the browser — no code required.
- API is the right fit when sending needs to be triggered automatically by another system — e.g. your online store automatically sending a WhatsApp notification when a new order comes in, or your CRM sending its own scheduled reminders.
| Feature | Dashboard | API |
|---|---|---|
| Send a Template to manual numbers | ||
| Send a Template to a contact group | ||
| Send a Carousel Template | ||
| Free-text reply to 1 number | ||
| Free-text broadcast to a whole groupnot on the API yet | ||
| CTA buttons (link / quick-reply)not on the API yet | ||
| Media attachment on free-textnot on the API yet | ||
| Schedule a send for laternot on the API yet | ||
| Delivery history & logs |
Need CTA, media, or scheduled sends over the API? Those are dashboard-exclusive for now — let us know if you need them opened up over the API too.
Webhook
The reverse of everything above — instead of you calling us, we automatically push data to your system the moment something happens, instead of you having to keep calling an endpoint just to check "is there anything new yet?".
What Can It Do?
Two events can currently be pushed to your system:
A New Reply Arrives — message.received
A customer just messaged your number. A fit for building your own customer-service bot
— when this event lands, your system can immediately call POST /send-text to auto-reply
(still inside that same 24-hour window), or simply log it to your own CRM/helpdesk without staff
ever opening our dashboard.
A Delivery Status Changes — message.status_update
A message you sent changes status (sent → delivered → read, or failed). The combo: call
GET /broadcasts/{id} once to get the initial state right after sending,
then stop polling that endpoint entirely — every subsequent status change gets
pushed here instead.
The webhook is an optional add-on, not a replacement — if plain API polling already works fine for your system, that's completely fine, you don't have to set up a webhook at all.
How to Set It Up — 1. Register your URL
Open the API Key
page in the dashboard and paste in one URL on your own server that can accept POST (must be
https://, and can't point at an internal/private address). We immediately send one verification
request to it — your endpoint must echo the random code back exactly for the status to flip to "Active".
This proves you actually control that URL, the same way we verify our own webhook back to Meta.
Verification request body we send
{
"event": "webhook.verification",
"sent_at": "2026-08-28T10:00:00+07:00",
"data": { "challenge": "aB3xZ..." }
}
Respond with 200 — either plain text containing exactly the challenge value, or
JSON {"challenge": "aB3xZ..."}. Either form is accepted.
How to Set It Up — 2. Verify the signature
Every request (verification or a real event) carries 2 extra headers — use these to confirm the request genuinely came from us, not someone else hitting your URL:
Headers on every webhook request
X-Webhook-Event: message.received X-Webhook-Signature: sha256=1a2b3c...
The signing secret lives on the same API Key page (the "Lihat" button on the Webhook card) —
never put it in client/browser-side code, only on your own server. To verify: recompute the
HMAC-SHA256 of the raw request body (exactly as received, before parsing it into an object)
using that secret, then compare it against the value after sha256= in the header — using a
timing-safe comparison (hash_equals), never plain ==.
Example receiving endpoint (PHP) — drop-in ready
$rawBody = file_get_contents('php://input'); // must be RAW, not already json_decode()'d
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = 'whsec_...'; // from the Webhook card on the API Key page
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
// Verified — safe to process now.
$payload = json_decode($rawBody, true);
$event = $payload['event']; // 'message.received' / 'message.status_update' / 'webhook.verification'
$data = $payload['data'];
Technical Details — Each Event's Shape
message.received — a new reply landed in Inbox:
Example event body
{
"event": "message.received",
"sent_at": "2026-08-28T10:05:00+07:00",
"data": {
"message_id": 1042,
"wamid": "wamid.HBg...",
"connection_id": 4,
"from": "6281234567890",
"contact_name": "Budi Santoso",
"type": "text",
"body": "Hi, is this in stock?",
"timestamp": "2026-08-28T10:05:00+07:00"
}
}
message.status_update — a message you sent changed status (same fields as one row of recipients from GET /broadcasts/{id}):
Example event body
{
"event": "message.status_update",
"sent_at": "2026-08-28T10:06:00+07:00",
"data": {
"broadcast_id": 514,
"phone": "6281234567890",
"message_id": "wamid.HBg...",
"status": "delivered",
"error": null,
"sent_at": "2026-08-28T10:05:50+07:00",
"delivered_at": "2026-08-28T10:06:00+07:00",
"read_at": null
}
}
status progresses sent → delivered → read, or goes
straight to failed (with a reason in error) — the exact same progression
GET /broadcasts/{id} uses, so any parsing code you've already written for that endpoint can be
reused here.
Delivery rules
- Respond with
200quickly (within 5 seconds) — a slow or unresponsive server counts as a failed delivery. - A failed delivery is automatically retried up to 3 times (30 seconds, then 2 minutes, then 10 minutes apart) before giving up.
- Every delivery attempt (success or failure, with its response code) is visible in the Webhook card on the API Key page.
- One client can have only 1 active webhook URL for now — shared across every event and every WhatsApp number you have.
Available Endpoints
Every endpoint below requires an Authorization: Bearer fvb_... header — get your API key from the
API Key page in the dashboard.
Without a valid key, every endpoint here returns 401.
You may see 2 different error shapes: missing/wrong-type fields return Laravel's default 422 shape
({"message": "...", "errors": {"field": ["..."]}}), while errors from our own business logic (template not found, 24-hour window closed, etc.) return the simpler {"error": "..."} shape — every error example below each endpoint uses the latter.
Postman Collection
Every endpoint below in one ready-to-import file — complete with example requests, responses, and a description of every parameter. Import it into Postman, or hand it to an AI/another developer for instant context.
Numbers & Connections
One API key works across every WhatsApp number your account owns. This resource tells you which numbers are available and each one's connection_id — a value every send operation below requires.
Operations 1
/api/broadcast/numbers
List Numbers
Which WhatsApp numbers this API key can send from.
Example Request
Example Success Response 200
{
"numbers": [
{
"connection_id": 4,
"phone_number": "6281234567890",
"label": "Nomor Utama",
"waba_name": "Toko Maju Jaya"
}
]
}
Templates
A Template is a fixed-format, Meta-approved message — the only way to start a new conversation with a number that hasn't messaged you before (see Core Concepts). This resource only lists templates you've flagged "Usable via API" in the dashboard.
Operations 2
/api/broadcast/templates
List Templates
Templates that are Meta-approved and toggled "Usable via API". Variables are exposed by position ({{1}}, {{2}}, ...) — how you fill them in is entirely up to your own system.
Example Request
Example Success Response 200
{
"templates": [
{
"name": "info_promo",
"category": "MARKETING",
"language": "id",
"body": "Halo {{1}}, ada promo spesial buat kamu: {{2}}!",
"variable_count": 2,
"waba_name": "Toko Maju Jaya",
"note": null
},
{
"name": "otp_login",
"category": "AUTHENTICATION",
"language": "id",
"body": "",
"variable_count": 0,
"waba_name": "Toko Maju Jaya",
"note": "Kategori Authentication: teks pesan dibuat otomatis oleh Meta, tidak ada variabel bebas. Kirim lewat POST /send-otp dengan parameter code."
}
]
}
/api/broadcast/templates/{name}
Single Template Detail
The full component structure of one template (HEADER/BODY/FOOTER/BUTTONS) — for when you only need one template, not the whole list.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | required | Path parameter — the template name from GET /templates. |
Example Request
Example Success Response 200
{
"name": "info_promo",
"category": "MARKETING",
"language": "id",
"variable_count": 2,
"waba_name": "Toko Maju Jaya",
"components": [
{
"type": "HEADER",
"format": "IMAGE"
},
{
"type": "BODY",
"text": "Halo {{1}}, ada promo spesial buat kamu: {{2}}!"
},
{
"type": "FOOTER",
"text": "Balas STOP untuk berhenti menerima pesan promosi."
}
],
"note": null
}
Example Error Response 404
{
"error": "Template 'info_promo' tidak ditemukan atau belum bisa dipakai lewat API."
}
Messages — Using a Template
Send a broadcast using a Meta-approved Template — the only way to start a new conversation with a number that hasn't messaged you before. The two operations below differ only in where the recipients come from: raw numbers you supply, or a contact group already stored in the dashboard.
Operations 4
/api/broadcast/groups
List Contact Groups
Every contact group this client owns, account-wide (not tied to one WABA) — this is what tells you the group_id value to use in the "Send to Contact Group" operation below.
Example Request
Example Success Response 200
{
"groups": [
{
"group_id": 12,
"name": "Pelanggan VIP",
"active_contacts_count": 48,
"total_contacts_count": 50
}
]
}
/api/broadcast/groups/{id}/contacts
List Contacts in a Group
The contents of one contact group — each contact's name, phone, custom fields, and opt-out status. Useful for previewing a group before calling "Send to Contact Group" below.
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | required | Path parameter — the group_id from GET /groups. |
Example Request
Example Success Response 200
{
"group_id": 12,
"name": "Pelanggan VIP",
"contacts": [
{
"contact_id": 30,
"name": "Andi Saputra",
"phone": "6281234567890",
"custom_fields": {
"kota": "Bandung"
},
"opted_out": false
}
]
}
Example Error Response 404
{
"error": "Grup kontak tidak ditemukan."
}
/api/broadcast/send
Send to Numbers Directly
Send a template broadcast straight to the phone numbers in your request — those numbers are never stored as our own contacts, ideal when your own system already manages customer data.
If template_name is a Media Card Carousel template, the cards parameter is REQUIRED (its count must exactly match the template's card count, same order as in the dashboard). For a regular template, omit cards entirely.
| Field | Type | Required | Description |
|---|---|---|---|
connection_id |
integer | required | From GET /numbers. |
template_name |
string | required | Must be approved on this WABA. |
numbers |
array<string> | required | Max 1000 numbers per request. |
variables |
array | optional | Values for {{1}}, {{2}}, ... in the main body — same values used for every number in this request. |
cards |
array | optional | REQUIRED if the template is a Carousel — one object per card, same order as in the dashboard: <code>header_media_url</code> (required, image/video URL), <code>header_type</code> (<code>image</code>/<code>video</code>, default <code>image</code>), <code>body_params</code> (that card's own <code>{{n}}</code> values), <code>buttons</code> (<code>[{type, text, payload}]</code>). |
dry_run |
boolean | optional | If <code>true</code>, only validates and returns the message text that would be sent (from our locally-stored template) for each number, including a per-card carousel preview (<code>cards_preview</code>) when applicable — no Meta call, no broadcast created or queued. |
Example Request
Example Success Response 201
{
"broadcast_id": 512,
"status": "queued",
"template": "info_promo",
"total_recipients": 2
}
Example Error Response 422
{
"error": "Template 'info_promo' tidak ditemukan atau belum approved buat nomor ini."
}
/api/broadcast/send-to-group
Send to Contact Group
Send to one of the contact groups already in our dashboard — that group must already have exactly one template mapped (via the Template page), unless you pass template_name to pick one explicitly.
Same as Send to Numbers Directly — if the mapped template_name is a Media Card Carousel template, the cards parameter is REQUIRED (same count and order as the dashboard), sent identically to every contact in this group. For a regular template, omit cards.
| Field | Type | Required | Description |
|---|---|---|---|
connection_id |
integer | required | From GET /numbers. |
group_id |
integer | required | Contact group ID. |
template_name |
string | optional | Required if the group has more than 1 mapped template. |
variables |
array | optional | Only for variables not already auto-filled from contact data. |
cards |
array | optional | REQUIRED if the template is a Carousel — same shape as <a href="#ep-send">Send to Numbers Directly</a>: one object per card (<code>header_media_url</code>, <code>header_type</code>, <code>body_params</code>, <code>buttons</code>), sent identically to every contact in the group. |
dry_run |
boolean | optional | If <code>true</code>, only validates and returns the message text that would be sent for each contact in the group (including variables auto-filled from contact data), including a per-card carousel preview (<code>cards_preview</code>) when applicable — no Meta call, no broadcast created or queued. |
Example Request
Example Success Response 201
{
"broadcast_id": 513,
"status": "queued",
"template": "info_promo",
"group": "Pelanggan VIP",
"total_recipients": 48
}
Example Error Response 422
{
"error": "Grup \"Pelanggan VIP\" punya lebih dari 1 template ter-mapping \u2014 sertakan 'template_name' buat pilih salah satu.",
"available_templates": [
"info_promo",
"reminder_bayar"
]
}
Messages — Free Text
A free-form reply with no Template — but Meta only allows it inside the 24-hour window since the customer last messaged your number. Good for customer-service auto-replies, not for promotional broadcasts to new numbers (use the Messages — Using a Template resource for that).
Operations 1
/api/broadcast/send-text
Send Free Text
Send a free-form text reply (no template) — only works inside Meta's 24-hour session window (the recipient must have messaged this WABA recently). Sent synchronously — the real result comes back immediately, not queued.
| Field | Type | Required | Description |
|---|---|---|---|
connection_id |
integer | required | From GET /numbers. |
phone |
string | required | Recipient number. |
message |
string | required | Max 4096 characters. |
Example Request
Example Success Response 201
{
"status": "sent",
"message_id": "wamid.HBg...",
"broadcast_id": 514
}
Example Error Response 422
{
"error": "(#131047) Message failed to send because more than 24 hours have passed since the customer last replied to this number.",
"error_code": 131047,
"broadcast_id": 514
}
Messages — OTP/Verification
Send an OTP/verification code (login, password change, etc.) using the dedicated Authentication Template category — unlike a regular Template, its wording is generated by Meta itself (you don't write it), you only supply the code. The code itself is generated and verified by your own system — this resource only delivers it over WhatsApp. Sent synchronously — the real result comes back immediately, not queued — a fit for someone actively waiting for a code on their phone.
Operations 1
/api/broadcast/send-otp
Send OTP Code
template_name must already be approved, category Authentication, and toggled "Usable via API" (see List Templates — templates in this category show up there too, flagged via the note field).
| Field | Type | Required | Description |
|---|---|---|---|
connection_id |
integer | required | From GET /numbers. |
template_name |
string | required | An approved Authentication-category template. |
phone |
string | required | Recipient number. |
code |
string | required | The OTP code itself — max 20 characters. Generated and stored by your own system, not by us. |
Example Request
Example Success Response 201
{
"status": "sent",
"message_id": "wamid.HBg...",
"broadcast_id": 515
}
Example Error Response 422
{
"error": "Template 'otp_login' tidak ditemukan, belum approved, bukan kategori Authentication, atau belum dicentang \"Bisa dipakai lewat API\"."
}
Broadcast Status
Every send operation above (/send, /send-to-group, /send-text, /send-otp) returns a broadcast_id as soon as the request is accepted — this resource lets your system check the outcome later, without opening the dashboard.
Operations 1
/api/broadcast/broadcasts/{id}
Check Broadcast Status
Overall totals (sent_count/delivered_count/read_count/failed_count) plus a per-recipient breakdown — per-recipient status progresses pending → sent → delivered → read as Meta sends us the callback for each step, or straight to failed if it didn't make it.
| Field | Type | Required | Description |
|---|---|---|---|
id |
integer | required | Path parameter — the broadcast_id from a send operation's response. |
Example Request
Example Success Response 200
{
"broadcast_id": 514,
"status": "completed",
"template": "info_promo",
"group": null,
"total_count": 2,
"sent_count": 0,
"delivered_count": 1,
"read_count": 1,
"failed_count": 1,
"created_at": "2026-08-18T02:10:00+00:00",
"recipients": [
{
"phone": "6281234567890",
"status": "read",
"message_id": "wamid.HBg...",
"error": null,
"sent_at": "2026-08-18T02:10:05+00:00",
"delivered_at": "2026-08-18T02:10:08+00:00",
"read_at": "2026-08-18T02:11:40+00:00"
},
{
"phone": "6281298765432",
"status": "failed",
"message_id": null,
"error": "Nomor Tidak Terdaftar di WhatsApp \u2014 (#131026) ...",
"sent_at": null,
"delivered_at": null,
"read_at": null
}
]
}
Example Error Response 404
{
"error": "Broadcast tidak ditemukan."
}
Want to try it live?
The dashboard's API Playground lets you send a real request to every endpoint above, straight from your browser, before writing a single line of integration code.