Get startedQuickstarts
Quickstart
Take your first one-time USDC payment with a checkout route, a pay button and a webhook route in Next.js, Express or Hono.
You build three pieces: a checkout route that creates the session on your server, a button that opens fianto's checkout in a popup, and a webhook route that marks the order paid.
Scroll sideways to see the whole diagram →
Show as text
- From your server, create a session with `POST /v1/checkout-sessions`. The first create returns its `url`; creating again for the same open order returns `url: null`, so reissue the link instead.
- Send the payer to that `url`: fianto's hosted checkout page.
- The payer's wallet signs the transaction. The wallet only signs; it does not send.
- The checkout page hands the signed transaction to fianto.
- fianto broadcasts it to Solana, and rebroadcasts it when needed.
- When the transaction is confirmed, fianto tells the checkout page, which shows the payer success. The order is not `PAID` yet.
- When it is finalized, the order becomes `PAID`, the payment `SUCCEEDED`, and fianto sends `order.paid` and `checkout.session.completed` to your webhook endpoint — only at finalized.
Before you start
- Your merchant account is approved.
- Your receiving wallet has a USDC token account. If it has none, send it any amount of USDC once.
- You created an application in the dashboard and copied its app id (
fian_app_…) and secret (fian_sk_live_…). The secret is shown only once. - You know the public
httpsaddress your webhook route will have. The application's webhook signing secret (whsec_…) appears once the application has a webhook URL (step 6). - Node.js 20.3 or later.
Steps
Install the packages
Pick the line for your framework.
# Next.js
pnpm add @fianto/nextjs @fianto/react @fianto/sdk
# Express
pnpm add @fianto/express @fianto/js @fianto/sdk
# Hono
pnpm add @fianto/hono @fianto/js @fianto/sdkSet your credentials
Put the three values in your server's environment. The SDK reads them by these names.
FIANTO_APP_ID=fian_app_...
FIANTO_APP_SECRET=fian_sk_live_...
FIANTO_WEBHOOK_SECRET=whsec_...Keep them on the server. The app secret must never reach a browser. You fill in
FIANTO_WEBHOOK_SECRET in step 6. FIANTO_BASE_URL is optional;
it defaults to https://api.fianto.xyz.
Next.js reads a .env.local file on its own. Express and Hono on Node do not read .env: load it
when you start the server, for example node --env-file=.env server.js (Node.js 20.6 or later),
or with the dotenv package.
Add a checkout route
This route decides the price and creates the session. Never take a price from the request body.
The route only accepts same-origin POST requests, opens checkout in popup mode and answers
{ id, url }. If the order already has an open session with the same terms, it reissues that
session's link.
// app/api/checkout/route.ts
import { Checkout } from '@fianto/nextjs';
// Prices live on your server.
const ITEMS: 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 item = body?.item ? ITEMS[body.item] : undefined;
if (!item) return new Response('Unknown item', { status: 400 });
return {
mode: 'payment',
order_id: `order_${crypto.randomUUID()}`, // use your own order's id
amount: item.amount, // a decimal USDC string
description: item.description, // required with amount
success_url: 'https://shop.example/thank-you',
cancel_url: 'https://shop.example/cart',
};
},
onError: (error) => console.error(error),
});success_url and cancel_url must be https. Make the page at success_url say that the payment
is being confirmed. Reaching it does not prove the payer paid.
The amount is what you receive
You receive the amount you set, here '10.00' USDC. When the platform's service fee is above zero,
it is added on top, so the payer pays the amount plus the fee. The checkout page shows the fee on
its own "Service fee" line.
Add the pay button
The button calls your checkout route and opens checkout in a popup. Serve the page that shows it
from the same origin as your success_url: the popup reports its result only to that origin.
// app/buy-button.tsx
'use client';
import { FiantoButton, fetchCheckoutSession } from '@fianto/react';
import { useState } from 'react';
// Never tell the payer "nothing happened": 'closed' means you do not know.
const MESSAGES: Record<string, string> = {
succeeded: 'Thanks! We are confirming your payment.',
canceled: 'Checkout canceled.',
expired: 'That checkout link expired. Please try again.',
closed: 'We could not tell what happened. Check your order status before paying again.',
};
export function BuyButton() {
const [message, setMessage] = useState<string | null>(null);
return (
<div>
<FiantoButton
session={() => fetchCheckoutSession('/api/checkout', { body: { item: 'beans' } })}
label="buy"
onResult={(result) => setMessage(MESSAGES[result.status] ?? null)}
onError={(error) => console.error(error)}
/>
{message ? <p role="status">{message}</p> : null}
</div>
);
}If your page sends a Cross-Origin-Opener-Policy header, set it to same-origin-allow-popups.
Add the webhook route and fulfil the order
fianto sends order.paid when the payment is finalized. Fulfil the order there, not from the
button's result or the success_url. The route verifies the signature and answers fianto's URL
verification itself.
// app/api/webhooks/fianto/route.ts
import { Webhooks } from '@fianto/nextjs';
export const POST = Webhooks({
secret: process.env.FIANTO_WEBHOOK_SECRET,
onOrderPaid: async (event) => {
// The same event can arrive more than once. 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);
},
});In Express and Hono, add this route to the same app as the checkout route.
Register the webhook URL
- In the dashboard, set your application's webhook URL to the route's public
httpsaddress. - Open "Reveal signing secret" in the same settings, put the
whsec_…value inFIANTO_WEBHOOK_SECRETand restart your server. - fianto sends the URL a signed verification challenge, and your route answers it once it has the right secret. If the first try failed because the secret was missing, click "Verify again".
Verify the URL before you take payments
Events published before your URL is first verified are stored but never delivered. You can still
read them with GET /v1/events. (A URL that was verified and is later suspended holds its events
instead, and gets them once it passes verification again.)
Check it worked
With your server running locally, send it a signed sample order.paid. This makes no API call.
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. Export it,
or paste the whsec_… value in its place.
The CLI prints → 200 order.paid (local sample) and your onOrderPaid logs the order. For
Express and Hono, forward to http://localhost:3000/webhooks/fianto. If your server listens on
another port, use that port.
Troubleshooting
| You see | Why | Fix |
|---|---|---|
Express answers 500 BodyAlreadyParsedError | A body parser such as express.json() ran before the fianto route. | Mount the fianto routes before any global JSON parser, or scope the parser away from them, such as app.use('/app', express.json()). Adding express.raw to the route does not help once the body is read. |
Webhook route answers 400 invalid_webhook | The secret is wrong, something re-encoded the body before the route, or your clock is more than 300 s off. | Use the application's whsec_ secret, remove body-reading middleware or proxy rewrites, and sync your clock. |
The button reports closed while the payer is still paying | Your page sends Cross-Origin-Opener-Policy: same-origin, which cuts the popup off. | Send Cross-Origin-Opener-Policy: same-origin-allow-popups on the page with the button. |
| The popup is blocked | Checkout was opened after an await or a setTimeout, not directly in the click. | Open checkout synchronously in the click handler. The button already does. |
Creating the session fails with url_insecure | success_url or cancel_url is http. | Use https URLs. |
| No result reaches your page | The popup posts its result only to the success_url origin. | Serve the page with the button from the same origin as success_url. |
Checkout route answers 403 forbidden_origin | Behind a proxy the route sees a different origin than the browser. | Pass allowedOrigins, or set Express trust proxy. |
Checkout route answers 500 internal_error | fianto refused for a reason the route does not pass to the browser: wrong credentials, an account not approved, or merchant_token_account_missing. | Read the error your onError logs. |
See also
Was this page helpful? Tell us