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.

4 min read

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.local on its own; Express and Hono on Node need node --env-file=.env or the dotenv package.
  • 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/sdk

Add 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:

CaseResponse
endpoint.verification with a valid signature200 {"challenge": "…"}
A verified event, callbacks finished200 {"received": true}
Signature, timestamp or secret check failed400 {"error": "invalid_webhook"}
Body larger than maxBodyBytes (default 1 MiB)413 {"error": "payload_too_large"}
Your callback or onEvent threw500 {"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:

reasonMeaning
missing_headerswebhook-id, webhook-timestamp or webhook-signature is missing or empty
invalid_signature_headerwebhook-signature is over 4,096 characters or carries more than 8 v1, values
timestamp_out_of_tolerancewebhook-timestamp is malformed, or further from your clock than the tolerance (300 seconds by default)
invalid_secretNo secret was given, or one is not whsec_ followed by base64 of at least 16 bytes
no_matching_signatureNo v1, signature matches any of your secrets
invalid_payloadThe 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

  1. 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.
  2. Until the old secret stops working, every delivery carries two signatures, one per secret, so a route holding either secret verifies it.
  3. 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 seeWhyFix
Route answers 400 invalid_webhook, reason no_matching_signatureThe 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_secretFIANTO_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_toleranceYour server clock is more than 300 seconds off.Sync the clock.
reason missing_headersA 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 BodyAlreadyParsedErrorexpress.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_probesMore than 10 checks in an hour for this endpoint, or 30 across your account.Wait for the hour to pass.
Route answers 500 handler_failedYour callback threw.Read what onError logged. fianto retries the delivery.

See also

Was this page helpful? Tell us

On this page