Verifying signatures
Every webhook DZBuild sends carries an HMAC-SHA256 signature. Your handler MUST verify it before trusting the body.
The recipe
expected = hex( hmac_sha256( WEBHOOK_SECRET, X-DZ-Timestamp + "." + sha256_hex(raw_body) ) )
if (!constant_time_equal(expected, X-DZ-Signature)) reject 401
if (abs(now - X-DZ-Timestamp) > 300) reject 401 # ±5 min replay window
Three rules to be safe:
- Use raw body bytes for the inner SHA-256. Re-serializing the JSON gives a different hash.
- Constant-time compare the signature. A regular
==leaks timing info that aids brute-force. - Reject stale timestamps (>5 minutes off your server's clock). Run NTP.
Code
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;
// IMPORTANT: capture the raw body for HMAC, separately from the parsed JSON.
app.post('/webhooks/dzbuild',
express.raw({ type: 'application/json' }),
(req, res) => {
const ts = req.get('X-DZ-Timestamp');
const sig = req.get('X-DZ-Signature');
if (!ts || !sig) return res.status(401).end();
if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {
return res.status(401).end(); // stale or future timestamp
}
const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${ts}.${bodyHash}`)
.digest('hex');
if (expected.length !== sig.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).end();
}
// Verified — now you can parse and act.
const event = JSON.parse(req.body.toString('utf8'));
console.log('verified', event.event, event.data);
res.status(200).end(); // ack ASAP
});
app.listen(3000);
PHP (raw)
<?php
$secret = getenv('DZBUILD_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // raw body
$ts = $_SERVER['HTTP_X_DZ_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';
if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }
$bodyHash = hash('sha256', $body);
$expected = hash_hmac('sha256', $ts . '.' . $bodyHash, $secret);
if (!hash_equals($expected, strtolower($sig))) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// Process $event['event'], $event['data'], $event['delivery_id']
http_response_code(200);
If you're in Laravel, use a route middleware or a controller that reads $request->getContent() for the raw body. Disable CSRF on the webhook route.
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/dzbuild')
def receive():
ts = request.headers.get('X-DZ-Timestamp', '')
sig = request.headers.get('X-DZ-Signature', '')
if not ts or not sig: abort(401)
if abs(int(time.time()) - int(ts)) > 300: abort(401)
body = request.get_data() # raw bytes — DO NOT use request.json
body_hash = hashlib.sha256(body).hexdigest()
expected = hmac.new(WEBHOOK_SECRET, f"{ts}.{body_hash}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.lower()): abort(401)
event = request.get_json()
print('verified', event['event'], event['data'])
return '', 200
Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))
func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "read", 400); return }
ts := r.Header.Get("X-DZ-Timestamp")
sig := r.Header.Get("X-DZ-Signature")
if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil { http.Error(w, "ts", 401); return }
if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }
h := sha256.Sum256(body)
bodyHash := hex.EncodeToString(h[:])
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + bodyHash))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "bad sig", 401); return
}
w.WriteHeader(http.StatusOK)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Re-serializing JSON body before hashing | Signature never matches | Use raw body bytes — see framework notes above |
| Server clock drift | "Timestamp out of window" rejections | Run NTP, check timedatectl status on Linux |
Comparing with == instead of constant-time | Subtle timing-attack vulnerability | Use crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| Logging the secret on disk | Secret ends up in your log files | Don't log it; use a secrets store; rotate if it leaks |
| Returning 200 immediately and processing later | Lost events when your worker crashes | Either persist to your own queue first then ack, OR do the work synchronously and ack last |
Idempotency on your side
The same delivery_id MAY arrive more than once if our delivery layer thinks the first attempt failed (network partition, your endpoint slow). Dedupe on the receiving side:
INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);
-- Catch UNIQUE violation on delivery_id → already processed, return 200 anyway
This pattern means even if our retry pings you again, you do the work once and ack quickly.