🛑 Important — API v1 webhook signatures cannot be verified by merchants yet
Deliveries from POST /v1/webhooks carry an X-DZ-Signature header, but it is not computed from the per-webhook secret you got back at registration — that secret plays no part in signing today.
So any HMAC check you write against your webhook secret will reject 100% of genuine API v1 deliveries. Treat X-DZ-Signature as opaque until per-webhook signing ships.
What to do instead for API v1 deliveries:
Make the URL unguessable — a long random path segment, or a shared token in the query string that you check on arrival.
Accept POST over HTTPS only, and check
X-DZ-Timestampis within 5 minutes of your clock.Re-read the record before acting. Call
GET /v1/orders/{id}with your API key and trust that, not the pushed body.Dedupe on the body's
delivery_id.
The code on the rest of this page applies to the merchant Webhooks addon (/dashboard/webhooks, Unlimited plan and up), whose signatures are verifiable with your own per-endpoint secret.
The recipe (merchant Webhooks addon)
The addon sends a Stripe-style comma-delimited header, and signs 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.
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', req.get('X-DZ-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', request.headers.get('X-DZ-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 — one attempt, then abandoned |
Total timeout | 10 seconds | Same: abandoned, never retried |
Redirects | Not followed | A |
TLS verification | Strict | Self-signed or expired certificates fail with no retry |
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 an API v1 delivery with your webhook secret | Every delivery rejected as "bad signature" | Expected — API v1 does not sign with that secret. See the banner at the top |
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 | The addon 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 |
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 is re-POSTed every 60 seconds, indefinitely. Duplicates from this path are routine, not exceptional — dedup is mandatory, not defensive.
An endpoint that times out gets no retry at all. The delivery is abandoned after one attempt, so a slow endpoint loses events rather than double-receiving them. Ack fast and do the work asynchronously.
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.