Skip to content

Rent and use a number

A rented number is a WhatsApp number MauKirim provisions and hosts for you. You can set its display name and profile photo, send messages, publish presence and receive events at your webhook. MauKirim keeps the WhatsApp session alive; you do not need to pair a QR code or run a client.

Choose and pay for a number in the dashboard or through MCP with a hosted checkout link. Once active, you can operate it over the API or through the corresponding MCP tools.

Renting is outside your package subscription. A package buys sending allowance; a rental buys a number, billed on its own term (monthly or annual) at the price the operator set for it, and it renews until you turn renewal off.

Step Where Why
Choose and rent a number Dashboard under Services → Rent a Number, or MCP list_rental_offers, quote_rental and purchase_rental The account’s country and the configured payment provider decide which offers can be bought. An MCP purchase requires a device-scoped key, an idempotency key and explicit confirmation; it returns a hosted checkout URL.
Use the number Dashboard, MCP or the public API Send text, upload bounded base64 media then send by media id, set branding and presence, receive events and stop renewal. Multipart REST endpoints are available for binary uploads.

There is no public REST purchase endpoint. Do not call the session-only /api/rentals route with a bearer key; MCP’s purchase_rental tool uses the same checkout rules on behalf of the authenticated account. The buyer completes payment on the provider’s hosted page.

Steps 1–3 work in the dashboard or through MCP. Step 4 can be a dashboard or MCP action. Steps 5–9 show the public API path, with MCP tools available for text and settings. Steps 1–5 are required, steps 6–7 are optional, and steps 8–9 wire the event hop and prove it works.

1. Sign in to an account that may buy the offer

Section titled “1. Sign in to an account that may buy the offer”

The storefront and MCP use the active account’s country to decide which numbers it may rent. A package is not required: rental sits outside the package lockout.

Services → Rent a Number and MCP list_rental_offers show numbers an operator has put on offer. An offer appears only when:

  • the device is allocated to rental and has an active plan,
  • the plan’s allowedCountries is empty or contains your country,
  • some enabled payment rail in the plan’s region settles the plan’s currency,
  • nobody else holds an open rental on it.

Each row shows the masked number, the plan, the price for the term you select, the plan’s included sends and whether the session is ready (Session offline means the checks in step 6 are currently failing). The storefront masks every number, because a listing is public. Once the rental is yours and usable — active or past_due — My Numbers and GET /devices show the full +E.164 number; a reservation still awaiting payment (pending_payment) and a released rental stay masked. Address a device by its id in API calls either way.

2. Choose the number, the term and any promotion code

Section titled “2. Choose the number, the term and any promotion code”

Pick a number and Monthly or Annual. The dashboard summary or MCP quote_rental shows the total due today, with any promotion code checked server-side before anything is bought.

In the dashboard, Continue to payment calls the session-only POST /api/rentals with an Idempotency-Key. In MCP, purchase_rental requires the device scope, an idempotency key and a confirmed number and term. Both use the same order and checkout rules. A bearer key cannot call the dashboard route directly; there is no public REST purchase endpoint.

From the moment the order exists the device is held — the rental is pending_payment and a partial unique index stops a second checkout taking the same number. Then one of two things happens:

  • The code covers the whole price (total 0): the order settles locally with no provider call, and the rental activates immediately.
  • Otherwise the dashboard redirects to the payment provider’s hosted page. MCP returns that checkout URL for you to open. MauKirim never sees a card number. The provider’s notification settles the order; returning from checkout only reports what MauKirim already knows.

A reservation that is not paid within the order window (24 hours) expires and the device is released, so a number can reappear in the catalogue. The dashboard shows these refusals as messages; the codes behind them are rental_region_blocked (your country is not on the plan’s list), rental_unavailable (the number is no longer for sale), rental_already_active (it is already reserved or rented) and currency_unsupported (no enabled rail settles the plan’s currency).

4. Create an API key with the scopes the rest of the path needs

Section titled “4. Create an API key with the scopes the rest of the path needs”

Under Developer → API keys. The API steps below use four scopes, and a key holding all of them can do everything on one number:

Scope What this page uses it for
read GET /devices, GET /devices/{id} — confirm the rental and poll the session
device PATCH /devices/{id}/profile, POST /devices/{id}/presence — brand the number, set presence
send POST /devices/{id}/messages — send from the number
webhook /devices/{id}/webhook, GET /webhook-deliveries — where events go and what happened to them

