On this page
- Mount checkout() and webhooks() before express.json(): once a parser has read the body, the SDK throws BodyAlreadyParsedError and the route answers 500.
- Send mode: subscription with a recurring price_id, and give each completed sign-up its own order_id, or Fianto answers 409 order_id_in_use.
- Grant on subscription.created, keep the row current on the other six subscription events, and revoke on subscription.ended.
Why does route order break Express webhooks?
Express webhooks break when express.json() runs first, because the parser consumes the request stream and the exact bytes Fianto signed are gone. The Express 5 reference says express.json() "parses incoming requests with JSON payloads" and puts "a new body object containing the parsed data" on req. (Express API reference, read 15 October 2026). A signature is computed over bytes, and a parsed object cannot give them back.
@fianto/express checks for this before it does anything. When the stream was already read, its middleware passes a BodyAlreadyParsedError to next(error), and Express's default error handler answers 500. Adding express. to the route afterwards does not help: the global parser has already drained the stream. The fix is ordering, and it applies to both Fianto routes, because the checkout route also reads the raw request.
What does a USDC subscription need in Express?
A USDC subscription in Express needs one recurring price in the Fianto dashboard and four pieces of your own code. A recurring price is a product price that charges every 30 days (Monthly) or every 365 days (Yearly); there are no other intervals. You create it in the dashboard, because the v1 API cannot create prices, and copy its fian_ id into your environment.
| Piece | Express code | Job |
|---|---|---|
POST /api/checkout | checkout({ createSession }) | Decides price and order_id for the signed-in user, answers { id, url } to the pay button |
POST /webhooks/fianto | webhooks({ onEvent }) | Verifies the signature, answers the URL check, stores each subscription change |
| Gate middleware | Your own | Lets a request through while the stored status allows it |
POST /account/cancel | fianto. | Schedules the end at the close of the paid period |
Install @fianto/express, @fianto/sdk and, for the button, @fianto/js. All three are at 0.3.0 and need Node 20.3 or later; the Express peer range is 4 and up. Your merchant account has to be approved before the API keys work. The browser half, the <fianto-button> element, is the same on every server and is covered in adding a Pay with USDC button.
How do you create a subscription checkout session in Express?
You create a subscription checkout session by returning mode: 'subscription' and a recurring price_ from createSession. Leave out amount and description: the price sets both, and sending them is refused with 400 amount_. The second argument is Express's own { req, res }, so whatever your auth middleware put on res. is there. Read from res, never send through it.
import express from 'express';
import { checkout } from '@fianto/express';
import { billing } from './billing.js'; // your own data layer
const app = express();
app.use(session()); // your own sign-in; it must not read the body
app.post(
'/api/checkout',
checkout({
createSession: async (_request, { res }) => {
const user = res.locals.user as { id: string } | undefined;
if (user === undefined) 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',
// Same id on every click until a subscription is created from it, then a fresh one.
order_id: await billing.signupOrderId(user.id),
price_id: priceId,
customer_reference: user.id,
success_url: 'https://app.example/billing/confirming',
cancel_url: 'https://app.example/pricing',
};
},
onError: (error) => console.error('fianto checkout failed', error),
}),
);The order_ rule is the one that bites later. While a session for that id is still open, the handler reissues its link, so a double click opens the same checkout instead of a second one. Once a subscription has been created from it, the id is used up: a later sign-up by the same user, after a cancel say, needs a new order_, or Fianto answers 409 order_. Store a counter per user and bump it when subscription. arrives. Why the order id and the idempotency key are two different guards is in idempotency keys and order ids.
The first session for a price also creates that price's plan on Solana, which Fianto signs and pays for. Until it is on chain, the checkout page shows the plan as preparing and no pay button. Fianto creates at most 50 new plans per merchant in 24 hours, so a script that opens sessions for dozens of fresh prices will meet 429 plan_.
The full option list for checkout() and webhooks(), including the 1 MiB body cap.
Open the developer overviewWhich subscription events should an Express webhook store?
An Express webhook for subscriptions should store all seven subscription. events, because together they are the only record of renewals, failures and cancels. Fianto sends them at least once and in no fixed order, retrying for about 28 hours, so the safe pattern is: on any subscription event, read the subscription back from the API and save that, recording event. in the same database transaction. A late, older event then cannot overwrite a newer status. onEvent runs for every verified event, so one switch covers all seven.
import { webhooks } from '@fianto/express';
import { Fianto } from '@fianto/sdk';
const fianto = new Fianto(); // reads FIANTO_APP_ID and FIANTO_APP_SECRET
// Above the checkout route and above any body parser.
app.post(
'/webhooks/fianto',
webhooks({
secret: process.env.FIANTO_WEBHOOK_SECRET,
onEvent: async (event) => {
switch (event.type) {
case 'subscription.created':
case 'subscription.renewed':
case 'subscription.payment_failed':
case 'subscription.past_due':
case 'subscription.cancel_scheduled':
case 'subscription.cancel_withdrawn':
case 'subscription.ended': {
const current = await fianto.subscriptions.retrieve(event.data.id);
// One transaction: insert event.id (skip if seen), upsert the row by current.id.
await billing.saveSubscription(event.id, current);
return;
}
default:
return; // answer 2xx to types you do not handle
}
},
onVerificationError: (error) => console.warn('fianto webhook rejected:', error.reason),
}),
);
app.use(express.json()); // parsers for your other routes go afterSave at least id, status, current_, cancel_ and customer. (the user id you passed as customer_). Amounts arrive as base-unit strings: "10000000" is 10 USDC. Convert them with usdc. from @fianto/sdk, never with floats. How the signature check works, and how to do it without the SDK, is in verifying Standard Webhooks signatures.
| Event | Status after it | What your app does |
|---|---|---|
subscription. | ACTIVE, first period paid | Grant access, bump the sign-up counter |
subscription. | ACTIVE | Extend to the new current_period_end |
subscription. | PAST_DUE, or ENDED when no attempt is left | Log failure.reason and the next attempt |
subscription. | PAST_DUE | Tell the user to top up USDC; Fianto sends them nothing |
subscription. | ACTIVE, ends at period end | Show the end date |
subscription. | ACTIVE | Hide the end date |
subscription. | ENDED, final | Revoke access |
How do you gate Express routes on a USDC subscription?
Gate Express routes on the status your webhook stored, never on the browser reaching success_. The pay button can report success before the subscribe transaction is finalized, and subscription. is sent only once it is, so the page that handles the result should say the payment is being confirmed and poll your own API.
import type { NextFunction, Request, Response } from 'express';
export async function requireSubscription(_req: Request, res: Response, next: NextFunction) {
const user = res.locals.user as { id: string } | undefined;
const sub = user === undefined ? undefined : await billing.subscriptionFor(user.id);
if (sub?.status === 'ACTIVE' || sub?.status === 'PAST_DUE') return next();
res.status(402).json({ error: 'subscription_required' });
}
app.use('/app', requireSubscription);Letting PAST_DUE through is a choice, and here is the reason for it. A renewal is tried at the period boundary plus two minutes, then about one hour, six hours and a day after the boundary: four attempts in all. After the fourth counted failure the subscription ends and subscription. arrives. So past due lasts at most about a day, and locking out a customer who is topping up their wallet in that window costs more goodwill than one day of access is worth. If your product hands out something that cannot be taken back, such as API credits, gate on ACTIVE alone. More on what happens in that window is in failed renewals and retries.
How does an Express app cancel a USDC subscription?
An Express app cancels with fianto., which stops Fianto charging at the end of the paid period and sends subscription.. at: 'now' ends it at once and sends subscription.. Either way the cancel is final: it cannot be undone, and it changes nothing on Solana. This is an ordinary route, so it can sit below express..
app.post('/account/cancel', async (_req, res) => {
const user = res.locals.user as { id: string } | undefined;
const sub = user === undefined ? undefined : await billing.subscriptionFor(user.id);
if (sub === undefined) {
res.status(404).end();
return;
}
await fianto.subscriptions.cancel(sub.id, { at: 'period_end' });
res.status(204).end(); // the stored row changes when the webhook arrives
});Your customers can also cancel themselves, in the customer portal at fianto.xyz/my, with one wallet signature; that runs to the end of the paid period too, and your route hears about it through the same events. Removing the USDC approval inside a wallet is not a cancel: the next renewal fails, the subscription goes past due and ends after the fourth failure. There is no refund API. A refund is USDC you send back from your own wallet.
What does each subscription charge cost in an Express integration?
Each subscription charge costs you, the merchant, nothing: the price you set is what lands in your wallet, and you never pay a Solana network fee on a renewal. The customer pays on top. Take a 9.99 USDC monthly plan created today. Its plan carries a flat network fee of 0.01 USDC per charge today, and there is no Fianto fee during the beta, so the event shows amount 9990000, fee_ 0, network_ 10000 and total_ 10000000: the customer pays exactly 10.00 USDC per period. After the beta, a Fianto fee of about 0.1 to 0.5% is added on top, still paid by the customer.
At subscribe time the customer also pays a Solana transaction fee under $0.01 and account rent in SOL. Measured on mainnet on 15 October 2026, the subscription account needed about 0.00144 SOL, returned when the customer reclaims it after the subscription ends, and a wallet's first subscription needed about 0.00119 SOL more for a setup account that is not returned. Solana is lowering rent in phases (SIMD-0437), so the checkout reads the live amount and shows it before the customer signs. How the whole thing renews without a card is explained in stablecoin subscriptions on Solana.
Which errors does an Express subscription integration hit first?
These are the failures an Express subscription integration usually hits first, with the fix for each.
| Symptom | Cause | Fix |
|---|---|---|
| 500 with BodyAlreadyParsedError | A body parser ran before the Fianto route | Move both Fianto routes above every parser |
| 413 payload_too_large | Body over 1 MiB, or over the webhook route's maxBodyBytes | Fianto's own requests are far smaller: check what is calling |
| 403 forbidden_origin behind a proxy | Express sees http:// or an internal Host header | app., or pass allowedOrigins |
| 409 order_id_in_use | The order_id already started a subscription | A new order_id per completed sign-up |
| 422 price_not_recurring | A one-time price_id in subscription mode | Use the recurring price |
| 400 invalid_webhook | Wrong secret, a re-encoding proxy, or a clock more than 300 s off | Read what onVerificationError logs |
You can replay every one of the seven events against your local route before any real payment, with npx @fianto/cli trigger subscription. and the secret exported in your shell. The full method, including one real 0.01 USDC payment, is in testing a USDC integration without a test mode. The Express reference with every option is on docs.fianto.xyz.