> For the complete documentation index, see [llms.txt](https://developers.gallantreecapital.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.gallantreecapital.com/webhooks/verify-signature.md).

# Verify signatures

Every webhook delivery is signed. Verify the signature before you trust the payload, or an attacker who guesses your endpoint URL can send you anything they like.

## The signature

* **Algorithm:** HMAC-SHA256.
* **Key:** the subscription's signing secret (from the portal, or from the create-subscription response).
* **Message:** the **exact raw request body**, byte-for-byte. Do not decode-and-re-encode JSON before hashing — even a whitespace change breaks the check.
* **Encoding:** the signature is delivered in the `X-Gallantree-Signature` header as **lower-case hexadecimal**.

## What you have to verify

Signature verification is not enough on its own. A complete implementation checks three things:

1. **The signature matches** — proves the payload came from Gallantree and hasn't been tampered with.
2. **The timestamp is fresh** — reject deliveries older than a few minutes (recommended: 5). The `X-Gallantree-Timestamp` header is included in the signed payload, so an attacker cannot replay an old delivery under a fresh timestamp without invalidating the signature.
3. **The event is one you subscribed to** — belt-and-braces: your endpoint should reject event types it doesn't handle rather than silently no-op.

## Use a constant-time comparison

Compare the signatures with a constant-time function (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python, `subtle.ConstantTimeCompare` in Go). Naive `==` on strings leaks timing information about how many bytes matched — a classic side-channel attack against HMAC.

## Node.js example

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

const app = express();

// IMPORTANT: keep the raw body for signing. If you use express.json() it consumes the stream
// and the raw bytes are gone. Use express.raw({ type: "application/json" }) and JSON.parse yourself.
app.post(
  "/webhooks/gallantree",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("X-Gallantree-Signature") ?? "";
    const timestamp = req.header("X-Gallantree-Timestamp") ?? "";
    const rawBody = req.body as Buffer;
    const secret = process.env.GALLANTREE_WEBHOOK_SECRET ?? "";

    // 1. Signature check (constant-time)
    const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
    const signatureBuf = Buffer.from(signature, "hex");
    const expectedBuf = Buffer.from(expected, "hex");
    if (signatureBuf.length !== expectedBuf.length ||
        !crypto.timingSafeEqual(signatureBuf, expectedBuf)) {
      return res.status(401).send("invalid signature");
    }

    // 2. Freshness check (reject deliveries older than 5 minutes)
    const deliveredAt = Date.parse(timestamp);
    if (!Number.isFinite(deliveredAt) || Math.abs(Date.now() - deliveredAt) > 5 * 60_000) {
      return res.status(401).send("stale delivery");
    }

    // 3. Payload is safe to trust — process it asynchronously and 2xx quickly
    const payload = JSON.parse(rawBody.toString("utf8"));
    enqueue(payload);
    return res.status(200).send();
  },
);
```

## Python example

```python
import hashlib
import hmac
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["GALLANTREE_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/gallantree")
def receive():
    signature = request.headers.get("X-Gallantree-Signature", "")
    timestamp = request.headers.get("X-Gallantree-Timestamp", "")
    raw = request.get_data()

    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401, "invalid signature")

    try:
        delivered_ms = int(time.mktime(time.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%fZ")) * 1000)
    except ValueError:
        abort(401, "malformed timestamp")
    if abs(time.time() * 1000 - delivered_ms) > 5 * 60 * 1000:
        abort(401, "stale delivery")

    enqueue(request.get_json(force=True))
    return "", 200
```

## Rotating the signing secret

The subscription has a `secret` and (optionally) a `previousSecret`. When you rotate — via the portal's **Rotate secret** button — the platform starts signing with the new secret and keeps signing a copy with the previous one for a grace window, so your receiver can validate against either.

Your receiver code should try the current secret first and fall back to the previous one. Once the grace window has elapsed, the previous secret is dropped and you no longer need to accept it.

## What to log when a signature fails

* **`X-Gallantree-Delivery`** — the delivery id (helps Gallantree support triage).
* **The timestamp** (both delivered and your server's clock — clock skew is the second-most-common cause of a false negative after "someone changed the JSON body in flight").
* **Whether the request was over HTTPS** — a failed signature on an HTTP endpoint is almost certainly not from us.

Do **not** log the raw body or the signing secret.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://developers.gallantreecapital.com/webhooks/verify-signature.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
