ℹ️ Info — One recipe for both webhook systems
API v1 webhooks (POST /v1/webhooks) and the merchant Webhooks addon (/dashboard/webhooks, Unlimited plan and up) sign deliveries the same way, each with the secret of that webhook or endpoint. For an API v1 webhook, that is the secret returned once by POST /v1/webhooks.
A webhook registered through the API before per-webhook signing was introduced still gets the old signature: a bare hex value with no t= part, which no secret you hold can reproduce. Delete that webhook and register it again, then verify with the new secret.
The recipe
Both systems send a Stripe-style comma-delimited header and sign the raw body, not a hash of it:
X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256>expected = hex( hmac_sha256( WEBHOOK_SECRET, t + "." + raw_body ) )if (!constant_time_equal(expected, v1)) reject 401 if (abs(now - t) > 300) reject 401 # ±5 min replay window
Three rules to be safe:
Use raw body bytes. Re-serializing the JSON changes the signature input.
Constant-time compare. A regular
==leaks timing info that aids brute-force.Reject stale timestamps (more than 5 minutes off your server's clock). Run NTP.
The full header set an addon delivery arrives with:
Content-Type: application/json User-Agent: DZBuild-Webhooks/1.0 X-DZ-Timestamp: <unix seconds> X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256> X-DZ-Event: order.confirmed X-DZ-Delivery: <numeric delivery id> X-DZ-Token: <your endpoint secret, in plain text>
X-DZ-Token is a convenience twin of the signature for no-code tools (n8n, Make, Zapier) that can only do header auth: compare it to your stored secret with a constant-time check. It is a bearer credential in a header — only ever use it over HTTPS, and prefer the HMAC when you're writing real code.
An API v1 delivery arrives with fewer headers:
Content-Type: application/json User-Agent: dzbuild-webhook/1 X-DZ-Timestamp: <unix seconds> X-DZ-Signature: t=<unix seconds>,v1=<hex hmac-sha256> X-DZ-Delivery-Id: <numeric delivery id>
It carries no X-DZ-Event and no X-DZ-Token: read the event name from the event field of the verified body. The signature covers the body only, so treat X-DZ-Delivery-Id as unauthenticated.
Code
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;// "t=1717112657,v1=abc..." → { t: "1717112657", v1: "abc..." }
function parseSigHeader(raw) {
const out = {};
for (const part of String(raw || '').split(',')) {
const i = part.indexOf('=');
if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
}
return out;
}// IMPORTANT: capture the raw body for HMAC, separately from the parsed JSON.
app.post('/webhooks/dzbuild',
express.raw({ type: 'application/json' }),
(req, res) => {
const { t: ts, v1: sig } = parseSigHeader(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 expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${ts}.${req.body.toString('utf8')}`) // raw body, not a hash of it
.digest('hex'); // Length check first — timingSafeEqual throws on unequal buffer lengths.
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);
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
$header = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';$parts = [];
foreach (explode(',', $header) as $piece) {
$kv = explode('=', trim($piece), 2);
if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
}
$ts = $parts['t'] ?? '';
$sig = $parts['v1'] ?? '';if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);if (!hash_equals($expected, strtolower($sig))) {
http_response_code(401);
exit;
}$event = json_decode($body, true);
// Process $event['event'], $event['data']
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, abortapp = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()def parse_sig(raw):
out = {}
for part in (raw or '').split(','):
k, _, v = part.partition('=')
if v:
out[k.strip()] = v.strip()
return [email protected]('/webhooks/dzbuild')
def receive():
parts = parse_sig(request.headers.get('X-DZ-Signature'))
ts, sig = parts.get('t'), parts.get('v1')
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
expected = hmac.new(WEBHOOK_SECRET,
ts.encode() + b'.' + body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.lower()): abort(401) event = request.get_json()
print('verified', event['event'], event)
return '', 200
Go (net/http)
package mainimport (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))func parseSig(h string) (ts, v1 string) {
for _, part := range strings.Split(h, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 { continue }
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
v1 = kv[1]
}
}
return
}func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "read", 400); return } ts, sig := parseSig(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 } mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + string(body)))
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 }
Receiver constraints
These apply to API v1 deliveries and are the usual answer to "my endpoint never gets called":
Constraint | Value | What happens if you break it |
Connect timeout | 5 seconds | Counted as a transport failure: retried with backoff, dropped after 5 attempts |
Total timeout | 10 seconds | Same: retried with backoff, dropped after 5 attempts |
Redirects | Not followed | A |
TLS verification | Strict | Self-signed or expired certificates fail every attempt |
Scheme |
| Registration answers |
Host | A host name, not an IP address, on port 443 or 80, resolving only to public addresses | Registration answers |
Response status |
| 5xx, |
Method / body | Plain | — |
Point the webhook at the final URL (no www → apex redirect, no HTTP → HTTPS bounce) and serve a publicly trusted certificate.
Common mistakes
Mistake | Symptom | Fix |
Verifying a delivery from a webhook registered before per-webhook signing | Every delivery rejected as "bad signature", and the header has no | Delete the webhook, register it again and use the new |
Re-serializing the JSON body before signing | Signature never matches | Use raw body bytes — see framework notes in Registering |
Hashing the body before HMAC | Signature never matches | DZBuild signs |
Reading | Parse errors / empty signature |
|
Server clock drift | "Timestamp out of window" rejections | Run NTP, check |
Comparing with | Subtle timing-attack vulnerability | Use |
|
| Compare lengths first, as in the Node sample |
Logging the secret on disk | Secret ends up in your log files | Don't log it; use a secrets store; regenerate if it leaks |
Returning 200 immediately and processing later | Lost events when your worker crashes | Persist to your own queue first then ack, OR do the work synchronously and ack last |
Deduping on the | A replayed delivery with a changed header value gets past your dedupe and is processed twice | Dedupe on the |
Idempotency on your side
The same delivery_id can arrive more than once. For API v1 the shape of that is specific:
An endpoint returning 5xx,
408or429, or timing out, is retried up to four times over about 2.5 hours. Duplicates are routine: a request that timed out on our side may already have been processed on yours, so dedup is mandatory.After the 5th failed attempt the delivery is dropped. A slow endpoint can still lose events, so ack fast and do the work asynchronously.
Dedupe on the receiving side, using the delivery_id from the verified body (never the unsigned X-DZ-Delivery-Id header):
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.