# Verifying signatures

One recipe for both webhook systems

API v1 webhooks (`POST /v1/webhooks`) and the merchant [Webhooks addon](https://dzbuild.com/api-docs/webhooks/overview.md) (`/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, 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', event['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":

| 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](https://dzbuild.com/api-docs/webhooks/registering.md) |
| 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.
