# Verify our signature

Confirm a delivery really came from Webhooker, with working examples in Node, Python and Go.

Source: https://docs.webhooker.eu/deliver/verify-signatures/

When a gateway has **Sign outbound requests** set to HMAC, every delivery carries
a signature over the exact bytes being sent. Verify it before you trust the body:
your endpoint is public, so the signature is what separates a real delivery from
anyone who guessed the URL.

## The headers

| Header | Value |
| --- | --- |
| `X-Webhooker-Signature` | `v1=<hex>` — HMAC-SHA256, hex-encoded. |
| `X-Webhooker-Timestamp` | Unix seconds at signing time. |
| `X-Webhooker-Event-Id` | The event id. Stable across attempts and replays — use it for idempotency. |

The signed string is the timestamp, a literal dot, then the raw request body:

```
HMAC_SHA256(secret, "{timestamp}.{raw body}")
```

Two rules decide whether your check works:

1. Use the **raw** body bytes. A body parsed into an object and re-serialised is
   not the same bytes, and the signature will not match.
2. Compare with a constant-time function. A plain `==` leaks timing information.

## Verify the signature

#### Node

```js
import crypto from "node:crypto";
import express from "express";

const app = express();
const signingSecret = process.env.WEBHOOKER_SIGNING_SECRET;
const toleranceSeconds = 300;

// The raw body is required: express.json() would destroy the exact bytes.
app.post("/hooks", express.raw({ type: "*/*" }), (request, response) => {
  const signatureHeader = request.get("X-Webhooker-Signature") ?? "";
  const timestamp = request.get("X-Webhooker-Timestamp") ?? "";
  const eventId = request.get("X-Webhooker-Event-Id") ?? "";

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return response.status(401).send("stale timestamp");
  }

  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(`${timestamp}.`)
    .update(request.body)
    .digest("hex");

  const received = signatureHeader.replace(/^v1=/, "");
  const expectedBuffer = Buffer.from(expected, "hex");
  const receivedBuffer = Buffer.from(received, "hex");

  const signatureValid =
    expectedBuffer.length === receivedBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, receivedBuffer);

  if (!signatureValid) {
    return response.status(401).send("bad signature");
  }

  // Answer first, process afterwards.
  response.status(200).end();
  handleEvent(eventId, JSON.parse(request.body.toString("utf8")));
});
```

#### Python

```python
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
SIGNING_SECRET = os.environ["WEBHOOKER_SIGNING_SECRET"].encode()
TOLERANCE_SECONDS = 300

@app.post("/hooks")
def receive_webhook():
    signature_header = request.headers.get("X-Webhooker-Signature", "")
    timestamp = request.headers.get("X-Webhooker-Timestamp", "")
    event_id = request.headers.get("X-Webhooker-Event-Id", "")

    try:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return "stale timestamp", 401
    except ValueError:
        return "bad timestamp", 401

    raw_body = request.get_data()
    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(SIGNING_SECRET, signed_payload, hashlib.sha256).hexdigest()
    received = signature_header.removeprefix("v1=")

    if not hmac.compare_digest(expected, received):
        return "bad signature", 401

    enqueue_event(event_id, raw_body)
    return "", 200
```

#### Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const toleranceSeconds = 300

func handleWebhook(writer http.ResponseWriter, request *http.Request) {
    signingSecret := []byte(os.Getenv("WEBHOOKER_SIGNING_SECRET"))
    signatureHeader := request.Header.Get("X-Webhooker-Signature")
    timestamp := request.Header.Get("X-Webhooker-Timestamp")
    eventID := request.Header.Get("X-Webhooker-Event-Id")

    signedAt, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || abs(time.Now().Unix()-signedAt) > toleranceSeconds {
        http.Error(writer, "stale timestamp", http.StatusUnauthorized)
        return
    }

    rawBody, err := io.ReadAll(request.Body)
    if err != nil {
        http.Error(writer, "unreadable body", http.StatusBadRequest)
        return
    }

    mac := hmac.New(sha256.New, signingSecret)
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(rawBody)
    expected := mac.Sum(nil)

    received, err := hex.DecodeString(strings.TrimPrefix(signatureHeader, "v1="))
    if err != nil || !hmac.Equal(expected, received) {
        http.Error(writer, "bad signature", http.StatusUnauthorized)
        return
    }

    writer.WriteHeader(http.StatusOK)
    go processEvent(eventID, rawBody)
}

func abs(value int64) int64 {
    if value < 0 {
        return -value
    }
    return value
}
```

:::caution
Compare signatures with a constant-time function — `crypto.timingSafeEqual`,
`hmac.compare_digest`, `hmac.Equal`. A plain `==` leaks timing information an
attacker can use to forge a signature byte by byte.
:::

## Rejecting stale deliveries

The timestamp header lets you refuse a request that was captured and replayed
later. A 5-minute window is a sensible default, as used above. Remember that a
genuine retry can arrive hours after the event was received — but it is
**re-signed at send time**, so its timestamp is always fresh. A stale timestamp
means the request was recorded and replayed, not that it was retried.

## Idempotency

`X-Webhooker-Event-Id` is the same for every attempt and every manual replay of
one event. Store it, and drop a delivery you have already processed. Delivery is
at-least-once: a response that gets lost on the way back is retried even though
your handler already did the work.

## Other authentication methods

- **API key** — the configured header carries the secret verbatim. Compare it in
  constant time too.
- **Basic auth** — a standard `Authorization: Basic …` header.
- **None** — nothing is added, and any caller can imitate a delivery. Use it only
  when the endpoint is protected by other means, such as mutual TLS or an
  allow-list.
