> ## Documentation Index
> Fetch the complete documentation index at: https://dugble.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> Authenticate Dugble webhook requests and reject replayed payloads

Dugble signs every webhook request with the signing secret shown when you create
or rotate an endpoint. Verify the signature before decoding JSON or applying
any side effects.

<Warning>
  Use the complete secret, including its `whsec_` prefix, as the HMAC key.
  Do not Base64-decode the characters after the prefix.
</Warning>

## Signature contract

The `X-Dugble-Signature` header has this format:

```text theme={null}
t=<unix_timestamp>,v1=<lowercase_hex_digest>
```

`t` is the delivery attempt time in Unix seconds. `v1` is the lowercase
hexadecimal encoding of an HMAC-SHA256 digest.

To calculate the expected digest:

1. Read the request body as its original bytes. Do not parse and re-serialize it.
2. Build the signed payload by concatenating the decimal timestamp, one ASCII
   period (`.`), and the raw body bytes.
3. Compute HMAC-SHA256 with the complete endpoint signing secret as the key.
4. Hex-encode the digest in lowercase.
5. Compare the received and expected digests with a constant-time function.
6. Reject timestamps more than five minutes away from your server time.

In bytes, the signed payload is:

```text theme={null}
UTF8(decimal_timestamp) || "." || raw_request_body
```

## TypeScript example

This Express example installs a route-specific raw-body parser. Register it
before any application-wide JSON parser that would consume the same request.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const toleranceSeconds = 5 * 60;

function verifyDugbleSignature(
    secret: string,
    signatureHeader: string | undefined,
    rawBody: Buffer,
): boolean {
    const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader ?? "");
    if (!match) return false;

    const timestamp = Number(match[1]);
    if (!Number.isSafeInteger(timestamp)) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
        return false;
    }

    const expected = createHmac("sha256", secret)
        .update(`${timestamp}.`)
        .update(rawBody)
        .digest();
    const received = Buffer.from(match[2], "hex");

    return received.length === expected.length && timingSafeEqual(received, expected);
}

app.post(
    "/webhooks/dugble",
    express.raw({ type: "application/json" }),
    async (request, response) => {
        const valid = verifyDugbleSignature(
            process.env.DUGBLE_WEBHOOK_SECRET!,
            request.header("X-Dugble-Signature"),
            request.body,
        );
        if (!valid) return response.sendStatus(400);

        const event = JSON.parse(request.body.toString("utf8"));
        // Persist event.id before applying idempotent side effects.
        return response.sendStatus(200);
    },
);
```

## Python example

```python theme={null}
import hashlib
import hmac
import re
import time

from flask import Flask, abort, request

app = Flask(__name__)
SIGNATURE_PATTERN = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")
TOLERANCE_SECONDS = 5 * 60


def verify_dugble_signature(secret: str, header: str | None, body: bytes) -> bool:
    match = SIGNATURE_PATTERN.fullmatch(header or "")
    if match is None:
        return False

    timestamp = int(match.group(1))
    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    signed_payload = str(timestamp).encode() + b"." + body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(match.group(2), expected)


@app.post("/webhooks/dugble")
def dugble_webhook():
    raw_body = request.get_data(cache=True)
    if not verify_dugble_signature(
        app.config["DUGBLE_WEBHOOK_SECRET"],
        request.headers.get("X-Dugble-Signature"),
        raw_body,
    ):
        abort(400)

    event = request.get_json()
    # Persist event["id"] before applying idempotent side effects.
    return "", 200
```

## Test vector

Use this deterministic vector to test your implementation. The timestamp is
intentionally old, so disable the replay-window check for this fixture only.

```text theme={null}
secret:    whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
timestamp: 1700000000
body:      {"event":"sms.delivered"}
header:    t=1700000000,v1=a5a2030c220be01d63f6a75eaa7dc3a5c291fec07d07bce95e5d6b29d526879f
```

## Operational guidance

* Return `400` for a malformed, stale, or invalid signature.
* Store the event ID with a unique constraint because delivery is at least once.
* Keep endpoint secrets out of logs, analytics, and error reports.
* Rotating an endpoint secret immediately makes the newly displayed secret the
  key for subsequent delivery attempts. Update your receiver before testing it.
