Signing & verification
Every webhook delivery includes an X-Watsi-Signature header. Watsi computes the value by hashing the raw request body with HMAC-SHA256 and your subscription secret.
Header format
X-Watsi-Signature: 8a9f0d1c...
Treat the header value as a hex-encoded digest. To verify it, recompute the same HMAC on your side and compare the two digests using a constant-time comparison.
Verification algorithm
- Read the raw request body bytes exactly as received.
- Read
X-Watsi-Signaturefrom the request headers. - Compute
HMAC-SHA256(secret, rawBody). - Hex-encode the computed digest.
- Compare the computed digest and header value in constant time.
- Reject the request with 401 if the values do not match.
Node.js example
import crypto from 'crypto'
import express from 'express'
const app = express()
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf
},
})
)
function verifySignature(req) {
const signature = req.headers['x-watsi-signature']
if (!signature || !Buffer.isBuffer(req.rawBody)) return false
const computed = crypto
.createHmac('sha256', process.env.WATSI_WEBHOOK_SECRET ?? '')
.update(req.rawBody)
.digest('hex')
const expectedBuf = Buffer.from(String(signature), 'utf8')
const computedBuf = Buffer.from(computed, 'utf8')
if (expectedBuf.length !== computedBuf.length) return false
return crypto.timingSafeEqual(expectedBuf, computedBuf)
}
app.post('/webhooks/watsi', (req, res) => {
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' })
}
console.log('Verified event', req.body.type)
res.status(200).json({ received: true })
})Python example
import hashlib
import hmac
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_signature(req):
signature = req.headers.get('X-Watsi-Signature')
if not signature:
return False
raw_body = req.get_data()
computed = hmac.new(
os.environ['WATSI_WEBHOOK_SECRET'].encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, computed)
@app.post('/webhooks/watsi')
def handle_webhook():
if not verify_signature(request):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json(force=True)
print('Verified event', event['type'])
return jsonify({'received': True}), 200Ruby example
require 'openssl'
require 'rack/utils'
post '/webhooks/watsi' do
raw_body = request.body.read
signature = request.env['HTTP_X_WATSI_SIGNATURE']
secret = ENV.fetch('WATSI_WEBHOOK_SECRET')
computed = OpenSSL::HMAC.hexdigest('sha256', secret, raw_body)
halt 401, 'Invalid signature' unless signature &&
signature.bytesize == computed.bytesize &&
Rack::Utils.secure_compare(signature, computed)
status 200
body 'OK'
endGo example
func verifySignature(rawBody []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(os.Getenv("WATSI_WEBHOOK_SECRET")))
mac.Write(rawBody)
computed := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(computed))
}PHP example
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WATSI_SIGNATURE'] ?? '';
$computed = hash_hmac('sha256', $rawBody, getenv('WATSI_WEBHOOK_SECRET'));
if (!hash_equals($computed, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}Operational tips
| Practice | Why it helps |
|---|---|
| Use the raw request body | Hash the exact bytes that arrived over HTTP before any JSON parsing or re-serialization. |
| Compare in constant time | Use timing-safe equality helpers to avoid leaking information through string comparison timing. |
| Store secrets outside code | Keep your signing secret in an environment variable or secret manager, never in the repository. |
| Log the delivery id | Persist X-Watsi-Delivery-Id or event.id so retries can be correlated cleanly. |