Sending Messages
Two endpoints send messages. POST /api/v1/waba/messages/template is a shortcut for the common case; POST /api/v1/waba/messages sends anything.
:::caution Use your secret key Your project has two keys. Only the secret one authenticates an API call:
| Key | Use |
|---|---|
mp_sk_… | This one. Server side, in the apiToken header |
mp_pk_… | Publishable. Carries no project, and every API call with it is refused |
Sending with the publishable key returns 403 and tells you so.
:::
Send an approved template
curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba/messages/template \
-H "apiToken: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "255712345678",
"from": "255687128012",
"templateName": "order_confirmation",
"language": "en",
"variables": { "1": "John", "2": "50,000", "3": "ORD-001" }
}'
| Field | Required | Notes |
|---|---|---|
to | Yes | E.164, 8 to 15 digits, leading + optional |
templateName | Yes | Must be APPROVED |
language | Yes | Must match the approved template exactly: en and en_US are different templates |
variables | No | Body placeholders only, keyed "1", "2", … Applied in numeric order |
values | No | Everything the template asks for: body, header and a value per button. See Filling a template's buttons |
from | No | Send from this number, e.g. "255687128012". Defaults to your default sender. See Choosing the sender number |
phoneNumberId | No | Meta's internal id for the same number. Accepted for existing integrations; prefer from |
Send variables when the template only has body placeholders. Send values
when it has anything else, which includes any button carrying a value.
Filling a template's buttons
A template button whose link contains a placeholder needs a value at send time. Without one WhatsApp renders the link with nothing substituted, so every recipient lands on the same page instead of their own. This is the difference between an invitation whose button opens that guest's card and one that opens your home page.
Ask the template what it needs. GET /api/v1/waba/templates returns an
inputs object per template:
{
"name": "wedding_invitation_card",
"language": "en",
"inputs": {
"kind": "standard",
"bodyVars": [1, 2],
"headerVar": false,
"headerMedia": "IMAGE",
"buttons": [
{ "index": 0, "subType": "url", "requiresParam": true, "label": "My Card" },
{ "index": 1, "subType": "quick_reply", "requiresParam": false, "label": "Ndiyo" }
]
}
}
Then send a value for each:
curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba/messages/template \
-H "apiToken: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "255712345678",
"templateName": "wedding_invitation_card",
"language": "en",
"values": {
"body": ["Erick", "A1356"],
"headerMediaLink": "https://cdn.example.com/card.png",
"buttons": { "0": "A1356" }
}
}'
values field | Notes |
|---|---|
body | Array, in {{1}}..{{n}} order |
header | Header text, when the header is TEXT and carries a placeholder |
headerMediaLink | Public URL of the header image, video or document |
headerMediaId | A media id from POST /waba/media, instead of a link |
buttons | Keyed by the button's index from inputs. A link button takes the part of the URL that changes; a coupon button takes the code; a quick-reply button takes the payload you want back on your webhook |
A button of type PHONE_NUMBER, and a link with no placeholder in it, take no
value and are not listed in inputs.
If a required value is missing the send is refused with 400 naming the field,
and nothing is billed. You do not pay for a message that WhatsApp would
have rejected.
Send plain text
Plain text needs no template — but it only works while a 24-hour window is open with that customer. A window opens when the customer messages you first, and it closes 24 hours after their last message.
curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba/messages \
-H "apiToken: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "255712345678",
"from": "255687128012",
"type": "text",
"text": { "body": "Your order is out for delivery." }
}'
That is the whole call. from is optional — leave it out to use your default
sender.
Why a template exists at all
You cannot open a conversation with plain text. If no window is open, the send is refused:
{
"success": false,
"details": "Free-text only reaches a customer inside an open 24-hour conversation. Send an approved template to start one."
}
Nothing is sent and nothing is billed. The fix is not to retry — it is to send an approved template, which is the only thing Meta lets you start a conversation with. Once the customer replies, the window is open and plain text works until it closes.
So the working pattern is:
- Template to reach someone cold (order confirmation, OTP, delivery notice)
- Plain text for everything after they reply, for the next 24 hours
- Template again once the window closes
Is a window open right now?
curl "https://core-prod.malipopay.co.tz/api/v1/waba/conversations?contactWaId=255712345678" \
-H "apiToken: YOUR_API_KEY"
An entry with expiresAt in the future and no closedAt means plain text will
reach them. See Conversations.
Send anything else
POST /api/v1/waba/messages takes a type and a matching body object. The
object named by type must be present.
Supported types: text, template, image, document, audio, video,
sticker, location, contacts, interactive, reaction.
| Field | Required | Notes |
|---|---|---|
to | Yes | E.164, 8 to 15 digits, leading + optional |
type | Yes | One of the types above |
| (the type's object) | Yes | e.g. text for type: "text" |
from | No | Send from this number, e.g. "255687128012" |
phoneNumberId | No | Meta's id for the same number; prefer from |
sendMode | No | Only applies when you name no sender |
The same 24-hour rule governs every non-template type: media, location, interactive and reactions all need an open window.
Media
Reference a public URL with link, or upload once and reuse the returned id:
{
"to": "255712345678",
"type": "image",
"image": { "link": "https://example.co.tz/receipt.png", "caption": "Your receipt" }
}
{
"to": "255712345678",
"type": "document",
"document": { "id": "1234567890", "filename": "invoice.pdf" }
}
Location
{
"to": "255712345678",
"type": "location",
"location": { "latitude": -6.7924, "longitude": 39.2083, "name": "Sinza Lion", "address": "Dar es Salaam" }
}
Reaction
message_id is the wamid you are reacting to. An empty emoji removes the reaction, which is why it is allowed to be empty:
{ "to": "255712345678", "type": "reaction", "reaction": { "message_id": "wamid.HBg...", "emoji": "👍" } }
Choosing the sender number
Pass from with the number as you know it. All of these are the same number:
"255687128012" "0687128012" "+255 687 128 012"
| Field | Purpose |
|---|---|
from | Send from this number. Must be one of the numbers you can send from |
phoneNumberId | Meta's internal id for the same number. Still accepted for existing integrations; prefer from |
sendMode | Only applies when you name no sender. merchant-then-platform (default) uses your number and falls back to the MALIPOPAY number; merchant-only fails instead of falling back; platform-only always uses the MALIPOPAY number |
Naming a sender is binding. If the number is not registered to you the send is
refused — it is never quietly swapped for a different one. Omit from and the
default sender is used instead.
Every send tells you which number it actually went out from:
{
"from": "255687128012",
"fromPhoneNumberId": "1185569601305427",
"fromSharedPlatformNumber": false
}
fromSharedPlatformNumber: true means your customer saw MALIPOPAY as the
sender, not your brand — you have no number of your own yet, so the shared one
was used.
Which numbers can I send from?
curl https://core-prod.malipopay.co.tz/api/v1/waba/phone-numbers \
-H "apiToken: YOUR_API_KEY"
{
"success": true,
"data": [
{
"msisdn": "255687128012",
"displayPhoneNumber": "+255 687 128 012",
"verifiedName": "Your Business",
"ownership": "OWNED",
"isDefaultSender": true,
"connectionStatus": "CONNECTED"
}
]
}
| Field | Meaning |
|---|---|
msisdn | What you pass as from |
ownership | OWNED yours · GRANTED shared with you · SHARED_PLATFORM the MALIPOPAY number, usable until you connect your own |
isDefaultSender | The number used when you send without a from |
connectionStatus | CONNECTED can send. Anything else is still being set up |
If the only entry is SHARED_PLATFORM, you have no number of your own yet and
your customers will see MALIPOPAY. Request your own number.
When a sender is refused
All three are 400, and the reason is in details:
| What you get | What it means |
|---|---|
... is not registered to this project | The number exists on MALIPOPAY but is not yours. Request it, or drop from |
... is not connected to Malipopay | Nothing on the platform matches that number. Check the digits, then request it |
... is not ready to send: its status is PENDING | It is yours but setup is unfinished |
Nothing is sent and nothing is billed when a send is refused.
Request your own number
curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba-requests/requests \
-H "apiToken: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"requestedNumber":"255687128012","notes":"Primary support line"}'
Meta sends the verification code to that handset, so the person holding the
phone has to complete it — nobody at MALIPOPAY can do it for you. Track it with
GET /api/v1/waba-requests/requests.
Conversation category
conversationCategory overrides what the send is billed as. Leave it alone unless you have a reason: a template is billed at its own category, and anything else is billed SERVICE when a window is open and UTILITY when it is not.
Upload media
curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba/media \
-H "apiToken: YOUR_API_KEY" \
-F "file=@receipt.png" \
-F "from=255687128012"
Returns a media id for use as image.id, document.id, video.id or audio.id.
A media id is scoped to the number that uploaded it and expires after 30 days on Meta's side. Upload under the same from you intend to send from, or the send fails.
Bulk send
POST /api/v1/waba/messages/bulk is a multipart upload: a CSV or .xlsx as
field file with a to column, one recipient per row, plus the shared message
as flat form fields (type, text_body, or template_name +
template_language, media_link, conversationCategory).
Upload the workbook rather than exporting it first. A phone number in a
General-formatted Excel cell exports to CSV as 2.55712E+11, and that row then
fails.
curl -X POST https://core-prod.malipopay.co.tz/api/v1/waba/messages/bulk \
-H "apiToken: YOUR_API_KEY" \
-F "file=@recipients.csv" \
-F "type=template" \
-F "template_name=order_confirmation" \
-F "template_language=en" \
-F "from=255687128012"
from is optional here as well, and follows the same rule: a number you are not
registered for is refused rather than replaced.
Giving each recipient their own values
For type=template, reserved columns fill the template per row, so a bulk send
is not limited to one identical message:
| Column | Fills |
|---|---|
to | The recipient. Required |
var1, var2, … | Body placeholders {{1}}, {{2}}, … |
header | Header text, when the header carries a placeholder |
header_media_link | Header image, video or document |
button_0, button_1, … | The button at that index, so each recipient gets their own link |
Any other column is ignored, so you can keep the columns you already track.
to,var1,var2,button_0
255712345678,Erick,A1356,A1356
255712345679,Asha,A1357,A1357
Returns 202 with a batchId, the queued count, and any rows it could not use. Each recipient is queued individually, so one bad row does not stop the batch: a row missing a required value is reported with its row index and the rest still go out.
Checking what a batch did
The batchId is stored on every message the batch produced:
curl "https://core-prod.malipopay.co.tz/api/v1/waba/messages?reference=wb_9f2c1a77b4e30d15" \
-H "apiToken: YOUR_API_KEY"
For counts rather than rows:
curl "https://core-prod.malipopay.co.tz/api/v1/waba/messages/batches/wb_9f2c1a77b4e30d15" \
-H "apiToken: YOUR_API_KEY"
{
"success": true,
"data": {
"reference": "wb_9f2c1a77b4e30d15",
"total": 400,
"byStatus": { "delivered": 380, "sent": 12, "failed": 8 },
"firstAt": "2026-08-29T09:12:04.000Z",
"lastAt": "2026-08-29T09:14:41.000Z"
}
}
queued means the row has not reached Meta yet, which is expected in the first
moments after upload.
Scan the list before you send. Junk numbers such as 255777777777 pass the format check, and failed deliveries to dead numbers damage your Meta quality rating, which throttles your throughput.
Responses
202 means accepted and dispatched to Meta, not delivered. Delivery arrives on your webhook.
{
"success": true,
"data": {
"wamid": "wamid.HBgMMjU1Nz...",
"from": "255687128012",
"fromPhoneNumberId": "1185569601305427",
"fromSharedPlatformNumber": false,
"chargedCredits": 5,
"category": "UTILITY"
}
}
from is the number your customer saw. When fromSharedPlatformNumber is
true, they saw MALIPOPAY rather than your brand, because you have no
number of your own connected yet.
400 covers validation failures, an insufficient wallet balance, a template that is not approved, and errors Meta returned. The message says which. If Meta rejects the send, the wallet charge is refunded automatically.
Reading what you sent
curl "https://core-prod.malipopay.co.tz/api/v1/waba/messages?direction=OUT&limit=50" \
-H "apiToken: YOUR_API_KEY"
Filter by direction (IN / OUT), status, and reference (a bulk batchId), and page with skip / limit / page. GET /api/v1/waba/messages/export returns the same data as CSV.