On this page
- Sign input: webhook-id, a full stop, webhook-timestamp, a full stop, then the raw body. Algorithm: HMAC-SHA256 keyed with the bytes after whsec_.
- Fianto signs each attempt when it sends it and puts two space-separated v1 signatures in the header while a secret roll runs.
- The SDK refuses timestamps more than 300 seconds from your clock and also checks that the body id equals webhook-id, which the generic library does not.
What a Standard Webhooks signature covers
A Standard Webhooks signature covers three things at once: the message id, the time of the attempt and the body. Standard Webhooks' home page introduces the project in one sentence: "Standard Webhooks is a set of open source tools and guidelines to send webhooks easily, securely and reliably." Its technical steering committee lists people from Zapier, Twilio, Svix, Supabase and other companies. The specification puts the rule in one line:
To achieve this, the message's: ID, timestamp and body are concatenated (delimited by full-stops) and then signed.
| Header | Holds | Why it is signed |
|---|---|---|
webhook-id | The event id, evt_ plus 32 hex characters at Fianto. The same on every retry. | It is your idempotency key, so it must not be forgeable |
webhook-timestamp | Unix seconds of this attempt | A replayed request carries an old time and fails the tolerance check |
webhook-signature | One or more v1,<base64 HMAC> values, separated by spaces | Several values let a sender roll its secret with no downtime |
The spec warns about the failure almost everyone hits once: "Cryptographic signatures are sensitive to even the smallest changes, and even a stray space can cause the signature to be invalid." If your framework parses the JSON and you serialise it again, the bytes change and no signature will ever match. Read the raw body, verify, and only then parse.
How Fianto signs its webhooks
Fianto's deliveries follow the Standard Webhooks format, so any Standard Webhooks library can verify them. The specifics worth knowing:
- The secret is
whsec_followed by base64 of 32 random bytes, inside the spec's recommended range of 24 to 64 bytes. You see it with Show signing secret on your application's page in the dashboard, after re-entering your password and two-factor code. - Each attempt is signed again when it is sent, so a retry hours later carries a fresh
webhook-timestampand the samewebhook-idand body. Requests also carryUser-Agent: Fianto-Webhooks/1..0 - While a secret roll's grace period runs (none, 1 hour or 24 hours, chosen when you roll), the header carries two signatures, one per valid secret. At most two secrets are valid at once.
- The body is the envelope
{ id, type, timestamp, data }, and itsidequalswebhook-id. The one exception isendpoint., the challenge Fianto sends when you set a URL: it has noverification idand expects{"challenge": "<value>"}back with HTTP 200 within 5 seconds. - Fianto's servers apply no tolerance of their own; rejecting stale timestamps is the receiver's job.
Verify with the Fianto SDK
The shortest correct route is the SDK's handler, which reads the raw body itself, checks headers and timestamp before reading the body, answers the verification challenge and calls one callback per event type. In Next.js:
import { Webhooks } from '@fianto/nextjs';
export const POST = Webhooks({
// One secret, or an array of two while a roll runs.
secret: process.env.FIANTO_WEBHOOK_SECRET,
onSubscriptionCreated: async (event) => {
// grant access; record event.id in the same transaction
},
onSubscriptionEnded: async (event) => {
// revoke access; event.data.end_reason says why
},
onVerificationError: (error) => console.warn('fianto webhook rejected:', error.reason),
});For a route you write yourself, verifyWebhook(rawBody, headers, { secret }) from @fianto/sdk/webhooks does the same check and resolves to the parsed event, or rejects with a WebhookVerificationError whose reason names the failure. Express and Hono have their own adapters, @fianto/express and @fianto/hono. In Express (worked through in USDC subscriptions in Express), mount the webhook route before any global express.: once a JSON parser has read the body, adding express. to the route cannot get the bytes back. The full Next.js setup is in accepting USDC in a Next.js app.
Verify by hand with node:crypto
Writing the check yourself takes about twenty lines, and doing it once is the best way to understand what can go wrong. This version follows the spec's verification notes: compare in constant time, accept any v1 value, check the timestamp.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyFianto(raw: Buffer, headers: Headers, secret: string): boolean {
const id = headers.get('webhook-id') ?? '';
const timestamp = headers.get('webhook-timestamp') ?? '';
const signatures = headers.get('webhook-signature') ?? '';
if (id === '' || signatures === '' || /^\d+$/.test(timestamp) === false) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(raw).digest();
// During a secret roll the header holds two values: either may match.
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version === 'v1' && value) {
const given = Buffer.from(value, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
}
return false;
});
}import { verifyFianto } from '@/lib/verify-fianto';
export async function POST(request: Request) {
const raw = Buffer.from(await request.arrayBuffer()); // the exact bytes, never request.json()
if (verifyFianto(raw, request.headers, process.env.FIANTO_WEBHOOK_SECRET ?? '') === false) {
return Response.json({ error: 'invalid_webhook' }, { status: 400 });
}
const event = JSON.parse(raw.toString('utf8'));
if (event.type === 'endpoint.verification') {
return Response.json({ challenge: event.data.challenge });
}
// Every other Fianto event carries its id in the body as well.
if (event.id === request.headers.get('webhook-id')) {
// handle once: skip event.id if your database has seen it
return Response.json({ received: true });
}
return Response.json({ error: 'invalid_webhook' }, { status: 400 });
}Three details matter. timingSafeEqual throws when lengths differ, hence the length check first. A plain === on the strings would leak timing, which the spec warns can turn a receiver into a signing oracle. And the HMAC runs over the bytes as received; decoding to a string and encoding again is fine for valid UTF-8, but bytes are never wrong.
Verify with the standardwebhooks library
The spec's own JavaScript package, standardwebhooks (1.1.1 on npm as of 15 October 2026), works with Fianto as is. Its Webhook class strips the whsec_ prefix, uses a 5-minute tolerance and returns the parsed body:
import { Webhook } from 'standardwebhooks';
export async function POST(request: Request) {
const body = await request.text();
let event: { id?: string; type: string; data: Record<string, unknown> };
try {
// Inside the handler: the constructor throws on an empty secret, so a build without the variable still works.
const wh = new Webhook(process.env.FIANTO_WEBHOOK_SECRET ?? '');
event = wh.verify(body, Object.fromEntries(request.headers)) as typeof event;
} catch {
return Response.json({ error: 'invalid_webhook' }, { status: 400 });
}
if (event.type === 'endpoint.verification') {
return Response.json({ challenge: event.data.challenge });
}
// The library does not compare event.id with webhook-id: do it here if you rely on it.
return Response.json({ received: true });
}The library verifies the signature and the timestamp and nothing about the payload. The Fianto SDK adds two checks on top: the body's id must equal webhook-id, and its route handlers refuse a body over 1 MiB by default. Pick the SDK unless you already standardise on the library across several providers.
What to do after the signature checks out
A valid signature proves the request came from Fianto; it does not make the event new, or the latest. Fianto delivers at least once and in no particular order, so:
- Record
webhook-idin the same database transaction as the work it triggers, and skip ids you have already handled. The spec gives the same advice: the id is the idempotency key. - Answer 2xx within 10 seconds and do slow work in a queue. Anything else is a failed attempt; a delivery gets up to 8 attempts, with waits of 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours (each up to 20% longer or shorter), about 28 hours in all.
- When the current state matters, read the order or subscription back from the API instead of trusting arrival order.
- Answer 2xx to event types you do not handle. There are 14 today plus
endpoint., and more may come.verification
After 5 exhausted deliveries in a row the endpoint is suspended and new deliveries are held, up to 10,000, until you fix the route and click Verify again. Events stay readable for 90 days through GET /v1/events. What each event means for access is in webhooks for access, and why order. waits for finalized is in how a merchant knows a crypto payment arrived. Two worked examples grant access from these events: paid Discord roles with USDC and a paid Telegram group for USDC.
Signed webhooks, the CLI and the SDKs, on one page.
See the developer toolsWhy a valid Fianto webhook fails verification
| SDK reason | Usual cause | Fix |
|---|---|---|
no_ | Wrong secret, or a parser or proxy changed the body first | Copy the secret again from Show signing secret; read the raw body before anything else touches it |
timestamp_ | Server clock more than 300 seconds off | Run NTP or your platform's time sync |
missing_ | A proxy dropped the webhook-* headers | Pass all three through unchanged |
invalid_ | The variable is unset in this process, or is not a whsec_ value | Load the env file and restart the server |
invalid_ | Not JSON, or the body id differs from webhook-id | Check nothing rewrote the body in transit |
Roll a secret with a grace period unless it leaked: with "Stop the old one now", every delivery fails until your server has the new secret, and waits for its next retry. To test the whole check locally, post signed samples with npx @fianto/cli trigger order.; the steps are in testing a USDC integration without a test mode, and every reason code is on the docs page Verify webhooks.