WWaSphere Docs
Guides

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:

  1. Your endpoint — an HTTPS URL that accepts POST requests
  2. Signature verification — validate that each request genuinely came from WaSphere
  3. Event handler — route each event type to the appropriate business logic

Step 1 — Create Your Endpoint

Your endpoint must:

  • Accept POST requests at a public HTTPS URL
  • Return 200 OK within 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/api

Testing 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 OK before 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 deliveryId as an idempotency key.
  • You log the deliveryId and event for 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);
}

On this page