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.

4 min read

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.

The steps of a one-time checkout
The steps of a one-time checkout, from your server to Solana and backYour serverfianto APICheckout pagePayer's walletSolanacreate session1send payer to url2payer signs3signed transaction4broadcast5confirmedpayer sees success6finalizedorder PAIDwebhooks7

Scroll sideways to see the whole diagram →

Show as text
  1. 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.
  2. Send the payer to that `url`: fianto's hosted checkout page.
  3. The payer's wallet signs the transaction. The wallet only signs; it does not send.
  4. The checkout page hands the signed transaction to fianto.
  5. fianto broadcasts it to Solana, and rebroadcasts it when needed.
  6. When the transaction is confirmed, fianto tells the checkout page, which shows the payer success. The order is not `PAID` yet.
  7. 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 https address 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/sdk

Set 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

  1. In the dashboard, set your application's webhook URL to the route's public https address.
  2. Open "Reveal signing secret" in the same settings, put the whsec_… value in FIANTO_WEBHOOK_SECRET and restart your server.
  3. 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 seeWhyFix
Express answers 500 BodyAlreadyParsedErrorA 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_webhookThe 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 payingYour 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 blockedCheckout 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_insecuresuccess_url or cancel_url is http.Use https URLs.
No result reaches your pageThe 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_originBehind a proxy the route sees a different origin than the browser.Pass allowedOrigins, or set Express trust proxy.
Checkout route answers 500 internal_errorfianto 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

On this page