Developer Integration Guide
WhatsApp Gateway is a self-hosted REST API for sending and receiving WhatsApp messages. This guide walks you through everything needed to integrate your application — from creating a session to receiving live messages via webhook.
Base URL:
How it works
The gateway runs alongside your application and manages one or more WhatsApp connections (called sessions). Each session is an independent WhatsApp account linked via QR code — the same way WhatsApp Web works.
Your app communicates with the gateway over HTTP. Incoming messages are pushed to your app in real time via a webhook URL you register.
Architecture
Your App
│
├─ POST /send → sends outbound messages
├─ POST /init → creates a new WhatsApp session
│
│ WhatsApp Gateway (this service)
│ │
│ ↔ WhatsApp Web protocol (Baileys)
│
└─ POST {webhookUrl} ← gateway pushes inbound messages here
Getting credentials
Contact the gateway administrator to receive:
- The gateway base URL (e.g.
https://whatsapp.yourdomain.com) - A session ID for your application (or permission to create one)
- The session API key for that session
Key types
| Key type | What it can do | How to get it |
|---|---|---|
| Master key | Create/delete sessions, access all sessions, view all logs | Set by the server administrator via WH_GATEWAY_KEY env var |
| Session key | Send messages, set webhook, check status — for one session only | Returned when a session is created; rotatable via POST /session/api-key |
Passing your key
Include the key in every request as an HTTP header. Avoid query-string keys in production — they appear in server logs.
x-api-key: YOUR_SESSION_KEY
Step 1 — Create a session
A session represents a single WhatsApp account. Create one per WhatsApp number you want to connect. You only do this once — sessions persist across server restarts.
POST /init
x-api-key: MASTER_KEY
Content-Type: application/json
{
"label": "Support Desk"
}
// Response
{
"success": true,
"sessionId": "a1b2c3d4"
}
Save the sessionId — you will use it in every subsequent API call. To get the session's API key, call GET /status?sessionId=a1b2c3d4 and read the apiKey field from the response.
Step 2 — Scan the QR code
GET /status until the status reaches WAITING_FOR_SCAN, then fetch the QR image.
The polling flow
POST /init
→ { sessionId: "a1b2c3d4" }
↓
Poll GET /status?sessionId=a1b2c3d4 (every 2s)
until status === "WAITING_FOR_SCAN"
↓
GET /qr?sessionId=a1b2c3d4
→ { image: "data:image/png;base64,..." }
↓
Display QR to user → they scan in WhatsApp → Linked Devices
↓
Poll GET /status?sessionId=a1b2c3d4 (every 2s)
until status === "CONNECTED"
↓
Session is live ✓
Complete polling example
const GATEWAY = 'https://whatsapp.yourdomain.com';
const KEY = process.env.WH_SESSION_KEY;
async function api(method, path, body) {
const res = await fetch(`${GATEWAY}${path}`, {
method,
headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
return res.json();
}
async function waitForStatus(sessionId, targetStatus, intervalMs = 2000) {
while (true) {
const { sessions } = await api('GET', `/status?sessionId=${sessionId}`);
const status = sessions[sessionId]?.status;
console.log(`[${sessionId}] status: ${status}`);
if (status === targetStatus) return;
if (status === 'DISCONNECTED' || status === 'FAILED') {
throw new Error(`Session entered terminal state: ${status}`);
}
await new Promise(r => setTimeout(r, intervalMs));
}
}
async function setupSession(label) {
// 1. Create session
const { sessionId } = await api('POST', '/init', { label });
console.log('Created session:', sessionId);
// 2. Wait until QR is ready
await waitForStatus(sessionId, 'WAITING_FOR_SCAN');
// 3. Fetch and display QR — refresh every 30s in case it expires
let qrInterval = setInterval(async () => {
const { image } = await api('GET', `/qr?sessionId=${sessionId}`);
if (image) renderQR(image); // replace this with your UI logic
}, 30_000);
const { image } = await api('GET', `/qr?sessionId=${sessionId}`);
renderQR(image); // show immediately
// 4. Wait for user to scan
await waitForStatus(sessionId, 'CONNECTED');
clearInterval(qrInterval);
console.log('Session connected:', sessionId);
return sessionId;
}
// Example: display QR as an
tag
function renderQR(dataUrl) {
document.getElementById('qr-img').src = dataUrl;
}
import requests, time, os
GATEWAY = 'https://whatsapp.yourdomain.com'
KEY = os.environ['WH_SESSION_KEY']
def api(method, path, body=None):
resp = requests.request(
method, f'{GATEWAY}{path}',
headers={'x-api-key': KEY},
json=body,
)
return resp.json()
def wait_for_status(session_id, target, interval=2):
while True:
data = api('GET', f'/status?sessionId={session_id}')
status = data['sessions'][session_id]['status']
print(f'[{session_id}] status: {status}')
if status == target:
return
if status in ('DISCONNECTED', 'FAILED'):
raise RuntimeError(f'Session entered terminal state: {status}')
time.sleep(interval)
def setup_session(label):
# 1. Create session
result = api('POST', '/init', {'label': label})
session_id = result['sessionId']
print('Created session:', session_id)
# 2. Wait for QR
wait_for_status(session_id, 'WAITING_FOR_SCAN')
# 3. Fetch QR image (base64 PNG)
qr_data = api('GET', f'/qr?sessionId={session_id}')
save_qr(qr_data['image']) # replace with your display logic
print('QR saved — scan it in WhatsApp → Linked Devices')
# 4. Wait for scan (re-fetch QR if it expires before scan)
deadline = time.time() + 300 # 5 min max
while True:
data = api('GET', f'/status?sessionId={session_id}')
status = data['sessions'][session_id]['status']
if status == 'CONNECTED':
break
if status == 'WAITING_FOR_SCAN' and time.time() % 30 < 2:
# Refresh QR every ~30s in case it expired
qr_data = api('GET', f'/qr?sessionId={session_id}')
save_qr(qr_data['image'])
if time.time() > deadline:
raise TimeoutError('QR not scanned within 5 minutes')
time.sleep(2)
print('Session connected:', session_id)
return session_id
def save_qr(data_url):
import base64
img_data = data_url.split(',')[1]
with open('qr.png', 'wb') as f:
f.write(base64.b64decode(img_data))
print('QR written to qr.png')
Fetching the QR image
GET /qr?sessionId=a1b2c3d4
x-api-key: YOUR_KEY
// Response
{
"success": true,
"qr": "2@rawqrstring...",
"image": "data:image/png;base64,iVBORw0KGgo..."
}
The image field is a data:image/png;base64,… string — set it directly as the src of an <img> tag, or decode the base64 portion and write it to a .png file. On the phone: open WhatsApp → Settings → Linked Devices → Link a Device, then scan.
Session status values
| Status | Meaning | Action |
|---|---|---|
| INITIALIZING | Session is starting up, connecting to WhatsApp servers | Wait and poll — typically resolves in 1–3 seconds |
| WAITING_FOR_SCAN | QR code is ready to be scanned | Fetch /qr, display it, start refreshing every 30s |
| CONNECTED | Authenticated and online — ready to send/receive | Stop polling, proceed with your integration |
| DISCONNECTED | Logged out from WhatsApp (phone unlinked the device) | Delete session with DELETE /session/:id and recreate it |
| FAILED | Initialization error | Contact the gateway administrator |
GET /qr to get it. Refresh your displayed QR every 30 seconds to stay current. If the session reaches DISCONNECTED, delete it with DELETE /session/:id and start over — you cannot re-scan a logged-out session.
Step 3 — Register your webhook
A webhook is an HTTP endpoint on your server that the gateway calls whenever a message arrives. Register it once per session (it persists in the database).
POST /session/webhook
x-api-key: YOUR_KEY
Content-Type: application/json
{
"sessionId": "a1b2c3d4",
"webhookUrl": "https://yourapp.com/whatsapp/incoming"
}
// Response
{ "success": true }
Webhook delivery behaviour
- The gateway POSTs each incoming message to your URL within milliseconds of receipt.
- If your server returns a non-2xx response (or times out after 10s), the gateway retries up to 3 times with a 30-second delay between attempts.
- After 3 failures, the delivery is logged as failed and not retried further.
- Your endpoint must respond with any
2xxstatus to acknowledge the message.
Step 4 — Receive messages
The gateway POSTs this JSON payload to your webhook URL when a message arrives. The type field tells you exactly what was sent:
{
"sessionId": "a1b2c3d4",
"from": "[email protected]",
"isGroup": false,
"type": "text",
"body": "Hello! I need help with my order.",
"extra": null,
"pushname": "Arun Kumar",
"timestamp": 1746355200
}
| Field | Type | Description |
|---|---|---|
| sessionId | string | Which session received the message — useful if you run multiple sessions behind one webhook |
| from | string | Sender's WhatsApp ID in JID format: {countrycode}{number}@s.whatsapp.net |
| isGroup | boolean | true if the message came from a group chat |
| type | string | Message type: text, image, video, audio, document, location, reaction, and more — see full list in API Reference |
| body | string | Primary text — message text, caption, place name, or selected option depending on type |
| extra | object|null | Type-specific metadata (mimetype, lat/lng, filename, etc.) — null for plain text |
| pushname | string | Sender's display name as set in their WhatsApp profile |
| timestamp | number | Unix timestamp in seconds when the message was sent |
Extracting the phone number from from
The from field is a WhatsApp JID. To get a plain phone number, strip everything from @ onwards:
// from = "[email protected]"
const phone = payload.from.split('@')[0]; // "919495236004"
const display = '+' + phone; // "+919495236004"
# from_ = "[email protected]"
phone = payload['from'].split('@')[0] # "919495236004"
display = '+' + phone # "+919495236004"
// $from = "[email protected]"
$phone = explode('@', $payload['from'])[0]; // "919495236004"
$display = '+' . $phone; // "+919495236004"
Example webhook server
const express = require('express');
const app = express();
app.use(express.json());
app.post('/whatsapp/incoming', (req, res) => {
const { sessionId, from, body, pushname, timestamp } = req.body;
const phone = from.split('@')[0];
console.log(`[${sessionId}] Message from +${phone} (${pushname}): ${body}`);
// Your business logic here
// e.g. store in DB, trigger a reply, etc.
res.sendStatus(200); // acknowledge — must respond 2xx
});
app.listen(3001, () => console.log('Webhook listening on :3001'));
from flask import Flask, request
app = Flask(__name__)
@app.route('/whatsapp/incoming', methods=['POST'])
def incoming():
data = request.get_json()
session_id = data['sessionId']
phone = data['from'].split('@')[0]
body = data['body']
pushname = data['pushname']
print(f"[{session_id}] From +{phone} ({pushname}): {body}")
# Your business logic here
return '', 200 # acknowledge
if __name__ == '__main__':
app.run(port=3001)
<?php
$payload = json_decode(file_get_contents('php://input'), true);
$sessionId = $payload['sessionId'];
$phone = explode('@', $payload['from'])[0];
$body = $payload['body'];
$pushname = $payload['pushname'];
error_log("[{$sessionId}] From +{$phone} ({$pushname}): {$body}");
// Your business logic here
http_response_code(200);
echo 'OK';
Step 5 — Send messages
All outbound messages go to POST /send. The type of message is determined by which fields are present in the request body.
CONNECTED status before you can send. If the session is disconnected, sending will fail with a 400 error.
Send a text message
POST /send
x-api-key: YOUR_KEY
Content-Type: application/json
{
"sessionId": "a1b2c3d4",
"to": "+919495236004",
"message": "Hello! Your order #1234 has been shipped."
}
// Response
{
"success": true,
"messageId": "3EB0C1A2B3C4D5E6F7"
}
The to field accepts any common phone number format — the gateway strips all non-digit characters automatically. +919495236004, 919495236004, and +91 94952 36004 all work.
Send an image or video
Provide a publicly accessible URL to the file. URLs ending in .mp4 are sent as video; everything else is sent as an image.
{
"sessionId": "a1b2c3d4",
"to": "+919495236004",
"mediaUrl": "https://yourapp.com/receipts/order-1234.jpg",
"caption": "Your receipt for order #1234"
}
Send a location pin
{
"sessionId": "a1b2c3d4",
"to": "+919495236004",
"location": {
"lat": 9.9312,
"lng": 76.2673
}
}
Send a contact card
{
"sessionId": "a1b2c3d4",
"to": "+919495236004",
"contact": {
"name": "Jane Doe",
"number": "+919495000000"
}
}
Send to a group
Pass the group's JID as to. You get the group JID from the from field of any incoming group message (isGroup: true).
{
"sessionId": "a1b2c3d4",
"to": "[email protected]",
"message": "Hello everyone!"
}
// Response
{ "success": true, "messageId": "3EB0..." }
Replying to an incoming message
To send a reply in the context of an existing conversation, you use the same POST /send with the to field set to the sender's phone number extracted from the webhook payload:
Full integration flow at a glance
POST /init
Create a session → receive sessionId
GET /qr?sessionId=…
Fetch QR image → display to user → they scan in WhatsApp
Poll GET /status until CONNECTED
Session is live once status = CONNECTED
POST /session/webhook
Register your webhook URL once — persists across restarts
Your server receives POST {webhookUrl}
Gateway pushes inbound messages → your app processes them
POST /send
Send text, media, location, or contact cards any time the session is CONNECTED
API Reference — Sessions
Create a new WhatsApp session. Returns a sessionId — save it for all future calls.
| Field | Type | Description | |
|---|---|---|---|
| label | string | Optional | Human-readable name shown in the dashboard |
{ "success": true, "sessionId": "a1b2c3d4" }
Get current status of a session. Omit sessionId with master key to get all sessions.
| Query param | Description | |
|---|---|---|
| sessionId | Optional | Specific session; omit for all (master key only) |
{
"success": true,
"sessions": {
"a1b2c3d4": {
"status": "CONNECTED",
"hasQr": false,
"info": { "user": "919495236004", "pushname": "Arun" },
"webhookUrl": "https://yourapp.com/webhook",
"apiKey": "abc123...",
"label": "Support Desk"
}
}
}
Get the current QR code for a session in WAITING_FOR_SCAN status. Returns 404 if already connected or QR not ready yet.
| Query param | Description | |
|---|---|---|
| sessionId | Required | Session to get QR for |
{
"success": true,
"qr": "2@rawqrstring...",
"image": "data:image/png;base64,..."
}
Log out and permanently delete a session. The WhatsApp link is revoked and all stored credentials are wiped. This cannot be undone.
DELETE /session/a1b2c3d4
{ "success": true }
Register or update the webhook URL for a session. Calling this again replaces the previous URL.
| Field | Type | Description | |
|---|---|---|---|
| sessionId | string | Required | |
| webhookUrl | string | Required | Public HTTPS URL on your server |
{ "success": true }
Rotate the API key for a session. The previous key is immediately invalidated — update any stored copies before rotating.
| Field | Type | Description | |
|---|---|---|---|
| sessionId | string | Required |
{ "success": true, "apiKey": "new64hexcharstring..." }
API Reference — Send
Send a message. The type is inferred from the fields present in the body.
Common fields (all message types)
| Field | Type | Description | |
|---|---|---|---|
| sessionId | string | Required | The session to send from (defaults to host if omitted) |
| to | string | Required | Recipient — phone number (any format) for individual chats, or a group JID ([email protected]) for group chats |
to instead of a phone number. Group JIDs look like [email protected] and are included in the from field of incoming group message webhooks (isGroup: true). Store the JID when you first receive a group message and use it to send replies back to that group.
Text message
| Field | Type | Description | |
|---|---|---|---|
| message | string | Required | Message body text |
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "message": "Hello!" }
// → { "success": true, "messageId": "3EB0..." }
Image or video
| Field | Type | Description | |
|---|---|---|---|
| mediaUrl | string | Required | Publicly accessible file URL. .mp4 → video; everything else → image |
| caption | string | Optional | Caption text displayed below the media |
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "mediaUrl": "https://yourapp.com/img.jpg", "caption": "See attached" }
// → { "success": true }
Location pin
| Field | Type | Description | |
|---|---|---|---|
| location.lat | number | Required | Latitude |
| location.lng | number | Required | Longitude |
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "location": { "lat": 9.9312, "lng": 76.2673 } }
// → { "success": true }
Contact card
| Field | Type | Description | |
|---|---|---|---|
| contact.name | string | Required | Contact display name |
| contact.number | string | Required | Contact phone number |
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "contact": { "name": "Jane Doe", "number": "+919495000000" } }
// → { "success": true }
API Reference — Webhook payload
Every inbound message triggers a POST to your registered webhook URL. The payload always includes a type field so your app knows exactly what was received.
Base fields (all message types)
{
"sessionId": "a1b2c3d4",
"from": "[email protected]",
"isGroup": false,
"type": "text",
"body": "Hello! I need help with my order.",
"extra": null,
"pushname": "Arun Kumar",
"timestamp": 1746355200
}
| Field | Type | Description |
|---|---|---|
| sessionId | string | Which session received the message |
| from | string | Sender's WhatsApp JID. Extract phone: from.split('@')[0] |
| isGroup | boolean | true if sent from a group chat |
| type | string | Message type — see table below |
| body | string | Primary text content — caption, name, selected option, or empty string |
| extra | object|null | Type-specific fields — see per-type examples below |
| pushname | string | Sender's WhatsApp display name |
| timestamp | number | Unix timestamp in seconds |
Message types & their extra fields
| type | body | extra fields |
|---|---|---|
| text | Message text | null |
| image | Caption (if any) | { mimetype } |
| video | Caption (if any) | { mimetype } |
| audio | empty | { mimetype, isVoiceNote, seconds } |
| document | File name / title | { mimetype, fileName, pageCount } |
| sticker | empty | { isAnimated } |
| location | Place name (if any) | { lat, lng, address } |
| live_location | empty | { lat, lng } |
| contact | Contact display name | { vcard } |
| contacts | Comma-separated names | { count } |
| reaction | Emoji character | { targetMessageId } |
| button_reply | Button label selected | { selectedId } |
| list_reply | Row title selected | { selectedId } |
| template_reply | Button label selected | { selectedIndex } |
| poll | Poll question | { options: [] } |
| poll_vote | empty | { pollMessageId } |
| unknown | empty | null |
Example payloads by type
Image with caption
{
"type": "image",
"body": "Here is my receipt",
"extra": { "mimetype": "image/jpeg" }
}
Voice note
{
"type": "audio",
"body": "",
"extra": { "mimetype": "audio/ogg; codecs=opus", "isVoiceNote": true, "seconds": 12 }
}
Location pin
{
"type": "location",
"body": "Kochi Airport",
"extra": { "lat": 9.9442, "lng": 76.2699, "address": "Nedumbassery, Kerala" }
}
Emoji reaction
{
"type": "reaction",
"body": "👍",
"extra": { "targetMessageId": "3EB0ABC123..." }
}
Handling by type in your webhook
app.post('/whatsapp/incoming', async (req, res) => {
res.sendStatus(200);
const { sessionId, from, type, body, extra, pushname } = req.body;
const phone = from.split('@')[0];
switch (type) {
case 'text':
await handleText(phone, body);
break;
case 'image':
case 'video':
await send(sessionId, phone, `Thanks ${pushname}, we received your ${type}${body ? ': ' + body : ''}.`);
break;
case 'audio':
const dur = extra?.seconds ? `(${extra.seconds}s)` : '';
await send(sessionId, phone, `We got your voice note ${dur}. For faster help, please type your query.`);
break;
case 'location':
await handleLocation(phone, extra.lat, extra.lng);
break;
case 'document':
await send(sessionId, phone, `Received your file: ${body || extra?.fileName}. We'll review it shortly.`);
break;
case 'reaction':
console.log(`${pushname} reacted with ${body}`);
break;
case 'button_reply':
case 'list_reply':
await handleMenuSelection(phone, body, extra?.selectedId);
break;
default:
await send(sessionId, phone, `Hi ${pushname}! We received your message but couldn't read it. Please type your query.`);
}
});
@app.route('/whatsapp/incoming', methods=['POST'])
def incoming():
data = request.get_json()
session = data['sessionId']
phone = data['from'].split('@')[0]
type_ = data['type']
body = data['body']
extra = data.get('extra') or {}
pushname = data['pushname']
if type_ == 'text':
handle_text(phone, body)
elif type_ in ('image', 'video'):
caption = f': {body}' if body else ''
send(session, phone, f'Thanks {pushname}, we received your {type_}{caption}.')
elif type_ == 'audio':
dur = f"({extra.get('seconds')}s)" if extra.get('seconds') else ''
send(session, phone, f'We got your voice note {dur}. Please type your query for faster help.')
elif type_ == 'location':
handle_location(phone, extra['lat'], extra['lng'])
elif type_ == 'document':
name = body or extra.get('fileName', 'file')
send(session, phone, f'Received your file: {name}. We\'ll review it shortly.')
elif type_ == 'reaction':
print(f'{pushname} reacted with {body}')
elif type_ in ('button_reply', 'list_reply'):
handle_menu_selection(phone, body, extra.get('selectedId'))
else:
send(session, phone, f'Hi {pushname}! Please type your query so we can help you.')
return '', 200
status@broadcast) are suppressed automatically and never delivered to your webhook.
API Reference — Logs
Recent connection events (last 20).
{
"success": true,
"logs": [
{ "id": 42, "session_id": "a1b2c3d4", "event": "CONNECTED", "reason": null, "timestamp": "2026-06-01 08:00:00" }
]
}
Recent webhook delivery attempts (last 20) — useful for debugging missed messages.
{
"success": true,
"webhooks": [
{
"id": 7, "session_id": "a1b2c3d4",
"endpoint": "https://yourapp.com/webhook",
"event_type": "INCOMING",
"payload": "{...}",
"status_code": 200,
"timestamp": "2026-06-01 08:01:00"
}
]
}
Unauthenticated health check. No API key required. Use for uptime monitors or load balancer health probes.
{ "ok": true }
API Reference — Errors
All error responses share this shape:
{ "success": false, "error": "Human-readable description" }
| HTTP status | Meaning | Common cause |
|---|---|---|
| 400 | Bad request | Session not connected, or a required field is missing |
| 403 | Unauthorized | Missing x-api-key header, or the key is invalid / belongs to a different session |
| 404 | Not found | Session ID doesn't exist, or QR is not ready |
| 500 | Server error | Failed to send the message (WhatsApp error) |
Handling errors in code
const res = await fetch(`${GATEWAY}/send`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, to, message }),
});
const data = await res.json();
if (!data.success) {
console.error('Send failed:', data.error);
// handle: session disconnected? retry? alert?
return;
}
console.log('Sent, messageId:', data.messageId);
import requests
resp = requests.post(
f'{GATEWAY}/send',
headers={'x-api-key': KEY},
json={'sessionId': session_id, 'to': to, 'message': message},
)
data = resp.json()
if not data.get('success'):
print('Send failed:', data.get('error'))
# handle: session disconnected? retry? alert?
else:
print('Sent, messageId:', data.get('messageId'))
Common integration issues
| Symptom | Cause | Fix |
|---|---|---|
| 403 on every request | Wrong API key or missing header | Check x-api-key header is set; confirm the key matches the session |
| 400 "Session not connected" | Session disconnected or not yet scanned | Check GET /status; re-scan QR if needed |
| Webhook never fires | URL not reachable from gateway server | Confirm the gateway can reach your URL; check firewall / NAT; use tunnel for local dev |
| Webhook fires once then stops | Your server returned non-2xx | Check GET /api/webhooks for delivery status; ensure your handler always returns 200 |
Messages received as [Media/Other] | Sender sent an image, video, or sticker | Expected — the gateway does not currently deliver media content, only text |