Create a key in the signed-in dashboard; its value is shown once, and MauKirim keeps only a SHA-256 hash. An MCP assistant can also create one, but only within the scopes its own key already holds — see Authentication and the MCP server.

The same rental appears under Services → My Numbers with its profile, webhook, delivery log, invoices and renewal. Use MCP list_my_rentals or the API’s read key to inspect it:

Terminal window
curl https://app.maukirim.com/api/v1/devices -H "Authorization: Bearer mk_live_…"
{
"ok": true,
"devices": [{
"id": "gwd_…",
"label": "Studio",
"number": "+6281234567890",
"plan": "Reserved monthly",
"status": "active",
"term": "monthly",
"currency": "IDR",
"priceMinor": 149000,
"autoRenew": true,
"currentPeriodEnd": "2026-10-28T00:00:00.000Z",
"pushName": "Studio Support",
"hasAvatar": true,
"connection": { "state": "connected", "isConnected": true, "isLoggedIn": true, "missingSince": null }
}]
}

The row appears as soon as the order exists, so status is the thing to read: pending_payment until the first invoice is paid, then active. Only active and past_due make the per-device routes reachable; every other state answers 404 rental_not_found on all of them — which is why this list, and not the per-device routes, is where you check a rental.

number depends on status. While the rental is usable — active or past_due — number is the full +E.164 number, +6281234567890 above, and pushName, hasAvatar and the connection object are the real values. While it is not — pending_payment before the first invoice is paid, or a released suspended/cancelled/expired rental — number is the storefront mask (+, first two digits, ••• •••, last four), pushName is null, hasAvatar is false, and every connection field is null. A number you can use is one you may call back; a number you cannot is not yours to keep in a log. Your customers’ numbers — the ones you send to — are always full E.164.

GET /devices/{id} reads one device instead and adds the rental terms (including includedMessages, the rental plan’s send allowance), the webhook settings, lastSeenAt and the same connection object. Only an active/past_due rental reaches it, so its device.number is always the full number. It never returns the webhook signing secret.

A rental being paid for is not the same as the number answering. Poll GET /devices (or read one device with GET /devices/{id}) until connection.isConnected and connection.isLoggedIn are both true. A row whose status is not active/past_due reports every connection field as null on purpose — that is the same mask that hides its number, not a stale observation, and readiness only means something once the rental is usable. For a usable rental, connection.state is the gateway’s own label for the session — disconnected, connecting, connected or logged_in, and null before the first sync has been observed. When the gateway’s status call did not answer, isConnected and isLoggedIn are null rather than false: treat anything other than true as not ready. missingSince becomes non-null if the gateway stops reporting the device. Until it is ready, a send, a branding write or a presence call is refused:

Code Condition
409 device_not_connected The WhatsApp session is not connected or not logged in
409 rental_device_not_ready The last observation is too old to act on — or the device or its gateway row is gone
409 gateway_disabled The gateway is switched off or has never been reached
404 device_not_found The device is no longer in the gateway’s inventory

The first two are worth a retry; the last two are an operator’s problem — contact support rather than retrying.

7. Brand the number and set its presence (optional)

Section titled “7. Brand the number and set its presence (optional)”

Both are covered in Branding and Presence below. Set the display name and photo once so recipients see your business rather than a bare number, and use presence if you want the number marked online while a human is answering. Neither is required to send.

8. Point the number’s events at your endpoint

Section titled “8. Point the number’s events at your endpoint”

MauKirim owns the device’s single gateway webhook, verifies what arrives, and forwards it to a destination you configure here:

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…" }

Store the secret now — it is returned only by the call that created the destination (or by a deliberate rotation with PUT); an edit and a read never return it. Your endpoint verifies every delivery with it:

import { createHmac, timingSafeEqual } from "node:crypto"
// Over the raw bytes, before any JSON parsing: re-serializing changes them and the signature stops matching.
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")
return a.length === b.length && timingSafeEqual(a, b) // constant time, length-checked first
}

Every delivery carries x-maukirim-event, x-maukirim-delivery and x-maukirim-signature: sha256=<hex HMAC-SHA256 of the raw request body>. Webhooks has the same function for PHP and for debugging by hand, and the two ways this usually goes wrong.

