Next.js
Sell from a server action.
The default way to take money in a Next.js app is a payments API: compute a total, create a session, redirect. The payment part works. The store part, what was sold, tax, refunds, history, becomes app code you write, migrate, and keep in sync. Flint puts that layer behind the same two calls: one server action creates a real order and hands the buyer to checkout, and your repo never grows a commerce engine.
"use server";
import { Flint } from "@flintpay/node";
import { redirect } from "next/navigation";
const flint = new Flint({ apiKey: process.env.FLINT_API_KEY! });
export async function buy() {
const order = await flint.orders.create({
lineItems: [
{ name: "Desk mat", quantity: 1,
unitPriceMoney: { amount: 3900, currency: "USD" } },
],
});
const session = await flint.checkoutSessions.create({
orderId: order.orderId,
redirects: {
successRedirectUrl: "https://example.com/thanks",
cancelRedirectUrl: "https://example.com/shop",
},
});
redirect(session.url);
}The whole purchase path in one server action: order in, checkout URL out, buyer redirected. The key stays in the environment and the math stays on Flint.
two calls to a paid order · zero money tables in your schema · 596 operations when you need more
01The default
The charge works. Then your repo grows a store.
A payments API is a fine way to take one payment. It is also how a Next.js project ends up owning an order schema, webhook glue, cart math, and refund proration, because the charge recorded an amount and the store needed everything else.
app/
api/checkout/route.ts # totals computed here, then a session
api/stripe-webhook/ # checkout.session.completed ->
route.ts # write YOUR order row, copy line items
account/orders/page.tsx # reads your orders table
lib/
cart.ts # subtotal, discount, tax math
refunds.ts # partial-refund proration math
prisma/
schema.prisma # Order, OrderItem, Refund models:
# migrated, indexed, synced, yoursNone of this is the payment. It is the store, rebuilt as app code: your database becomes a second source of truth about money, and every future feature lands on tables you now migrate and sync.
app/
api/checkout/route.ts # two calls: order, then session
api/flint/route.ts # fulfill on order.paid
# no money tables. orders, line items, totals, refunds,
# and history are queryable records on the API:
# GET /v1/orders
# GET /v1/orders/{order_id}
# POST /v1/refundsThe store lives behind the API. Orders, line items, computed totals, refunds, and history are queryable records, so the order page and the refund button read Flint instead of your tables.
This matters double when an agent writes the code. Prompted for checkout, it will happily scaffold the left column, a schema, the glue, the math, and every bug in it is yours to find. The right column gives it almost nothing to invent.
02The boundary
Money stays on the server side of the line.
Anything in a client component ships to every visitor: the math, the key, all of it. Generated code crosses that line constantly because nothing pushes back. Flint pushes back by shape.
"use client";
// what a prompt generates when nothing pushes back
const KEY = process.env.NEXT_PUBLIC_PAY_KEY; // in every browser now
export function Checkout({ items }) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const total = subtotal * 1.08; // tax, hopefully
async function handlePay() {
await fetch("https://api.example-payments.com/charges", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}` },
body: JSON.stringify({ amount: Math.round(total * 100) }),
});
}
return <button onClick={handlePay}>Pay ${total.toFixed(2)}</button>;
}A key in a NEXT_PUBLIC_ variable is in every browser that loads the page, and a total computed in a component is a total anyone can edit before it is charged.
Every Flint key is a server credential. There is no publishable key to leak, so there is nothing correct for a client component to do with one.
server-only keysKeep the key in an env var without the NEXT_PUBLIC_ prefix and Next keeps it out of every client bundle.
.env.localThe buyer's browser sees one thing: the hosted checkout URL. Card fields render on Flint's page, never in your components.
POST /v1/checkout-sessionsTotals, tax, and balances compute server-side on the order, so no arithmetic ships to the client to drift.
pricing_amounts · settlement_amounts# .env.local
# no NEXT_PUBLIC_ prefix, so Next keeps it out of every client bundle
FLINT_API_KEY=your-sandbox-key03Two doors
Server action or route handler. Same two calls.
A form starts a purchase through a server action. A client component starts one through a route handler. Either way the server creates the order, opens a checkout session, and passes back a URL.
"use server";
import { Flint } from "@flintpay/node";
import { redirect } from "next/navigation";
const flint = new Flint({ apiKey: process.env.FLINT_API_KEY! });
export async function buy() {
const order = await flint.orders.create({
lineItems: [
{ name: "Desk mat", quantity: 1,
unitPriceMoney: { amount: 3900, currency: "USD" } },
],
});
const session = await flint.checkoutSessions.create({
orderId: order.orderId,
redirects: {
successRedirectUrl: "https://example.com/thanks",
cancelRedirectUrl: "https://example.com/shop",
},
});
redirect(session.url);
}Wire it to a form or a button in a server-rendered page. redirect() sends the buyer straight to checkout.
The order is created server-side from line items you control, not from a price the browser sent.
POST /v1/ordersThe checkout session wraps that order, so the buyer pays exactly what the record says.
POST /v1/checkout-sessionsRetries are safe: send an idempotency key and the same purchase cannot create two orders.
Idempotency-KeyNothing here is host-specific: plain route handlers, server actions, and one env var run the same on Vercel, a container, or a laptop. When the store outgrows two calls, catalog, promotions, subscriptions, and inventory attach to the same order record. The commerce API page walks the engine.
04Webhooks
The webhook is just a route.
A success redirect is a hint; the webhook is the truth. In the App Router this is easier than the folklore suggests, because route handlers hand you the raw bytes signatures need. And it fulfills; it does not reconstruct the order, because the order already exists.
export async function POST(request: Request) {
// route handlers do not pre-parse, so these are the exact
// bytes the signature signs. No raw-body middleware dance.
const rawBody = await request.text();
// verify before trusting: a Standard Webhooks library checks
// the webhook-id, webhook-timestamp and webhook-signature
// headers against your endpoint secret
verify(rawBody, request.headers);
const event = JSON.parse(rawBody);
if (event.event_type === "order.paid") {
// fulfill here: the payload carries the order id and totals
}
// acknowledge fast; do slow work after responding
return new Response(null, { status: 204 });
}No body-parser configuration and no raw-body middleware: request.text() is already the exact payload the signature signs. The webhooks guide has the complete verifier to paste.
Deliveries carry Standard Webhooks headers, so any library that speaks the spec verifies them.
webhook-signatureFailed deliveries retry with backoff. The event id is identical on every retry, which makes deduplication one lookup.
webhook-idMoney news arrives at order altitude, so your handler branches on outcomes, not processor internals.
order.paid · order.refunded · subscription.past_dueLocal dev, real signatures
The CLI streams sandbox events to your dev server with a real signature. The verification code you write is the one production runs.
On the wire
- webhook-id
- webhook-timestamp
- webhook-signature
05The dead end
Month two is where charge pipes get expensive.
A bare charge works in the demo. Then the store wants what stores want, and each ask lands on whatever recorded the sale. If that record is an amount plus your own tables, you build the rest.
Refund one item from a two-item sale. A charge knows 4428; the order knows the tote, the pins, and each one's tax, so item-level refunds are a call, not a spreadsheet.
POST /v1/refundsShow a customer their order history. Charge-first, that is the orders table you now maintain; order-first, the records already exist and the dashboard reads them.
GET /v1/ordersAdd a discount without breaking tax. Promotions and tax compute on the same record in the right sequence, instead of in two client-side patches that disagree.
pricing_amounts.discount_moneyAdd subscriptions without a second system. Every renewal is an order, so the code path you built for one sale handles the recurring ones.
POST /v1/subscriptionsKeep Prisma for what is yours. Content, accounts, and entitlements stay in your schema; money state never enters it, so there is no drift between your tables and the processor's truth to reconcile.
zero money tablesThis is the argument in full on the sell online with AI page: month two on an order record is more calls to the same object. Month two on a charge pipe is a rebuild an agent has to invent from memory.
06Start
Scaffold, paste, sell.
Add checkout to my Next.js app with Flint. Create
the order and the checkout session in a server
action with the sandbox key in FLINT_API_KEY, send
the buyer to the session URL, and add a webhook
route that marks the sale fulfilled on order.paid.Or write it yourself: the hero's server action plus the webhook route above is the whole integration.
Install the SDK
Then read these
FAQ
Questions worth asking first.
Why use this instead of Stripe Checkout in my Next.js app?
If all you will ever need is one charge, a payments API does that well, and Stripe processes Flint's card payments anyway. The difference is everything attached to the charge. Charge-first, your app owns the order model: a Prisma schema for orders and line items, webhook glue mapping sessions to rows, tax and discount math, refund proration, and a second source of truth to keep in sync with the processor. On Flint the order is the API's object: totals and tax compute server-side, refunds take line items, history is queryable, and your schema keeps zero money tables. Same processor underneath; the difference is which side of the API the store lives on.
Should I use a server action or a route handler for checkout?
Both work and both stay server-side. Reach for a server action when a form or button in a server-rendered page starts the purchase: it can create the order and redirect to checkout in one function. Reach for a route handler when a client component needs to start it, or when something outside your app posts to you. Webhooks are always a route handler, because the sender is Flint, not your UI.
Can I call Flint from a client component?
No, and that is the design. There is no browser-safe Flint key: every key is a server credential. A client component collects intent and hands it to a server action or route handler; the buyer's browser only ever sees the hosted checkout URL that comes back. Anything a page bundles ships to every visitor, so the boundary is what keeps the key and the math trustworthy.
How do I test Flint webhooks in Next.js locally?
Run your dev server, then flint listen with the forward flag pointed at your webhook route. The CLI streams your sandbox's events and posts them to localhost with a real signature, so the same verification code runs in development and production. No tunnel, no registered endpoint, no fake payloads.
Does this work on Vercel and other serverless hosts?
Yes. The integration is plain route handlers, server actions, and one environment variable, which is exactly what serverless Next.js hosts run. Webhook deliveries retry with backoff, so a cold start is survivable; respond fast and do slow work after acknowledging, and use the event id as your deduplication key since retries reuse it.
Can Claude Code or Cursor write this integration?
That is the expected path. Connect the Flint docs MCP server or paste SKILL.md into context and the agent works from live endpoint schemas rather than memory. The sell-online-with-AI page covers the agent workflow end to end, including the guardrails that apply when an agent holds a key.
Do I need a database to track orders?
Not for the commerce itself. The order record lives on Flint with totals, payment state, refunds, and history, and the dashboard reads it without any code. Your database holds what is yours, content, accounts, entitlements, and a webhook route on order.paid is the join point where a sale updates it.