No Fianto fee during the beta. Any fee comes with at least 30 days notice. See pricing
Developers9 min read

Verify Standard Webhooks signatures for USDC payment events.

Verifying a webhook signature means recomputing an HMAC-SHA256 over the exact bytes you received and comparing it, in constant time, with the one in the request. Under the Standard Webhooks format that Fianto uses, the signed content is webhook-id.webhook-timestamp.body, the key is the base64 part of your whsec_ secret, and the result arrives as v1,<base64> in the webhook-signature header. Check the timestamp too, then dedupe on the id.

On this page
What a Standard Webhooks signature coversHow Fianto signs its webhooksVerify with the Fianto SDKVerify by hand with node:cryptoVerify with the standardwebhooks libraryWhat to do after the signature checks outWhy a valid Fianto webhook fails verificationSourcesQuestions
TL;DR
  • 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.

Standard Webhooks specification
HeaderHoldsWhy it is signed
webhook-idThe 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-timestampUnix seconds of this attemptA replayed request carries an old time and fails the tolerance check
webhook-signatureOne or more v1,<base64 HMAC> values, separated by spacesSeveral 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-timestamp and the same webhook-id and body. Requests also carry User-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 its id equals webhook-id. The one exception is endpoint.verification, the challenge Fianto sends when you set a URL: it has no id and 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.
A webhook delivery log for one subscription: subscription.created, subscription.renewed and subscription.past_due, each delivered and signed with HMAC-SHA256, tried up to 8 times over about 28 hours.

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:

app/api/webhooks/fianto/route.ts
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.json(): once a JSON parser has read the body, adding express.raw 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.

lib/verify-fianto.ts
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;
  });
}
app/api/webhooks/fianto/route.ts
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:

app/api/webhooks/fianto/route.ts
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:

  1. Record webhook-id in 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.
  2. 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.
  3. When the current state matters, read the order or subscription back from the API instead of trusting arrival order.
  4. Answer 2xx to event types you do not handle. There are 14 today plus endpoint.verification, and more may come.

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.paid 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 tools

Why a valid Fianto webhook fails verification

SDK reasonUsual causeFix
no_matching_signatureWrong secret, or a parser or proxy changed the body firstCopy the secret again from Show signing secret; read the raw body before anything else touches it
timestamp_out_of_toleranceServer clock more than 300 seconds offRun NTP or your platform's time sync
missing_headersA proxy dropped the webhook-* headersPass all three through unchanged
invalid_secretThe variable is unset in this process, or is not a whsec_ valueLoad the env file and restart the server
invalid_payloadNot JSON, or the body id differs from webhook-idCheck 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.paid --forward-to http://localhost:3000/api/webhooks/fianto --secret "$FIANTO_WEBHOOK_SECRET"; the steps are in testing a USDC integration without a test mode, and every reason code is on the docs page Verify webhooks.

Sources
Questions

Questions about webhook signatures.

What does Fianto sign in a webhook?

The webhook-id header, a full stop, the webhook-timestamp header, a full stop and the raw body, with HMAC-SHA256 keyed by the base64-decoded part of your whsec_ secret. The result is sent as v1, followed by the base64 signature.

Why does my webhook signature never match?

Usually because the body was parsed and serialised again before verification, or the secret belongs to another application. Verify the exact bytes you received, with the secret shown for that application.

Can I use any Standard Webhooks library with Fianto?

Yes. Fianto follows the spec, so libraries such as standardwebhooks verify its requests. You still answer the endpoint.verification challenge yourself.

How long is a webhook timestamp valid?

The SDK accepts timestamps up to 300 seconds from your clock by default, configurable from 1 to 3,600 seconds and never off. Retries are signed again when sent, so they pass.

Why are there two signatures in the header?

A secret roll is running. Until the old secret stops working, Fianto signs with both, and a match on either is valid.

Read next.

Developers9 min read

Accept USDC in a Next.js app, and gate pages by subscription status

To accept USDC in a Next.js App Router app, install @fianto/nextjs, export Checkout() from a route that creates the session and Webhooks() from a route that receives signed events, then render FiantoButton from @fianto/react. Grant and revoke access from the webhook, never from the browser: store each subscription's status (ACTIVE, PAST_DUE or ENDED) and let your pages read it.

Developers9 min read

Testing a USDC integration when there is no test mode

Fianto has no test mode, no test keys and no hosted devnet: its API settles real USDC on Solana mainnet. You still test almost everything without spending money, by unit-testing your webhook route with signed sample events, replaying any event type with fianto trigger, and asking for a real signed test.event. The checkout itself you test once with a real payment of 0.01 USDC, the lowest price allowed, sent from a second wallet of your own back to yourself.

Use casesDevelopers10 min read

Paid Discord roles with USDC: a bot, a checkout and two webhook events

To sell a Discord role for USDC, run a bot that holds the Manage Roles permission, let a /join command open a Fianto subscription checkout with the member's Discord user ID as the customer reference, and add or remove the role when Fianto's subscription.created and subscription.ended webhooks arrive. Fianto has no Discord integration of its own: it collects the USDC into your wallet and tells your server what happened, and your bot does the Discord part.

Raw bytes in, one HMAC out.

Verify before you parse, then dedupe on webhook-id.

New posts are announced on X and Discord.