Skip to content

Signature Verification

Every webhook POST Watzy sends includes an X-Watzy-Signature header. Verify it before processing the payload to confirm the request came from Watzy and was not tampered with.

How the signature is computed

HMAC-SHA256(rawRequestBody, signingSecret)

The value in the header is prefixed with sha256=:

X-Watzy-Signature: sha256=a3f1c8...

Where to find your signing secret

When you add a webhook endpoint in the Dashboard → Webhook Endpoints, Watzy shows the signing_secret once. If you missed it, delete the endpoint and add a new one.

Verification steps

  1. Read the raw request body before any JSON parsing.
  2. Compute sha256= + HMAC-SHA256(rawBody, signingSecret).
  3. Use a constant-time comparison to compare with the header value (to prevent timing attacks).
  4. If they don't match, return HTTP 401 and discard the event.

Code examples

js
const crypto = require('crypto')
const express = require('express')
const app = express()

// Important: use express.raw() so you get the un-parsed body buffer
app.post('/watzy-events', express.raw({ type: 'application/json' }), (req, res) => {
  const secret    = process.env.WATZY_SIGNING_SECRET
  const signature = req.headers['x-watzy-signature'] ?? ''
  const expected  = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(req.body)          // req.body is a Buffer here
    .digest('hex')

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.sendStatus(401)
  }

  const event = JSON.parse(req.body)
  console.log('Event:', event.event, event.data)
  res.sendStatus(200)
})
python
import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post('/watzy-events')
def watzy_webhook():
    secret    = os.environ['WATZY_SIGNING_SECRET'].encode()
    signature = request.headers.get('X-Watzy-Signature', '')
    raw_body  = request.get_data()  # raw bytes before parsing

    expected = 'sha256=' + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature):
        abort(401)

    event = request.get_json()
    print('Event:', event['event'], event['data'])
    return '', 200
php
<?php
$secret    = getenv('WATZY_SIGNING_SECRET');
$signature = $_SERVER['HTTP_X_WATZY_SIGNATURE'] ?? '';
$rawBody   = file_get_contents('php://input');

$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Unauthorized');
}

$event = json_decode($rawBody, true);
// handle $event['event'] and $event['data']
http_response_code(200);
ruby
require 'sinatra'
require 'openssl'
require 'json'

post '/watzy-events' do
  secret    = ENV['WATZY_SIGNING_SECRET']
  signature = request.env['HTTP_X_WATZY_SIGNATURE'] || ''
  raw_body  = request.body.read

  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
  unless Rack::Utils.secure_compare(expected, signature)
    halt 401, 'Unauthorized'
  end

  event = JSON.parse(raw_body)
  puts "Event: #{event['event']}"
  status 200
end

Common pitfalls

ProblemFix
Signature always failsMake sure you're reading the raw body, not a re-serialized version of the parsed JSON
Off-by-one mismatchThe sha256= prefix must be included when comparing
Using == instead of constant-time compareAlways use crypto.timingSafeEqual / hmac.compare_digest / hash_equals to prevent timing attacks
Wrong secretEach endpoint has its own signing secret — don't mix them up

Built with ❤️ on top of the WhatsApp Business API.