Webhooks
Event types, payload schemas, HMAC-SHA256 verification, retry policy, and testing webhooks.
Webhooks
WaSphere sends an HTTP POST request to your configured endpoint every time a WhatsApp event occurs — incoming messages, delivery receipts, session status changes, and more. Your endpoint must respond with a 2xx status within 10 seconds.
Registering a Webhook
In the dashboard, go to Webhooks → New Webhook and provide:
- Name — a label for the endpoint
- URL — your HTTPS endpoint (e.g.
https://yourapp.com/webhooks/wasphere) - Events — select which event types to receive, or select "All events"
Or via API:
curl -X POST "https://api.yourdomain.com/workspaces/be11c51b-1a5a-4bc9-9cb6-7ee7040ec4d8/webhooks" \
-H "Authorization: Bearer wsk_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "primary",
"url": "https://yourapp.com/webhooks/wasphere",
"events": ["message.received"],
"retryMax": 3,
"isActive": true
}'
The webhook payload is not signed with a per-webhook secret. Every webhook is signed with the single global WEBHOOK_SIGNING_SECRET set on the WA Server (see Configuration). The create request accepts only name, url, events, retryMax, and isActive — there is no secret field. Verify all deliveries against that one shared secret.
Request Format
Every webhook delivery is an HTTP POST with these headers:
Content-Type: application/json
X-WaSphere-Event: message.received
X-WaSphere-Signature: v1,sha256=<hmac-hex>
X-WaSphere-Timestamp: 1748168400
The X-WaSphere-Timestamp is a Unix timestamp (seconds). Verify it is within ±5 minutes of your server time to guard against replay attacks.
Payload structure:
{
"event": "message.received",
"sessionId": "my-session",
"timestamp": "2026-05-26T00:00:00.000Z",
"deliveryId": "abc123",
"data": { }
}
The data object contains event-specific fields described in the Event Types section below.
HMAC Signature Verification
Always verify the signature before processing the payload.
The signature is computed using the global WEBHOOK_SIGNING_SECRET as:
HMAC-SHA256(WEBHOOK_SIGNING_SECRET, "{timestamp}.{rawBody}")
Then formatted as:
v1,sha256={hex}
Note the v1,sha256= prefix — strip it before comparing if your library computes the hex digest directly, or construct the full string for comparison.
Never skip signature verification. Without it, anyone who discovers your webhook URL can send fake events to your application.
import crypto from 'crypto';
/**
* Express.js middleware — use express.raw() to capture rawBody before JSON parsing.
*/
function verifyWaSphereSignature(req, res, next) {
const signature = req.headers['x-wasphere-signature'];
const timestamp = req.headers['x-wasphere-timestamp'];
const secret = process.env.WASPHERE_WEBHOOK_SECRET;
if (!signature || !timestamp || !secret) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Guard against replay attacks — reject if timestamp is > 5 minutes old
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) {
return res.status(401).json({ error: 'Request timestamp too old' });
}
// Compute expected signature: HMAC-SHA256("{timestamp}.{rawBody}")
const rawBody = req.rawBody; // Must be captured before JSON parsing
const hmacPayload = `${timestamp}.${rawBody}`;
const hmacHex = crypto
.createHmac('sha256', secret)
.update(hmacPayload)
.digest('hex');
const expected = `v1,sha256=${hmacHex}`;
// Constant-time comparison to prevent timing attacks
try {
const sigBuffer = Buffer.from(signature);
const expBuffer = Buffer.from(expected);
if (sigBuffer.length !== expBuffer.length) {
return res.status(401).json({ error: 'Invalid signature' });
}
if (!crypto.timingSafeEqual(sigBuffer, expBuffer)) {
return res.status(401).json({ error: 'Invalid signature' });
}
} catch {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
}
// Register with Express (rawBody capture must come first)
app.use(
'/webhooks/wasphere',
express.raw({ type: 'application/json' }),
(req, res, next) => {
req.rawBody = req.body.toString();
req.body = JSON.parse(req.rawBody);
next();
},
verifyWaSphereSignature,
(req, res) => {
const { event, data } = req.body;
console.log(`Received event: ${event}`, data);
res.json({ received: true });
}
);import hashlib
import hmac
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
WASPHERE_SECRET = os.environ['WASPHERE_WEBHOOK_SECRET']
def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
# Replay attack guard — reject if > 5 minutes old
try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False
if age > 300:
return False
# Compute expected signature: HMAC-SHA256("{timestamp}.{rawBody}")
hmac_payload = f'{timestamp}.{raw_body.decode()}'.encode()
hmac_hex = hmac.new(
WASPHERE_SECRET.encode(),
hmac_payload,
hashlib.sha256
).hexdigest()
expected = f'v1,sha256={hmac_hex}'
return hmac.compare_digest(signature.encode(), expected.encode())
@app.route('/webhooks/wasphere', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-WaSphere-Signature', '')
timestamp = request.headers.get('X-WaSphere-Timestamp', '')
raw_body = request.get_data()
if not verify_signature(raw_body, signature, timestamp):
abort(401)
payload = request.get_json()
event = payload['event']
data = payload['data']
print(f'Received event: {event}', data)
return {'received': True}<?php
function verifyWaSphereSignature(string $rawBody, string $signature, string $timestamp): bool
{
// Replay attack guard — reject if > 5 minutes old
if (abs(time() - (int)$timestamp) > 300) {
return false;
}
$secret = $_ENV['WASPHERE_WEBHOOK_SECRET'];
// Compute expected signature: HMAC-SHA256("{timestamp}.{rawBody}")
$hmacPayload = $timestamp . '.' . $rawBody;
$hmacHex = hash_hmac('sha256', $hmacPayload, $secret);
$expected = 'v1,sha256=' . $hmacHex;
return hash_equals($expected, $signature);
}
// Capture raw body before any framework parsing
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WASPHERE_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_WASPHERE_TIMESTAMP'] ?? '';
if (!verifyWaSphereSignature($rawBody, $signature, $timestamp)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
$event = $payload['event'];
$data = $payload['data'];
error_log("Received event: {$event}");
http_response_code(200);
echo json_encode(['received' => true]);Event Types
WaSphere emits the following event types. Subscribe to a subset, or select "All events" to receive everything.
Messaging
| Event | Description |
|---|---|
message.received | Incoming message from a contact or group |
message.receipt | Delivery / read receipt for an outgoing message |
messages.update | An existing message changed (edited, deleted/revoked, reaction) |
Presence & contacts
| Event | Description |
|---|---|
presence.update | A contact's online / typing / recording presence changed |
contacts.update | Contact metadata changed (name, push name, avatar) |
call | An incoming WhatsApp call was offered |
Groups
| Event | Description |
|---|---|
groups.update | Group metadata changed (subject, description, settings) |
group.participants.update | Participants added, removed, promoted, or demoted |
Session lifecycle
| Event | Description |
|---|---|
session.qr | New QR code generated (waiting to be scanned) |
session.connected | Session successfully connected to WhatsApp |
session.disconnected | Session disconnected |
session.logged_out | The number was logged out from WhatsApp Linked Devices |
session.failed | The session failed to connect |
session.deleted | The session was deleted |
session.config_updated | A session's anti-ban / settings were updated |
Bulk sends
| Event | Description |
|---|---|
bulk.sent | A bulk send job completed |
bulk.failed | A bulk send job failed |
message.received — example payload
{
"event": "message.received",
"sessionId": "my-session",
"timestamp": "2026-05-26T00:00:00.000Z",
"deliveryId": "abc123",
"data": {
"messageId": "3EB0C767D097B7C7A5C1",
"from": "923001234567@s.whatsapp.net",
"fromName": "Alice",
"to": "923009999999@s.whatsapp.net",
"type": "text",
"isGroup": false,
"content": {
"text": "Hello, I need help with my order"
}
}
}
For media messages the content object includes a mediaUrl field pointing to the downloaded file, along with mimeType and fileSize.
Retry Policy
If your endpoint returns a non-2xx status code, times out, or is unreachable, WaSphere retries with these delays:
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | — |
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 30 seconds |
After 3 retries (4 total attempts) the delivery is marked failed and no further retries occur. You can view the full delivery log in the dashboard under Webhooks → Deliveries.
Respond to webhooks immediately with 200 OK and process the payload asynchronously. If your handler takes longer than 10 seconds, WaSphere marks the delivery as failed and retries — causing duplicate processing on your end.
Using Webhooks in n8n
Step 1 — Set up the Webhook node
- Add a Webhook node to your workflow.
- Set HTTP Method to
POST. - Copy the generated webhook URL and paste it into WaSphere's webhook registration (dashboard or API).
- Set Response Mode to "When Last Node Finishes" so n8n returns
200 OKafter processing.
n8n's Webhook node uses application/json body parsing by default. To verify the HMAC signature you need the raw body string — use the Binary Data option in the Webhook node and read the body as text in the first Code node.
Step 2 — Verify the signature in a Code node
Add a Code node immediately after the Webhook node with this JavaScript:
import crypto from 'crypto';
const secret = 'your-signing-secret'; // store in n8n credentials instead
const rawBody = $input.first().binary?.data
? Buffer.from($input.first().binary.data, 'base64').toString()
: JSON.stringify($input.first().json);
const signature = $input.first().headers['x-wasphere-signature'] ?? '';
const timestamp = $input.first().headers['x-wasphere-timestamp'] ?? '';
// Reject stale requests
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) {
throw new Error('Webhook timestamp too old — possible replay attack');
}
// Compute expected signature
const hmacPayload = `${timestamp}.${rawBody}`;
const hmacHex = crypto
.createHmac('sha256', secret)
.update(hmacPayload)
.digest('hex');
const expected = `v1,sha256=${hmacHex}`;
if (signature !== expected) {
throw new Error('Invalid webhook signature');
}
// Signature valid — pass the parsed event downstream
return $input.all();
Step 3 — Route by event type
Add a Switch node after the Code node, set Mode to "Rules", and add one rule per event you want to handle:
| Rule | Value |
|---|---|
{{ $json.event }} equals | message.received |
{{ $json.event }} equals | session.disconnected |
Each output branch connects to the appropriate downstream nodes (e.g. a Respond to Webhook node, a database insert, or a notification).
Testing Webhooks
Test button in the dashboard
Every registered webhook has a Test button. Clicking it sends a sample payload to your endpoint using a real delivery attempt, including all signature headers — so you can confirm your verification logic works end to end.
Test via API
curl -X POST "https://api.yourdomain.com/workspaces/be11c51b-1a5a-4bc9-9cb6-7ee7040ec4d8/webhooks/WEBHOOK_ID/test" \
-H "Authorization: Bearer wsk_xxxxx"
Response:
{
"deliveryId": "del_test_xyz",
"status": "delivered",
"statusCode": 200,
"durationMs": 145
}
Local development with ngrok
Expose your local server with ngrok:
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
Use the ngrok HTTPS URL as your webhook endpoint in the dashboard. ngrok's web inspector at http://localhost:4040 lets you inspect and replay individual deliveries during development.