Send a notification
A notification is a templated WhatsApp message sent from a MauKirim-managed number. It is the right tool for “your order shipped”, “your invoice is ready”, “your appointment is tomorrow” — messages you initiate, where the content is known in advance.
Notifications are template-bound on purpose: an operator approves a template for your account, and every send names it. That is what keeps a compromised key from broadcasting arbitrary text from MauKirim’s number.
The request
Section titled “The request”curl -X POST https://app.maukirim.com/api/v1/messages/send \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-8821-shipped" \ -d '{ "phone": "+6281234567890", "templateId": "tpl_…", "variables": { "name": "Rizky", "order": "8821" } }'{ "ok": true, "batchId": "gwb_…" }| Field | Required | Notes |
|---|---|---|
phone |
yes | E.164, separators stripped |
templateId |
yes | A template that exists, is approved, and belongs to your account |
variables |
yes | An object (not an array) whose keys equal the template’s placeholder set exactly. Each value is a string, or an array of objects for a list section. |
attachmentUploadId |
only for per-send templates | The media.id from a private upload to /api/v1/messages/media. Omit it for text-only and fixed-attachment templates. |
The body must be application/json and at most 8 KB. A missing Content-Type is a 400, not a guess.
Variables: exact match or nothing
Section titled “Variables: exact match or nothing”If the template is Hi {{name}}, order {{order}} shipped. then variables must have exactly name and
order — same names, same count. A missing key, an extra key, or a typo is refused with
400 invalid_input before anything is sent, so you cannot half-render a message.
Each scalar value is a string of at most 64 characters matching letters, digits, and _ .,:!{}@+/-()&.
Invoice numbers such as 3260/VII/FDA/2026, common email addresses such as
billing+ap@example.com, names such as Rina (PIC billing), and companies such as PT A & B
render unchanged. These limits bound inserted text; they do not sanitize URLs that fit the allowed
characters. Supply trusted values only. If you need arbitrary text, send it from your own rented
number — see Send a message from your number.
Repeat a list of line items inside one message
Section titled “Repeat a list of line items inside one message”A template can also repeat part of itself. A list section is the block between {{#name}} and
{{/name}}, and the variable named there is an array of objects instead of a string. This repeats
content inside one message to one recipient; it is not the recipient loop further down.
Say the template’s body is:
Order {{order}}:{{#items}}- {{name}} x{{quantity}}{{/items}}An operator still has to approve a template before any send, and editing it — including adding a
section — returns it to review. Once approved, variables holds order as a string and items as an
array, one object per rendered line. Each object’s keys must be exactly the placeholders used inside
the section:
curl -X POST https://app.maukirim.com/api/v1/messages/send \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-8821" \ -d '{ "phone": "+6281234567890", "templateId": "tpl_…", "variables": { "order": "8821", "items": [ { "name": "Kopi luwak", "quantity": "2" }, { "name": "Teh botol", "quantity": "3" } ] } }'That is one message and one charge — the header line followed by the two item lines — not two notifications. The same array shape works from the dashboard’s Notification → Integration & test form and from the MCP tools below.
The scalar rules above still apply, plus these:
- A list holds 0–10 objects. An empty array renders the section with no lines; a text-only notification still needs some text outside that section. An attachment can be sent without a caption.
- Every object carries every placeholder in its section and nothing else. A missing or stray key is a
400 invalid_inputbefore anything is sent. - Every field is still a string matching
^[\p{L}\p{N}_ .,:!{}@+\/()&-]{1,64}$; keep quantities and numbers as text. - A template may declare several lists with different names, at most 8 top-level names in total; sections never nest, section names are unique, and each list binds at most 8 fields.
- The rendered message is limited to 4096 characters, or 1024 characters as an attachment caption.
The template summary reports lists as { name, fields }[] and variables as all top-level names —
every scalar placeholder and every list name — so you can build the payload without guessing.
Send different variables to several recipients
Section titled “Send different variables to several recipients”This is a different axis from the list section above: a list section repeats content inside one message, while this loop sends one message per recipient, each with its own charge.
In the dashboard, open Notification → Integration & test → Send to multiple recipients. Choose one approved template, then add up to 10 rows. Each row has its own phone and values for that template’s placeholders. MauKirim queues and charges each row separately. It locks queued rows on the page so a retry sends only failed rows.
The REST endpoint still takes one recipient per request. For an integration, loop over the recipients and give each send its own stable idempotency key:
for (const recipient of [ { phone: "+6281234567890", variables: { name: "Rizky", order: "8821" }, key: "shipment-8821-rizky" }, { phone: "+6281234567891", variables: { name: "Ayu", order: "8822" }, key: "shipment-8822-ayu" },]) { const response = await fetch("https://app.maukirim.com/api/v1/messages/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.MAUKIRIM_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": recipient.key, }, body: JSON.stringify({ phone: recipient.phone, templateId: "tpl_…", variables: recipient.variables, }), }) const result = await response.json() console.log({ queued: response.ok && result.ok, batchId: result.batchId, code: result.code })}A failure on one row does not cancel rows already queued. Keep the same key and unchanged payload
when retrying a row whose result is uncertain; a successful replay returns its original batchId
without another charge. The dashboard keeps keys while that page is open, not across a reload.
An MCP client with notification scope can call send_notification_loop with confirm: true,
an approved templateId, and 1–10 {phone, variables, idempotencyKey} entries. The user must
approve every phone and the per-message charges first. The tool returns queued, failed,
and one result per recipient (status: "queued" with batchId, or status: "failed" with code).
Each row’s key must be distinct and stable across retries. A recipient’s variables may hold a list
array for a list-section template, exactly as in the REST payload; it still renders one message for
that recipient. For a per-send attachment, supply one attachmentUploadId to use on every row. The
tool validates all phones and variable sets before sending the first row, but later billing or device
failures can still leave only some rows queued.
Attachments
Section titled “Attachments”An approved template chooses exactly one attachment mode:
| Mode | What the recipient gets |
|---|---|
| Text only | The rendered message as text. An attachmentUploadId is refused. |
| Fixed image or document | The same private file the administrator inspected and approved. Do not include attachmentUploadId in a send. |
| Per-send image or document | An account-owned upload supplied as attachmentUploadId with each request. The same upload may be reused for several sends while available. The rendered message becomes its caption. |
To use per-send media, submit a template with Different image or file with each send in the
dashboard, or call MCP create_notification_template with attachmentMode: "per_send". An
administrator must approve that permission. Approval covers the template text and permission to
attach later files; it does not review each file’s bytes. Only the account that owns the approved
template can send with this mode.
Upload a file from your server with a notification-scoped API key:
curl -X POST https://app.maukirim.com/api/v1/messages/media \ -H "Authorization: Bearer mk_live_…" \ -F "file=@invoice-8821.pdf"{ "ok": true, "media": { "id": "gwm_…", "kind": "file", "fileName": "invoice-8821.pdf", "contentType": "application/pdf", "byteSize": 12345, "expiresAt": "2026-10-06T00:00:00.000Z" } }In MCP, upload_notification_media accepts bounded base64 bytes and returns the same private upload
id. Use it with create_notification_template for a fixed attachment, or pass it on an approved
per-send template’s send_sample_notification call. Neither tool returns a bucket key.
Then send with the approved per-send template and that upload id:
curl -X POST https://app.maukirim.com/api/v1/messages/send \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: invoice-8821" \ -d '{"phone":"+6281234567890","templateId":"tpl_…","variables":{"order":"8821"},"attachmentUploadId":"gwm_…"}'The same upload endpoint stores JPEG or PNG images up to 5 MB, or supported PDF, Office, CSV, TXT
and ZIP documents up to 16 MB. The response returns an id, never a public file URL or bucket key.
An unused upload expires after seven days; a queued item holds its own claim until it no longer
needs the bytes. A retry with the same Idempotency-Key must use the same upload id. A different
id with that key returns 409 batch_idempotency_conflict.
Fixed files can be uploaded in the dashboard, through MCP upload_notification_media, or with the
private media endpoint, then submitted for review with attachmentMode: "static" and
attachmentUploadId. Editing fixed bytes or changing the mode resets the template to pending review.
Because the rendered message becomes the caption, both fixed and per-send media are limited to 1024
caption characters. OTP messages never carry media.
What happens after the response
Section titled “What happens after the response”200 means the message was queued, not delivered. The response gives you a batchId; the sequence from
there is:
- MauKirim’s worker claims the item and hands it to the WhatsApp gateway. Acceptance is not delivery.
- When the gateway reports
deliveredorread, MauKirim records the item’s new state. If your account has an active notification callback, it also queues a signed receipt for your endpoint. - Seven days after the item becomes terminal, the message body is purged. The ids, states and timestamps stay in the dashboard and delivery API.
Just before it hands the message over, the worker asks the sending number to show a typing indicator to the recipient, so a greeting does not arrive out of a silent chat. That is best effort and nothing more: it lasts about 1.2 seconds, it is not a delivery signal, and a number that refuses to show it does not stop the message.
Notification callbacks are account-owned. You do not need access to the shared sending number’s webhook, and a rented number’s webhook never receives these notification receipts.
Optional delivery and read callbacks
Section titled “Optional delivery and read callbacks”Set a callback in Notification webhook settings, or use
MCP set_notification_webhook with a webhook-scoped key and confirm: true. Supply a public HTTPS URL
without credentials, query parameters or a fragment. Only one callback URL can be active per account.
The signing secret appears once when you create or rotate it; save it in your receiver’s secret store.
get_notification_webhook and list_notification_webhook_deliveries use read scope and never reveal it.
The endpoint receives one JSON POST per observed state transition:
{ "event": "notification.delivered", "delivery_id": "nwd_…", "batch_id": "gwb_…", "item_id": "gwi_…", "status": "delivered", "created_at": "2026-10-01T00:00:00.000Z"}For a read receipt, event is notification.read and status is read. batch_id matches the
send response; item_id identifies its item. created_at is when MauKirim recorded the receipt,
not a timestamp asserted by the recipient. The callback contains no sender device, shared phone
number, recipient number, provider message id or message body.
Verify x-maukirim-signature: sha256=… as the HMAC-SHA256 of the raw POST body using your
notification callback secret, before parsing the JSON. The webhook verification example
uses the same signing format. x-maukirim-event repeats event, and x-maukirim-delivery repeats
delivery_id. Deduplicate by delivery id and reply 2xx, including on repeats: attempts are
at-least-once, time out after 10 seconds, and retry up to five times. A redirect is a failure.
Delivery failures do not resend or roll back the WhatsApp message. View attempts in notification
webhook settings or with MCP list_notification_webhook_deliveries.
Without a callback, MauKirim still records any receipts its gateway reports for the dashboard and delivery API; it makes no outbound POST. Pausing stops new callbacks and marks queued attempts exhausted on the worker’s next pass. Deleting removes the configuration and its delivery log. Rotating the secret invalidates the old one immediately, so update your receiver before its next delivery. An active callback requires a shared sender with a managed receipt connection: if none is ready, a new notification is refused before charging rather than silently sending without delivery tracking. This does not affect accounts without an active callback.
Common failures
Section titled “Common failures”| What you see | What it means |
|---|---|
400 invalid_input with “variables” in the log |
The variable set does not match the template. Read the template’s placeholders and match them exactly, including each list object’s keys. |
403 forbidden |
The template exists but is not approved, or belongs to another account. MauKirim will not send it. |
410 media_unavailable |
The fixed file is gone, or a per-send upload is missing, expired or owned by another account. Upload a new file; an id cannot be reused after expiry. |
400 invalid_input with “attachment” |
The upload id is missing for per-send mode, or was supplied for a fixed or text-only template. |
402 insufficient_credits |
The balance is below this message’s cost. |
409 batch_idempotency_conflict |
You reused an Idempotency-Key with a different body. Use a fresh key for a new message. |