GET /devices/{id}/webhook also reports gatewayWired: if it is false, MauKirim has not yet confirmed that the gateway reports to us, so nothing will arrive and the fault is not at your endpoint.

Left out, events means every event; unknown names are rejected and duplicates removed. Valid url values are https only, without credentials, query or fragment, and must name a public host — not an IP literal and not localhost, because the worker POSTs on your behalf. A handler running on your laptop therefore needs a public tunnel in front of it.

9. Send a test message, then prove the hop

Section titled “9. Send a test message, then prove the hop”

Send to a phone you control (scope send):

Terminal window
curl -X POST https://app.maukirim.com/api/v1/devices/gwd_…/messages \
-H "Authorization: Bearer mk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: setup-check-1" \
-d '{"phone": "+6281234567890", "message": "Setup check from our new number."}'
{ "ok": true, "batchId": "gwb_…", "created": true,
"progress": { "total": 1, "counts": { "queued": 1 }, "terminal": false } }

created: false means the Idempotency-Key replayed an identical request and the earlier batch was returned — not a second message. There is no batch-status endpoint: the snapshot returned here is not a subscription, and the outcome arrives as a webhook (message when a message is sent or received, message.ack for the recipient’s receipt).

Then check both hops:

  1. Send a WhatsApp message to the rented number from your personal phone. A message event should reach your endpoint, signed.
  2. Read what was forwarded and what your endpoint answered:
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": "…", "completedAt": "…"
}],
"hasMore": false
}

state is queued, delivered, failed (one non-2xx answer) or exhausted (all 5 attempts used, or the webhook is inactive). Any 2xx counts as success — a 3xx is a failure — and retries reuse the same x-maukirim-delivery id, so deduplicate on it.

What you see Read this first What it means
Every device route answers 404 rental_not_found GET /devices No active/past_due rental for that device on this key’s account. A device that belongs to somebody else answers identically on purpose. If the row says pending_payment, the payment has not settled; suspended, cancelled and expired are terminal.
403 api_key_scope_denied The operations table below The key does not carry the scope the route requires. Create one that does; scopes cannot be added to an existing key.
401 api_key_invalid / api_key_revoked / api_key_expired Authentication The key is wrong, revoked or past its expiry. Rotation is create-then-deploy-then-revoke.
409 device_not_connected or 409 rental_device_not_ready GET /devices → connection The session is down or the last health observation is stale. Retry once it reports isConnected and isLoggedIn; if it stays down, contact support.
409 gateway_disabled, 404 device_not_found — The gateway is off/unreachable or the device left the gateway’s inventory. Not something your code can fix: contact support.
410 media_unavailable — No private media bucket is configured, so an avatar or attachment cannot be stored.
400 invalid_input Errors The rejected field is named in the message: phone not E.164, pushName over 64 characters, an avatar that is not JPEG/PNG or whose bytes do not match its type, a webhook URL that is not public https, an unknown event name.
409 batch_idempotency_conflict Idempotency The same Idempotency-Key was reused with a different body. Use a new key for a new message.
Nothing arrives at your webhook GET /devices/{id}/webhook webhook: null means none is configured; active: false means it is paused; gatewayWired: false means the gateway is not confirmed wired — ask MauKirim; otherwise check the events list and your signature check.
The delivery log says endpoint_unreachable, endpoint_rejected, webhook_inactive, webhook_secret_unreadable Webhooks Your endpoint did not answer, answered non-2xx, the webhook is paused, or the stored secret could not be opened (tell support).
A message shows accepted but the customer says it never arrived message.ack on your webhook accepted is MauKirim’s side of the hop (queued → sending → accepted/failed); the recipient’s delivery and read receipts arrive later as message.ack events, so the customer’s phone is the other half of the story. uncertain means the attempt may or may not have arrived and was not retried.
The rental page still says pending /services/rent/return The provider’s notification has not settled the order yet. Refresh; a late activation is not a lost number, and the reservation holds until the order window elapses.
What Request Scope
List your rented numbers GET /devices read
Read one device, its rental and its webhook settings GET /devices/{id} read
Set the WhatsApp display name or profile photo PATCH /devices/{id}/profile device
Set your presence to online/offline POST /devices/{id}/presence device
Show or clear typing in one chat POST /devices/{id}/chat-presence device
Send text, an image or a file from the number POST /devices/{id}/messages send
Configure where events are delivered GET/POST/PUT/DELETE /devices/{id}/webhook webhook
Read what was delivered and what your endpoint replied GET /webhook-deliveries webhook

