← Back to Dashboard

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.

No phone number required from you. Each session connects to an existing WhatsApp account by scanning a QR code on the phone. Your app never handles phone credentials — just the API key.

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:

Key types

Key typeWhat it can doHow to get it
Master keyCreate/delete sessions, access all sessions, view all logsSet by the server administrator via WH_GATEWAY_KEY env var
Session keySend messages, set webhook, check status — for one session onlyReturned when a session is created; rotatable via POST /session/api-key
As an integrating developer, you will typically receive only a session key scoped to your application's session. You do not need the master key for normal send/receive operations.

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.

Creating sessions requires the master key. If you have been given a pre-created session, skip to Step 2.
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

The QR is not pushed to you. There is no webhook for QR readiness — your app must poll 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

INITIALIZING WAITING_FOR_SCAN CONNECTED DISCONNECTED FAILED
StatusMeaningAction
INITIALIZINGSession is starting up, connecting to WhatsApp serversWait and poll — typically resolves in 1–3 seconds
WAITING_FOR_SCANQR code is ready to be scannedFetch /qr, display it, start refreshing every 30s
CONNECTEDAuthenticated and online — ready to send/receiveStop polling, proceed with your integration
DISCONNECTEDLogged out from WhatsApp (phone unlinked the device)Delete session with DELETE /session/:id and recreate it
FAILEDInitialization errorContact the gateway administrator
QR codes expire after ~60 seconds. The gateway automatically issues a new one — but you must re-fetch 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 }
Your webhook URL must be publicly reachable by the gateway server. During local development, use a tunneling tool (ngrok, Cloudflare Tunnel, etc.) to expose your local server.

Webhook delivery behaviour

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
}
FieldTypeDescription
sessionIdstringWhich session received the message — useful if you run multiple sessions behind one webhook
fromstringSender's WhatsApp ID in JID format: {countrycode}{number}@s.whatsapp.net
isGroupbooleantrue if the message came from a group chat
typestringMessage type: text, image, video, audio, document, location, reaction, and more — see full list in API Reference
bodystringPrimary text — message text, caption, place name, or selected option depending on type
extraobject|nullType-specific metadata (mimetype, lat/lng, filename, etc.) — null for plain text
pushnamestringSender's display name as set in their WhatsApp profile
timestampnumberUnix 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.

The session must be in 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:

app.post('/whatsapp/incoming', async (req, res) => {
  const { sessionId, from, body } = req.body;
  res.sendStatus(200); // acknowledge first, then process

  if (body.toLowerCase().includes('hello')) {
    await fetch('https://gateway.yourapp.com/send', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.WH_SESSION_KEY,
      },
      body: JSON.stringify({
        sessionId,
        to: from.split('@')[0], // strip @s.whatsapp.net
        message: 'Hi there! How can I help you today?',
      }),
    });
  }
});
import requests, os

@app.route('/whatsapp/incoming', methods=['POST'])
def incoming():
    data    = request.get_json()
    session = data['sessionId']
    from_   = data['from'].split('@')[0]
    body    = data['body']

    if 'hello' in body.lower():
        requests.post(
            'https://gateway.yourapp.com/send',
            headers={'x-api-key': os.environ['WH_SESSION_KEY']},
            json={
                'sessionId': session,
                'to':        from_,
                'message':   'Hi there! How can I help you today?',
            }
        )

    return '', 200

Full integration flow at a glance

1

POST /init

Create a session → receive sessionId

2

GET /qr?sessionId=…

Fetch QR image → display to user → they scan in WhatsApp

3

Poll GET /status until CONNECTED

Session is live once status = CONNECTED

4

POST /session/webhook

Register your webhook URL once — persists across restarts

5

Your server receives POST {webhookUrl}

Gateway pushes inbound messages → your app processes them

6

POST /send

Send text, media, location, or contact cards any time the session is CONNECTED

API Reference — Sessions

POST/init
🔑 Master key required

Create a new WhatsApp session. Returns a sessionId — save it for all future calls.

FieldTypeDescription
labelstringOptionalHuman-readable name shown in the dashboard
{ "success": true, "sessionId": "a1b2c3d4" }
GET/status
🔑 Master key (all sessions) · Session key (own session only)

Get current status of a session. Omit sessionId with master key to get all sessions.

Query paramDescription
sessionIdOptionalSpecific 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/qr
🔑 Master key or Session key

Get the current QR code for a session in WAITING_FOR_SCAN status. Returns 404 if already connected or QR not ready yet.

