Skip to content

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.

Terminal window
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.

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.

POST /hooks/maukirim HTTP/1.1
Content-Type: application/json
x-maukirim-event: message
x-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.

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.

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.

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:

Terminal window
# 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.jpg

The 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.

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.

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.

Terminal window
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.

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'] ?? '');
Terminal window
# debugging by hand
expected="sha256=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
[ "$expected" = "$SIGNATURE_HEADER" ] && echo ok || echo mismatch

Two 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.

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.

Terminal window
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.