Sends from a rented number are covered by the rental’s own terms, not by the OTP/notification credit allowance — see Credits and pricing.

Sending accepts multipart/form-data as well as JSON: a file part (an image up to 5 MiB, or a document up to 16 MiB) with phone and an optional caption, and the filename you send is the filename the recipient reads. An image renders inline with the caption, a document arrives as a named file.

Terminal window
# the display name customers see
curl -X PATCH https://app.maukirim.com/api/v1/devices/gwd_…/profile \
-H "Authorization: Bearer mk_live_…" -H "Content-Type: application/json" \
-d '{"pushName": "Studio Support"}'
# the profile photo
curl -X PATCH https://app.maukirim.com/api/v1/devices/gwd_…/profile \
-H "Authorization: Bearer mk_live_…" \
-F "avatar=@logo.png;type=image/png"

The display name is 1–64 characters; the photo is a JPEG or PNG up to 5 MiB whose bytes must match its declared type. Both take effect on WhatsApp immediately, and the next sync reports the name back as pushName. There is no “remove photo” operation — WhatsApp’s API for this only replaces, so changing the photo is the only mutating action.

Terminal window
curl -X POST https://app.maukirim.com/api/v1/devices/gwd_…/presence \
-H "Authorization: Bearer mk_live_…" -H "Content-Type: application/json" \
-d '{"type": "available"}'

type is available or unavailable. This is WhatsApp’s “online” indicator for the number — nothing is recorded on MauKirim’s side, so it is not a status endpoint. Like a send, it requires the session to be reachable, and fails with the same 409/404 codes when it is not.

Incoming typing updates use the rented device’s chat_presence webhook event. To show typing from your number in one recipient chat, use the separate endpoint below.

Start typing for the recipient (scope device):

Terminal window
curl -X POST https://app.maukirim.com/api/v1/devices/gwd_…/chat-presence \
-H "Authorization: Bearer mk_live_…" -H "Content-Type: application/json" \
-d '{"phone":"+6281234567890","action":"start"}'

Then submit the message with POST /devices/{id}/messages and its Idempotency-Key (scope send). The send response means queued, not dispatched. When your workflow no longer wants typing shown, send stop with the same number:

Terminal window
curl -X POST https://app.maukirim.com/api/v1/devices/gwd_…/chat-presence \
-H "Authorization: Bearer mk_live_…" -H "Content-Type: application/json" \
-d '{"phone":"+6281234567890","action":"stop"}'

Do not stop solely because the send request returned 200; the queue may not have dispatched the message yet. The typing calls are immediate gateway actions, take no Idempotency-Key, and do not bill a message. They require a healthy gateway and a connected, recently seen rented device. See Device actions for the response and errors.

Renewal, suspension and what happens if you stop paying

Section titled “Renewal, suspension and what happens if you stop paying”
Rental status What it means What the API does
pending_payment A reservation while the first invoice is unpaid Nothing yet — the device is held, and released if the order window lapses
active Paid for the current period Everything works
past_due The renewal invoice is unpaid but still inside the grace period Everything still works — this is your window to fix billing
suspended The grace period ended Every device route answers 404 rental_not_found
cancelled The rental ended Same as suspended
expired A reservation that was never paid Same as suspended; the number is back in the catalogue

MauKirim invoices a rental before the period starts (three days ahead) and gives you a three-day grace window after the due date before anything stops, so a failed card does not take a number offline the same hour. Renewal is invoicing, not a stored card: each period arrives as an invoice you pay through the same checkout, and the invoice history is on My Numbers. Cancel renewal turns autoRenew off and keeps the period you already paid for: the rental ends at currentPeriodEnd and moves to cancelled then.

The events from WhatsApp — incoming messages, acknowledgements, reactions, presence, group changes — are delivered to your endpoint, not kept inside MauKirim. Configure it with POST /devices/{id}/webhook, verify the signature, and read Webhooks before you build on it: retries, ordering and the x-maukirim-delivery id all matter.