Skip to main content

Verifying signatures

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:

  1. Make the URL unguessable — a long random path segment, or a shared token in the query string that you check on arrival.
  2. Accept POST over HTTPS only, and check X-DZ-Timestamp is within 5 minutes of your clock.
  3. Re-read the record before acting. Call GET /v1/orders/{id} with your API key and trust that, not the pushed body.
  4. 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:

  1. Use raw body bytes. Re-serializing the JSON changes the signature input.
  2. Constant-time compare. A regular == leaks timing info that aids brute-force.
  3. 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, abort

app = 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 out

@app.post('/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 main

import (
"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":

ConstraintValueWhat happens if you break it
Connect timeout5 secondsCounted as a transport failure — one attempt, then abandoned
Total timeout10 secondsSame: abandoned, never retried
RedirectsNot followedA 301/302 is a failure, and 3xx is never retried
TLS verificationStrictSelf-signed or expired certificates fail with no retry
Method / bodyPlain POST, JSON body

Point the webhook at the final URL (no www → apex redirect, no HTTP → HTTPS bounce) and serve a publicly trusted certificate.

Common mistakes

MistakeSymptomFix
Verifying an API v1 delivery with your webhook secretEvery 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 signingSignature never matchesUse raw body bytes — see framework notes in Registering
Hashing the body before HMACSignature never matchesThe addon signs t + "." + raw_body, not a digest of the body
Reading t from X-DZ-Timestamp but v1 from a bare headerParse errors / empty signatureX-DZ-Signature is comma-delimited: t=…,v1=…
Server clock drift"Timestamp out of window" rejectionsRun NTP, check timedatectl status on Linux
Comparing with == instead of constant-timeSubtle timing-attack vulnerabilityUse crypto.timingSafeEqual / hmac.compare_digest / hash_equals
timingSafeEqual without a length checkRangeError thrown instead of a 401Compare lengths first, as in the Node sample
Logging the secret on diskSecret ends up in your log filesDon't log it; use a secrets store; regenerate if it leaks
Returning 200 immediately and processing laterLost events when your worker crashesPersist 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.