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/v1

Authentication

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...
Keep keys server-side. Anyone holding a key can send messages from your account. Never embed an API key in mobile apps, browser bundles or client-side code.

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, mobile
  • 14155552671 — United States
  • 447911123456 — 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; an externalId is now populated.
  • DELIVERED / READ — Receipts from the recipient's device.
  • FAILED — Permanent failure; errorCode and errorReason explain 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

  1. Create an account in the dashboard and verify your email.
  2. Add a WhatsApp account and pair it by scanning the QR code with your phone (WhatsApp → Linked devices).
  3. Open the account, go to API keys and create a key. Copy the secret somewhere safe — it's shown only once.
  4. 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

POST/public/messages/textAPI key
FieldTypeDescription
to*stringRecipient phone number (E.164 without +) or full JID.
text*stringPlain 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

POST/public/messages/imageAPI key
FieldTypeDescription
to*stringRecipient phone number or JID.
source*stringEither a public HTTPS URL the API can fetch, or a data URL (data:image/jpeg;base64,…). Max ~16 MB.
captionstringOptional caption shown beneath the image (max 1024 chars).
fileNamestringOptional file name. Useful if the URL has no extension.
mimeTypestringForce 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

POST/public/messages/videoAPI key

Same 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

POST/public/messages/audioAPI key

For 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

POST/public/messages/documentAPI key

Use 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

POST/public/messages/locationAPI key
FieldTypeDescription
to*stringRecipient.
latitude*numberDecimal latitude between -90 and 90.
longitude*numberDecimal longitude between -180 and 180.
namestringOptional place name (e.g. "SPWP HQ").
addressstringOptional 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 mimeType explicitly so we don't have to guess.
  • WhatsApp imposes its own size and codec limits. If a media message ends up FAILED, inspect errorReason on the message record.

Look up & verify

Get a message

GET/public/messages/:idAPI key

Fetch 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

POST/public/messages/check-phoneAPI key
FieldTypeDescription
phone*stringPhone 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

FieldTypeDescription
MESSAGE_RECEIVEDeventAn inbound message arrived (text, media, location, reaction, etc.).
MESSAGE_STATUSeventAn outbound message you sent transitioned status (SENT → DELIVERED → READ, or FAILED).
ACCOUNT_CONNECTEDeventThe WhatsApp account paired and is online. Fired after QR scan and on automatic reconnects.
ACCOUNT_DISCONNECTEDeventThe 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 2xx response 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-Id header 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

FieldTypeDescription
VALIDATION_FAILED400Request body or query is missing/invalid. Check details for the exact field.
INVALID_API_KEY401Missing X-API-Key header, or the key has been revoked.
ACCOUNT_NOT_CONNECTED409The WhatsApp account this key belongs to is offline. Reconnect before sending.
ACCOUNT_LIMIT_REACHED403User has hit their WhatsApp account quota.
RATE_LIMITED429You're sending too fast. Back off and retry; obey the Retry-After header if present.
MEDIA_FETCH_FAILED400We couldn't download the URL you passed in source. Make sure it's publicly reachable.
INTERNAL_ERROR500Something 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.