Quickstart
Three calls from zero to a delivered message. Set two shell variables first:
# your deployment + the API_KEY env var you configured BASE=https://your-gateway.onrender.com KEY=your-api-key
Create a session — the response carries a QR to scan (or a pairing code).
Poll the session status until WhatsApp finishes linking.
Fire your first message through /v1/messages/text.
# 1 — create the session curl -X POST $BASE/v1/sessions -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"name":"main"}' # 2 — check status (repeat until "connected") curl $BASE/v1/sessions/main -H "Authorization: Bearer $KEY" # 3 — send a message curl -X POST $BASE/v1/messages/text -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","text":"Hello from Relay 👋"}'
Prefer clicking to curling? The dashboard does all of this visually against the same API.
Authentication
Every /v1 request needs your API key as a Bearer token. The key is whatever you set as the API_KEY environment variable on your deployment — rotate it there whenever you like.
curl $BASE/v1/sessions -H "Authorization: Bearer $KEY"
Missing or wrong key → 401 unauthorized. Key not configured on the server → 503 server_not_configured.
Errors
Errors are JSON with a stable machine-readable error code and a human-readable message. Hygiene refusals add a detail object with specifics (like retryAfterSeconds).
HTTP/1.1 429
{
"error": "rate_limited",
"message": "Rate limit of 20 messages/minute reached for this session.",
"detail": { "retryAfterSeconds": 41 }
}
| Code | Status | Meaning |
|---|---|---|
| unauthorized | 401 | Missing or wrong API key. |
| missing_fields | 400 | A required body field is absent — the message names it. |
| session_not_found | 404 | No session with that name. |
| session_not_connected | 409 | Session exists but isn't linked/online yet. |
| recipient_opted_out | 403 | Recipient asked to stop. Never bypassable. |
| quiet_hours | 403 | Sends paused overnight. ignoreQuietHours: true for transactional messages only. |
| rate_limited | 429 | Per-minute limit hit — retry after detail.retryAfterSeconds. |
| daily_cap_reached | 429 | Warmup / daily cap hit — resumes at midnight UTC. |
| duplicate | 409 | Identical message sent seconds ago. allowDuplicate: true if intentional. |
Sessions
A session is one linked WhatsApp number, addressed by name in every call. Credentials persist on the server's disk, so a linked number stays linked across restarts.
| Field | Type | |
|---|---|---|
| name | string | 1–32 chars: letters, digits, _, -. Creating an existing session is idempotent — you get its current state back. |
| phoneNumber | string? | Switches linking from QR to a pairing code for this number (international digits). |
| webhookUrl | string? | Per-session webhook target (overrides the global WEBHOOK_URL). |
Request
curl -X POST $BASE/v1/sessions -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"name":"main"}'
Response — 201
{
"name": "main",
"status": "qr_pending",
"qr": "data:image/png;base64,iVBORw0KG…", // render this image, scan it
"createdAt": "2026-08-16T09:00:00.000Z",
"sentToday": 0
}
Render qr in an <img> and scan: phone → WhatsApp → Settings → Linked devices → Link a device. QR codes rotate about once a minute — re-fetch the session to get the current one.
Pairing code instead of QR
Pass phoneNumber and the response carries a short code instead — no camera needed. On that phone: Linked devices → Link a device → Link with phone number instead. The code is bound to that exact number and expires after a few minutes.
Request
curl -X POST $BASE/v1/sessions -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"name":"main","phoneNumber":"15550001111"}'
Response — 201
{ "name": "main", "status": "qr_pending", "pairingCode": "PV58-622Q", … }
Status & list
Response — 200
{
"name": "main",
"status": "connected", // qr_pending · connecting · connected · offline · logged_out
"warming": true, // still inside the warmup schedule (lower daily caps)
"warmup": {
"ageDays": 2, // how long since the number was linked
"warmupDay": 3, // 1-indexed day in the schedule (null once graduated)
"warmupTotalDays": 7,
"dailyCap": 200, // today's send ceiling (null = uncapped)
"sentToday": 12,
"remainingToday": 188,
"stage": "warming" // "warming" → "ready" once past the schedule
},
"phoneNumber": "15550001111",
"createdAt": "2026-08-16T09:00:00.000Z",
"sentToday": 12
}
GET /v1/sessions returns { "sessions": [ … ] } with the same shape per entry. A dropped connection shows offline while the watchdog reconnects it — no action needed on your side. The warmup block is what the dashboard's progress bar reads: while stage is warming, keep volume low and let the caps rise; once it's ready, the number has aged past the schedule.
Logout
Logs the device out of WhatsApp and wipes its stored credentials. Re-creating the session starts a fresh pairing.
Response — 200
{ "ok": true, "deleted": "main" }
Messages
| Field | Type | All send endpoints |
|---|---|---|
| session | string | Which linked number sends it. |
| to | string | Recipient — international digits (15550001111) or a full JID (…@g.us for groups). |
| allowDuplicate | bool? | Skip the duplicate guard for this send. |
| ignoreQuietHours | bool? | Send during quiet hours — transactional messages only. |
| humanize | bool | {delayMs}? | Human-feel: mark their messages read, show "typing…", pause naturally (scales with length, max 8s), then send. Recommended for bot replies. |
Response — 200 (every send endpoint)
{ "id": "3EB0538DA65A59F6D8A251", "to": "15550001111", "session": "main", "status": "sent" }
Keep the id — delivery/read receipts arrive on your webhook as message.ack events carrying it.
Text
curl -X POST $BASE/v1/messages/text -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","text":"Your order *shipped* 📦"}'
WhatsApp formatting works: *bold*, _italic_, ~strikethrough~, ```monospace```.
Image · video · document · sticker
One endpoint, four types. The file comes from a public url or inline base64 — pass exactly one.
| Field | Type | |
|---|---|---|
| type | string | image · video · document · sticker |
| url | string? | Public URL the gateway fetches the file from. |
| base64 | string? | The file itself, base64-encoded (data-URL prefix allowed). Up to ~16MB decoded. |
| caption | string? | Image, video, document. |
| fileName | string? | Document — the name the recipient sees. |
| mimetype | string? | Document — e.g. application/pdf. |
Request — image by URL
curl -X POST $BASE/v1/messages/media -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","type":"image", "url":"https://picsum.photos/800","caption":"Random beauty"}'
Request — PDF from a local file (base64)
curl -X POST $BASE/v1/messages/media -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d "{\"session\":\"main\",\"to\":\"15550001111\",\"type\":\"document\", \"fileName\":\"invoice.pdf\",\"mimetype\":\"application/pdf\", \"base64\":\"$(base64 -w0 invoice.pdf)\"}"
Notes per type: videos deliver most reliably as MP4/H.264 · stickers must be .webp (512×512 is the sweet spot) · WhatsApp caps most media around 16MB.
Voice note
Delivered as a push-to-talk voice note (the waveform bubble) by default; pass voiceNote: false for a plain audio file. Same url/base64 choice as media. OGG/Opus is WhatsApp's native voice format; MP3 and M4A generally play fine.
| Field | Type | |
|---|---|---|
| url / base64 | string | Exactly one — the audio source. |
| voiceNote | bool? | Default true (push-to-talk bubble). |
| mimetype | string? | Default audio/ogg; codecs=opus — set it to match your file, e.g. audio/mpeg for MP3. |
curl -X POST $BASE/v1/messages/audio -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","url":"https://cdn.example.com/note.ogg"}'
Bring your own TTS: generate audio with your provider (ElevenLabs, OpenAI, …) and pass its URL — passing tts returns 501 by design.
Buttons
Up to three native tappable buttons — three kinds, mixable in one message. Relay sends them with the business-interactive envelope that makes WhatsApp clients render them (verified on current versions; a very old client may fall back to plain text — keep a menu or poll in flows that must reach everyone).
| Button shape | Renders as | What happens on tap |
|---|---|---|
{ text, id } | Reply button | Sends the label back to you as a message.received event. |
{ text, url } | Link button | Opens the URL. |
{ text, phone } | Call button | Starts a phone call. |
Top-level fields: text (the body, required), optional header and footer, and buttons (1–3).
curl -X POST $BASE/v1/messages/buttons -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111", "text":"Your order is ready — what next?", "footer":"Acme Store", "buttons":[ {"text":"Confirm pickup","id":"confirm"}, {"text":"Track order","url":"https://acme.example/track"}, {"text":"Call us","phone":"15550002222"} ]}'
List message
The native WhatsApp “menu”: a button that opens a list of selectable rows grouped into sections. Sent with the same rendering envelope as buttons. Row selections arrive on your message.received webhook.
| Field | Type | |
|---|---|---|
| text | string | Message body. |
| buttonText | string? | Label on the button that opens the list. Default “Menu”. |
| sections | array | [{ title, rows: [{ title, id?, description? }] }] |
| title / footer | string? | Optional header title and footer. |
curl -X POST $BASE/v1/messages/list -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","text":"How can we help?", "buttonText":"View menu", "sections":[{"title":"Support","rows":[ {"title":"Track my order","id":"track"}, {"title":"Speak to an agent","id":"agent"} ]}]}'
Numbered menu — always works
The bulletproof interactive pattern: a formatted text menu the recipient answers by replying with a number. Works on every WhatsApp version. Read their reply from the message.received webhook.
| Field | Type | |
|---|---|---|
| prompt | string | The question / intro line. |
| options | string[] | 1–20 choices — numbered automatically. |
| footer | string? | Default: “Reply with the number of your choice.” |
curl -X POST $BASE/v1/messages/menu -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","prompt":"How can we help?", "options":["Track my order","Speak to support","Something else"]}' # the recipient sees: # How can we help? # # 1. Track my order # 2. Speak to support # 3. Something else # # Reply with the number of your choice.
Poll
A native poll that works on every WhatsApp version — the most universally compatible tappable interaction. Votes arrive on your webhook as poll.vote events.
| Field | Type | |
|---|---|---|
| question | string | The poll title. |
| options | string[] | 2–12 choices. |
| selectableCount | number? | How many options one person can pick. Default 1. |
curl -X POST $BASE/v1/messages/poll -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111", "question":"Confirm your appointment?", "options":["✅ Confirm","📅 Reschedule","❌ Cancel"]}'
Location
Drops a map pin. latitude/longitude required; name and address label the pin.
curl -X POST $BASE/v1/messages/location -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111","latitude":51.5033,"longitude":-0.1196, "name":"London Eye","address":"Riverside Building"}'
Contact card
Sends a tappable contact card. contactName and contactNumber required, organization optional.
curl -X POST $BASE/v1/messages/contact -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","to":"15550001111", "contactName":"Relay Support","contactNumber":"15550002222"}'
Number check
Is this number on WhatsApp? Check before you send instead of burning failed messages. ?session= is optional when exactly one session exists.
curl "$BASE/v1/check/15550001111?session=main" -H "Authorization: Bearer $KEY" { "number": "15550001111", "onWhatsApp": true, "jid": "15550001111@s.whatsapp.net" }
Opt-out list
Anyone who replies STOP (or UNSUBSCRIBE — configurable via OPTOUT_KEYWORDS) is added automatically, and the gateway refuses further sends to them with 403 recipient_opted_out. No override exists — by design. Remove a number only when that person has asked to hear from you again.
{ "optout": ["15550001111", "15550002222"] }
curl -X POST $BASE/v1/optout -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" -d '{"number":"15550001111"}'
{ "ok": true, "removed": true }
Campaigns — paced drip sends
A campaign delivers one message to a list of recipients, spread out over time with human-like jitter instead of a blast. Every send still runs through the full hygiene pipeline — rate limits, warmup caps, quiet hours, opt-out and duplicate guard all apply — the scheduler only decides when each one goes out. Campaigns survive restarts and resume automatically.
confirmOptIn: true, and opted-out numbers are skipped automatically. Blasting strangers is the fastest way to get a number banned.| Field | Type | |
|---|---|---|
| session | string | Which linked number sends the campaign. |
| recipients | string[] | Phone numbers (international digits) or full jids. De-duplicated automatically. |
| label | string? | Instead of recipients: target everyone carrying this WhatsApp Business label — e.g. "1 Month" for renewal reminders to that tier. |
| text | string | The message body sent to everyone. |
| perHour | number? | Pacing — messages per hour, 1–120. Default 12 (one every ~5 min, jittered ±40%). |
| startAt | string? | ISO timestamp to begin. Default: now. |
| confirmOptIn | bool | Required. Must be true — affirms recipients opted in. |
curl -X POST $BASE/v1/campaigns -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"session":"main","perHour":15,"confirmOptIn":true, "recipients":["15550001111","15550002222"], "text":"Your appointment is tomorrow at 10am. Reply STOP to opt out."}' // → 201 { "id": "camp_1a2b3c", "session": "main", "total": 2, "perHour": 15, "status": "running", "counts": { "pending": 2, "sent": 0, "failed": 0, "skipped": 0 }, "nextSendAt": "2026-08-16T09:00:00.000Z" }
Lists all campaigns with live progress counts. GET /v1/campaigns/:id adds a recipients[] array with each number's status (pending · sent · skipped · failed), scheduled time, and message id.
Cancels all remaining sends (already-sent messages are unaffected) and returns how many were cancelled.
Inbound media
When someone sends your number a photo, voice note, or document, the message.received webhook includes a media block with a downloadUrl. Fetch it (with your Bearer key) to get the raw bytes.
// webhook payload for an inbound image { "event": "message.received", "data": { "id": "3EB0…", "from": "15550001111", "type": "imageMessage", "hasMedia": true, "media": { "kind": "image", "mimetype": "image/jpeg", "downloadUrl": "/v1/media/main/3EB0…" } } } curl $BASE/v1/media/main/3EB0… -H "Authorization: Bearer $KEY" --output photo.jpg
React · edit · delete · read · reply
Every send accepts quotedId to reply to a specific message. Message actions reference a message by id:
| Endpoint | Body | |
|---|---|---|
| POST /v1/messages/reaction | { session, to, targetMessageId, emoji } | React (empty emoji removes it). |
| POST /v1/messages/edit | { session, to, targetMessageId, text } | Edit a message you sent. |
| POST /v1/messages/delete | { session, to, targetMessageId, forEveryone? } | Delete/revoke. |
| POST /v1/messages/read | { session, to } | Mark a chat's inbound messages read. |
| GET /v1/messages/:session/:id/status | Delivery state of a sent message: pending → sent → delivered → read (or error), with statusAt. | |
| POST /v1/presence | { session, to, state } | composing = typing indicator; also recording, paused, available. |
Inbound reactions arrive as a message.reaction webhook (emoji, targetMessageId).
Chats & history
The gateway keeps a rolling history window per session (last ~300 messages per chat) so you can build an inbox.
curl $BASE/v1/chats/main -H "Authorization: Bearer $KEY" # recent chats curl $BASE/v1/chats/main/15550001111 -H "Authorization: Bearer $KEY" # conversation (?limit, ?before) curl $BASE/v1/chats/main/15550001111/picture -H "Authorization: Bearer $KEY" # profile picture URL
Labels WhatsApp Business
Tag chats with the labels you created in the WhatsApp Business app ("Trial",
"1 month", "VIP"…) — the label shows up on the chat in WhatsApp itself, on every
device. Perfect for automations: when your bot grants a trial, label the chat.
Labels are created in the app (Business tools → Labels); the API lists and
applies them. Chats returned by /v1/chats/:session include their
labels.
# what labels exist on this number (synced from the phone) curl $BASE/v1/labels/main -H "Authorization: Bearer $KEY" # → { "labels": [ { "id": "6", "name": "Trial", "color": 3 }, … ] } # tag a client — label by name (or id); action defaults to "add" curl -X POST $BASE/v1/labels/main/chat -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"to":"15550001111","label":"Trial"}' # everyone carrying a label — e.g. all Trial clients (for follow-ups, campaigns…) curl "$BASE/v1/labels/main/chats?label=Trial" -H "Authorization: Bearer $KEY" # → { "label": {…}, "count": 12, "chats": [ { "number": "…", "name": "…", … } ] } # remove it (e.g. when the trial converts to a subscription) curl -X POST $BASE/v1/labels/main/chat -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"to":"15550001111","label":"Trial","action":"remove"}'
Numbers linked before label support are backfilled automatically on their next
reconnect; POST /v1/labels/:session/sync forces a full re-sync any time.
Label changes (from the phone or the API) also fire chat.labeled /
chat.unlabeled webhooks with chat, labelId and
labelName — so a flow can react when you hand-label someone on your phone.
Works on WhatsApp Business accounts; on a regular WhatsApp account there are no labels
and the list is empty.
Groups
| Endpoint | |
|---|---|
| GET /v1/groups/:session | List groups this number is in. |
| POST /v1/groups/:session | { subject, participants[] } — create. |
| GET /v1/groups/:session/:groupId | Metadata + participant list. |
| POST /v1/groups/:session/:groupId/participants | { action: add\|remove\|promote\|demote, participants[] } |
| GET /v1/groups/:session/:groupId/invite | Invite code + chat.whatsapp.com link. |
| POST /v1/groups/:session/join | { code } — join by invite. |
| DELETE /v1/groups/:session/:groupId | Leave. |
Profile & status
# update the linked number's own profile curl -X POST $BASE/v1/profile/main -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Acme Support","status":"Here to help 9–5"}' # post a text status/story (recipients who can see it) curl -X POST $BASE/v1/status/main -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/json" \ -d '{"text":"We are open!","backgroundColor":"#2947FF","recipients":["15550001111"]}'
Webhooks
Point Relay at your server and it POSTs JSON events as they happen. Configure globally with the WEBHOOK_URL env var, or per session with webhookUrl at creation. Failed deliveries retry 3 times (5s → 30s → 2m). Timeouts are 10s — respond fast, process async.
| Event | When | data payload |
|---|---|---|
| message.received | Inbound message | id, from, fromJid, pushName, text, type, timestamp |
| message.sent | Any outbound message | id, to, text, type, source: api (this gateway) or phone (you typed it) — use phone to pause a bot when a human takes over |
| message.ack | Status change on a sent message | id, to, status: sent → delivered → read (played for voice) |
| poll.vote | Someone votes on your poll | pollId, question, votes[] with per-option voter lists |
| message.failed | A sent message errored (ack status 0) | id, to — also fires an alert |
| chat.labeled / chat.unlabeled | Label added to / removed from a chat (phone or API) | chat, chatJid, labelId, labelName |
| session.connected | Number linked / reconnected | phoneNumber |
| session.disconnected | Connection dropped or removed | reason: connection_lost · logged_out · deleted |
Example delivery — message.received
POST your-webhook-url
Content-Type: application/json
X-Relay-Signature: sha256=7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069
{
"event": "message.received",
"session": "main",
"timestamp": "2026-08-16T09:41:00.000Z",
"data": {
"id": "3EB0538DA65A59F6D8A251",
"from": "15550001111",
"fromJid": "15550001111@s.whatsapp.net",
"pushName": "Sara",
"text": "hey! is this still available?",
"type": "conversation",
"timestamp": 1786786860
}
}
Example delivery — poll.vote
{
"event": "poll.vote", "session": "main", "timestamp": "…",
"data": {
"pollId": "3EB0…",
"question": "Confirm your appointment?",
"votes": [
{ "option": "✅ Confirm", "voters": ["15550001111"] },
{ "option": "📅 Reschedule", "voters": [] }
]
}
}
Verifying signatures
Set WEBHOOK_SECRET on your deployment and every delivery is signed: the X-Relay-Signature header carries sha256= + HMAC-SHA256 of the raw body. Verify before trusting the payload:
// Node / Express — capture the RAW body, then compare digests import crypto from "node:crypto"; app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { const expected = "sha256=" + crypto .createHmac("sha256", process.env.WEBHOOK_SECRET) .update(req.body) .digest("hex"); const given = req.get("x-relay-signature") || ""; if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) return res.sendStatus(401); const event = JSON.parse(req.body); res.sendStatus(200); // ack fast, process async });
Sending hygiene
On by default and tuned by environment variables on your deployment. These exist to protect the people you message — and, as a side effect, your numbers.
| Variable | Default | |
|---|---|---|
| RATE_PER_MINUTE | 20 | Sliding-window per-session send limit. Exceeding → 429 with retryAfterSeconds. |
| WARMUP_SCHEDULE | 50,100,200,400,800,1500,3000 | Daily caps for a session's first week — day 1 gets 50, day 2 gets 100, and so on. Sessions report warming: true during this. |
| DAILY_CAP | 0 | Cap after warmup. 0 = uncapped. |
| QUIET_HOURS | off | e.g. 22-08 — no sends overnight. QUIET_TZ_OFFSET shifts the window from UTC. |
| DUPLICATE_WINDOW_SECONDS | 120 | Identical send to the same recipient inside this window → 409 duplicate. |
| OPTOUT_KEYWORDS | stop, unsubscribe, optout, opt out | Inbound words (exact, case-insensitive) that opt the sender out. |
Events (activity)
Every webhook event is also kept in a rolling in-gateway log, so you can see recent activity without standing up a webhook consumer.
curl "$BASE/v1/events?limit=100&event=message.received" -H "Authorization: Bearer $KEY" curl "$BASE/v1/ops" -H "Authorization: Bearer $KEY" # one-glance ops: sessions, sent/failed today, queues, alerts
Alerts. Session trouble (logged out, repeated reconnects, recovery, failed sends) fires the alert channel. Two options, combinable: ALERT_WEBHOOK_URL (Slack/Discord/any webhook), and — the one you'll actually see — WhatsApp itself: set ALERT_SESSION (a second linked number to send from) and ALERT_NUMBER (your personal phone). Never use the business number as its own alert sender — when it drops it can't tell you.
Machine-readable API spec: /openapi.yaml (OpenAPI 3.0 — import into Postman/Insomnia). n8n starter workflows are in the repo's integrations/ folder.