Appearance
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
- Read the raw request body before any JSON parsing.
- Compute
sha256=+HMAC-SHA256(rawBody, signingSecret). - Use a constant-time comparison to compare with the header value (to prevent timing attacks).
- If they don't match, return
HTTP 401and 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 '', 200php
<?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
endCommon pitfalls
| Problem | Fix |
|---|---|
| Signature always fails | Make sure you're reading the raw body, not a re-serialized version of the parsed JSON |
| Off-by-one mismatch | The sha256= prefix must be included when comparing |
Using == instead of constant-time compare | Always use crypto.timingSafeEqual / hmac.compare_digest / hash_equals to prevent timing attacks |
| Wrong secret | Each endpoint has its own signing secret — don't mix them up |