DevelopersTools
Express
Mount Fianto's checkout and webhook middleware in Express before any body parser, with the 1 MiB body cap and proxy settings.
@fianto/express turns the SDK's two route handlers into Express middleware: checkout() creates a
checkout session for the pay button, and webhooks() verifies Fianto's webhook requests. Both need
the request body exactly as it arrived, which decides where you mount them.
Before you start
- An Express app. The peer dependency is
express4 or later. - Your application's app id and secret, and its webhook signing secret (
whsec_…) once it has a webhook URL. See Authentication. - Node.js 20.3 or later.
Steps
Install the packages
@fianto/js is for the pay button on your pages.
pnpm add @fianto/express @fianto/sdk @fianto/jsLoad your credentials
The SDK reads FIANTO_APP_ID, FIANTO_APP_SECRET and FIANTO_WEBHOOK_SECRET from the process
environment. Node does not read .env by itself: start the server with
node --env-file=.env server.js (Node.js 20.6 or later), or load the file with the dotenv package.
Mount the Fianto routes before any body parser
Register both routes before express.json(), express.urlencoded() or any other middleware that
reads the body. createSession gets the fetch Request and Express's own { req, res }, so
req.user and anything else earlier middleware set is there. Read from res, never send through it:
the middleware sends its own response.
// server.ts
import express from 'express';
import { checkout, webhooks } 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, { req }) => {
console.log('checkout from', req.ip);
const body = (await request.json().catch(() => null)) as { item?: string } | null;
const price = body?.item ? PRICES[body.item] : undefined;
if (!price) 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),
}),
);
app.post(
'/webhooks/fianto',
webhooks({
secret: process.env.FIANTO_WEBHOOK_SECRET,
onOrderPaid: async (event) => {
// Record event.id in the same database transaction as the fulfilment.
console.log('paid', event.data.order_id, event.id);
},
onVerificationError: (error) => console.warn('fianto webhook rejected:', error.reason),
}),
);
app.use(express.json()); // parsers for your other routes go after
app.listen(3000);express.raw does not rescue a route behind a global parser
If app.use(express.json()) runs before the Fianto route, the body is already read, and adding
express.raw(...) to the route does not bring it back. Move the Fianto routes first, or scope the
parser to paths that are not Fianto's, such as app.use('/app', express.json()).
When the body was already read, the middleware passes a BodyAlreadyParsedError to next(error),
so your error handler (Express's default answers 500) receives it.
The payer pays the amount plus the service fee
amount is what you receive. When the service fee is above zero, Fianto adds it on top and the
payer pays it.
Know the body cap
checkout() reads at most 1 MiB of body. webhooks() reads at most its maxBodyBytes, 1 MiB by
default. A larger body is answered 413 {"error":"payload_too_large"} by the middleware itself,
before the webhook handler runs, so onVerificationError is not called for it.
Set up for a proxy
When the browser does not send Sec-Fetch-Site: same-origin, the checkout route compares its
Origin with the request URL, which the middleware builds from req.protocol and the Host header.
Behind a proxy that ends TLS, Express sees http://, and the Host header may be an internal name,
so the route answers 403 forbidden_origin. Setting Express's trust proxy fixes the protocol;
allowedOrigins with your public origins fixes both:
import express from 'express';
import { checkout } from '@fianto/express';
const app = express();
app.set('trust proxy', 1); // match your own proxy setup
app.post(
'/api/checkout',
checkout({
allowedOrigins: ['https://shop.example'],
createSession: async () => ({
mode: 'payment',
order_id: `order_${crypto.randomUUID()}`,
amount: '10.00',
description: 'Coffee beans, 1 kg',
success_url: 'https://shop.example/thank-you',
cancel_url: 'https://shop.example/cart',
}),
}),
);The proxy must pass the webhook body through unchanged: a proxy that decompresses or re-encodes it breaks the signature.
Serve the pay button
Your pages load <fianto-button> from the installed @fianto/js. See
Browser for the script and the events.
Check it worked
With the server running on port 3000, send the webhook route a signed sample. It makes no API call.
npx @fianto/cli trigger order.paid \
--forward-to http://localhost:3000/webhooks/fianto \
--secret "$FIANTO_WEBHOOK_SECRET"$FIANTO_WEBHOOK_SECRET must be exported in this shell; a .env file is not enough. The CLI prints
→ 200 order.paid (local sample) and your onOrderPaid logs the sample order.
Troubleshooting
| You see | Why | Fix |
|---|---|---|
500 with BodyAlreadyParsedError | A body parser read the body before the Fianto route. | Mount the Fianto routes before any global parser, or scope the parser away from them. express.raw on the route does not help once the body is read. |
413 {"error":"payload_too_large"} | The body is over 1 MiB, or over the webhook route's maxBodyBytes. | Fianto's own requests are far smaller; check what is calling the route. |
Checkout route answers 403 forbidden_origin behind a proxy | Express sees http:// or an internal host, so the request URL's origin does not match the browser's. | Set trust proxy, or pass allowedOrigins: ['https://your.shop']. |
Checkout route answers 500 internal_error | Fianto refused for a reason the route does not pass to the browser, such as wrong credentials. | Read the original error in onError. |
Webhook route answers 400 invalid_webhook though mounted first | The secret is wrong, a proxy re-encoded the body, or your clock is more than 300 s off. | Check what onVerificationError logs. |
A GET to the route answers 404 | Routes registered with app.post only see POST. | Nothing to fix: Fianto and the button always send POST. |
See also
Was this page helpful? Tell us