← Webhooks

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

  1. Read the raw request body bytes exactly as received.
  2. Read X-Watsi-Signature from the request headers.
  3. Compute HMAC-SHA256(secret, rawBody).
  4. Hex-encode the computed digest.
  5. Compare the computed digest and header value in constant time.
  6. 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}), 200

Ruby 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'
end

Go 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

PracticeWhy it helps
Use the raw request bodyHash the exact bytes that arrived over HTTP before any JSON parsing or re-serialization.
Compare in constant timeUse timing-safe equality helpers to avoid leaking information through string comparison timing.
Store secrets outside codeKeep your signing secret in an environment variable or secret manager, never in the repository.
Log the delivery idPersist X-Watsi-Delivery-Id or event.id so retries can be correlated cleanly.