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

# Webhook Signature Verification

> Confirm every webhook genuinely came from Macropay and wasn't tampered with in transit.

Your endpoint is a public URL on the internet, which means anyone can POST to it. A fabricated `order.paid` could provision an entitlement that was never bought; a spoofed `subscription.canceled` could revoke access a customer is still paying for. Signature verification closes that gap: every payload Macropay sends is cryptographically signed with a secret only you and Macropay share, so your handler can prove an event is authentic before acting on it.

<Warning>
  Treat verification as mandatory, not optional. An unverified endpoint lets an attacker forge any event — granting licenses, flipping subscription state, or triggering downstream automation — without ever touching your account.
</Warning>

Macropay follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so any compliant library works out of the box and the scheme is identical across every event type — orders, subscriptions, refunds, payouts, and agent [Signals API](https://api.macropay.ai) activity alike.

## What gets signed

Every delivery carries three headers:

| Header              | Purpose                                                       |
| ------------------- | ------------------------------------------------------------- |
| `webhook-id`        | Unique ID for this delivery (also your idempotency key)       |
| `webhook-timestamp` | Unix timestamp, in seconds, of when Macropay sent the event   |
| `webhook-signature` | One or more space-separated, versioned HMAC-SHA256 signatures |

The signature is an HMAC-SHA256 over the concatenation:

```
{webhook-id}.{webhook-timestamp}.{raw-request-body}
```

keyed by your endpoint's signing secret. Three rules fall out of this:

* **Use the raw body.** Sign the exact bytes you received. Re-serializing parsed JSON reorders keys and breaks the digest.
* **The ID and timestamp are part of the message.** They aren't decoration — they're folded into what's signed, which is what makes replay protection possible.
* **Multiple signatures can appear.** During secret rotation, Macropay sends signatures for both the old and new secret so you never miss a beat.

## Get your signing secret

A unique secret is minted the moment you create an endpoint. Retrieve it from either place:

1. **Dashboard** — **Settings → Webhooks**, then open the endpoint.
2. **API** — the response when [creating a webhook endpoint](/api-reference/webhooks/endpoints/create).

It looks like `whsec_` followed by a Base64-encoded key. Store it as a secret (environment variable, secrets manager) and never commit it.

## Verify with a library (recommended)

Reach for an official [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) library first. It handles Base64 decoding, the timestamp window, multi-signature comparison, and constant-time matching — all the places a hand-rolled check tends to go wrong.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { Webhook } from "standardwebhooks";

  const wh = new Webhook(process.env.MACROPAY_WEBHOOK_SECRET!);

  export async function handleWebhook(request: Request): Promise<Response> {
    const body = await request.text(); // raw body — do not pre-parse

    try {
      const event = wh.verify(body, {
        "webhook-id": request.headers.get("webhook-id")!,
        "webhook-timestamp": request.headers.get("webhook-timestamp")!,
        "webhook-signature": request.headers.get("webhook-signature")!,
      });

      // Verified + parsed. Safe to act on.
      if (event.type === "order.paid") {
        await grantLicense(event.data.customer_id);
      }

      return new Response("OK", { status: 200 });
    } catch (err) {
      console.error("Rejected unverified webhook:", err);
      return new Response("Invalid signature", { status: 401 });
    }
  }
  ```

  ```py Python theme={null}
  import os
  from standardwebhooks.webhooks import Webhook

  wh = Webhook(os.environ["MACROPAY_WEBHOOK_SECRET"])

  def handle_webhook(request):
      body = request.body.decode("utf-8")  # raw body — do not pre-parse

      try:
          event = wh.verify(
              body,
              {
                  "webhook-id": request.headers["webhook-id"],
                  "webhook-timestamp": request.headers["webhook-timestamp"],
                  "webhook-signature": request.headers["webhook-signature"],
              },
          )

          # Verified + parsed. Safe to act on.
          if event["type"] == "order.paid":
              grant_license(event["data"]["customer_id"])

          return 200, "OK"
      except Exception as e:
          print(f"Rejected unverified webhook: {e}")
          return 401, "Invalid signature"
  ```
</CodeGroup>

Install:

<CodeGroup>
  ```bash TypeScript theme={null}
  npm install standardwebhooks
  ```

  ```bash Python theme={null}
  pip install standardwebhooks
  ```
</CodeGroup>

## Verify manually

If you'd rather not add a dependency, reproduce the four steps yourself: enforce the timestamp window, rebuild the signed string, recompute the HMAC, and compare in constant time.

<CodeGroup>
  ```ts TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "crypto";

  function verifyWebhook(
    body: string,
    headers: Record<string, string>,
    secret: string
  ): boolean {
    const msgId = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatures = headers["webhook-signature"];

    // 1. Reject anything outside a 5-minute window (replay protection).
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
      throw new Error("Webhook timestamp too old or too new");
    }

    // 2. Rebuild the exact string Macropay signed.
    const signedContent = `${msgId}.${timestamp}.${body}`;

    // 3. Recompute the HMAC with the Base64-decoded secret (drop "whsec_").
    const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
    const expected = createHmac("sha256", secretBytes)
      .update(signedContent)
      .digest("base64");

    // 4. Match against each provided signature; values look like "v1,<base64>".
    for (const sig of signatures.split(" ")) {
      const [, sigValue] = sig.split(",");
      if (
        sigValue &&
        timingSafeEqual(Buffer.from(expected), Buffer.from(sigValue))
      ) {
        return true;
      }
    }

    return false;
  }
  ```

  ```py Python theme={null}
  import base64
  import hashlib
  import hmac
  import time

  def verify_webhook(
      body: str,
      headers: dict[str, str],
      secret: str,
  ) -> bool:
      msg_id = headers["webhook-id"]
      timestamp = headers["webhook-timestamp"]
      signatures = headers["webhook-signature"]

      # 1. Reject anything outside a 5-minute window (replay protection).
      if abs(time.time() - int(timestamp)) > 300:
          raise ValueError("Webhook timestamp too old or too new")

      # 2. Rebuild the exact string Macropay signed.
      signed_content = f"{msg_id}.{timestamp}.{body}"

      # 3. Recompute the HMAC with the Base64-decoded secret (drop "whsec_").
      secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
      expected = base64.b64encode(
          hmac.new(secret_bytes, signed_content.encode("utf-8"), hashlib.sha256).digest()
      ).decode("utf-8")

      # 4. Match against each provided signature; values look like "v1,<base64>".
      for sig in signatures.split(" "):
          _, _, sig_value = sig.partition(",")
          if sig_value and hmac.compare_digest(expected, sig_value):
              return True

      return False
  ```
</CodeGroup>

## Hardening checklist

<Check>Compare signatures with `timingSafeEqual` (Node) or `hmac.compare_digest` (Python). Plain `===` leaks timing information that can be exploited to forge a match.</Check>
<Check>Enforce the 5-minute timestamp window so a captured-but-valid payload can't be replayed later.</Check>
<Check>Serve the endpoint over HTTPS only.</Check>
<Check>Acknowledge fast — return a `2xx` within 30 seconds and offload heavy work to a queue.</Check>
<Check>Make handlers idempotent. Macropay retries with exponential backoff, so key your processing on `webhook-id` and ignore IDs you've already handled. See [Webhook Delivery](/integrate/webhooks/delivery) for the full retry schedule.</Check>

<Note>
  Idempotency matters most for money-touching events. Because Macropay is your **Merchant of Record** — the seller of record that collects and remits sales tax and VAT on your behalf — events like `order.refunded` and `payout.paid` are the source of truth for reconciliation. Process each one exactly once and your books stay clean.
</Note>

## Skip it entirely with an adapter

Our framework adapters verify signatures for you, then hand you a typed, already-trusted event. Drop in the route and move straight to your business logic.

<CardGroup cols={2}>
  <Card title="Next.js" icon="react" href="/integrate/sdk/adapters/nextjs">
    Route handler with verification baked in.
  </Card>

  <Card title="Express" icon="node-js" href="/integrate/sdk/adapters/express">
    Middleware for your webhook routes.
  </Card>

  <Card title="Fastify" icon="node-js" href="/integrate/sdk/adapters/fastify">
    Plugin that validates before your handler runs.
  </Card>

  <Card title="Hono" icon="js" href="/integrate/sdk/adapters/hono">
    Edge-ready middleware for Hono.
  </Card>
</CardGroup>
