API reference
Everything you need to send and receive WhatsApp messages from your own software. One base URL, one API key, plain JSON. The interactive reference below is generated from this server's OpenAPI spec, so it always matches what's deployed.
Getting started
Three things, in order:
| Step | Where |
|---|---|
| 1. Create an account and an API key | Dashboard → API keys. The key is shown once — store it in your secrets manager. |
| 2. Connect a WhatsApp number | Dashboard → Numbers → Add number → scan the QR from WhatsApp → Linked devices. You get a Phone ID like ph_fxf1gq…. |
| 3. Send your first message | The request below. Replace PHONE_ID and YOUR_KEY. |
The response is 202 Accepted with the message object. Sends are queued and paced (see Sending speed); the message's status moves through queued → sent → delivered → read, and you can watch it via webhooks or the Logs page.
Authentication
Every request carries your key in the Authorization header:
Authorization: Bearer sk_live_…
Keys belong to the account, not to a number — one key can drive every number you connect. Create one key per integration (one for Sheets, one for n8n, one for your CRM) so revoking one never breaks the others. There is no rate limit on API calls themselves; the limits that matter are the per-number sending caps described under Sending speed.
Where's my Product ID / Token? If you're coming from Maytapi: your Account ID is on the Connect & docs page (only needed for support), your Phone ID is under each number, and your API key replaces the token. There's no separate product id in requests — the key already identifies your account.
Sending messages
One endpoint sends everything: POST /v1/phones/{id}/messages. The body has to (a phone number with country code, in any format, or a group JID) and content, whose kind chooses the type.
Idempotency. Send an Idempotency-Key header (any unique string, e.g. your order id) and a retried request returns the original result instead of sending twice. Use this from anything that might retry — Sheets scripts, queue workers, flaky networks.
Quoting. Add "replyTo": "<waMessageId>" to quote an earlier message (yours or theirs) so the bubble shows the reply context.
Content types
| kind | Fields | Notes |
|---|---|---|
text | text, linkPreview? | WhatsApp formatting works: *bold*, _italic_, ~strike~, ```mono```. |
image | source, caption? | source is one of {"url": "…"}, {"base64": "…"} or {"mediaId": "…"} from POST /v1/media. Upload once, send many times. |
video | source, caption?, gif? | |
audio | source, ptt? | |
document | source, filename, caption? | |
sticker | source | |
location | latitude, longitude, name?, address? | |
contact | displayName, vcard | A standard vCard 3.0 string. |
reaction | targetWaMessageId, emoji | Send an empty emoji to remove. |
forward | waMessageId, fromChatJid | Forwards a message this number has seen. |
buttons | body, buttons[], footer?, header? | Up to 3 quick-reply buttons. See below. |
list | body, buttonText, sections[] | Up to 10 rows across sections. |
cta | body, buttons[] | URL, call, copy-to-clipboard and reply buttons mixed. |
poll | name, options[], selectableCount? | Native WhatsApp poll; renders everywhere. |
Buttons & lists
Interactive messages let a recipient answer with a tap. The id you give each button comes back verbatim in the message.received webhook as data.reply.id — route on that, never on the label text, so translations and wording changes don't break your flow.
Rendering caveat. Buttons and lists are a business-account feature and WhatsApp changes how consumer clients render them. PulseApi sends the current native format (and can fall back with "render": "legacy"). If a recipient's app shows nothing, poll is the fallback that renders on every client and still gives you a tappable answer.
Sending speed & warm-up
Bulk-sending from a fresh number is how numbers get banned. PulseApi paces every number automatically along a warm-up ladder: a small daily cap and a gap between messages on day one, opening up over a month of normal use. Messages beyond today's cap simply wait in the queue and go out tomorrow — nothing is dropped.
| Number age | Daily cap | Gap between sends |
|---|---|---|
| Day 0 | 20 | 30 s |
| Day 1 | 40 | 20 s |
| Day 2 | 80 | 15 s |
| Day 3–6 | 150 | 10 s |
| Day 7–13 | 400 | 6 s |
| Day 14–29 | 1,000 | 4 s |
| Day 30+ | 5,000 | none |
A number that has been on WhatsApp for years is not a day-zero risk. Toggle Established number on the Numbers page and it starts at the day-7 rung. GET /v1/phones/{id} returns the live figures under throughput, and GET /v1/phones/{id}/queue shows what's waiting and how long it will take.
Anti-ban: how numbers get banned
WhatsApp never publishes its rules, but years of operating numbers through unofficial APIs show the same handful of patterns behind almost every ban. Knowing them is most of the defence.
| Ban type | What triggers it | What you see | Recovery |
|---|---|---|---|
| Spam / rate ban (temporary) | Too many messages too fast from a young number; hundreds of identical texts; big bursts after silence. | "You can't send messages for N hours" or status banned for a period. | Wait it out (hours to days). Do not re-pair or retry aggressively; every retry makes the next ban longer. |
| Report ban (temporary → permanent) | Recipients tap Report or Block. A few reports on a young number are enough. | Sudden ban shortly after a campaign to people who did not expect it. | Appeal inside the app. Stop messaging lists that never opted in. |
| Pattern ban | Machine fingerprints: sends at a perfectly regular interval, 24×7 activity, zero inbound traffic, no typing indicators, no read receipts. | Repeated short bans escalating to a permanent one. | Turn on Safe Mode, set quiet hours, and make sure people reply to this number. |
| Device / session ban | Many linked-device sessions churning (re-pairing repeatedly), or the same number paired from many IPs in a short time. | logged_out in a loop, or "can't link device". | Pair once, keep the session, avoid re-scanning the QR unless you must. |
| Permanent ban | Repeated offences of any type above, or clearly abusive content. | "This account is no longer available on WhatsApp." | Usually final. Appeal is possible in-app but rarely succeeds. Use a new number and warm it up properly. |
The single best predictor of a number's survival is its reply ratio — inbound messages divided by outbound. A number that gets replies to a third of what it sends looks like a person; one below a tenth looks like a broadcaster. The Anti-Ban page shows this per number for the last seven days.
Rules of thumb
| Do | Don't |
|---|---|
| Message people who know you and expect to hear from you. | Buy lists, scrape numbers, or message people who have never replied — in bulk. |
| Personalise text (name, order, context) so no two messages are identical. | Send the same 200-character blast to hundreds of contacts. |
| Let a new number warm up for its first month (PulseApi does this for you). | Pair a fresh SIM and send 500 messages the same afternoon. |
| Keep the phone itself in use: reply from it, join groups, have a profile photo and name. | Leave the handset in a drawer with no profile, no photo, no status. |
| Use Safe Mode and quiet hours. | Send at 3 am at a metronome-steady 6-second gap. |
| Prefer conversations (buttons, polls, questions) that invite a reply. | Broadcast one-way announcements only. |
How Safe Mode works
Safe Mode is on by default for every number. It does four things, each aimed at a specific machine fingerprint:
| Mechanism | What it does | Fingerprint it defeats |
|---|---|---|
| Typing indicator | Shows "typing…" (or "recording…" for audio) for roughly as long as a person would need to type the message — about 35 ms per character, capped at 4 s — then sends. | Messages that appear instantly with no composing state. |
| Uneven gaps | On top of the ladder's minimum gap and its normal jitter, each gap gets a random extra of 0–50%. | A perfectly periodic send timeline. |
| Breaks | After roughly every 15th message the number pauses for one to three minutes. | Hours of uninterrupted activity. |
| Quiet hours (optional) | Holds sends between two local hours you choose; queued messages resume when the window ends, spread over a couple of minutes so a fleet doesn't all wake at 08:00:00. | Round-the-clock sending, and messages that annoy people at night. |
Together with the warm-up ladder, this is the same behaviour you'd get from a careful human operator — just applied to every message without anyone having to think about it. Everything is configurable per number on the Anti-Ban page, or via PATCH /v1/phones/{id} with safeMode, quietHours and pacing.
Custom pacing. You can override the ladder with your own gap and daily cap (floors: 1 second, 10,000/day). That is appropriate for a long-established number whose history you know. It is not appropriate for a number you paired last week — the ladder exists because that is exactly how numbers get banned.
Proxies per number
Every WhatsApp number linked through PulseApi connects from your server's IP. One number from one IP looks like a normal linked device. Ten numbers from the same IP look like a farm — and when one gets flagged, its neighbours tend to follow. A proxy gives each number its own exit address, ideally in the same country as the SIM.
| Do | Avoid |
|---|---|
| Residential or mobile (4G/5G) proxies, one per number, in the SIM's country. | Shared datacenter proxies — WhatsApp scores those addresses badly and many are already blacklisted. |
| A sticky session (same IP for days or weeks). | Rotating IPs every few minutes — a device that hops countries hourly is its own red flag. |
| Test first (the Anti-Ban page shows the exit IP and latency), then Save & reconnect. | Changing the proxy repeatedly on a live number; each change is a re-login from WhatsApp's point of view. |
Set it per number on the Anti-Ban page or with PATCH /v1/phones/{id} and "proxyUrl": "http://user:pass@host:port" (also socks5://). The number reconnects through the proxy immediately; pass null to go direct again. Credentials are stored on your server and never shown back in full. POST /v1/phones/{id}/proxy-test checks a URL without saving it.
Webhooks
Register a URL on the Webhooks page (or POST /v1/webhooks) and choose events. Each event is a JSON POST to your URL. Respond with any 2xx within 10 seconds; anything else is retried with backoff, and you can see and re-send every delivery on the Logs page.
Verifying signatures
Every delivery carries x-pulseapi-signature: t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of "<t>.<raw body>" with your endpoint's secret (shown once when you create it). Verify it before trusting a payload, and reject timestamps older than five minutes to defeat replays.
Apps Script can't read request headers, so for Google Sheets use a secret in the URL instead (?key=…) — the Sheets template does this for you.
Errors
Errors are JSON with a stable code you can branch on and a human message:
{ "error": { "code": "unprocessable", "message": "Phone is disconnected. It must be connected before it can send.", "details": null } }
| HTTP | code | Meaning |
|---|---|---|
| 400 | bad_request | Body or query failed validation; details says which field. |
| 401 | unauthorized | Missing or revoked API key. |
| 402 | payment_required | The trial or subscription has ended. Only /v1/me, /v1/billing, /v1/account and /v1/keys keep working until a plan is active. |
| 404 | not_found | No such phone/message/webhook in your account. |
| 409 | conflict | Already exists, or already connected. |
| 422 | unprocessable | Valid request, but the number isn't in a state to do it (not connected, message already sent). |
| 429 | rate_limited | Too many signups/logins from one IP. Wait and retry. |
Google Sheets, n8n, Make
Google Sheets. The dashboard's Connect page has a ready template: paste one script, fill three cells, and a PulseApi menu appears in the sheet for sending texts, images and buttons to every row — with auto-replies on button taps.
n8n / Make / Zapier. Use an HTTP Request node with Header Auth (Authorization: Bearer …), or import the OpenAPI spec so every endpoint appears as a typed action. For inbound, point a Webhook trigger node's URL at PulseApi → Webhooks.
Postman. Download the collection, set the baseUrl and apiKey variables, and every request works.
Contacts & Google sync
PulseApi keeps an address book per account. Fill it three ways: import a CSV (any file with a header row — Google Contacts, Outlook and Excel exports all work; columns are matched by name in any order), sync Google Contacts (read-only OAuth; nothing is written back to Google), or add by hand. Numbers are normalised to digits with a country code so duplicates collapse.
From the Contacts page you can search, tag, bulk-check who is on WhatsApp through one of your numbers (remembered per contact), send a quick message, and export everything back to CSV. The same data is on the API under /v1/contacts.
Enabling Google sync (operator, once)
| Step | Where |
|---|---|
| 1. Create a project and enable the People API | Google Cloud Console → APIs & Services → Library |
2. Configure the OAuth consent screen (External, scope contacts.readonly) | APIs & Services → OAuth consent screen. While in "Testing", add your users' Google emails as test users; publish when ready. |
3. Create an OAuth client, type Web application, redirect URI https://YOUR-DOMAIN/v1/integrations/google/callback | APIs & Services → Credentials |
4. Put the client id and secret in .env as GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET and restart | Server |
Refresh tokens are stored encrypted at rest. Disconnecting removes the token; synced contacts stay.
Migrating from Maytapi
PulseApi answers Maytapi's sendMessage call exactly as Maytapi does, so software already written against Maytapi moves over by changing its base URL and three credential values. No code change, no new SDK.
| Maytapi setting | PulseApi value | Where to find it |
|---|---|---|
Base URL https://api.maytapi.com | Your PulseApi address, e.g. https://getpulseapi.com | Dashboard → Developers → Product ID & Token |
product_id | Your account id (ten_…) | Same page |
phone_id | The number's numeric id (e.g. 10001); its ph_… id works too | Same page, or under each number on the Numbers page |
x-maytapi-key | Any PulseApi API key — same header name | Developers → API keys |
The call
POST {base}/api/{product_id}/{phone_id}/sendMessage
x-maytapi-key: sk_live_…
content-type: application/json
{"to_number": "919830000001", "type": "text", "message": "Hello 👋"}
Media is the same shape as Maytapi's: type media, message either an http(s) URL or an inline data:image/jpeg;base64,… URI (also PDF, PNG, MP4), text as the caption and filename as the name the recipient sees. Inline files up to 32 MB are stored on the server and sent from there, so the queue never carries the base64. Numbers are bare digits with the country code, exactly as Maytapi takes them.
The answer
HTTP 200
{"success": true, "data": {"chatId": "919830000001@c.us", "msgId": "msg_…", "phoneId": 10001}}
200 means the message is queued and paced like every other PulseApi message; the WhatsApp delivery status arrives on webhooks and in Logs. Anything that could not be queued is a non-200 with {"success": false, "message": "…"}, so a caller that only checks the status code and one that only reads success both see the same thing. A number that is briefly reconnecting still accepts messages (they wait for the session); one that has been logged out returns 422 until it is scanned again.
What is and is not covered
Covered: text, media (URL or inline), location ("lat,lng" in message). Not covered on this endpoint: polls, buttons, lists, contacts, and Maytapi's status, QR, contact and group endpoints — use the native API for those; it has all of them and more. Inbound messages are the native webhooks (message.received carries the receiving phone, the sender's bare number, text, timestamp and a fromMe flag), which is what a reply-matching integration needs.
Several sending identities
Maytapi installations often send from more than one number on purpose — an office number for staff, a public one for customers. In PulseApi that is one account with several numbers, each with its own phone_id. Create one API key per identity (named "staff", "client", …) so each can be revoked alone; every key on the account works with every number, so the fallback chain in your software keeps working unchanged.
Running both during the switch
The same WhatsApp number can be linked to Maytapi and PulseApi at once (each is one linked device, out of four). Sending works independently. Inbound messages, however, arrive at both, so keep only one side's webhook wired to any auto-reply until Maytapi is unlinked.