Skip to main content
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 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.
Provision the secret once with POST /api/notifications/signing-secret - it is returned a single time and never shown again. See Quickstart for provisioning and Secret rotation below for replacing it.

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.
// 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);
  }
);
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 directly: recompute the signature and compare. A dependency-free reference implementation in Node’s built-in crypto:
verify.ts (Node crypto)
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;
}
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).

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:
FrameworkHow to get the raw body
ExpressMount 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.
FastifyRemove 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.
FastAPIbody = await request.body() returns the raw bytes. Use it directly; do not declare a parsed Pydantic body model on the route.
Flaskrequest.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 GatewayUse event.body (the raw string). If event.isBase64Encoded is true, base64-decode it first, then verify against the decoded bytes.
Next.js App Router (route.ts)
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 you already use for retries covers replays. Implementation: Best Practices.

Test vector

Validate your verifier offline against a known-good vector before pointing real traffic at it.
Standard Webhooks test vector
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:
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.
Rotation is an immediate swap: every delivery signed after the call uses only the new secret, so deploy it to your receivers promptly.

Next steps

Delivery & retries

Acknowledgement window, retry schedule, at-least-once semantics, and ordering guarantees.

Event payloads

The transfer.received envelope, field semantics, and how to parse amounts and Tron addresses.

Quickstart

Create a subscription, provision your signing secret, and receive your first event.

Best practices

Idempotency, fast acknowledgement, and hardening your receiver for production.