Webhooks
MauKirim forwards the events it receives for your rented number to a URL you control. Your endpoint is the system of record for what happened; MauKirim’s delivery log is how you debug the hop.
Configure the destination
Section titled “Configure the destination”curl -X POST https://app.maukirim.com/api/v1/devices/gwd_…/webhook \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -d '{"url": "https://api.your-app.com/hooks/maukirim", "events": ["message", "message.ack"]}'{ "ok": true, "webhook": { "id": "rwh_…", "deviceId": "gwd_…", "url": "https://…", "events": ["message", "message.ack"], "active": true }, "secret": "9f3c…64 hex characters…" }| Field | Required | Notes |
|---|---|---|
url |
yes | https only. No credentials, no query string, no fragment, and not an IP literal or localhost — a webhook must be a real service reachable from the public internet. |
events |
no | Defaults to [], which means every event. Unknown names are rejected. |
active |
no | Defaults to true. Set it false to pause delivery without losing the destination. |
The secret is returned once, on creation. Store it before you do anything else. Rotating with PUT
returns a new one and invalidates the old immediately; an edit (another POST) keeps the existing secret
and does not return it.
Event names you can subscribe to
Section titled “Event names you can subscribe to”message, message.reaction, message.revoked, message.edited, message.ack, message.deleted,
chat_presence, group.participants, group.joined, label.edit, label.association,
newsletter.joined, newsletter.left, newsletter.message, newsletter.mute, call.offer
GET /devices/{id}/webhook returns this list as availableEvents plus gatewayWired, which tells you
whether the gateway has been confirmed wired to MauKirim. If gatewayWired is false, events will not flow
yet — ask MauKirim to confirm the gateway before you debug your own endpoint.
What arrives
Section titled “What arrives”POST /hooks/maukirim HTTP/1.1Content-Type: application/jsonx-maukirim-event: messagex-maukirim-delivery: whd_…x-maukirim-signature: sha256=…User-Agent: MauKirim-Webhooks/1{ "event": "message", "delivery_id": "whd_…", "device_id": "gwd_…", "created_at": "2026-09-28T13:05:00.000Z", "payload": { "…": "event-specific data" }}The envelope is the same for every event. Incoming message events use MauKirim’s normalized payload
below; other events, such as chat_presence, carry the gateway’s event data with credential-shaped
fields redacted.
Receiving images and files
Section titled “Receiving images and files”A WhatsApp user can send an image or file to your rented number. Subscribe to message using the
configuration above, with a key that has webhook scope. Keep any other events you need in the same
events array; replacing it with only ["message"] removes those subscriptions. An empty array receives
all events. This is separate from the shared-number OTP and notification services.
MauKirim collects the attachment before forwarding the message. Your endpoint receives the event once the media is stored or collection has failed, so an attachment can delay the event. There is no pending media event that you must poll.
Incoming image example
Section titled “Incoming image example”This illustrative payload shows a user sending an image with a caption:
{ "event": "message", "delivery_id": "whd_example_01", "device_id": "gwd_example", "created_at": "2026-10-13T10:00:02.000Z", "payload": { "id": "3EB0EXAMPLE", "chat_id": "6281234567890@s.whatsapp.net", "from": "6281234567890@s.whatsapp.net", "from_name": "Customer", "timestamp": "2026-10-13T10:00:00.000Z", "is_from_me": false, "body": "Here is my payment receipt.", "media": { "kind": "image", "available": true, "url": "https://cdn.maukirim.com/wa/gwd_example/3EB0EXAMPLE.jpg", "content_type": "image/jpeg", "byte_size": 184320, "file_name": "receipt.jpg" } }}| Field | Meaning |
|---|---|
payload.id |
WhatsApp message id. The webhook delivery id is separate. |
payload.chat_id, payload.from |
WhatsApp chat and sender identifiers, not necessarily E.164 phone numbers. |
payload.from_name |
Sender name, omitted when unknown. |
payload.timestamp |
When the message occurred. Envelope created_at is when the webhook delivery was created. |
payload.is_from_me |
false for forwarded incoming messages. The number’s own outgoing messages are not forwarded through this incoming-message path. |
payload.body |
Text or attachment caption, omitted when absent. |
payload.media |
Omitted for text-only messages. Present when the message carries media, even if unavailable. |
payload.media.kind |
Media kind; an image uses image, a document uses document. |
payload.media.available |
Whether MauKirim could provide a stored media URL when it built this payload. |
payload.media.url |
Public CDN URL to the collected file, or null when there is no usable link. |
payload.media.content_type |
MIME type, or null when unknown. |
payload.media.byte_size |
File size in bytes, or null when unknown. |
payload.media.file_name |
File name, or null when unknown. Treat it as untrusted input, not a local filesystem path. |
Download the attachment
Section titled “Download the attachment”Verify x-maukirim-signature over the raw request body using the signature example below before
parsing or processing the event. Deduplicate by x-maukirim-delivery, durably save the event or queue
your work, then return 2xx. Downloading in background work avoids exceeding the 10-second webhook
timeout.
For a message event, check that payload.media exists, available is true, and url is non-null.
Copy that URL into IMAGE_URL:
# Replace this illustrative URL with payload.media.url from your verified webhook.IMAGE_URL="https://cdn.maukirim.com/wa/gwd_example/3EB0EXAMPLE.jpg"curl --fail --show-error --location "$IMAGE_URL" --output received-image.jpgThe CDN download does not need your MauKirim API key or webhook secret. Do not send either credential
to the media URL. For a document, use the same steps and save it with an appropriate extension.
Use payload.media.url, not a gateway statics/… path or WhatsApp’s encrypted media address.
Unavailable media and retention
Section titled “Unavailable media and retention”If collection fails, the message still arrives with media.available: false and media.url: null.
The media metadata may also be null. Preserve any caption and record that an attachment was sent;
ask the sender to resend it if you need the bytes. Returning an error only retries the same webhook
delivery; it does not trigger another media collection. Files above the 32 MiB inbound collection
limit cannot be collected.
Download files you need to keep into your own storage. MauKirim clears stored incoming message bodies and media references after seven days, measured from the message timestamp. This does not revoke a CDN URL you already received: the current cleanup does not delete the stored object.
Media URLs are public: anyone holding one can download the file. Treat them as sensitive, avoid publishing them in public logs, and use your own access-controlled storage when your application needs authenticated downloads.
Chat presence on a rented number
Section titled “Chat presence on a rented number”To receive per-chat presence events, include chat_presence in the rented device’s webhook subscription.
Keep any other events you need in the same events array; replacing the subscription with only
chat_presence stops forwarding the other event types. An empty array subscribes to all events.
curl -X POST https://app.maukirim.com/api/v1/devices/gwd_…/webhook \ -H "Authorization: Bearer mk_live_…" -H "Content-Type: application/json" \ -d '{"url":"https://api.your-app.com/hooks/maukirim","events":["message","chat_presence"]}'When the gateway reports chat presence, your endpoint receives the same signed envelope as other events:
{ "event": "chat_presence", "delivery_id": "whd_…", "device_id": "gwd_…", "created_at": "2026-09-28T13:05:00.000Z", "payload": { "chat_id": "6281234567890@s.whatsapp.net", "state": "composing" }}The payload is the gateway’s event data with credential-shaped fields redacted; treat this shape as an
example, not a fixed schema. Verify x-maukirim-signature before reading it, and deduplicate by
x-maukirim-delivery. This event reports activity in a chat. To show typing from your own rented number,
call POST /devices/{id}/chat-presence;
the number-wide online indicator uses a different endpoint.
Verify the signature — always
Section titled “Verify the signature — always”x-maukirim-signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed by
your webhook secret. Compute it over the bytes you received, before any JSON parsing or re-serialization.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyMauKirim(rawBody, signatureHeader, secret) { const expected = "sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex") const a = Buffer.from(expected, "utf8") const b = Buffer.from(signatureHeader ?? "", "utf8") // Constant time, and length-checked first so a wrong length cannot throw. return a.length === b.length && timingSafeEqual(a, b)}$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);$ok = hash_equals($expected, $_SERVER['HTTP_X_MAUKIRIM_SIGNATURE'] ?? '');# debugging by handexpected="sha256=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"[ "$expected" = "$SIGNATURE_HEADER" ] && echo ok || echo mismatchTwo failure modes are worth naming: parsing JSON and re-encoding it changes the bytes and the signature will
not match; and == on the whole header is not constant time. Use the raw body and a timing-safe comparison.
Delivery, retries and duplicates
Section titled “Delivery, retries and duplicates”| Timeout | 10 seconds per attempt |
| Success | Any 2xx. A 3xx is treated as a failure, not a redirect to follow. |
| Retries | Up to 5 attempts, backoff 0s, 30s, 2m, 10m, 30m |
| After 5 | The delivery is exhausted — terminal, and distinct from failed |
| Duplicates | Retries reuse the same x-maukirim-delivery id. Deduplicate on it. |
Deliveries are at-least-once. Your handler must be idempotent keyed on x-maukirim-delivery, and must
return 2xx even for an event you have already processed — returning an error for a duplicate makes MauKirim
retry something you already have.
An inactive webhook is exhausted immediately with webhook_inactive, which is the honest way to say “you
paused this” rather than retrying for hours.
The delivery log
Section titled “The delivery log”curl "https://app.maukirim.com/api/v1/webhook-deliveries?deviceId=gwd_…&limit=25" \ -H "Authorization: Bearer mk_live_…"{ "ok": true, "deliveries": [{ "id": "whd_…", "deviceId": "gwd_…", "event": "message", "state": "delivered", "attempts": 1, "responseStatus": 200, "errorCode": null, "payload": { "…": "sanitized event body" }, "createdAt": "2026-09-28T13:05:00.000Z", "completedAt": "2026-09-28T13:05:00.412Z" }], "hasMore": false}| Query | Default | Notes |
|---|---|---|
deviceId |
all devices | Filter to one device |
limit |
25 | Clamped to 1–100 |
offset |
0 | Negative values are treated as 0 |
state is queued, failed, delivered or exhausted. errorCode explains a failure:
endpoint_unreachable (no answer), endpoint_rejected (non-2xx), webhook_inactive (paused),
webhook_secret_unreadable (MauKirim could not open the stored secret — tell support).
The logged payload is the customer’s own event data with anything credential-shaped replaced by
"[redacted]" on write, so it is safe to read in a support conversation.
The log covers your currently usable rental’s destination. Removing it deletes its deliveries, so export anything you still need first; a number that changes hands, even back to you, starts with an empty log.