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

# Signature verification

**Always verify webhook signatures.** Without verification, an attacker who learns your webhook URL can POST arbitrary payloads and trick your system into recording fake payments, opening fake disputes, etc.

We sign every outbound webhook with **HMAC-SHA256**, using a platform signing secret configured server-side. The signature is bound to a timestamp so a captured `(body, signature)` pair becomes invalid the moment your clock advances past the 5-minute freshness window.

## Getting your signing secret

You receive your signing secret out-of-band during integration. Store it in your secret manager and load it into your receiver as `MC_WEBHOOK_SIGNING_SECRET` (or whatever name you prefer).

It is **not** the same as your API key (`ca_...`) and is **not** rotated automatically. Coordinate any rotation with platform support so deliveries don't fail signature verification mid-cutover.

## Algorithm

```
signed_payload = "{timestamp}.{body}"     # literal "." between, body is raw bytes
signature      = hex(HMAC_SHA256(secret, signed_payload))
```

* `timestamp` is the value of the `X-Webhook-Timestamp` header (Unix epoch seconds as a string).
* `body` is the **exact bytes** of the POST body, before any JSON parsing.
* The signature is a raw hex digest with **no `sha256=` prefix**.

## Verification steps

```
1. Read the X-Signature header (64 hex chars, no prefix).
2. Read the X-Webhook-Timestamp header. Parse as integer epoch seconds.
3. If |now - timestamp| > 300 seconds (5 minutes), REJECT. Replay protection.
4. Compute HMAC-SHA256 of the byte string  "{timestamp}.{raw_body}"  using the shared secret.
5. Compare the computed hex digest to the received one using a constant-time comparison.
6. If they match, the request is authentic.
```

## Implementation: Python (FastAPI receiver)

```python
import hmac
import hashlib
import os
import time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SIGNING_SECRET = os.environ["MC_WEBHOOK_SIGNING_SECRET"]

@app.post("/webhooks/moderncollections")
async def receive_webhook(request: Request):
    raw_body = await request.body()
    signature_header = request.headers.get("X-Signature", "")
    timestamp_header = request.headers.get("X-Webhook-Timestamp", "")

    # 1. Timestamp must exist and be recent
    try:
        timestamp = int(timestamp_header)
    except ValueError:
        raise HTTPException(400, "missing or invalid X-Webhook-Timestamp")
    if abs(int(time.time()) - timestamp) > 300:
        raise HTTPException(400, "timestamp too old; possible replay")

    # 2. Compute expected signature over "{timestamp}.{body}"
    signed_payload = timestamp_header.encode() + b"." + raw_body
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()

    # 3. Constant-time compare
    if not hmac.compare_digest(expected, signature_header):
        raise HTTPException(401, "invalid signature")

    # 4. Now safe to parse and process
    event = await request.json()
    enqueue_for_processing(event)
    return {"received": True}
```

## Implementation: Node.js (Express receiver)

```javascript
import express from "express";
import crypto from "crypto";

const app = express();

// IMPORTANT: keep the raw body. The default JSON parser would discard it
// and the HMAC is bound to the exact bytes we POSTed.
app.post(
  "/webhooks/moderncollections",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body; // Buffer
    const signatureHeader = req.get("X-Signature") || "";
    const timestampHeader = req.get("X-Webhook-Timestamp") || "";

    const timestamp = parseInt(timestampHeader, 10);
    if (Number.isNaN(timestamp)) {
      return res.status(400).send("missing or invalid X-Webhook-Timestamp");
    }
    if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {
      return res.status(400).send("timestamp too old; possible replay");
    }

    const signedPayload = Buffer.concat([
      Buffer.from(timestampHeader, "utf8"),
      Buffer.from(".", "utf8"),
      rawBody,
    ]);
    const expected = crypto
      .createHmac("sha256", process.env.MC_WEBHOOK_SIGNING_SECRET)
      .update(signedPayload)
      .digest("hex");

    const a = Buffer.from(expected);
    const b = Buffer.from(signatureHeader);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(rawBody.toString("utf8"));
    enqueueForProcessing(event);
    res.status(200).json({ received: true });
  },
);
```

## Implementation: Ruby (Rails receiver)

```ruby
require "openssl"

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def moderncollections
    raw_body = request.raw_post
    signature_header = request.headers["X-Signature"].to_s
    timestamp_header = request.headers["X-Webhook-Timestamp"].to_s
    timestamp = timestamp_header.to_i

    return head :bad_request if (Time.now.to_i - timestamp).abs > 300

    signed_payload = "#{timestamp_header}.#{raw_body}"
    expected = OpenSSL::HMAC.hexdigest(
      "SHA256",
      ENV.fetch("MC_WEBHOOK_SIGNING_SECRET"),
      signed_payload,
    )

    return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(
      expected, signature_header
    )

    event = JSON.parse(raw_body)
    EnqueueWebhookJob.perform_later(event)
    head :ok
  end
end
```

## Common mistakes

| Mistake                                                | What goes wrong                                                              |
| ------------------------------------------------------ | ---------------------------------------------------------------------------- |
| Verifying against `request.json()` instead of raw body | JSON re-serialization changes whitespace and key ordering → HMAC won't match |
| Signing the body only (no timestamp prefix)            | Doesn't match our scheme; replay protection won't work                       |
| Expecting a `sha256=` prefix                           | We send a raw hex digest                                                     |
| Using `==` instead of constant-time compare            | Timing attack vulnerability                                                  |
| Skipping the timestamp freshness check                 | Replay attack: attacker can capture and replay an old delivery               |
| Using your API key as the signing secret               | They're different secrets. `ca_*` ≠ webhook signing secret.                  |
| Not handling missing headers                           | NullPointerException → 500 → looks like an outage                            |

## What to do on verification failure

* Return **401 Unauthorized**.
* Log the `event` name (NOT the signature, secret, or raw body).
* Don't auto-retry on your side.
* If you see persistent failures, your secret may be out of sync — contact platform support to coordinate a rotation.

## See also

* [Webhook overview](/webhooks/overview.md)
* [Retries](/webhooks/retries.md)


---

# 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://docs.moderncollections.io/webhooks/signature-verification.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.
