On this page
- The payment side is three files: app/api/checkout/route.ts, app/api/webhooks/fianto/route.ts and a client button component.
- Pass your user id as customer_reference when you create the session, and every subscription event carries it back as customer.reference.
- A subscription is ACTIVE, PAST_DUE or ENDED. Gate on your own stored copy; the API allows 600 reads a minute per application, too few to ask on every page view.
What you need before writing any code
You need an approved Fianto merchant account, one application with its app id and secret, and a recurring price created in the dashboard. Before approval the API answers 401 to every key, so do the approval first. Prices are read-only in the API: you make them in the dashboard and copy the fian_ id.
- Next.js with the App Router.
@fianto/nextjsdeclaresnext15 or later as its peer dependency. - Node.js 20.3 or later, which every Fianto SDK package requires.
- A public https origin once deployed. The production API refuses http success and cancel URLs with
400 url_, localhost included.insecure
If you only want a one-off "Pay" button for a fixed amount, the shorter walk-through is adding a Pay with USDC button. This post builds the subscription version and the access gate that goes with it; how stablecoin subscriptions renew on Solana explains what the customer approves. The general picture, without a framework, is the guide to accepting USDC on Solana.
Install the packages and set the environment
pnpm add @fianto/nextjs @fianto/sdk @fianto/react
All three packages are at 0.3.0 on npm as of 15 October 2026. Put the credentials in .. Next.js's environment guide says it plainly: "By default, environment variables are only available on the server." Never give these a NEXT_ prefix, because that inlines the value into the JavaScript sent to every visitor.
# Server only. Never prefix these with NEXT_PUBLIC_. FIANTO_APP_ID=fian_app_... FIANTO_APP_SECRET=fian_sk_live_... FIANTO_WEBHOOK_SECRET=whsec_... FIANTO_PRICE_PRO=fian_price_... SITE_URL=https://app.example
The SDK reads FIANTO_ and FIANTO_ on its own and calls https://api. unless FIANTO_ says otherwise. There is no test key: secrets start with fian_ on every deployment. How to test without one is in testing a USDC integration when there is no test mode.
Create the checkout session in a route handler
Checkout() returns a Route Handler for POST. It accepts only same-origin requests, always makes a popup-mode session and answers { id, url } to the button. Your createSession decides the price on the server; the request body never chooses what the customer pays.
import { Checkout } from '@fianto/nextjs';
import { currentUser } from '@/lib/auth'; // your own sign-in
import { subscribeOrderId } from '@/lib/billing'; // your own data layer
export const POST = Checkout({
createSession: async () => {
const user = await currentUser();
if (user === null) return new Response('Sign in first', { status: 401 });
const priceId = process.env.FIANTO_PRICE_PRO;
if (priceId === undefined) return new Response('Plan not configured', { status: 500 });
return {
mode: 'subscription',
price_id: priceId,
// The same id for the whole life of this user's subscription.
order_id: await subscribeOrderId(user.id, 'pro'),
// Comes back on every subscription event as customer.reference.
customer_reference: user.id,
success_url: `${process.env.SITE_URL}/welcome`,
cancel_url: `${process.env.SITE_URL}/pricing`,
};
},
onError: (error) => console.error('fianto checkout failed', error),
});Two fields carry most of the design. customer_ (up to 128 characters) is your own id for the payer, and it returns on the subscription as customer., so a webhook can find the user without a lookup table. order_ is your id for the purchase. In subscription mode one order_ serves one completed checkout only: reuse it while the subscription lives and a second subscribe is refused with 409 order_, which stops a double subscription. Issue a fresh one only after subscription.. The SDK's own example keeps a counter per user and plan for exactly this.
The first session for a recurring price also creates that price's plan on Solana, before anyone pays. Until the plan is on chain the hosted page shows it as preparing and offers no pay button, so expect the very first session to wait a little.
Render the pay button in a client component
'use client';
import { FiantoButton, fetchCheckoutSession } from '@fianto/react';
export function SubscribeButton() {
return (
<FiantoButton
session={() => fetchCheckoutSession('/api/checkout')}
label="subscribe"
onResult={(result) => console.log(result.status)}
onError={(error) => console.error(error)}
/>
);
}The button reports one of four statuses: succeeded, canceled, expired or closed. None of them is proof of payment. succeeded means the browser saw the checkout page finish, and closed means unknown, not "nothing was charged". Show a "confirming your payment" message and let the webhook decide. Serve the page from the same origin as success_: the popup reports its result only there.
The developer overview puts the API, the SDK packages, the CLI and signed webhooks on one page.
See the developer overviewStore subscription status from the webhook route
Webhooks() verifies the signature over the raw body, answers Fianto's endpoint. challenge for you and calls one callback per event type. A subscription creates no order, so order. never fires for it; subscriptions send subscription. events. Delivery is at least once and unordered, which is why the handler below reads the subscription back instead of trusting the event that happened to arrive last.
import { Webhooks } from '@fianto/nextjs';
import { Fianto } from '@fianto/sdk';
import { saveSubscription } from '@/lib/billing'; // your own data layer
// Created on first use: new Fianto() reads FIANTO_APP_ID and FIANTO_APP_SECRET
// and throws if either is missing, which would otherwise break next build.
let fianto: Fianto | undefined;
// Re-read the subscription: events can arrive out of order.
async function sync(eventId: string, subscriptionId: string) {
fianto ??= new Fianto();
const sub = await fianto.subscriptions.retrieve(subscriptionId);
// In one transaction: skip eventId if already seen, else upsert the row.
await saveSubscription(eventId, {
id: sub.id,
userId: sub.customer.reference,
orderId: sub.order_id,
status: sub.status, // 'ACTIVE' | 'PAST_DUE' | 'ENDED'
periodEnd: sub.current_period_end,
});
}
export const POST = Webhooks({
secret: process.env.FIANTO_WEBHOOK_SECRET,
onSubscriptionCreated: (event) => sync(event.id, event.data.id),
onSubscriptionRenewed: (event) => sync(event.id, event.data.id),
onSubscriptionPastDue: (event) => sync(event.id, event.data.id),
onSubscriptionCancelScheduled: (event) => sync(event.id, event.data.id),
onSubscriptionEnded: (event) => sync(event.id, event.data.id),
onVerificationError: (error) => console.warn('fianto webhook rejected:', error.reason),
onError: (error, event) => console.error('fianto webhook failed:', event.id, error),
});If a callback throws, the handler answers 500 and Fianto retries: any 2xx within 10 seconds counts as delivered, and a delivery gets up to 8 attempts over about 28 hours. Keep the callbacks short. Signature details, secret rolls and verifying by hand are in verifying Standard Webhooks signatures; the access rules per event are in webhooks for access.
Gate a page or an API route by subscription status
Gate on the status you stored, because a Fianto subscription has exactly three: ACTIVE, PAST_DUE and ENDED. ENDED is final. PAST_DUE means a renewal failed and Fianto is still trying: the attempts run two minutes after the period boundary, then about one hour, six hours and a day after it, and the fourth counted failure ends the subscription. That gives you a natural grace window.
| Stored status | What it means | A sensible gate |
|---|---|---|
ACTIVE | The current period is paid. A scheduled cancel still counts as paid until the period ends. | Full access |
PAST_ | A renewal failed; retries continue for about a day after the boundary | Keep access and show a banner: add USDC, or re-authorise in the portal if the wallet approval was removed |
ENDED | Cancelled, ended by you, its price ended, or the fourth counted failure | No access; offer to subscribe again with a new order_id |
| No row | Never subscribed, or the first event has not arrived yet | No access; the welcome page says "confirming" |
import { redirect } from 'next/navigation';
import { currentUser } from '@/lib/auth';
import { findSubscription } from '@/lib/billing';
import type { ReactNode } from 'react';
export default async function MembersLayout({ children }: { children: ReactNode }) {
const user = await currentUser();
if (user === null) redirect('/login');
const sub = await findSubscription(user.id, 'pro');
if (sub === null || sub.status === 'ENDED') redirect('/pricing');
return (
<>
{sub.status === 'PAST_DUE' ? <p role="status">Your last renewal failed. Add USDC to your wallet, or re-authorise at fianto.xyz/my, to keep access.</p> : null}
{children}
</>
);
}An API route gates the same way: read the row, answer 402 or 403 when it is missing or ENDED. Do not call Fianto on every request. The API allows 600 reads and 120 writes a minute per application, and 300 requests a minute per IP address, so a busy page would hit 429 rate_ quickly. Use fianto. only to repair a row, for example when a user says they paid and you have no event. The same pattern backs API keys in accepting USDC for API and RPC plans, and credit balances in accepting USDC for an AI app.
Mistakes that break a Next.js integration
- Something reads the body before the webhook route. The handler needs the exact bytes. Keep it a plain Route Handler with nothing in front that parses or rewrites the body, or every delivery fails with
invalid_.webhook 403 forbidden_from the checkout route. Behind a proxy the request URL can be an internal host. Setorigin allowedOriginsto your public origins.500 internal_with nothing obvious wrong. The route hides credential and approval errors from the browser. Log them inerror onError; a missingFIANTO_shows up there by name.APP_ ID - Fulfilling on the success page. The payer sees success at confirmed, while Fianto sends its events at finalized. A visit to
/welcomeproves nothing. - A clock that drifts. The SDK refuses a
webhook-timestampmore than 300 seconds from your server clock by default. - Promising features that do not exist. Payment links, coupons and the shop have no API, there is no free trial, and subscriptions renew monthly (every 30 days) or yearly (every 365 days), in USDC on Solana only.
Every option, the troubleshooting table and the Edge runtime note are on the Next.js SDK page in the docs.