Files
rmyndharis--openwa/docs/examples/webhook-signature-verification.md
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

3.3 KiB

Webhook Signature Verification

OpenWA signs webhook deliveries when a webhook is configured with a secret. Receivers should verify the signature before processing the event.

Headers

OpenWA sends these system headers with webhook deliveries:

Header Description
X-OpenWA-Signature HMAC-SHA256 signature, present only when the webhook has a secret
X-OpenWA-Event Event name, for example message.received
X-OpenWA-Idempotency-Key Stable key for duplicate detection
X-OpenWA-Delivery-Id Unique identifier for this delivery (stable across retry attempts)
X-OpenWA-Retry-Count Retry count for the current delivery

The signature format is:

sha256=<hex digest>

The digest is computed over the exact raw request body bytes using the webhook secret.

Node.js / Express

Use express.raw() for the webhook route so the signature is checked against the raw body. Parse JSON only after verification succeeds.

const crypto = require('crypto');
const express = require('express');

const app = express();
const WEBHOOK_SECRET = process.env.OPENWA_WEBHOOK_SECRET;

function verifyOpenWASignature(rawBody, signature, secret) {
  if (!signature || !secret) return false;

  const expected =
    'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

  const signatureBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expected);

  if (signatureBuffer.length !== expectedBuffer.length) return false;

  return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
}

app.post('/openwa/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('X-OpenWA-Signature');

  if (!verifyOpenWASignature(req.body, signature, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Process event here.
  // Return a 2xx response only after the event is safely accepted.
  return res.status(200).send('OK');
});

Python / FastAPI

Read the raw request body before parsing JSON.

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = os.environ["OPENWA_WEBHOOK_SECRET"]


def verify_openwa_signature(raw_body: bytes, signature: str | None, secret: str) -> bool:
    if not signature:
        return False

    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)


@app.post("/openwa/webhook")
async def openwa_webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("x-openwa-signature")

    if not verify_openwa_signature(raw_body, signature, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = await request.json()

    # Process event here.
    return {"status": "ok"}

Processing Checklist

  • Verify X-OpenWA-Signature before trusting or parsing the event.
  • Use the exact raw request body received by your HTTP server.
  • Use a constant-time comparison function.
  • Return 401 for invalid signatures.
  • Use X-OpenWA-Idempotency-Key to avoid duplicate processing on retries.
  • Return a 2xx response only after the event is accepted for processing.