On this page
- Fianto has no publishable key: the button calls your own route, which creates the session server-side with Basic auth and answers { id, url }.
- The SDK route helpers always create popup sessions; if the browser blocks the popup, the button falls back to a full-page redirect by default.
- Mark the order paid only on a verified order.paid webhook or a server-side read showing PAID, never on the button result "succeeded".
What do you need before adding a USDC checkout button?
Before the button can take money, you need an approved merchant account and four things in your server environment:
- An application's app id (
fian_) and secret (app_ fian_), from the Developers page of the dashboard. Until your account is approved, the API answers 401.sk_ live_ - Its webhook signing secret (
whsec_), shown in the dashboard once the application has a webhook URL. - A receiving wallet that already holds a USDC token account; otherwise session creation fails with
merchant_.token_ account_ missing - Node.js 20.3 or later for the SDK packages, which are at version 0.3.0 on npm as of 15 October 2026.
There is no publishable or browser key, so the amount is always decided on your server and a visitor cannot edit the price in the page to pay less. Fianto takes USDC on Solana only, and the merchant pays no fee and no network fee. If you would rather not write code at all, compare this with a dashboard-made link in payment link or checkout API.
Should the button open a popup or redirect the page?
Use the popup for a button on your own site, because the customer never leaves your page and your code learns how checkout ended. A checkout session has a ui_ of redirect (the API default) or popup. The SDK checkout routes always create popup sessions.
| Popup | Redirect | |
|---|---|---|
| How it opens | A 480 x 720 window over your page | The whole tab goes to the checkout page |
| After a successful payment | The popup posts its result to your page and closes | The page stays on its receipt, "Payment received", with a "Continue to" button, named after your shop, that opens your success_ |
| What your page learns | succeeded, canceled, expired or closed | Nothing: Fianto adds no query parameters to your URLs |
| What it needs | Your page on the same origin as success_, and a popup opened straight from the click | Nothing extra |
Browsers allow a popup only right after a user action. MDN's entry on transient activation says a popup "must be triggered from a UI element's event handler." The Fianto button opens the window first and only then waits for your route, so it passes. If the popup is blocked anyway, the default fallback: 'redirect' sends the whole page to checkout instead.
How to create the checkout session on your server
The server route receives the click, decides the price from your own data, and asks Fianto for a session. With Express, checkout() from @fianto/express does the API call and answers the button with { id, url }. Install @fianto/express, @fianto/sdk and @fianto/js, and mount the route before any body parser:
import express from 'express';
import { checkout } from '@fianto/express';
const PRICES: Record<string, { amount: string; description: string }> = {
beans: { amount: '10.00', description: 'Coffee beans, 1 kg' },
};
const app = express();
app.post(
'/api/checkout',
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 === undefined) return new Response('Unknown item', { status: 400 });
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',
};
},
onError: (error) => console.error('fianto checkout failed', error),
}),
);
// The webhook route from the last step goes here too, before any body parser.
app.use(express.json()); // parsers for your other routes go afterThe SDK reads FIANTO_ and FIANTO_ from the environment. The amount is a decimal string from 0.01 to 1,000,000 USDC with at most 6 decimals, and it is what you receive. If the same order_ already has an open session with the same terms, the route reissues that session's link and returns it; with different terms it answers 409 order_, so give a changed cart a fresh order id. The Next.js and Hono packages offer the same route as Checkout() and checkout().
How to put the button on the page
Plain HTML: the fianto-button element
For a page without a build step, serve dist/fianto-button. from the installed @fianto/js on your own site and drop in the element. On click it posts its data-* attributes as JSON to your route, so data-item="beans" arrives as { "item": "beans" }.
<script src="/fianto-button.js"></script>
<fianto-button session-endpoint="/api/checkout" data-item="beans" label="buy"></fianto-button>
<p id="status" role="status"></p>
<script>
var button = document.querySelector('fianto-button');
button.addEventListener('fianto:result', function (event) {
document.getElementById('status').textContent = event.detail.status;
});
button.addEventListener('fianto:error', function (event) {
console.error(event.detail.code, event.detail.message);
});
</script>React: FiantoButton
'use client';
import { FiantoButton, fetchCheckoutSession } from '@fianto/react';
export function BuyButton() {
return (
<FiantoButton
session={() => fetchCheckoutSession('/api/checkout', { body: { item: 'beans' } })}
label="pay"
onResult={(result) => console.log(result.status)}
onError={(error) => console.error(error)}
/>
);
}Both take a label (plain, pay, buy, checkout, subscribe or donate), a theme and a shape. If you need your own design, useCheckout from @fianto/react gives you open, status and result for any element. Every option is in the browser SDK docs.
What does each button result mean?
Each button result describes what the browser saw, not what your wallet received:
| Result | Meaning | What to show |
|---|---|---|
succeeded | Solana confirmed the customer's transaction on the checkout page | "Thanks, we are confirming your payment" |
canceled | The customer cancelled on the checkout page | A way back to the cart |
expired | The session ran out of time, 30 minutes by default | Ask them to check the order before paying again |
closed | Unknown: no result reached your page, and a payment may still be confirming | Ask them to check the order before paying again |
Even succeeded is reported at the confirmed stage, before Fianto treats the payment as final. Your order should change only on the server, in the next step. Why the two moments differ is covered in how a merchant knows a crypto payment arrived.
The button, the hosted page and the receipt are built for you. You write the two routes.
See the developer toolsHow to mark the order paid from the webhook
Mark the order paid when a verified order. webhook arrives, which Fianto sends once the payment is finalized on Solana. verifyWebhook checks the Standard Webhooks signature over the raw body; your route also answers the one-off endpoint. challenge that proves you own the URL:
import { isWebhookVerificationError, verifyWebhook } from '@fianto/sdk/webhooks';
// Before any global express.json(): express.raw keeps req.body as the untouched bytes.
app.post('/webhooks/fianto', express.raw({ type: '*/*' }), async (req, res, next) => {
let event;
try {
event = await verifyWebhook(req.body, req.headers, { secret: process.env.FIANTO_WEBHOOK_SECRET });
} catch (error) {
if (isWebhookVerificationError(error)) {
res.status(400).json({ error: 'invalid_webhook' });
return;
}
next(error);
return;
}
switch (event.type) {
case 'endpoint.verification':
res.status(200).json({ challenge: event.data.challenge });
return;
case 'order.paid':
// Your own code: mark event.data.order_id paid, once per event.id.
await markOrderPaid(event.data.order_id, event.id);
break;
default:
break; // answer 2xx for types you do not handle
}
res.status(200).json({ received: true });
});Delivery is at least once and in no fixed order, so record event. with the fulfilment and skip ids you have seen. Any 2xx within 10 seconds counts as delivered; otherwise Fianto tries up to 8 times over about 28 hours. If you would rather check on demand, fianto. from @fianto/sdk reads the order by your own id; fulfil only when its status is PAID. Granting and revoking access from these events is laid out in webhooks for access.
How to test a USDC button without a test mode
Fianto has no test mode or sandbox, so you test the button in two halves. The full routine is in testing a USDC integration when there is no test mode. The webhook half runs locally with signed samples from the CLI, which makes no API call:
npx @fianto/cli trigger order.paid \ --forward-to http://localhost:3000/webhooks/fianto \ --secret "$FIANTO_WEBHOOK_SECRET"
The checkout half needs a real session on Solana mainnet. Create one for 0.01 USDC, the lowest price allowed, and pay it from a second wallet of your own. The test costs that wallet the Solana network fee, under $0.01, plus the price, which lands in your receiving wallet; there is no Fianto fee during the beta. After the beta the customer pays about 0.1 to 0.5% on top, and you still pay nothing. If you also want to sell without code, a USDC payment link gives you the same checkout from the dashboard.