Query paramDescription
sessionIdRequiredSession to get QR for
{
  "success": true,
  "qr":    "2@rawqrstring...",
  "image": "data:image/png;base64,..."
}
DELETE/session/:sessionId
🔑 Master key required

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 }
POST/session/webhook
🔑 Master key or Session key

Register or update the webhook URL for a session. Calling this again replaces the previous URL.

FieldTypeDescription
sessionIdstringRequired
webhookUrlstringRequiredPublic HTTPS URL on your server
{ "success": true }
POST/session/api-key
🔑 Master key required

Rotate the API key for a session. The previous key is immediately invalidated — update any stored copies before rotating.

FieldTypeDescription
sessionIdstringRequired
{ "success": true, "apiKey": "new64hexcharstring..." }

API Reference — Send

POST/send
🔑 Master key or Session key

Send a message. The type is inferred from the fields present in the body.

Common fields (all message types)

FieldTypeDescription
sessionIdstringRequiredThe session to send from (defaults to host if omitted)
tostringRequiredRecipient — phone number (any format) for individual chats, or a group JID ([email protected]) for group chats
Sending to a group: pass the group's JID as 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

FieldTypeDescription
messagestringRequiredMessage body text
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "message": "Hello!" }
// → { "success": true, "messageId": "3EB0..." }

Image or video

FieldTypeDescription
mediaUrlstringRequiredPublicly accessible file URL. .mp4 → video; everything else → image
captionstringOptionalCaption text displayed below the media
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "mediaUrl": "https://yourapp.com/img.jpg", "caption": "See attached" }
// → { "success": true }

Location pin

FieldTypeDescription
location.latnumberRequiredLatitude
location.lngnumberRequiredLongitude
{ "sessionId": "a1b2c3d4", "to": "+919495236004", "location": { "lat": 9.9312, "lng": 76.2673 } }
// → { "success": true }

Contact card

FieldTypeDescription
contact.namestringRequiredContact display name
contact.numberstringRequiredContact 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
}
FieldTypeDescription
sessionIdstringWhich session received the message
fromstringSender's WhatsApp JID. Extract phone: from.split('@')[0]
isGroupbooleantrue if sent from a group chat
typestringMessage type — see table below
bodystringPrimary text content — caption, name, selected option, or empty string
extraobject|nullType-specific fields — see per-type examples below
pushnamestringSender's WhatsApp display name
timestampnumberUnix timestamp in seconds

Message types & their extra fields

typebodyextra fields
textMessage textnull
imageCaption (if any){ mimetype }
videoCaption (if any){ mimetype }
audioempty{ mimetype, isVoiceNote, seconds }
documentFile name / title{ mimetype, fileName, pageCount }
stickerempty{ isAnimated }
locationPlace name (if any){ lat, lng, address }
live_locationempty{ lat, lng }
contactContact display name{ vcard }
contactsComma-separated names{ count }
reactionEmoji character{ targetMessageId }
button_replyButton label selected{ selectedId }
list_replyRow title selected{ selectedId }
template_replyButton label selected{ selectedIndex }
pollPoll question{ options: [] }
poll_voteempty{ pollMessageId }
unknownemptynull

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
Media file content is not included in the webhook payload — only metadata (mimetype, filename, dimensions, etc.). The actual bytes of images, videos, and documents are not downloaded or forwarded by the gateway.
Status updates (status@broadcast) are suppressed automatically and never delivered to your webhook.

API Reference — Logs

GET/api/logs
🔑 Master key required

Recent connection events (last 20).

{
  "success": true,
  "logs": [
    { "id": 42, "session_id": "a1b2c3d4", "event": "CONNECTED", "reason": null, "timestamp": "2026-06-01 08:00:00" }
  ]
}
GET/api/webhooks
🔑 Master key required

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"
    }
  ]
}
GET/healthz

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 statusMeaningCommon cause
400Bad requestSession not connected, or a required field is missing
403UnauthorizedMissing x-api-key header, or the key is invalid / belongs to a different session
404Not foundSession ID doesn't exist, or QR is not ready
500Server errorFailed 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

SymptomCauseFix
403 on every requestWrong API key or missing headerCheck x-api-key header is set; confirm the key matches the session
400 "Session not connected"Session disconnected or not yet scannedCheck GET /status; re-scan QR if needed
Webhook never firesURL not reachable from gateway serverConfirm the gateway can reach your URL; check firewall / NAT; use tunnel for local dev
Webhook fires once then stopsYour server returned non-2xxCheck GET /api/webhooks for delivery status; ensure your handler always returns 200
Messages received as [Media/Other]Sender sent an image, video, or stickerExpected — the gateway does not currently deliver media content, only text