DevelopersWebhooks
Verify webhooks
Check fianto's webhook signatures in Next.js, Express or Hono with the SDK or verifyWebhook, and roll your signing secret safely.
Every request fianto sends your webhook route is signed with your application's whsec_ signing
secret. Your route must check that signature over the raw request body before it trusts the event.
The SDK's route handlers do this, answer the URL verification challenge, and call one function per
event type. verifyWebhook does the same check when you want to write the route yourself.
Before you start
- Your application has a webhook URL in the dashboard. Its signing secret (
whsec_…) is shown by "Reveal signing secret" in the same settings, after you re-enter your password (and 2FA code). - The secret is in your server's environment as
FIANTO_WEBHOOK_SECRET. Next.js reads.env.localon its own; Express and Hono on Node neednode --env-file=.envor thedotenvpackage. - Node.js 20.3 or later.
Steps
Install the SDK for your framework
# Next.js
pnpm add @fianto/nextjs @fianto/sdk
# Express
pnpm add @fianto/express @fianto/sdk
# Hono
pnpm add @fianto/hono @fianto/sdkAdd the webhook route
The handler reads the raw body itself, checks the signature and the timestamp, answers
endpoint.verification, and calls the callback for the event's type. Wire onVerificationError
and onError: a wrong or missing secret fails exactly like a forged request, and the response never
says why.
// app/api/webhooks/fianto/route.ts
import { Webhooks } from '@fianto/nextjs';
export const POST = Webhooks({
secret: process.env.FIANTO_WEBHOOK_SECRET,
onOrderPaid: async (event) => {
// Record event.id in the same database transaction as the fulfilment,
// and skip ids you have already seen.
console.log('paid', event.data.order_id, event.id);
},
onSubscriptionEnded: async (event) => {
console.log('ended', event.data.id, event.data.end_reason);
},
onVerificationError: (error) => console.warn('fianto webhook rejected:', error.reason),
onError: (error, event) => console.error('fianto webhook failed:', event.id, error),
});Keep it a plain Route Handler: no middleware in front of it may read or rewrite the body.
What the handler answers:
| Case | Response |
|---|---|
endpoint.verification with a valid signature | 200 {"challenge": "…"} |
| A verified event, callbacks finished | 200 {"received": true} |
| Signature, timestamp or secret check failed | 400 {"error": "invalid_webhook"} |
Body larger than maxBodyBytes (default 1 MiB) | 413 {"error": "payload_too_large"} |
Your callback or onEvent threw | 500 {"error": "handler_failed"}, so fianto retries |
Not a POST (Next.js; Express and Hono routes registered with app.post answer 404 instead) | 405 |
There is one callback per event type (onCheckoutSessionCompleted … onTestEvent), plus
onEvent, which runs after the type's own callback for every verified event except the
verification challenge.
Or verify by hand with verifyWebhook
verifyWebhook(rawBody, headers, { secret }) checks the signature and returns the parsed event. Pass
the body exactly as received, as a string, Uint8Array or ArrayBuffer, never re-serialised JSON.
Your route then answers endpoint.verification itself.
// app/api/webhooks/fianto/route.ts
import { isWebhookVerificationError, verifyWebhook } from '@fianto/sdk/webhooks';
export async function POST(request: Request) {
const body = await request.arrayBuffer(); // the raw bytes, never request.json()
let event;
try {
event = await verifyWebhook(body, request.headers, { secret: process.env.FIANTO_WEBHOOK_SECRET });
} catch (error) {
if (isWebhookVerificationError(error)) {
console.warn('fianto webhook rejected:', error.reason);
return Response.json({ error: 'invalid_webhook' }, { status: 400 });
}
throw error;
}
switch (event.type) {
case 'endpoint.verification':
return Response.json({ challenge: event.data.challenge });
case 'order.paid':
console.log('paid', event.data.order_id, event.id);
break;
default:
break; // answer 2xx for types you do not handle, including newer ones
}
return Response.json({ received: true });
}verifyWebhook throws a WebhookVerificationError whose reason is one of:
reason | Meaning |
|---|---|
missing_headers | webhook-id, webhook-timestamp or webhook-signature is missing or empty |
invalid_signature_header | webhook-signature is over 4,096 characters or carries more than 8 v1, values |
timestamp_out_of_tolerance | webhook-timestamp is malformed, or further from your clock than the tolerance (300 seconds by default) |
invalid_secret | No secret was given, or one is not whsec_ followed by base64 of at least 16 bytes |
no_matching_signature | No v1, signature matches any of your secrets |
invalid_payload | The body is not a JSON event, or its id differs from webhook-id |
toleranceSeconds can be set from 1 to 3,600; it cannot be switched off.
Keep your clock in sync
The timestamp check compares webhook-timestamp with your server's clock. fianto signs every
attempt when it sends it, so retries hours later still pass. A server clock more than 300 seconds
off rejects every delivery with timestamp_out_of_tolerance. Run NTP or your platform's time sync.
Roll the signing secret without dropping deliveries
- In the application's webhook settings, choose Roll signing secret. Pick when the current secret stops working: "Immediately", "In 1 hour" (the default) or "In 24 hours". The roll asks for your password (and 2FA code) and shows the new secret.
- Until the old secret stops working, every delivery carries two signatures, one per secret, so a route holding either secret verifies it.
- Put the new secret in your server's environment before the old one stops working. While you switch over, you can give the handler both secrets; a delivery verifies when any of them matches.
// app/api/webhooks/fianto/route.ts
import { Webhooks } from '@fianto/nextjs';
// Drop whichever variable is not set (before the switch, or after the old one is retired).
const secrets = [process.env.FIANTO_WEBHOOK_SECRET, process.env.FIANTO_WEBHOOK_SECRET_OLD].filter(
(secret): secret is string => Boolean(secret),
);
export const POST = Webhooks({
// An empty array throws when the route is set up; undefined falls back to FIANTO_WEBHOOK_SECRET.
secret: secrets.length ? secrets : undefined,
onOrderPaid: async (event) => {
console.log('paid', event.data.order_id, event.id);
},
});verifyWebhook takes the same array as its secret. At most two secrets are valid at once: a new
roll ends any older secret's grace period at once.
Immediately means right now
With "Immediately", fianto signs only with the new secret from that moment. Until your server has it, every delivery fails verification and waits for its next retry.
Check it worked
With your server running locally, send it a signed sample event. This makes no API call.
npx @fianto/cli trigger order.paid \
--forward-to http://localhost:3000/api/webhooks/fianto \
--secret "$FIANTO_WEBHOOK_SECRET"$FIANTO_WEBHOOK_SECRET must be exported in this shell; a .env file is not enough. The CLI prints
→ 200 order.paid (local sample). For Express and Hono, forward to
http://localhost:3000/webhooks/fianto. Then, with your URL verified, send a real delivery with
npx @fianto/cli trigger test.event (it needs FIANTO_APP_ID and FIANTO_APP_SECRET) or the
dashboard's test event button, and watch it arrive at your route.
Troubleshooting
| You see | Why | Fix |
|---|---|---|
Route answers 400 invalid_webhook, reason no_matching_signature | The secret is not this application's current whsec_ secret, or something changed the body before your route read it. | Copy the secret from "Reveal signing secret", and read the raw body before any parser or proxy touches it. |
reason invalid_secret | FIANTO_WEBHOOK_SECRET is not set in this process, or it is not a whsec_ value. | Load your .env (Next.js reads .env.local; Node needs --env-file or dotenv) and restart the server. |
reason timestamp_out_of_tolerance | Your server clock is more than 300 seconds off. | Sync the clock. |
reason missing_headers | A proxy or framework dropped the webhook-* headers, or the request is not from fianto. | Pass the three headers through to your route unchanged. |
Express answers 500 BodyAlreadyParsedError | express.json() or another body parser read the body before the fianto route. | Mount the fianto route before any global JSON parser, or scope the parser away from the webhook path, such as app.use('/api', express.json()). Adding express.raw to the route does not help once the body is read. |
| Verification fails with "Your endpoint did not echo the challenge back." | The route answered 200, but its body was not {"challenge": "…"} with the value it received. | Answer endpoint.verification as in step 3, or use the SDK handler, then click "Verify again". |
| Verification fails with "Your endpoint did not answer with HTTP 200." | The route answered another status, such as 400 for a wrong secret or 404 for a wrong path. | Check the path and the secret, then click "Verify again". |
| Verification fails with "Your endpoint answered with a redirect." | fianto does not follow redirects. | Register the exact https URL your route answers at, then click "Verify again". |
| Verification fails with "Your endpoint did not answer within 5 seconds." | The route did not answer the challenge in time. | Make sure the URL is publicly reachable and answers at once, then click "Verify again". |
429 too_many_probes | More than 10 checks in an hour for this endpoint, or 30 across your account. | Wait for the hour to pass. |
Route answers 500 handler_failed | Your callback threw. | Read what onError logged. fianto retries the delivery. |
See also
Was this page helpful? Tell us