API reference
Send WhatsApp messages from your app
A simple REST API for sending text, media and location messages over WhatsApp, plus webhooks for real-time delivery and inbound events. Authentication is one HTTP header. Most endpoints return in milliseconds.
Introduction
The SPWP API is organised into resources you'd expect — accounts, messages, webhooks. This page documents the public, API-key authenticated endpoints you'll use to integrate WhatsApp into your product. All requests and responses use JSON.
Base URL
Every request goes to:
https://api.spwp.app/api/v1Authentication
Public endpoints are authenticated with an API key. Generate one in the dashboard under Accounts → your account → API keys. API keys are scoped to a single WhatsApp account, so the account is implicit — you only need to pass the key.
Pass the secret on every request in the X-API-Key header. The full secret is shown only once at creation; we store a hash, so if you lose it you'll need to revoke and create a new one.
X-API-Key: wak_live_a1b2c3d4e5f6g7h8i9j0...Response & error format
Successful responses are wrapped in a data envelope alongside request metadata:
{
"data": { /* endpoint payload */ },
"meta": {
"requestId": "01J7...",
"timestamp": "2026-05-04T13:24:18.835Z"
}
}Errors are returned with a non-2xx HTTP status and a stable code string you can branch on:
{
"statusCode": 400,
"code": "VALIDATION_FAILED",
"message": "to should not be empty",
"details": [ /* zod/class-validator errors */ ],
"path": "/api/v1/public/messages/text",
"timestamp": "2026-05-04T13:24:18.835Z"
}Phone number format
Use full international numbers without the leading +, spaces or dashes. The country code is always required. Examples:
9647507644809— Iraq, mobile14155552671— United States447911123456— United Kingdom
You can also pass a full WhatsApp JID (9647507644809@s.whatsapp.net for individuals or …@g.us for groups). The server normalises plain numbers to JIDs automatically.
Message status lifecycle
Send endpoints return immediately with 202 Accepted and a message in QUEUED state. The status then progresses asynchronously:
QUEUED → SENDING → SENT → DELIVERED → READ
↓
FAILED (terminal)QUEUED— Accepted by the API and waiting in the worker queue.SENDING— A worker is dispatching the message to WhatsApp.SENT— WhatsApp accepted the message; anexternalIdis now populated.DELIVERED/READ— Receipts from the recipient's device.FAILED— Permanent failure;errorCodeanderrorReasonexplain why.
Subscribe a webhook to receive status changes in real time, or poll GET /public/messages/:id if a webhook isn't an option for you.
Quickstart
- Create an account in the dashboard and verify your email.
- Add a WhatsApp account and pair it by scanning the QR code with your phone (WhatsApp → Linked devices).
- Open the account, go to API keys and create a key. Copy the secret somewhere safe — it's shown only once.
- Send your first message:
curl -X POST https://api.spwp.app/api/v1/public/messages/text \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"text": "Hello from SPWP 👋"
}'A successful response looks like this:
{
"data": {
"id": "9b1d7f6c-…",
"accountId": "5ad…",
"direction": "OUT",
"type": "TEXT",
"remoteJid": "9647507644809@s.whatsapp.net",
"status": "QUEUED",
"payload": { "text": "Hello from SPWP 👋" },
"createdAt": "2026-05-04T13:24:18.835Z"
},
"meta": { "timestamp": "2026-05-04T13:24:18.835Z" }
}Send messages
All send endpoints accept a JSON body, return 202 Accepted immediately, and produce a message record. They share the same response shape — what differs is the request body for each media type.
Send a text message
/public/messages/textAPI key| Field | Type | Description |
|---|---|---|
| to* | string | Recipient phone number (E.164 without +) or full JID. |
| text* | string | Plain text body, up to 4096 characters. Emoji allowed. |
curl -X POST https://api.spwp.app/api/v1/public/messages/text \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"text": "Your verification code is 482310"
}'Send an image
/public/messages/imageAPI key| Field | Type | Description |
|---|---|---|
| to* | string | Recipient phone number or JID. |
| source* | string | Either a public HTTPS URL the API can fetch, or a data URL (data:image/jpeg;base64,…). Max ~16 MB. |
| caption | string | Optional caption shown beneath the image (max 1024 chars). |
| fileName | string | Optional file name. Useful if the URL has no extension. |
| mimeType | string | Force the MIME type. Required when source is a URL with no extension or a generic CDN path. |
curl -X POST https://api.spwp.app/api/v1/public/messages/image \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"source": "https://picsum.photos/seed/spwp/600/400.jpg",
"caption": "Today's preview"
}'Send a video
/public/messages/videoAPI keySame body as image — pass a video URL or data:video/mp4;base64,…. WhatsApp supports MP4 (H.264 / AAC) reliably; other codecs may be re-encoded by the recipient's app.
curl -X POST https://api.spwp.app/api/v1/public/messages/video \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"source": "https://example.com/clip.mp4",
"caption": "Onboarding tour"
}'Send an audio message
/public/messages/audioAPI keyFor voice notes, use OGG/Opus encoded audio. MP3 and M4A also work but won't render with the voice-note UI on the recipient's phone.caption and fileName are accepted but ignored by WhatsApp for audio.
curl -X POST https://api.spwp.app/api/v1/public/messages/audio \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"source": "https://example.com/voice.ogg",
"mimeType": "audio/ogg; codecs=opus"
}'Send a document
/public/messages/documentAPI keyUse this for PDFs, spreadsheets, ZIPs and any other file that should be downloadable rather than embedded. Always pass a sensible fileName — it's what the recipient sees.
curl -X POST https://api.spwp.app/api/v1/public/messages/document \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"source": "https://example.com/invoice-3214.pdf",
"fileName": "invoice-3214.pdf",
"mimeType": "application/pdf",
"caption": "Your invoice for May"
}'Send a location
/public/messages/locationAPI key| Field | Type | Description |
|---|---|---|
| to* | string | Recipient. |
| latitude* | number | Decimal latitude between -90 and 90. |
| longitude* | number | Decimal longitude between -180 and 180. |
| name | string | Optional place name (e.g. "SPWP HQ"). |
| address | string | Optional human-readable address shown under the name. |
curl -X POST https://api.spwp.app/api/v1/public/messages/location \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9647507644809",
"latitude": 36.1911,
"longitude": 44.0094,
"name": "Erbil Citadel",
"address": "Erbil, Kurdistan Region, Iraq"
}'Media handling rules
- URL sources are fetched server-side. The URL must be reachable over the public internet and return the file directly (no HTML pages, no auth walls, no redirects to login).
- Data URLs are decoded inline. Useful when the file lives only on your server. Keep them under ~16 MB after base64 encoding to avoid request body limits.
- When the URL has no file extension (typical for CDNs that hash names), set
mimeTypeexplicitly so we don't have to guess. - WhatsApp imposes its own size and codec limits. If a media message ends up
FAILED, inspecterrorReasonon the message record.
Look up & verify
Get a message
/public/messages/:idAPI keyFetch the current state of a message you sent. Useful for polling when you don't have webhooks set up.
curl https://api.spwp.app/api/v1/public/messages/9b1d7f6c-… \
-H "X-API-Key: $SPWP_API_KEY"Response (truncated):
{
"data": {
"id": "9b1d7f6c-…",
"direction": "OUT",
"type": "TEXT",
"remoteJid": "9647507644809@s.whatsapp.net",
"externalId": "3EB0870363BFDA49AB9A72",
"status": "DELIVERED",
"payload": { "text": "Your verification code is 482310" },
"createdAt": "2026-05-04T13:24:18.835Z",
"sentAt": "2026-05-04T13:24:19.121Z",
"deliveredAt": "2026-05-04T13:24:21.402Z"
}
}Check whether a number is on WhatsApp
/public/messages/check-phoneAPI key| Field | Type | Description |
|---|---|---|
| phone* | string | Phone number to check (E.164 without +). |
curl -X POST https://api.spwp.app/api/v1/public/messages/check-phone \
-H "X-API-Key: $SPWP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "9647507644809" }'Response when the number is registered:
{
"data": {
"phone": "9647507644809",
"exists": true,
"jid": "9647507644809@s.whatsapp.net"
}
}If your WhatsApp account is not currently connected, the response is { "exists": false, "reason": "NOT_CONNECTED" }. Reconnect from the dashboard and retry.
Webhooks
Webhooks let your server receive events as they happen — incoming messages, delivery receipts, connection state changes — without polling. Configure them per WhatsApp account in the dashboard (Accounts → your account → Webhooks). The first time you save a webhook, the secret is shown to you once; treat it like a password.
Events
| Field | Type | Description |
|---|---|---|
| MESSAGE_RECEIVED | event | An inbound message arrived (text, media, location, reaction, etc.). |
| MESSAGE_STATUS | event | An outbound message you sent transitioned status (SENT → DELIVERED → READ, or FAILED). |
| ACCOUNT_CONNECTED | event | The WhatsApp account paired and is online. Fired after QR scan and on automatic reconnects. |
| ACCOUNT_DISCONNECTED | event | The WhatsApp account went offline (network drop, logged out, etc.). The payload includes the reason when known. |
Payload format
Every delivery is a POST with a JSON body and these headers:
Content-Type: application/json
X-WA-Event: MESSAGE_RECEIVED
X-WA-Delivery-Id: 7c2a…
X-WA-Timestamp: 1746364218835
X-WA-Signature: t=1746364218835,v1=<hmac-sha256-hex>Example payload for an inbound text message:
{
"event": "MESSAGE_RECEIVED",
"accountId": "5ad…",
"occurredAt": "2026-05-04T13:24:21.402Z",
"data": {
"id": "01J7…",
"direction": "IN",
"type": "TEXT",
"remoteJid": "9647507644809@s.whatsapp.net",
"externalId": "3EB08703…",
"status": "RECEIVED",
"payload": { "text": "Hi there!" }
}
}Example payload for a status change on a message you sent:
{
"event": "MESSAGE_STATUS",
"accountId": "5ad…",
"occurredAt": "2026-05-04T13:24:21.402Z",
"data": {
"id": "9b1d7f6c-…",
"externalId": "3EB0870363BFDA49AB9A72",
"status": "DELIVERED",
"deliveredAt": "2026-05-04T13:24:21.402Z"
}
}Signature verification
The X-WA-Signature header proves the request came from us and wasn't tampered with. The signature is computed as:
signature = HMAC_SHA256(secret, "<X-WA-Timestamp>.<raw-request-body>")Always verify before parsing or trusting the body:
import crypto from "node:crypto";
import express from "express";
const app = express();
// IMPORTANT: capture the RAW body so the HMAC matches exactly.
app.post(
"/webhooks/spwp",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.header("X-WA-Timestamp");
const sig = req.header("X-WA-Signature") ?? "";
const v1 = sig.match(/v1=([a-f0-9]+)/)?.[1];
if (!ts || !v1) return res.status(400).end();
// Reject anything older than 5 minutes (replay protection).
if (Math.abs(Date.now() - Number(ts)) > 5 * 60_000) {
return res.status(400).end();
}
const expected = crypto
.createHmac("sha256", process.env.SPWP_WEBHOOK_SECRET)
.update(`${ts}.${req.body.toString("utf8")}`)
.digest("hex");
const ok =
v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// …handle event…
res.status(200).end();
},
);Retries & idempotency
- We expect a
2xxresponse within 10 seconds. Anything else is treated as failure. - Failed deliveries are retried with exponential backoff up to a few attempts. Use the
X-WA-Delivery-Idheader to deduplicate — the same delivery id always carries the same payload. - Respond as quickly as possible. Acknowledge first (
200 OK), then process the event in a background job. - During local development, expose your server with a tunnel (ngrok, Cloudflare Tunnel) and point the webhook there.
Errors & rate limits
Error codes
| Field | Type | Description |
|---|---|---|
| VALIDATION_FAILED | 400 | Request body or query is missing/invalid. Check details for the exact field. |
| INVALID_API_KEY | 401 | Missing X-API-Key header, or the key has been revoked. |
| ACCOUNT_NOT_CONNECTED | 409 | The WhatsApp account this key belongs to is offline. Reconnect before sending. |
| ACCOUNT_LIMIT_REACHED | 403 | User has hit their WhatsApp account quota. |
| RATE_LIMITED | 429 | You're sending too fast. Back off and retry; obey the Retry-After header if present. |
| MEDIA_FETCH_FAILED | 400 | We couldn't download the URL you passed in source. Make sure it's publicly reachable. |
| INTERNAL_ERROR | 500 | Something went wrong on our side. The requestId in the response helps us trace it. |
Rate limits
Limits protect both your account and WhatsApp's anti-spam systems. Hit them and you'll get 429 RATE_LIMITED. As a rule of thumb:
- Spread bulk sends out — a few per second is safe; bursts of dozens per second risk a WhatsApp ban on your number.
- Auth-sensitive endpoints (login, register, password reset, email resend) are throttled per IP.
- If you need higher throughput, use multiple WhatsApp accounts and round-robin between them.
Need help?
Open an issue on your account, or contact us with the requestId from the failed response so we can dig through logs quickly.