Skip to main content

Verifying signatures

How to authenticate inbound DZBuild webhooks. Constant-time HMAC comparison + freshness check, with copy-paste code in Node.js, PHP, Python, and Go.

Written by Support

🛑 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:

  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, 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 301/302 is a failure, and 3xx is never retried

TLS verification

Strict

Self-signed or expired certificates fail with no retry

Method / body

Plain 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

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 t + "." + raw_body, not a digest of the body

Reading t from X-DZ-Timestamp but v1 from a bare header

Parse errors / empty signature

X-DZ-Signature is comma-delimited: t=…,v1=…

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

timingSafeEqual without a length check

RangeError thrown instead of a 401

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.

Did this answer your question?