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

# Verify Signatures

> Library and manual Standard Webhooks verification, raw-body handling, and replay protection.

Every WalletSuite delivery is signed: an HMAC-SHA256 signature computed over the exact request with a per-account secret that only WalletSuite and you hold. A valid signature is cryptographic proof of origin and integrity - independent of network path or source IP. **Verify the signature on every delivery, and reject anything that fails - before you read a single field from the body.**

WalletSuite implements [Standard Webhooks](https://www.standardwebhooks.com) exactly: the official `standardwebhooks` libraries verify deliveries out of the box, and the open spec defines the full scheme if you want to implement it yourself. Every delivery carries the three spec headers - `Webhook-Id`, `Webhook-Timestamp`, `Webhook-Signature` - shown in [How a delivery looks](/webhooks/delivery#how-a-delivery-looks).

<Note>
  Provision the secret once with `POST /api/notifications/signing-secret` - it is returned a single time and never shown again. See [Quickstart](/webhooks/quickstart) for provisioning and [Secret rotation](#secret-rotation) below for replacing it.
</Note>

## Verify with a library

Use the official `standardwebhooks` package for your language. Each one takes the raw request body, the headers, and your `whsec_` secret, and either returns the verified payload or throws.

<CodeGroup>
  ```ts Node / TypeScript theme={null}
  // npm install standardwebhooks
  import { Webhook } from "standardwebhooks";
  import express from "express";

  const app = express();

  // Capture the RAW body - see "Verify against the raw body" below.
  app.post(
    "/webhooks/walletsuite",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const wh = new Webhook(process.env.WALLETSUITE_WEBHOOK_SECRET!); // "whsec_..."

      let payload: unknown;
      try {
        payload = wh.verify(req.body, {
          "webhook-id": req.header("webhook-id")!,
          "webhook-timestamp": req.header("webhook-timestamp")!,
          "webhook-signature": req.header("webhook-signature")!,
        });
      } catch {
        return res.status(400).send("invalid signature");
      }

      res.status(204).end();
      void handleEvent(payload);
    }
  );
  ```

  ```python Python theme={null}
  # pip install standardwebhooks
  import os

  from standardwebhooks import Webhook
  from fastapi import FastAPI, Request, Response

  app = FastAPI()

  @app.post("/webhooks/walletsuite")
  async def receive(request: Request):
      wh = Webhook(os.environ["WALLETSUITE_WEBHOOK_SECRET"])  # "whsec_..."

      body = await request.body()  # raw bytes, before any JSON parsing
      headers = {
          "webhook-id": request.headers["webhook-id"],
          "webhook-timestamp": request.headers["webhook-timestamp"],
          "webhook-signature": request.headers["webhook-signature"],
      }

      try:
          payload = wh.verify(body, headers)
      except Exception:
          return Response(status_code=400, content="invalid signature")

      enqueue(payload)
      return Response(status_code=204)
  ```

  ```go Go theme={null}
  // go get github.com/standard-webhooks/standard-webhooks/libraries/go
  package main

  import (
  	"io"
  	"net/http"
  	"os"

  	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
  )

  func receive(w http.ResponseWriter, r *http.Request) {
  	wh, err := standardwebhooks.NewWebhook(os.Getenv("WALLETSUITE_WEBHOOK_SECRET")) // "whsec_..."
  	if err != nil {
  		http.Error(w, "server misconfigured", http.StatusInternalServerError)
  		return
  	}

  	body, err := io.ReadAll(r.Body) // raw bytes
  	if err != nil {
  		http.Error(w, "bad request", http.StatusBadRequest)
  		return
  	}

  	if err := wh.Verify(body, r.Header); err != nil {
  		http.Error(w, "invalid signature", http.StatusBadRequest)
  		return
  	}

  	w.WriteHeader(http.StatusNoContent)
  	go handleEvent(body)
  }
  ```

  ```java Java theme={null}
  // Maven: com.standardwebhooks:standardwebhooks
  import com.standardwebhooks.Webhook;
  import com.standardwebhooks.exceptions.WebhookVerificationException;
  import java.util.List;
  import java.util.Map;

  // rawBody is the exact request body String; headers carry the three Webhook-* values.
  Webhook wh = new Webhook(System.getenv("WALLETSUITE_WEBHOOK_SECRET")); // "whsec_..."

  Map<String, List<String>> headers = Map.of(
      "webhook-id", List.of(request.getHeader("Webhook-Id")),
      "webhook-timestamp", List.of(request.getHeader("Webhook-Timestamp")),
      "webhook-signature", List.of(request.getHeader("Webhook-Signature")));

  try {
      wh.verify(rawBody, headers); // throws on failure
  } catch (WebhookVerificationException e) {
      // Reject with 400 - do not process.
  }
  ```
</CodeGroup>

The library handles signed-string construction, the `whsec_` prefix, base64 decoding, constant-time comparison, multiple signatures, and timestamp tolerance for you.

## Verify manually

If no official library fits your stack, implement the [spec](https://www.standardwebhooks.com) directly: recompute the signature and compare. A dependency-free reference implementation in Node's built-in `crypto`:

```ts verify.ts (Node crypto) theme={null}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

/**
 * Verify a WalletSuite (Standard Webhooks) delivery.
 * @param secret  Your signing secret, e.g. "whsec_MfKQ9r8x..."
 * @param headers Lower-cased header lookup for the request.
 * @param rawBody The EXACT raw request body bytes/string - never re-serialized JSON.
 * @returns true if the signature and timestamp are valid.
 */
export function verifyWebhook(
  secret: string,
  headers: {
    "webhook-id": string;
    "webhook-timestamp": string;
    "webhook-signature": string;
  },
  rawBody: string
): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) return false;

  // Replay protection: reject stale / future-dated messages.
  const now = Math.floor(Date.now() / 1000);
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) {
    return false;
  }

  // Derive the HMAC key: strip "whsec_", then base64-decode.
  if (!secret.startsWith("whsec_")) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");

  // Compute the expected signature.
  const signedContent = `${id}.${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", key)
    .update(signedContent, "utf8")
    .digest("base64");
  const expectedBuf = Buffer.from(expected, "utf8");

  // The header may carry multiple signatures - accept if any "v1" entry matches.
  for (const part of signatureHeader.split(/\s+/)) {
    const comma = part.indexOf(",");
    if (comma === -1) continue;
    const version = part.slice(0, comma);
    const value = part.slice(comma + 1);
    if (version !== "v1") continue;

    const candidateBuf = Buffer.from(value, "utf8");
    // Constant-time comparison; length guard avoids timingSafeEqual throwing.
    if (
      candidateBuf.length === expectedBuf.length &&
      crypto.timingSafeEqual(candidateBuf, expectedBuf)
    ) {
      return true;
    }
  }

  return false;
}
```

<Warning>
  Never compare signatures with `===` or string equality. A naive comparison leaks timing information that can let an attacker recover a valid signature byte by byte. Always use a constant-time comparator such as `crypto.timingSafeEqual` (Node), `hmac.compare_digest` (Python), `hmac.Equal` (Go), or `MessageDigest.isEqual` (Java).
</Warning>

## Verify against the raw body

The signature is computed over the **exact bytes** WalletSuite sent, so verification works on the raw request body - captured before any JSON parsing. (A parsed-and-re-serialized body is a different byte string: key order, whitespace, and number formatting all shift.) Capturing it is one route-level setting in every framework:

| Framework                    | How to get the raw body                                                                                                                                                                                                                                                                                                                                                                                                                              |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Express**                  | Mount `express.raw({ type: "application/json" })` on the webhook route **before** `express.json()`, or apply `express.json()` globally with an exception for the webhook path. `req.body` is then a `Buffer`.                                                                                                                                                                                                                                        |
| **Fastify**                  | Remove the built-in JSON parser first (re-adding `application/json` without removing it throws `FST_ERR_CTP_ALREADY_PRESENT`), then register one that keeps the raw `Buffer`: `fastify.removeContentTypeParser("application/json"); fastify.addContentTypeParser("application/json", { parseAs: "buffer" }, (req, body, done) => done(null, body))`. `request.body` is then a `Buffer` - or use the `fastify-raw-body` plugin for `request.rawBody`. |
| **Next.js (App Router)**     | Capture the raw body once with `const rawBody = await req.text();` - the App Router does not auto-parse it, so this returns the exact bytes. The body is a single-use stream, so parse the captured string with `JSON.parse(rawBody)` rather than calling `req.json()`.                                                                                                                                                                              |
| **Next.js (Pages Router)**   | Disable the built-in parser with `export const config = { api: { bodyParser: false } };` and read the raw stream into a `Buffer`.                                                                                                                                                                                                                                                                                                                    |
| **FastAPI**                  | `body = await request.body()` returns the raw `bytes`. Use it directly; do not declare a parsed Pydantic body model on the route.                                                                                                                                                                                                                                                                                                                    |
| **Flask**                    | `request.get_data()` returns the raw `bytes` - not `request.get_json()`.                                                                                                                                                                                                                                                                                                                                                                             |
| **Go (net/http)**            | Read `io.ReadAll(r.Body)` once into a `[]byte` and verify before unmarshaling.                                                                                                                                                                                                                                                                                                                                                                       |
| **AWS Lambda / API Gateway** | Use `event.body` (the raw string). If `event.isBase64Encoded` is `true`, base64-decode it first, then verify against the decoded bytes.                                                                                                                                                                                                                                                                                                              |

```ts Next.js App Router (route.ts) theme={null}
export async function POST(req: Request) {
  const rawBody = await req.text(); // raw string, captured first

  const ok = verifyWebhook(
    process.env.WALLETSUITE_WEBHOOK_SECRET!,
    {
      "webhook-id": req.headers.get("webhook-id") ?? "",
      "webhook-timestamp": req.headers.get("webhook-timestamp") ?? "",
      "webhook-signature": req.headers.get("webhook-signature") ?? "",
    },
    rawBody
  );
  if (!ok) return new Response("invalid signature", { status: 400 });

  const event = JSON.parse(rawBody); // parse AFTER verifying
  return new Response(null, { status: 204 });
}
```

## Replay protection

A valid signature proves origin. Two checks keep deliveries fresh:

* **Timestamp window** - reject deliveries whose `Webhook-Timestamp` is more than 5 minutes from your clock, in either direction. The timestamp is covered by the signature, so it is tamper-proof, and every retry is signed with a fresh timestamp - the window never rejects a legitimate delivery.
* **Deduplication** - dedupe on `Webhook-Id`: the same [idempotency key](/webhooks/delivery#idempotency) you already use for retries covers replays. Implementation: [Best Practices](/webhooks/best-practices).

## Test vector

Validate your verifier offline against a known-good vector before pointing real traffic at it.

```text Standard Webhooks test vector theme={null}
secret:             whsec_AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=
id:                 3f0a8d52-7e14-4b9c-a6d2-c8e1f4b09a7d
timestamp:          1769436168
body:               {"schema_version":"1.0","event_id":"3f0a8d52-7e14-4b9c-a6d2-c8e1f4b09a7d","event_type":"transfer.received","subscription_id":"9b7c1e2a-4f6d-4c3b-8a1e-2d5f7a9c0b13","occurred_at":"2026-05-26T14:22:48.123Z","data":{"chain":"ethereum","direction":"incoming","tx_hash":"0x9f3a1c","block_number":18573245,"from":"0x1230000000000000000000000000000000000abc","to":"0xabc0000000000000000000000000000000000123","amount":"1.5","asset_type":"erc20","asset_contract":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"}}
Webhook-Signature:  v1,tszN+ej8Qas8ASkHlc1b34HWB4+BAIoJEs8UHdDXYUA=
```

Feed the `secret`, headers, and `body` into your verifier - library or manual - and assert the verify call returns success. The vector's `timestamp` is fixed in the past, so pin your clock to it (or widen the tolerance) for this test; a live timestamp check would otherwise reject it. Then flip one byte of the `body` and confirm verification now **fails**.

Manual implementations can additionally check the math directly: build the signed string `id.timestamp.body`, run HMAC-SHA256 + base64 with the decoded secret, and confirm it equals the `v1,` value shown.

## Secret rotation

Rotate the signing secret whenever you suspect exposure, or on a routine schedule:

```bash theme={null}
curl -X POST "https://api.walletsuite.io/api/notifications/signing-secret/rotate" \
  -H "x-api-key: $WALLETSUITE_API_KEY"
```

The new secret is returned **once**. A `404 SIGNING_SECRET_NOT_FOUND` means no secret exists yet - provision one first with `POST /api/notifications/signing-secret`.

<Warning>
  Rotation is an **immediate swap**: every delivery signed after the call uses only the new secret, so deploy it to your receivers promptly.
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="Delivery & retries" icon="repeat" href="/webhooks/delivery">
    Acknowledgement window, retry schedule, at-least-once semantics, and ordering guarantees.
  </Card>

  <Card title="Event payloads" icon="braces" href="/webhooks/events">
    The `transfer.received` envelope, field semantics, and how to parse amounts and Tron addresses.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/webhooks/quickstart">
    Create a subscription, provision your signing secret, and receive your first event.
  </Card>

  <Card title="Best practices" icon="shield-check" href="/webhooks/best-practices">
    Idempotency, fast acknowledgement, and hardening your receiver for production.
  </Card>
</CardGroup>
