WebSocket and Webhook Testing: Real-Time APIs Explained
Not every API waits for you to ask. Live cricket scores, stock tickers, ride tracking and chat are pushed to you over WebSockets; payment confirmations, shipping updates and GitHub events are pushed to your server as webhooks. Both break in ways request-response testing never sees: dropped connections, out-of-order messages, duplicate events, forged signatures. This lesson shows you how to test both, with live echo servers and runnable Playwright code.
Polling vs WebSocket vs Webhook
| Polling (REST) | WebSocket | Webhook | |
|---|---|---|---|
| Direction | Client asks repeatedly | Two-way, persistent connection | Server calls your URL on events |
| Latency | Up to the polling interval | Milliseconds | Seconds |
| Typical use | Simple status checks | Chat, live scores, trading, ride tracking, multiplayer | Payments, CI events, CRM updates, shipping status |
| Who connects | Client → server | Client → server, then both send | Provider → your server |
WebSocket Basics for Testers
- The client sends an HTTP request with
Upgrade: websocketto awss://URL. - The server answers 101 Switching Protocols. The connection stays open.
- Both sides exchange messages (text, usually JSON, or binary) in any order.
- Either side closes with a close code (1000 normal, 1001 going away, 1006 abnormal, 1008 policy violation, 1011 server error).
Many apps add a protocol on top: subscribe/unsubscribe messages, heartbeats (ping/pong), message ids and acknowledgements. Socket.IO, STOMP and GraphQL subscriptions are common higher-level protocols.
Try It: Public Echo Servers
Two free echo servers send back whatever you send, which is perfect for learning:
wss://echo.websocket.org: first sends a greeting likeRequest served by 4d896d95b55478, then echoes each message.wss://ws.postman-echo.com/raw: echoes immediately.
In Postman
- New → WebSocket. URL:
wss://ws.postman-echo.com/raw. Click Connect. - Type
{"event":"subscribe","match":"IND-vs-AUS"}and Send. - The Messages pane shows sent (up arrow) and received (down arrow) messages with timestamps.
- Click Disconnect and check the close event.
In the browser console
const ws = new WebSocket('wss://ws.postman-echo.com/raw');
ws.onopen = () => ws.send(JSON.stringify({ ping: 1 }));
ws.onmessage = (e) => console.log('received', e.data); // received {"ping":1}
ws.onclose = (e) => console.log('closed', e.code);
WebSocket Test Cases
| Area | Test | Expected |
|---|---|---|
| Handshake | Connect to the URL | 101; protocol/subprotocol agreed |
| Auth | Connect without token / expired token | Rejected (401 on handshake or close 1008) |
| Message format | Subscribe to a topic | Ack message in the documented schema |
| Server push | Trigger an event (e.g. place an order) | Update arrives within the SLA (e.g. 2 s) |
| Ordering | Several rapid events | Received in order, or sequence numbers allow reordering |
| Invalid input | Malformed JSON, unknown action | Error message, connection stays healthy (or documented close) |
| Heartbeat | Idle for 60 s | Ping/pong keeps it alive; dead connections detected |
| Reconnect | Drop the network | Client reconnects with back-off and resubscribes; no lost or duplicated updates |
| Authorization | Subscribe to another user’s channel | Refused |
| Load | Thousands of concurrent connections | Stable latency and memory (k6, Artillery) |
Automating WebSocket Tests in Playwright
For a pure API-level test, use the WebSocket class built into Node.js 22+ inside a Playwright test. A small helper turns events into promises:
import { test, expect } from '@playwright/test';
function openSocket(url: string): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.onopen = () => resolve(ws);
ws.onerror = () => reject(new Error(`could not connect to ${url}`));
});
}
function nextMessage(ws: WebSocket, timeoutMs = 5_000): Promise<string> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('no message received')), timeoutMs);
ws.addEventListener('message', (e) => { clearTimeout(timer); resolve(String(e.data)); }, { once: true });
});
}
test('echo server returns the exact JSON we send', async () => {
const ws = await openSocket('wss://ws.postman-echo.com/raw');
const msg = { event: 'subscribe', match: 'IND-vs-AUS', id: Date.now() };
const reply = nextMessage(ws);
ws.send(JSON.stringify(msg));
expect(JSON.parse(await reply)).toEqual(msg);
ws.close(1000);
});
test('messages come back in order', async () => {
const ws = await openSocket('wss://ws.postman-echo.com/raw');
const received: string[] = [];
const done = new Promise<void>((resolve) => {
ws.onmessage = (e) => { received.push(String(e.data)); if (received.length === 5) resolve(); };
});
for (let i = 1; i <= 5; i++) ws.send(`ball-${i}`);
await done;
expect(received).toEqual(['ball-1', 'ball-2', 'ball-3', 'ball-4', 'ball-5']);
ws.close();
});
For web apps, Playwright can also watch the browser’s own sockets with page.on('websocket', ws => ws.on('framereceived', ...)) and mock a socket server with page.routeWebSocket(), which is useful when testing how the UI reacts to pushed messages.
Webhook Basics for Testers
payment.captured to the store’s webhook URL with a JSON body and a signature header. The store verifies the signature, marks the order paid and replies 200. If the store’s server is down, the gateway retries later, so the same event can arrive twice and the store must not ship two parcels.A typical webhook request your server receives:
POST /webhooks/payments HTTP/1.1
Content-Type: application/json
X-Signature: 5d7c3e...a91f (HMAC-SHA256 of the raw body with a shared secret)
X-Event-Id: evt_01JD8K2Q
X-Timestamp: 1794482821
{"event":"payment.captured","payment_id":"pay_123","order_id":"ord_987","amount":229746,"currency":"INR"}
Webhook Test Cases
| Area | Test | Expected |
|---|---|---|
| Delivery | Trigger the event in the provider’s test mode | Your URL receives the correct payload and headers |
| Signature valid | Correct HMAC | 2xx; order updated |
| Signature invalid / missing | Change one byte of the body | 401/400; nothing processed |
| Replay | Resend an old request with an old timestamp | Rejected (timestamp tolerance, e.g. 5 minutes) |
| Duplicates | Same event id twice | Processed once (idempotent); second returns 2xx |
| Ordering | payment.refunded before payment.captured | Handled correctly (state checks or re-fetch) |
| Retries | Your endpoint returns 500 / times out | Provider retries with back-off; eventual success processes once |
| Speed | Measure response time | Fast 2xx (heavy work queued), within the provider’s timeout |
| Unknown events | Event type you don’t handle | 2xx and ignored, not 500 |
| Security | HTTPS only; secret not logged | As configured |
Tools for capturing webhooks
- webhook.site / RequestBin: get a public URL and see every request sent to it. Don’t send real customer data there.
- ngrok / Cloudflare Tunnel: expose your local server to a provider during development.
- Provider CLIs and dashboards: many payment providers can resend events and show delivery logs.
Testing Webhook Signatures in Code
Most providers sign webhooks with HMAC-SHA256 over the raw body (sometimes with the timestamp). Your receiver recomputes the signature and compares it in constant time. Here’s a self-contained test of that logic:
import { test, expect } from '@playwright/test';
import crypto from 'node:crypto';
const SECRET = 'whsec_test_123'; // test secret; real secrets come from environment variables
const sign = (rawBody: string, timestamp: number) =>
crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex');
function verify(rawBody: string, timestamp: number, signature: string, now = Date.now() / 1000): boolean {
if (Math.abs(now - timestamp) > 300) return false; // replay window: 5 minutes
const expected = Buffer.from(sign(rawBody, timestamp), 'hex');
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const body = JSON.stringify({ event: 'payment.captured', order_id: 'ord_987', amount: 229746 });
const ts = Math.floor(Date.now() / 1000);
test('valid signature is accepted', () => {
expect(verify(body, ts, sign(body, ts))).toBe(true);
});
test('tampered body is rejected', () => {
const tampered = body.replace('229746', '100');
expect(verify(tampered, ts, sign(body, ts))).toBe(false);
});
test('old (replayed) request is rejected', () => {
const old = ts - 3600;
expect(verify(body, old, sign(body, old))).toBe(false);
});
test('duplicate delivery is processed once', () => {
const processed = new Set<string>();
const handle = (eventId: string) => { if (processed.has(eventId)) return 'duplicate'; processed.add(eventId); return 'processed'; };
expect(handle('evt_01JD8K2Q')).toBe('processed');
expect(handle('evt_01JD8K2Q')).toBe('duplicate');
});
In an integration test, the same sign() helper lets you POST signed fake events straight to your own webhook endpoint with request.post(), so you don’t need the real provider for every run.
Common Bugs in Real-Time APIs
- WebSocket server accepts connections without authentication.
- Client never reconnects after the phone switches from Wi-Fi to mobile data.
- Messages arrive out of order during bursts; UI shows an old score.
- Memory leak: server keeps dead connections forever.
- Webhook signature not checked, or checked on a re-serialised body instead of the raw body.
- Duplicate webhook ships an order twice or credits a wallet twice.
- Webhook handler does slow work synchronously and times out, triggering endless retries.
Interview Questions: WebSocket and Webhook Testing
What is the difference between a WebSocket and a webhook?
A WebSocket is a persistent two-way connection opened by the client, used for real-time messaging. A webhook is a one-way HTTP POST sent by a provider to a URL you registered when an event occurs.
How do you test a WebSocket API?
Verify the handshake (101), authentication on connect, subscribe and message schemas, server pushes within SLA, message ordering, invalid message handling, heartbeats, reconnection after network loss, channel authorization and behaviour under many concurrent connections.
What status code indicates a successful WebSocket handshake?
101 Switching Protocols.
How do you test webhooks?
Trigger events in the provider’s test mode or send signed fake events, verify payloads and headers, signature validation, quick 2xx responses, idempotent handling of duplicates, out-of-order events, retries after failures and replay protection.
Why must webhook handlers be idempotent?
Providers retry when they don’t get a timely 2xx, and networks can duplicate deliveries, so the same event may arrive more than once. Storing processed event ids prevents double processing.
How is a webhook signature verified?
The receiver recomputes an HMAC (usually SHA-256) of the raw request body, often with a timestamp, using the shared secret, and compares it with the signature header in constant time. It also rejects old timestamps to prevent replays.
Which tools do you use to test WebSockets?
Postman’s WebSocket requests, browser DevTools (Network > WS), Playwright (Node WebSocket client, page.on(‘websocket’), routeWebSocket), and k6 or Artillery for load.
How do you test webhooks locally?
Use tunnels such as ngrok to expose a local server, inspection services like webhook.site, the provider’s event replay tools, or send signed test payloads directly to your endpoint from automated tests.
What are WebSocket close codes?
Numbers sent when closing a connection, such as 1000 normal closure, 1001 going away, 1006 abnormal closure without a close frame, 1008 policy violation and 1011 internal error. Tests check the right code is used.
What would you check if live updates sometimes show stale data?
Message ordering and sequence numbers, reconnection and resubscription logic, missed messages during disconnects, caching on the client, and whether the client re-fetches state after reconnecting.