DevelopersTools

Next.js

Add Fianto's checkout and webhook routes to a Next.js App Router app with Checkout() and Webhooks() from @fianto/nextjs.

2 min read

@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 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.
  • 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/react

Put 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 seeWhyFix
Checkout route answers 403 forbidden_originThe 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_errorFianto 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_mismatchThis 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_webhookThe 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 405The request was not a POST.Call it with POST; the button and fetchCheckoutSession do.

See also

Was this page helpful? Tell us

On this page