No Fianto fee during the beta. Any fee comes with at least 30 days notice. See pricing
Developers10 min read

USDC subscriptions in an Express app, from checkout route to access gate.

To sell a USDC subscription from Express, register two routes from @fianto/express before express.json(): checkout() creates a subscription session for a recurring price, and webhooks() verifies the subscription.* events you store. Your own middleware then lets a user in while their stored status is ACTIVE or PAST_DUE. A subscription never creates an order, so order.paid never fires for it.

On this page
Why does route order break Express webhooks?What does a USDC subscription need in Express?How do you create a subscription checkout session in Express?Which subscription events should an Express webhook store?How do you gate Express routes on a USDC subscription?How does an Express app cancel a USDC subscription?What does each subscription charge cost in an Express integration?Which errors does an Express subscription integration hit first?SourcesQuestions
TL;DR
  • 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.body (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.raw({ type: '*/*' }) 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_price_... id into your environment.

PieceExpress codeJob
POST /api/checkoutcheckout({ createSession })Decides price and order_id for the signed-in user, answers { id, url } to the pay button
POST /webhooks/fiantowebhooks({ onEvent })Verifies the signature, answers the URL check, stores each subscription change
Gate middlewareYour ownLets a request through while the stored status allows it
POST /account/cancelfianto.subscriptions.cancel()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_id from createSession. Leave out amount and description: the price sets both, and sending them is refused with 400 amount_not_allowed. The second argument is Express's own { req, res }, so whatever your auth middleware put on res.locals is there. Read from res, never send through it.

src/server.ts
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_id 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_id, or Fianto answers 409 order_id_in_use. Store a counter per user and bump it when subscription.created 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_limit_reached.

The full option list for checkout() and webhooks(), including the 1 MiB body cap.

Open the developer overview

Which 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.id 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.

src/server.ts
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 after

Save at least id, status, current_period_end, cancel_at_period_end and customer.reference (the user id you passed as customer_reference). Amounts arrive as base-unit strings: "10000000" is 10 USDC. Convert them with usdc.fromBaseUnits 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.

EventStatus after itWhat your app does
subscription.createdACTIVE, first period paidGrant access, bump the sign-up counter
subscription.renewedACTIVEExtend to the new current_period_end
subscription.payment_failedPAST_DUE, or ENDED when no attempt is leftLog failure.reason and the next attempt
subscription.past_duePAST_DUETell the user to top up USDC; Fianto sends them nothing
subscription.cancel_scheduledACTIVE, ends at period endShow the end date
subscription.cancel_withdrawnACTIVEHide the end date
subscription.endedENDED, finalRevoke 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_url. The pay button can report success before the subscribe transaction is finalized, and subscription.created 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.

src/gate.ts
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.ended 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.subscriptions.cancel(id, { at: 'period_end' }), which stops Fianto charging at the end of the paid period and sends subscription.cancel_scheduled. at: 'now' ends it at once and sends subscription.ended. 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.json().

src/server.ts
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_amount 0, network_fee_amount 10000 and total_amount 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.

SymptomCauseFix
500 with BodyAlreadyParsedErrorA body parser ran before the Fianto routeMove both Fianto routes above every parser
413 payload_too_largeBody over 1 MiB, or over the webhook route's maxBodyBytesFianto's own requests are far smaller: check what is calling
403 forbidden_origin behind a proxyExpress sees http:// or an internal Host headerapp.set('trust proxy', 1), or pass allowedOrigins
409 order_id_in_useThe order_id already started a subscriptionA new order_id per completed sign-up
422 price_not_recurringA one-time price_id in subscription modeUse the recurring price
400 invalid_webhookWrong secret, a re-encoding proxy, or a clock more than 300 s offRead 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.created --forward-to http://localhost:3000/webhooks/fianto 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.

Sources
Questions

Questions about USDC subscriptions in Express.

Can I use express.raw() on the Fianto webhook route instead of moving it?

Only if no global parser runs before it. Once app.use(express.json()) has read the body, express.raw() on the route cannot bring the bytes back, and the middleware answers with BodyAlreadyParsedError.

Does order.paid fire when a customer subscribes?

No. A subscription creates no order. Grant access on subscription.created and keep it in step with the other subscription events.

Does @fianto/express work with Express 4?

Yes. The peer dependency is express 4 or later, and the package is tested on both Express 4 and Express 5.

Can I create the recurring price from my Express server?

No. Products and prices are read-only in the v1 API. Create the price in the Fianto dashboard and pass its fian_price_ id.

Which intervals can a USDC subscription use?

Monthly, every 30 days, or yearly, every 365 days. There are no weekly, quarterly, custom or usage-based intervals and no free trials.

Read next.

Developers8 min read

Add USDC checkout with an AI coding agent: the Fianto agent skill

Run npx skills add fianto-xyz/skills in your project, then ask your coding agent to add Fianto checkout: the skill gives it the real API, the official @fianto/* packages and the rules that keep money safe. It cannot create your merchant account, your API keys or your webhook secret, and it is told to ask you before anything irreversible, such as cancelling a real subscription or moving USDC.

Developers8 min read

Accept USDC payments with Hono on Node, Cloudflare Workers or Bun

Hono takes USDC payments through @fianto/hono's two handlers: checkout() creates a one-off checkout session for your pay button, and webhooks() verifies the order.paid event you fulfil from. Both pass Hono's untouched c.req.raw to the SDK and use only fetch, Request, Response and Web Crypto. On Node the code reads process.env; on Cloudflare Workers you build the handlers inside the route from c.env, and Fianto's CI does not yet test Workers or Bun.

Developers9 min read

Verify Standard Webhooks signatures for payment events

Verifying a webhook signature means recomputing an HMAC-SHA256 over the exact bytes you received and comparing it, in constant time, with the one in the request. Under the Standard Webhooks format that Fianto uses, the signed content is webhook-id.webhook-timestamp.body, the key is the base64 part of your whsec_ secret, and the result arrives as v1,<base64> in the webhook-signature header. Check the timestamp too, then dedupe on the id.

Two routes above the parser.

Then a status column, and a gate that reads it.

New posts are announced on X and Discord.