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 officialstandardwebhooks 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.
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-incrypto:
verify.ts (Node crypto)
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. |
Next.js App Router (route.ts)
Replay protection
A valid signature proves origin. Two checks keep deliveries fresh:- Timestamp window - reject deliveries whose
Webhook-Timestampis 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, 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:404 SIGNING_SECRET_NOT_FOUND means no secret exists yet - provision one first with POST /api/notifications/signing-secret.
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.