Realtime WebSocket

Receive events over a WebSocket connection instead of polling. Every event that is delivered to your outgoing webhooks is also pushed in realtime to any WebSocket connections your organization has open — with the exact same payload shape, so you can reuse your webhook parsing logic.

Webhooks vs realtime: the WebSocket is a best-effort, low-latency push channel. Outgoing webhooks remain the guaranteed delivery channel (with retries and delivery logs). If your integration must never miss an event, consume webhooks (or re-sync via REST) and use the WebSocket for responsiveness.

Endpoint

GET wss://<host>/v1/realtime

The connection is scoped to your organization: you receive events for all phones and WABAs in the organization unless you narrow the subscription (see Filtering).

Try it in 30 seconds

With websocat and your API key:

websocat -H "Authorization: Bearer sk_org_..." wss://<host>/v1/realtime

You will immediately receive a connection.established message. Send a WhatsApp message to one of your connected phones and watch the message.received event arrive.

Authentication

There are two ways to authenticate, depending on where your client runs.

Your client Method
Server (Node, Python, Go, ...) Authorization: Bearer sk_org_... header on the handshake
Browser / mobile webview Ephemeral client secret in the query string

Server-side clients: API key header

Any non-browser WebSocket client can set headers on the handshake. Pass your organization API key as a Bearer token:

import WebSocket from "ws";

const ws = new WebSocket("wss://<host>/v1/realtime", {
  headers: { Authorization: "Bearer sk_org_..." },
});

ws.on("message", (raw) => {
  if (raw.toString() === "pong") return;
  const event = JSON.parse(raw.toString());
  console.log(event.event, event.data);
});

Browsers: ephemeral client secrets

Browsers cannot set headers on new WebSocket(), and you must never expose your API key in frontend code. The pattern (mirroring OpenAI's Realtime API):

  1. Your backend mints a short-lived, single-use client secret with your API key.
  2. Your backend hands the secret value to the browser.
  3. The browser connects with the secret as a query parameter.

Mint a secret:

POST /v1/realtime/client_secrets
Authorization: Bearer sk_org_...
Content-Type: application/json

{ "expiresInSeconds": 60 }

Response:

{
  "data": {
    "value": "ek_4f9a1c2b3d4e5f60718293a4b5c6d7e8f9a0b1c2d3e4f506",
    "expiresAt": "2026-06-11T15:30:00.000Z"
  }
}

The body is optional; expiresInSeconds defaults to 60 (min 10, max 600). You can also scope the secret with phoneNumberIds and events (see Filtering).

Connect from the browser:

const ws = new WebSocket(`wss://<host>/v1/realtime?client_secret=${value}`);

Client secrets are single-use (consumed by the first connection that presents them, whether the connection succeeds or not). A used, expired, or unknown secret is rejected with HTTP 401 before the upgrade. Mint a fresh secret for every connection attempt, including reconnections.

A typical mint endpoint on your own backend:

// POST /realtime-token on YOUR backend — the browser calls this, never the API directly
app.post("/realtime-token", requireYourAppAuth, async (req, res) => {
  const response = await fetch("https://<host>/v1/realtime/client_secrets", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.WPP_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ expiresInSeconds: 60 }),
  });
  const { data } = await response.json();

  res.json({ value: data.value, url: "wss://<host>/v1/realtime" });
});

Message envelope

Every message is a JSON object with the same envelope as webhook deliveries:

{
  "event": "message.received",
  "timestamp": "2026-06-11T15:30:01.123Z",
  "organizationId": "0b6cdd2a-...",
  "data": { "messageId": "...", "conversationId": "...", "phoneNumberId": "...", "...": "..." }
}

You receive all the event types documented in the webhooks guide, with identical data payloads:

Two additional control events exist only on the WebSocket:

There is no client→server protocol beyond the ping keepalive: to send messages, mark as read, etc., use the REST API.

Filtering

By default a connection receives every event in the organization. To narrow it:

phoneNumberIds accepts at most 100 IDs. events accepts the event types listed above plus "*".

Filters are not an authorization boundary. They are best-effort delivery filters within your organization: events that do not carry a phoneNumberId are delivered to every connection regardless of phone filters, and phoneNumberIds are not validated against your organization's phones. Do not rely on them to isolate untrusted end users — anyone holding a connection credential is trusted with the organization's event stream.

Keepalive

Send the literal string ping periodically (every 30 seconds is a good default); the server answers with pong without any cost to you. Connections idle for too long may be closed by intermediaries, so a heartbeat is strongly recommended.

setInterval(() => ws.send("ping"), 30_000);

Two things to handle:

Reconnection

Connections can drop at any time: network issues, and notably every deploy of the API disconnects all WebSocket clients. Your client must:

  1. Reconnect with exponential backoff (e.g. 1s, 2s, 4s... capped at 30s). Mint a fresh client secret per attempt if you are in a browser.
  2. Re-sync missed state through the REST API after reconnecting (e.g. re-fetch conversations/messages updated since your last received event). Events are not replayed over the WebSocket.

A complete server-side client

A production-shaped Node.js consumer with heartbeat, liveness detection, backoff reconnection, and a resync hook:

import WebSocket from "ws";

const URL = "wss://<host>/v1/realtime";
const API_KEY = process.env.WPP_API_KEY;
const HEARTBEAT_MS = 30_000;
const LIVENESS_MS = 75_000;

let attempt = 0;

function connect() {
  const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${API_KEY}` } });
  let lastSeen = Date.now();

  const heartbeat = setInterval(() => {
    if (Date.now() - lastSeen > LIVENESS_MS) {
      ws.terminate();
      return;
    }
    if (ws.readyState === WebSocket.OPEN) ws.send("ping");
  }, HEARTBEAT_MS);

  ws.on("open", () => {
    attempt = 0;
    // Recover anything missed while disconnected
    resyncFromRestApi();
  });

  ws.on("message", (raw) => {
    lastSeen = Date.now();
    const text = raw.toString();
    if (text === "pong") return;

    const event = JSON.parse(text);
    switch (event.event) {
      case "connection.established":
        console.log("connected as", event.data.connectionId);
        break;
      case "message.received":
        handleInbound(event.data);
        break;
      case "message.status_updated":
        handleStatus(event.data);
        break;
    }
  });

  ws.on("close", () => {
    clearInterval(heartbeat);
    const delay = Math.min(30_000, 1000 * 2 ** attempt++);
    setTimeout(connect, delay);
  });

  ws.on("error", () => {
    // "close" fires afterwards; reconnection is handled there
  });
}

connect();

Errors and close codes

HTTP errors at the handshake (before the upgrade):

Status Cause
401 Missing/invalid API key, or invalid/expired/used client secret
426 Request is not a WebSocket upgrade (Upgrade: websocket header missing)
400 Invalid phoneNumberIds/events query filters
429 Too many concurrent connections for the organization (max 100)

WebSocket close codes after the connection is established:

Code Meaning What to do
1000 Normal closure (you closed, or echo of your close) Nothing
1006 Abnormal closure — network drop or an API deploy Reconnect with backoff
1008 Protocol violation (repeated unexpected client messages) Fix your client: only ping should be sent
1011 Server error (e.g. connection scope too large) Reduce filter size; reconnect

Note: POST /v1/realtime/client_secrets returns 401 errors as { "error": "<message>" } (the org-auth middleware shape), while GET /v1/realtime returns the standard envelope { "error": { "message", "code" } }.

Integration checklist