# Next.js (/developers/sdks/nextjs)

`@fianto/nextjs` gives you two App Router route handlers. `Checkout()` creates a checkout session
for the pay button, and `Webhooks()` verifies Fianto's webhook requests and calls your code per event.
Both are the [`@fianto/sdk/handlers`](/developers/sdks/sdk) handlers with the same
options, so `export const POST = …` is the whole route.

## Before you start [#before-you-start]

* A Next.js app with the App Router. The peer dependency is `next` 15 or later.
* Your application's app id and secret, and its webhook signing secret (`whsec_…`) once it has a
  webhook URL. See [Authentication](/developers/authentication).
* Node.js 20.3 or later.

## Steps [#steps]

**Step 1.**

### Install the packages [#install-the-packages]

`@fianto/react` is for the pay button in step 5.

#### pnpm

```bash
pnpm add @fianto/nextjs @fianto/sdk @fianto/react
```

#### npm

```bash
npm install @fianto/nextjs @fianto/sdk @fianto/react
```

#### yarn

```bash
yarn add @fianto/nextjs @fianto/sdk @fianto/react
```

#### bun

```bash
bun add @fianto/nextjs @fianto/sdk @fianto/react
```

**Step 2.**

### Put your credentials in `.env.local` [#put-your-credentials-in-envlocal]

Next.js loads `.env.local` on its own. Keep these values on the server: the app secret must never
reach a browser.

```bash
FIANTO_APP_ID=fian_app_...
FIANTO_APP_SECRET=fian_sk_live_...
FIANTO_WEBHOOK_SECRET=whsec_...
# FIANTO_BASE_URL is optional: set it only for a self-hosted or local fianto backend.
```

**Step 3.**

### Add the checkout route [#add-the-checkout-route]

`Checkout()` accepts only same-origin `POST` requests (or an `Origin` in `allowedOrigins`), always creates a popup-mode session, and
answers `{ id, url }`. `createSession` gets the request and the App Router's route context
(`context.params` for a dynamic route). Decide the price here, never from the request body.

```ts
// app/api/checkout/route.ts
import { Checkout } from '@fianto/nextjs';

const PRICES: Record<string, { amount: string; description: string }> = {
  beans: { amount: '10.00', description: 'Coffee beans, 1 kg' },
};

export const POST = Checkout({
  createSession: async (request) => {
    const body = (await request.json().catch(() => null)) as { item?: string } | null;
    const price = body?.item ? PRICES[body.item] : undefined;
    if (!price) return new Response('Unknown item', { status: 400 }); // a Response refuses the request

    return {
      mode: 'payment',
      order_id: `order_${crypto.randomUUID()}`, // use your own order's id
      amount: price.amount,
      description: price.description,
      success_url: 'https://shop.example/thank-you',
      cancel_url: 'https://shop.example/cart',
    };
  },
  // Only needed when a caller does not send Sec-Fetch-Site: same-origin, for example behind a proxy.
  allowedOrigins: ['https://shop.example'],
  onError: (error) => console.error('fianto checkout failed', error),
});
```

If the order already has an open session with the same terms, the route reissues that session's
link. With different terms it answers 409 `order_session_mismatch`: give changed terms a new
`order_id`, for example with a cart version in it.

> **The payer pays the amount plus the service fee:**
>
> `amount` is what you receive. When the service fee is above zero, Fianto adds it on top and the
> payer pays it.

**Step 4.**

### Add the webhook route [#add-the-webhook-route]

```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);
  },
  onVerificationError: (error) => console.warn('fianto webhook rejected:', error.reason),
  onError: (error, event) => console.error('fianto webhook failed:', event.id, error),
});
```

Keep both routes plain Route Handlers. The handlers read the raw request body themselves and need
the exact bytes, so no middleware in front of them may read or rewrite the body. A request that is
not a `POST` gets 405 from the handler. Every option and callback is on
[Verify webhooks](/developers/webhooks/verify).

**Step 5.**

### Add the pay button [#add-the-pay-button]

In a client component, render `FiantoButton` with a session from your route. Serve that page from
the same origin as `success_url`: the popup reports its result only to that origin. See
[React](/developers/sdks/react).

```tsx
// app/buy-button.tsx
'use client';

import { FiantoButton, fetchCheckoutSession } from '@fianto/react';

export function BuyButton() {
  return (
    <FiantoButton
      session={() => fetchCheckoutSession('/api/checkout', { body: { item: 'beans' } })}
      onResult={(result) => console.log(result.status)}
      onError={(error) => console.error(error)}
    />
  );
}
```

**Step 6.**

### Choose the runtime [#choose-the-runtime]

Both handlers use only `fetch` and Web Crypto, so they are designed to run in the Node.js runtime
and the Edge runtime alike. CI tests the SDK on Node.js 22 and 24 and smoke-tests the built
`@fianto/sdk` on Node.js 20.3.0; Edge is not tested yet.

## Check it worked [#check-it-worked]

> With `next dev` running on port 3000, send your webhook route a signed sample. It makes no API call.
>
> ```bash
> npx @fianto/cli trigger order.paid \
>   --forward-to http://localhost:3000/api/webhooks/fianto \
>   --secret "$FIANTO_WEBHOOK_SECRET"
> ```
>
> The CLI reads your shell, not `.env.local`: export `FIANTO_WEBHOOK_SECRET` or paste the value. It
> prints `→ 200 order.paid (local sample)` and your `onOrderPaid` logs the sample order. Then click the
> button: the popup opens Fianto's checkout page for the new session.

## Troubleshooting [#troubleshooting]

| You see | Why | Fix |
|---|---|---|
| Checkout route answers 403 `forbidden_origin` | The request did not carry `Sec-Fetch-Site: same-origin`, and its `Origin` is not in `allowedOrigins` (default: the request URL's own origin, which behind a proxy can be an internal host). | Call the route from your own pages, or set `allowedOrigins` to your public origins. |
| Checkout route answers 500 `internal_error` | Fianto refused for a reason the route does not pass to the browser: wrong credentials (401), an account not approved (403), a 5xx, or an unlisted code such as `merchant_token_account_missing`. | Read the original error in `onError`. |
| Checkout route answers 409 `order_session_mismatch` | This `order_id` already has an open session with other terms. | Use a new `order_id` for the new terms, or cancel the open session first. |
| Webhook route answers 400 `invalid_webhook` | The secret is wrong or missing, middleware changed the body, or your clock is more than 300 s off. | Check what `onVerificationError` logs; see Verify webhooks. |
| Checkout route answers 500 and `onError` logs `Missing appId: pass it to new Fianto({ appId }) or set FIANTO_APP_ID.` | The variable is not in the server's environment, so the default client cannot be created. | Put it in `.env.local` and restart `next dev`. |
| The route answers 405 | The request was not a `POST`. | Call it with `POST`; the button and `fetchCheckoutSession` do. |

## See also [#see-also]

- [Quickstart](/get-started/quickstart): The whole one-time payment flow in Next.js.

- [React](/developers/sdks/react): useCheckout and the FiantoButton component.

- [Verify webhooks](/developers/webhooks/verify): Every webhook option, and verifyWebhook by hand.

- [@fianto/sdk](/developers/sdks/sdk): The client and handler options in full.