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:
- URL — your HTTPS endpoint (e.g.
https://yourapp.com/webhooks/wasphere) - Events — select which event types to receive, or select "All events"
- Signing Secret — used to sign payloads (HMAC-SHA256)
Or via API:
curl -X POST "https://api.yourdomain.com/workspaces/be11c51b-1a5a-4bc9-9cb6-7ee7040ec4d8/webhooks" \
-H "Authorization: Bearer wsk_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/wasphere",
"events": ["message.received"],
"signingSecret": "your-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
X-WaSphere-Delivery-Id: abc123
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 as:
HMAC-SHA256(signingSecret, "{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 10 event types:
| Event | Description |
|---|---|
message.received | Incoming message from a contact |
message.sent | Outgoing message accepted by WhatsApp servers |
message.failed | Outgoing message could not be delivered |
message.delivered | Message delivered to the recipient's device |
message.read | Message read by the recipient |
message.reaction | Emoji reaction added or removed on a message |
session.connected | Session successfully connected to WhatsApp |
session.disconnected | Session disconnected |
session.qr | New QR code generated (waiting to be scanned) |
webhook.test | Test delivery triggered from the dashboard or API |
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 webhook.test payload to your endpoint using a real delivery attempt including all signature headers.
Test via API
curl -X POST "https://api.yourdomain.com/workspaces/be11c51b-1a5a-4bc9-9cb6-7ee7040ec4d8/webhooks/WEBHOOK_ID/test" \
-H "Authorization: Bearer wsk_live_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.