# Verify webhooks (/developers/webhooks/verify)

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 [#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 [#steps]

**Step 1.**

### Install the SDK for your framework [#install-the-sdk-for-your-framework]

#### pnpm

```bash
# Next.js
pnpm add @fianto/nextjs @fianto/sdk
# Express
pnpm add @fianto/express @fianto/sdk
# Hono
pnpm add @fianto/hono @fianto/sdk
```

#### npm

```bash
# Next.js
npm install @fianto/nextjs @fianto/sdk
# Express
npm install @fianto/express @fianto/sdk
# Hono
npm install @fianto/hono @fianto/sdk
```

#### yarn

```bash
# Next.js
yarn add @fianto/nextjs @fianto/sdk
# Express
yarn add @fianto/express @fianto/sdk
# Hono
yarn add @fianto/hono @fianto/sdk
```

#### bun

```bash
# Next.js
bun add @fianto/nextjs @fianto/sdk
# Express
bun add @fianto/express @fianto/sdk
# Hono
bun add @fianto/hono @fianto/sdk
```

**Step 2.**

### Add the webhook route [#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.

#### Next.js

```ts
// 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.

#### Express

```ts
// server.ts
import express from 'express';
import { webhooks } from '@fianto/express';

const app = express();

// Mount the fianto route BEFORE express.json(): it needs the raw body.
app.post(
  '/webhooks/fianto',
  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),
  }),
);

app.use(express.json()); // parsers for your other routes go after
app.listen(3000);
```

A global `app.use(express.json())` registered before this route breaks it, and adding
`express.raw(...)` to the route does not help: the body is already read. If a JSON parser must be
global, scope it away from the webhook path instead, for example `app.use('/api', express.json())`.

#### Hono

```ts
// src/index.ts
import { Hono } from 'hono';
import { webhooks } from '@fianto/hono';

const app = new Hono();

app.post(
  '/webhooks/fianto',
  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),
  }),
);

export default app;
```

No earlier `app.use(...)` may read or replace the request body. `export default app` only exports
the app: on Node, start it with `@hono/node-server`, `serve({ fetch: app.fetch, port: 3000 })`.

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.

**Step 3.**

### Or verify by hand with verifyWebhook [#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.

#### Next.js

```ts
// 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 });
}
```

#### Express

```ts
// server.ts
import express from 'express';
import { isWebhookVerificationError, verifyWebhook } from '@fianto/sdk/webhooks';

const app = express();

// Before any global express.json(): express.raw keeps req.body as the untouched bytes (a Buffer).
app.post('/webhooks/fianto', express.raw({ type: '*/*' }), async (req, res, next) => {
  let event;
  try {
    event = await verifyWebhook(req.body, req.headers, { secret: process.env.FIANTO_WEBHOOK_SECRET });
  } catch (error) {
    if (isWebhookVerificationError(error)) {
      console.warn('fianto webhook rejected:', error.reason);
      res.status(400).json({ error: 'invalid_webhook' });
      return;
    }
    next(error);
    return;
  }

  switch (event.type) {
    case 'endpoint.verification':
      res.status(200).json({ challenge: event.data.challenge });
      return;
    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
  }
  res.status(200).json({ received: true });
});

app.use(express.json());
app.listen(3000);
```

#### Hono

```ts
// src/index.ts
import { Hono } from 'hono';
import { isWebhookVerificationError, verifyWebhook } from '@fianto/sdk/webhooks';

const app = new Hono();

app.post('/webhooks/fianto', async (c) => {
  const body = await c.req.arrayBuffer(); // the raw bytes, never c.req.json()
  let event;
  try {
    event = await verifyWebhook(body, c.req.raw.headers, { secret: process.env.FIANTO_WEBHOOK_SECRET });
  } catch (error) {
    if (isWebhookVerificationError(error)) {
      console.warn('fianto webhook rejected:', error.reason);
      return c.json({ error: 'invalid_webhook' }, 400);
    }
    throw error;
  }

  switch (event.type) {
    case 'endpoint.verification':
      return c.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 c.json({ received: true });
});

export default app;
```

`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.

**Step 4.**

### Keep your clock in sync [#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.

**Step 5.**

### Roll the signing secret without dropping deliveries [#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.

```ts
// 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 [#check-it-worked]

> With your server running locally, send it a signed sample event. This makes no API call.
>
> ```bash
> 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 [#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 [#see-also]

- [Webhooks](/developers/webhooks/overview): Endpoints, the verification challenge and the envelope.

- [Event types](/developers/webhooks/events): What each event carries.

- [Testing](/developers/testing): Signed samples, test.event and forwarding real events.

- [Retries and delivery](/developers/webhooks/retries-and-delivery): What happens when your route fails.