Webhooks Integration
End-to-end webhook setup with signature verification examples in Express.js, FastAPI, and Laravel.
Webhooks Integration
This guide walks through a complete webhook integration — from registering an endpoint in WaSphere, to verifying signatures in production code, to building a simple incoming-message router.
Overview
The integration has three parts:
- Your endpoint — an HTTPS URL that accepts
POSTrequests - Signature verification — validate that each request genuinely came from WaSphere
- Event handler — route each event type to the appropriate business logic
Step 1 — Create Your Endpoint
Your endpoint must:
- Accept
POSTrequests at a public HTTPS URL - Return
200 OKwithin 10 seconds - Not require any authentication headers (WaSphere uses HMAC signing, not auth headers)
For local development, use ngrok to expose your local server:
ngrok http 3000
# Forwarding https://abc123.ngrok.io -> http://localhost:3000
Step 2 — Register the Webhook in WaSphere
curl -X POST https://wa.yourdomain.com/api/webhooks \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/wasphere",
"events": [
"message.received",
"message.status",
"session.connected",
"session.disconnected"
],
"secret": "your-strong-webhook-secret-here"
}'
Response:
{
"id": "wh_abc123",
"url": "https://yourapp.com/webhooks/wasphere",
"events": ["message.received", "message.status", "session.connected", "session.disconnected"],
"active": true,
"createdAt": "2026-05-25T10:00:00.000Z"
}
Store the webhook secret in your application's environment as WASPHERE_WEBHOOK_SECRET.
Step 3 — Implement the Handler
// webhooks/wasphere.js
import express from 'express';
import crypto from 'crypto';
const router = express.Router();
// IMPORTANT: use express.raw() so we get the raw body for signature verification
router.post(
'/wasphere',
express.raw({ type: 'application/json' }),
verifySignature,
handleEvent
);
function verifySignature(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) {
return res.status(401).json({ error: 'Missing signature headers' });
}
// Reject requests older than 5 minutes (replay attack prevention)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) {
return res.status(401).json({ error: 'Request timestamp too old' });
}
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body) // req.body is Buffer here (express.raw)
.digest('hex');
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse JSON after verification
req.body = JSON.parse(req.body.toString());
next();
}
async function handleEvent(req, res) {
// Acknowledge immediately
res.json({ received: true });
const { event, data } = req.body;
// Route to handlers (non-blocking — don't await)
switch (event) {
case 'message.received':
handleIncomingMessage(data).catch(console.error);
break;
case 'message.status':
handleMessageStatus(data).catch(console.error);
break;
case 'session.connected':
console.log(`Session connected: ${data.sessionName} (${data.phoneNumber})`);
break;
case 'session.disconnected':
console.warn(`Session disconnected: ${data.sessionName} — reason: ${data.reason}`);
// Alert your team (e.g. send email/Slack notification)
break;
default:
console.log(`Unhandled event: ${event}`);
}
}
async function handleIncomingMessage(data) {
const { sessionId, from, content, type } = data;
console.log(`Incoming ${type} message from ${from}`);
if (type === 'text') {
const text = content.text.toLowerCase().trim();
// Simple keyword router
if (text.includes('order status')) {
await sendReply(sessionId, from, 'Please share your order number.');
} else if (text.includes('human') || text.includes('agent')) {
await sendReply(sessionId, from, 'Connecting you to a support agent...');
// Trigger your support ticket system here
} else {
await sendReply(sessionId, from, 'Hi! How can I help you today?');
}
}
}
async function handleMessageStatus(data) {
const { messageId, status } = data;
console.log(`Message ${messageId} is now: ${status}`);
// Update your database with delivery status
}
async function sendReply(sessionId, to, text) {
await fetch('https://wa.yourdomain.com/api/messages/send/text', {
method: 'POST',
headers: {
'X-API-Key': process.env.WASPHERE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionId, to, text }),
});
}
export default router;app.js setup:
import express from 'express';
import webhookRouter from './webhooks/wasphere.js';
const app = express();
// Mount webhook router BEFORE any global JSON body parser
app.use('/webhooks', webhookRouter);
// Global JSON parser for other routes
app.use(express.json());
app.listen(3000, () => console.log('Server running on port 3000'));# routers/webhooks.py
import hashlib
import hmac
import os
import time
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
router = APIRouter(prefix="/webhooks")
WEBHOOK_SECRET = os.environ["WASPHERE_WEBHOOK_SECRET"]
WASPHERE_API_KEY = os.environ["WASPHERE_API_KEY"]
WASPHERE_BASE_URL = os.environ.get("WASPHERE_BASE_URL", "https://wa.yourdomain.com/api")
def verify_signature(raw_body: bytes, signature: str, timestamp: str) -> bool:
# Replay attack guard
try:
age = abs(time.time() - int(timestamp))
if age > 300:
return False
except (ValueError, TypeError):
return False
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected.encode(), signature.encode())
@router.post("/wasphere")
async def wasphere_webhook(request: Request, background_tasks: BackgroundTasks):
raw_body = await request.body()
signature = request.headers.get("x-wasphere-signature", "")
timestamp = request.headers.get("x-wasphere-timestamp", "")
if not verify_signature(raw_body, signature, timestamp):
raise HTTPException(status_code=401, detail="Invalid signature")
payload = await request.json()
event = payload.get("event")
data = payload.get("data", {})
# Acknowledge immediately, process in background
background_tasks.add_task(handle_event, event, data)
return {"received": True}
async def handle_event(event: str, data: dict):
import httpx
if event == "message.received":
await handle_incoming_message(data)
elif event == "session.disconnected":
print(f"Session disconnected: {data.get('sessionName')} — {data.get('reason')}")
elif event == "message.status":
print(f"Message {data.get('messageId')} status: {data.get('status')}")
async def handle_incoming_message(data: dict):
import httpx
session_id = data["sessionId"]
from_jid = data["from"]
content = data.get("content", {})
msg_type = data.get("type")
if msg_type == "text":
text = content.get("text", "").lower().strip()
if "order" in text:
reply = "Please share your order number and we'll look it up."
elif "cancel" in text:
reply = "To cancel, please visit your account page or reply with your order number."
else:
reply = "Hello! How can I help you today?"
async with httpx.AsyncClient() as client:
await client.post(
f"{WASPHERE_BASE_URL}/messages/send/text",
headers={
"X-API-Key": WASPHERE_API_KEY,
"Content-Type": "application/json",
},
json={"sessionId": session_id, "to": from_jid, "text": reply},
)main.py:
from fastapi import FastAPI
from .routers import webhooks
app = FastAPI()
app.include_router(webhooks.router)<?php
// app/Http/Controllers/WaSphereWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
class WaSphereWebhookController extends Controller
{
private string $secret;
public function __construct()
{
$this->secret = config('services.wasphere.webhook_secret');
}
public function handle(Request $request)
{
// Verify signature
if (!$this->verifySignature($request)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
$event = $request->input('event');
$data = $request->input('data', []);
// Acknowledge immediately, dispatch to queue
dispatch(fn() => $this->handleEvent($event, $data))->afterResponse();
return response()->json(['received' => true]);
}
private function verifySignature(Request $request): bool
{
$signature = $request->header('X-WaSphere-Signature', '');
$timestamp = $request->header('X-WaSphere-Timestamp', '');
// Replay attack guard
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$rawBody = $request->getContent();
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $this->secret);
return hash_equals($expected, $signature);
}
private function handleEvent(string $event, array $data): void
{
match ($event) {
'message.received' => $this->handleIncomingMessage($data),
'session.disconnected' => $this->handleSessionDisconnected($data),
'message.status' => $this->handleMessageStatus($data),
default => Log::info("Unhandled WaSphere event: {$event}", $data),
};
}
private function handleIncomingMessage(array $data): void
{
$sessionId = $data['sessionId'];
$from = $data['from'];
$type = $data['type'];
$content = $data['content'] ?? [];
if ($type !== 'text') {
return;
}
$text = strtolower(trim($content['text'] ?? ''));
$reply = match (true) {
str_contains($text, 'order') => 'Please share your order number.',
str_contains($text, 'refund') => 'I\'ll connect you with our billing team.',
default => 'Hello! How can I help you today?',
};
$this->sendWhatsAppMessage($sessionId, $from, $reply);
}
private function handleSessionDisconnected(array $data): void
{
Log::warning('WaSphere session disconnected', [
'session' => $data['sessionName'],
'reason' => $data['reason'],
]);
// Send alert to ops team
}
private function handleMessageStatus(array $data): void
{
// Update message status in your database
\App\Models\Message::where('wasphere_id', $data['messageId'])
->update(['status' => $data['status']]);
}
private function sendWhatsAppMessage(string $sessionId, string $to, string $text): void
{
Http::withHeaders([
'X-API-Key' => config('services.wasphere.api_key'),
])->post(config('services.wasphere.base_url') . '/messages/send/text', [
'sessionId' => $sessionId,
'to' => $to,
'text' => $text,
]);
}
}routes/api.php:
use App\Http\Controllers\WaSphereWebhookController;
Route::post('/webhooks/wasphere', [WaSphereWebhookController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);config/services.php:
'wasphere' => [
'api_key' => env('WASPHERE_API_KEY'),
'webhook_secret' => env('WASPHERE_WEBHOOK_SECRET'),
'base_url' => env('WASPHERE_BASE_URL', 'https://wa.yourdomain.com/api'),
],.env:
WASPHERE_API_KEY=wsp_live_...
WASPHERE_WEBHOOK_SECRET=your-secret-here
WASPHERE_BASE_URL=https://wa.yourdomain.com/apiTesting Your Integration
1. Use the WaSphere test button
After registering your webhook, click Test in the dashboard. This sends a real message.received payload with a sample body to your endpoint.
2. Use cURL to simulate a delivery
# Generate a test signature
SECRET="your-webhook-secret"
TIMESTAMP=$(date +%s)
PAYLOAD='{"event":"message.received","deliveryId":"test","timestamp":"2026-05-25T10:00:00.000Z","data":{"sessionId":"sess_test","from":"447700900456@s.whatsapp.net","type":"text","content":{"text":"Hello"}}}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -X POST http://localhost:3000/webhooks/wasphere \
-H "Content-Type: application/json" \
-H "X-WaSphere-Signature: $SIGNATURE" \
-H "X-WaSphere-Timestamp: $TIMESTAMP" \
-d "$PAYLOAD"
3. Check the delivery log
In the dashboard under Webhooks → Deliveries, you can see:
- Every delivery attempt with its HTTP status code
- Full request and response bodies
- Round-trip latency
- Retry history for failed deliveries
Production Checklist
Before going live, verify:
- Your endpoint URL uses HTTPS (Traefik + Let's Encrypt handles this automatically if self-hosted)
- Signature verification is enabled and tested — a failed signature returns 401 before any business logic runs
- You respond with
200 OKbefore processing (process asynchronously in a background job/queue) - Your handler is idempotent — WaSphere may retry deliveries, so processing the same event twice must be safe. Use
deliveryIdas an idempotency key. - You log the
deliveryIdandeventfor every processed webhook for debugging
// Idempotency example using Redis
async function handleEvent(req, res) {
const { deliveryId, event, data } = req.body;
// Check if already processed
const alreadyProcessed = await redis.get(`webhook:${deliveryId}`);
if (alreadyProcessed) {
return res.json({ received: true, duplicate: true });
}
// Mark as received immediately
await redis.setex(`webhook:${deliveryId}`, 86400, '1'); // 24h TTL
res.json({ received: true });
// Process asynchronously
await processEvent(event, data);
}