Skip to main content

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:

KeyUse
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" }
}'
FieldRequiredNotes
toYesE.164, 8 to 15 digits, leading + optional
templateNameYesMust be APPROVED
languageYesMust match the approved template exactly: en and en_US are different templates
variablesNoBody placeholders only, keyed "1", "2", … Applied in numeric order
valuesNoEverything the template asks for: body, header and a value per button. See Filling a template's buttons
fromNoSend from this number, e.g. "255687128012". Defaults to your default sender. See Choosing the sender number
phoneNumberIdNoMeta'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 fieldNotes
bodyArray, in {{1}}..{{n}} order
headerHeader text, when the header is TEXT and carries a placeholder
headerMediaLinkPublic URL of the header image, video or document
headerMediaIdA media id from POST /waba/media, instead of a link
buttonsKeyed 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:

  1. Template to reach someone cold (order confirmation, OTP, delivery notice)
  2. Plain text for everything after they reply, for the next 24 hours
  3. 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.

FieldRequiredNotes
toYesE.164, 8 to 15 digits, leading + optional
typeYesOne of the types above
(the type's object)Yese.g. text for type: "text"
fromNoSend from this number, e.g. "255687128012"
phoneNumberIdNoMeta's id for the same number; prefer from
sendModeNoOnly 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"
FieldPurpose
fromSend from this number. Must be one of the numbers you can send from
phoneNumberIdMeta's internal id for the same number. Still accepted for existing integrations; prefer from
sendModeOnly 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"
}
]
}
FieldMeaning
msisdnWhat you pass as from
ownershipOWNED yours · GRANTED shared with you · SHARED_PLATFORM the MALIPOPAY number, usable until you connect your own
isDefaultSenderThe number used when you send without a from
connectionStatusCONNECTED 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 getWhat it means
... is not registered to this projectThe number exists on MALIPOPAY but is not yours. Request it, or drop from
... is not connected to MalipopayNothing on the platform matches that number. Check the digits, then request it
... is not ready to send: its status is PENDINGIt 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:

ColumnFills
toThe recipient. Required
var1, var2, …Body placeholders {{1}}, {{2}}, …
headerHeader text, when the header carries a placeholder
header_media_linkHeader 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.