DevelopersTools
Next.js
Add Fianto's checkout and webhook routes to a Next.js App Router app with Checkout() and Webhooks() from @fianto/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 handlers with the same
options, so export const POST = … is the whole route.
Before you start
- A Next.js app with the App Router. The peer dependency is
next15 or later. - Your application's app id and secret, and its webhook signing secret (
whsec_…) once it has a webhook URL. See Authentication. - Node.js 20.3 or later.
Steps
Install the packages
@fianto/react is for the pay button in step 5.
pnpm add @fianto/nextjs @fianto/sdk @fianto/reactPut your credentials in .env.local
Next.js loads .env.local on its own. Keep these values on the server: the app secret must never
reach a browser.
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.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.
// 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.
Add the webhook route
// 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.
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.
// 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)}
/>
);
}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
With next dev running on port 3000, send your webhook route a signed sample. It makes no API call.
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
| 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
Was this page helpful? Tell us