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

ℹ️ 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:

  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.

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

TLS verification

Strict

Self-signed or expired certificates fail every attempt

Scheme

https:// only

Registration answers 400 bad_request "url must be https"

Host

A host name, not an IP address, on port 443 or 80, resolving only to public addresses

Registration answers 400 bad_request. The address is checked again before every attempt, so a host that later resolves to a private address, or stops resolving, fails that attempt; it is retried, then dead-lettered

Response status

2xx = delivered

5xx, 408 and 429 are retried; any other 4xx, and every 3xx, dead-letters the delivery at once

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 a delivery from a webhook registered before per-webhook signing

Every delivery rejected as "bad signature", and the header has no t= part

Delete the webhook, register it again and use the new secret. See the note 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

DZBuild 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

Deduping on the X-DZ-Delivery-Id header

A replayed delivery with a changed header value gets past your dedupe and is processed twice

Dedupe on the delivery_id in the verified body

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, 408 or 429, 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.

Did this answer your question?