payments
npx skills add https://github.com/crystallizeapi/ai --skill paymentsCrystallize Payments
Crystallize is agnostic about payments: it holds the cart, the order and a record of every payment, and the money moves at a payment gateway of your choice. This skill is the join between them — how to get from a cart to a paid order through any provider without ever charging one amount and delivering another, and how to keep the order right when the payment later changes (captured, refunded, cancelled). Each provider has its own reference; this page is what they all share.
The cart and order calls themselves are documented in the mutation skill —
hydrate and place on /cart,
createFromCart, addPayments, setPayments on
/order. This skill says when to call them and what to put in them.
The common flow
The flow is the same with every gateway:
- Towards the end of checkout, the shopper has a cart and wants to pay.
- The checkout page loads the gateway’s form, or links to a page hosted by the gateway.
- The shopper enters their payment details.
- The gateway hands the shopper back to your site (often a redirect) and the page updates.
- Invisibly, asynchronously, server-to-server: the gateway calls your service to report the payment status. This is the step that matters.
Never validate a payment from the client. The browser can close, lie, replay a URL or arrive twice; only a server-to-server notification you have verified (most gateways sign them) or a status you fetched from the gateway yourself proves a payment. On Crystallize that becomes:
Storefront hydrate → set customer, shipping, selections everything the order needs, on the cartShop /cart place cart frozen; place's total = what you chargeServer create provider session / intent amount from place, cart id as the referenceBrowser pay provider's hosted page or embedded componentProvider ───► your webhook verify → customer → createFromCart onceBrowser return page read-only: wait until the cart is `ordered` … later …Crystallize order enters the "Shipped" stage ───► your hook provider capture → setPaymentsWhen to save the order. You can create the order before payment and add the payment to it later,
or create it once the payment is confirmed. This skill does the second: the placed cart is your saved
checkout, and createFromCart turns it into an order (with the same id) only when the money is there.
Creating orders up front leaves an unpaid order behind for every abandoned payment.
Lock the cart before you charge
The attack this prevents. A shopper opens checkout in two tabs. In tab A they start paying for a cart worth 100. In tab B they add a sofa: the cart is now worth 2 100. Tab A completes the payment of 100, the webhook arrives with the cart id, the server turns “the cart” into an order — and ships 2 100 worth of goods for 100. Any flow that charges an amount computed from a cart that can still change has this hole, with or without malice (back button, a stale tab, a slow network).
Crystallize closes it with place:
placefreezes the cart. A placed cart cannot be hydrated or edited, and there is no way back to thecartstate.createFromCartrefuses a cart that is notplaced(“The cart is not placed yet.”), so an order can only ever come from a frozen cart.- Place first, then create the provider session — never the other way round. Charge the
totalthatplacereturns:placere-prices the cart from the catalogue one last time, so it can differ from the lasthydrate. Never take an amount from the browser. - Everything the order needs goes on the cart before
place: customer and addresses (setCustomer), shipping as an external item so it is part of the total, and checkout choices that shape the order, such as a pickup point or a B2B company (cartmeta). A payment method or bank preselection does not change the amount: keep it on the cart, or pass it when you create the session (and put it in the session’s idempotency key) so the shopper can switch method on the same placed cart. - Writes to a placed cart fail silently.
addSkuItem,addExternalItem,setCustomer,setMetaand item changes answer with a cart that shows your change — but nothing is saved. Read the cart’sstatebefore editing it. - Back from the payment page = a new cart. If the shopper wants to change anything,
hydratea new cart without an id (copy the items over) and swap your cookie. The old placed cart keeps its own session; if that session is paid later, it pays for exactly the old cart — still consistent. Expire or cancel that old session where the provider allows it (each reference says how). - On a placed cart,
isStaleturnstrueafter about an hour and means nothing: placed prices never change. Carts (placed included) are deleted about three months after they expire.
// app/api/checkout/pay/route.ts — the storefront's "Pay" buttonimport { carts, PLACED_CART, type PlacedCart } from "@/lib/crystallize-payments";
export async function POST(req: Request) { const cartId = getCartIdFromCookie(req); // your session handling const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: "cart" | "placed" | "ordered" | "abandoned"; }; if (cart.state !== "cart" && cart.state !== "placed") return Response.json({ error: "closed" }, { status: 409 }); const placed = cart.state === "placed" ? cart : ((await carts.place(cartId, PLACED_CART)) as unknown as PlacedCart); // → the provider reference's create function: amount = placed.total.gross, reference = placed.id. It reuses // the cart's existing session, and answers "already paid" (→ the return page) when that session completed.}One payment per cart
Placing stops the cart from changing; it does not stop two tabs from both paying for the same placed cart. Make the provider session idempotent per cart, so both tabs get the same session — every reference names its provider’s mechanism (an idempotency key derived from the cart id, a reference the provider refuses twice, or looking the payment up by cart id before creating one), or says there is none (Klarna Payments), in which case the duplicate flag below is the safety net.
If a second successful payment still arrives for a cart that is already an order, createOrderOnce
below records it on the order with meta attention=duplicate-payment and logs it: someone must refund
it. Never create a second order and never refund automatically from a webhook.
The webhook
The same skeleton for every provider:
- Verify the request (each reference gives the exact algorithm) over the raw body — read it
with
await req.text()before anything else. Unsigned notifications (Klarna’s authorization callback and HPPstatus_update, Mollie, Qliro) prove nothing on their own: re-fetch the payment from the provider’s API and act on what the provider returns. - Find the cart id in the provider’s reference or metadata — never in a URL the shopper controls, unless it is protected by a signed token.
- Decide by the provider’s status (paid, authorized, pending, failed — the reference has the table). Pending and failed create nothing.
createOrderOnce: reads the cart, creates the Core customer if needed, and callscreateFromCartonce. Deliveries that collide in the same instant are a trade-off: see Serialising order writes.- Answer fast. 2xx when done or deliberately ignored; 5xx when it failed on your side, so the
provider retries. Never answer 2xx to a bad signature (answer 401/400) and never 3xx (a redirect from
auth or i18n middleware counts as delivered or failed, depending on the provider). The one exception:
a provider that demands 2xx before the work (Klarna’s widget authorization callback) gets it, the work
runs after the answer, and a scheduled re-check of carts still
placedcatches what failed.
| Rule | Why |
|---|---|
| Verify, then trust | An unverified endpoint lets anyone POST “paid” and receive goods |
| Raw bytes, timing-safe compare | JSON.stringify(await req.json()) is not the bytes that were signed; === leaks timing |
| Re-fetch when unsigned | The notification is only a hint that something changed |
| One order per cart | Providers deliver at least once, retry for hours or days, and may deliver twice at once |
| 5xx on your own failure | 4xx is permanent for some providers; 5xx makes them retry |
Math.round(major * 100) |
19.99 * 100 is 1998.9999…. Check zero- and three-decimal currencies in each reference |
| Currency and country from the market | Never hardcode NOK / NO, and never guess the country from the currency |
| Secrets stay on the server | Only publishable or client keys reach the browser; never put a Crystallize token in a bundle |
| Test mode first, then a tunnel | Gateways cannot reach localhost: ngrok, cloudflared or a CLI forwarder (some block ngrok) |
| No long polling in the request | It dies on serverless. Webhook first; a scheduled job may re-check carts still placed later |
The return page
The page the provider sends the shopper back to only reads. Put the cart id in the return URL (an app
switch can open it in another browser, without your cookie) and fetch the cart: ordered → show the
confirmation (the order id is the cart id) and clear the cart cookie; still placed → ask the provider
for that payment’s status (keep its id in a cookie or the URL): failed or cancelled → offer to pay again;
otherwise “We are confirming your payment…” and refresh every few seconds — the webhook usually lands
within seconds.
Adyen’s redirect methods need a call from this page (submitDetails); Qliro’s thank-you snippet, Two’s confirm
and reading Klarna’s HPP session are optional. The reference says so; the page still never creates the order.
lib/crystallize-payments.ts
The provider references import these helpers. Everything goes through @crystallize/js-api-client (7.5 or later):
createCartManager for the Shop API /cart, createShopOrderManager for /order (createFromCart,
addPayments, setPayments) and createShopCustomerManager for /customer. The client fetches one Shop API
token for all of them.
import { createCartManager, createClient, createShopCustomerManager, createShopOrderManager,} from "@crystallize/js-api-client";
export const api = createClient( { tenantIdentifier: process.env.CRYSTALLIZE_TENANT_IDENTIFIER!, accessTokenId: process.env.CRYSTALLIZE_ACCESS_TOKEN_ID!, accessTokenSecret: process.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET!, }, { shopApiToken: { scopes: ["cart", "order", "customer"] } }, // one token for every endpoint used here);export const carts = createCartManager(api);export const orders = createShopOrderManager(api);const customers = createShopCustomerManager(api);
// What a provider session needs from the placed cart. `price` is the line total, `variant.price` the unit.// `type` tells product lines from external ones (`shipping`, `fee`, `promotion`, …).const ADDRESS = { type: true, firstName: true, lastName: true, street: true, street2: true, streetNumber: true, postalCode: true, city: true, state: true, country: true, phone: true, email: true,};const CUSTOMER = { identifier: true, isGuest: true, type: true, email: true, firstName: true, lastName: true, phone: true, companyName: true, taxNumber: true, addresses: ADDRESS,};export const PLACED_CART = { total: { gross: true, net: true, taxAmount: true, currency: true }, items: { lineId: true, type: true, name: true, quantity: true, variant: { sku: true, price: { gross: true, net: true, taxPercent: true } }, price: { gross: true, net: true, taxAmount: true, taxPercent: true }, }, customer: CUSTOMER, meta: true,};type Address = { type: "delivery" | "billing" | "other" } & Partial< Record<Exclude<keyof typeof ADDRESS, "type">, string | null>>;type CartCustomer = { identifier?: string | null; isGuest?: boolean; type?: "individual" | "organization" | null; email?: string | null; firstName?: string | null; lastName?: string | null; phone?: string | null; companyName?: string | null; taxNumber?: string | null; addresses?: Address[] | null;};export type PlacedCart = { id: string; total: { gross: number; net: number; taxAmount: number; currency: string }; items: { lineId: string | null; type: "standard" | "shipping" | "fee" | "promotion" | "service" | "digital" | string | null; name: string; quantity: number; variant: { sku: string | null; price: { gross: number; net: number; taxPercent: number } } | null; price: { gross: number; net: number; taxAmount: number; taxPercent: number }; }[]; customer: { identifier?: string; type?: "individual" | "organization"; email?: string; firstName?: string; lastName?: string; phone?: string; companyName?: string; taxNumber?: string; addresses?: Address[]; } | null; meta: Record<string, string> | null;};
export type PaymentStatus = "paid" | "partiallyPaid" | "partiallyRefunded" | "refunded" | "unpaid";export type Payment = { provider: string; // lower-case provider name: "stripe", "klarna", "two", … method?: string; // what the shopper used: card, vipps, invoice, bank, … transactionId: string; // the provider's id for this payment or refund amount: number; // MAJOR units, like the cart total createdAt?: string; meta?: { key: string; value: string }[];};
/** Thrown when the provider should retry: answer 5xx. */export class RetryLater extends Error {}
type CartState = { id: string; state: "cart" | "placed" | "ordered" | "abandoned"; customer: CartCustomer | null };export const readCart = async (id: string) => (await carts.fetch(id, { state: true, customer: CUSTOMER })) as unknown as CartState | null;
type OrderRead = { id: string; coreId: string | null; payments: | { provider: string; method: string | null; transactionId: string | null; amount: number | null; createdAt: string | null; meta: Record<string, string> | null; // written as [{ key, value }], read back as an object }[] | null;};const ORDER = { coreId: true, payments: { provider: true, method: true, transactionId: true, amount: true, createdAt: true, meta: true },};/** null while the order is not readable yet: createFromCart and payment writes persist just after answering. */export const readOrder = (id: string) => orders.fetch<OrderRead>(id, ORDER).catch(() => null);
/** The order id is the cart id. Creates it once; a later, different payment is recorded and flagged. */export async function createOrderOnce( cartId: string, paymentStatus: PaymentStatus, payment: Payment, pipelines?: { identifier: string; stage?: string }[], // e.g. [{ identifier: "fulfilment", stage: "new" }]) { const cart = await readCart(cartId); if (cart?.state === "ordered") return addRecord(cartId, payment, true); if (cart?.state !== "placed") throw new Error(`cart ${cartId} is ${cart?.state ?? "missing"}`); // alert a human await ensureCustomer(cart.customer); await orders.createFromCart(cartId, { type: "standard", paymentStatus, payments: [payment], pipelines });}
const toInput = (p: NonNullable<OrderRead["payments"]>[number]): Payment => ({ provider: p.provider, method: p.method ?? undefined, transactionId: p.transactionId ?? "", amount: p.amount ?? 0, createdAt: p.createdAt ?? undefined, meta: Object.entries(p.meta ?? {}).map(([key, value]) => ({ key, value: String(value) })),});
/** Refunds: append a record unless this transactionId is already on the order. */export const recordPayment = (orderId: string, payment: Payment) => addRecord(orderId, payment, false);
async function addRecord(orderId: string, payment: Payment, isAnotherCharge: boolean) { const order = await readOrder(orderId); if (!order) throw new RetryLater(`order ${orderId} not readable yet`); if (order.payments?.some((p) => p.transactionId === payment.transactionId)) return; // a redelivery const flagged = isAnotherCharge && payment.meta?.find((m) => m.key === "type")?.value !== "refund"; if (flagged) console.error(`[payments] second payment ${payment.transactionId} for order ${orderId}: refund it`); const record = flagged ? { ...payment, meta: [...(payment.meta ?? []), { key: "attention", value: "duplicate-payment" }] } : payment; await orders.addPayments(orderId, [record]);}
/** Capture, cancel: change one record and write the whole list back (setPayments replaces all). */export async function updatePayment(orderId: string, transactionId: string, change: (p: Payment) => Payment) { const order = await readOrder(orderId); if (!order?.payments) throw new RetryLater(`order ${orderId} not readable yet`); const payments = order.payments.map(toInput).map((p) => (p.transactionId === transactionId ? change(p) : p)); await orders.setPayments(orderId, payments);}
export const withMeta = (p: Payment, values: Record<string, string>, amount = p.amount): Payment => ({ ...p, amount, meta: [ ...(p.meta ?? []).filter((m) => !(m.key in values)), ...Object.entries(values).map(([key, value]) => ({ key, value })), ],});
/** The Shop API rejects null where a field is optional: send only what the cart has. */const defined = <T extends object>(o: T) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined)) as { [K in keyof T]?: NonNullable<T[K]>; };
/** The docs' "create the customer in Crystallize if it does not exist yet". Guests are skipped. */async function ensureCustomer(customer: CartCustomer | null) { if (!customer?.identifier || customer.isGuest) return; const exists = await customers.fetch(customer.identifier).then( () => true, () => false, // fetch rejects when the customer does not exist ); if (exists) return; // never overwrite a known customer with checkout data const { isGuest, addresses, ...fields } = customer; await customers.upsert({ ...defined(fields), identifier: customer.identifier, type: customer.type ?? (customer.companyName ? "organization" : "individual"), addresses: addresses?.map((address) => ({ ...defined(address), type: address.type })), });}A provider webhook route then reads:
try { // … verified, cart id found, provider says "paid" … await createOrderOnce(cartId, "paid", { provider: "stripe", transactionId: pi.id, amount, meta: [...] }); return new Response("ok");} catch (error) { console.error(error); return new Response("retry", { status: 500 }); // RetryLater and real failures alike}Serialising order writes (optional)
createOrderOnce reads the cart’s state, then calls createFromCart. That is enough for most shops and adds
nothing to the webhook’s latency. What it leaves open is two deliveries for the same cart arriving at the same
moment: createFromCart answers before it moves the cart to ordered, so both can see placed and both create
the order. Crystallize still keeps one order (its id is the cart id); the second call rewrites it with its own
payment list:
- The same payment twice (a provider redelivering): nothing is lost.
- Two different payments for one cart, in the same second (two tabs both paid): the first payment’s record disappears from the order. The money was taken, and nothing flags it for a refund.
- A capture and a refund handled at the same instant:
updatePaymentreads, changes and writes the whole list, so one of the two changes is lost.
The Shop API’s /lock endpoint closes those gaps, at a cost on every webhook: two more round trips, the request
held until the cart shows ordered (up to a few seconds), and a 5xx (so a provider retry) whenever two deliveries
collide. It is a trade-off for the merchant: opt in for expensive or made-to-order goods, for a provider with no
per-cart idempotency that delivers concurrently, or for orders with frequent after-sales operations. Never put it
in the shopper’s path (the Pay route).
To opt in, add "lock" to the shopApiToken scopes and wrap the writes:
// lib/crystallize-payments.ts — opt-in serialisation through the Shop API lockimport { createShopLock } from "@crystallize/js-api-client";
const lock = createShopLock(api);
export async function withCartLock<T>(cartId: string, work: () => Promise<T>): Promise<T> { const key = `order:${cartId}`; if (!(await lock.acquire(key, 60))) throw new RetryLater(`cart ${cartId} is being processed`); // → 5xx, retry try { return await work(); } finally { await lock.release(key).catch(() => {}); }}
/** createFromCart answers before it moves the cart to `ordered`: hold the lock until it has. */export async function waitUntilOrdered(cartId: string) { for (let i = 0; i < 10; i++) { if ((await readCart(cartId))?.state === "ordered") return; await new Promise((r) => setTimeout(r, 500)); } throw new RetryLater(`order ${cartId} created, cart not ordered yet`);}
// In a webhook:// await withCartLock(cartId, async () => {// await createOrderOnce(cartId, "paid", payment);// await waitUntilOrdered(cartId);// });// Capture vs refund:// await withCartLock(cartId, () => updatePayment(cartId, transactionId, change));Mapping to Crystallize
The payment record
createFromCart, addPayments and setPayments take the same generic record (OrderPaymentInput)
for every provider — provider is a free string. Use it for all of them:
{ provider: 'klarna', // lower-case provider name method: 'pay_later', // what the shopper used transactionId: 'a8c3…', // the provider's id: capture, refund and cancel need it amount: 1499.0, // MAJOR units, like the cart total — not cents createdAt: '2026-10-06T10:12:00Z', meta: [ { key: 'state', value: 'authorized' }, // authorized | captured | cancelled { key: 'cartId', value: cartId }, // lets a Core-side webhook find the Shop order (see capture) ],}Crystallize stores it as a custom payment whose properties are provider, transactionId, amount,
method, createdAt and every meta key — so never use those five names as meta keys. Keep meta
small: state, type (refund on refund records), cartId, and the few provider ids needed later.
The Shop API returns meta as an object ({ state: "authorized" }); toInput above turns it back into
the [{ key, value }] list the mutations take.
paymentStatus
paymentStatus is one of paid, partiallyPaid, partiallyRefunded, refunded, unpaid — there is
no “authorized”. It is set once, by createFromCart; the Shop API has no mutation to change it
later. Many providers authorize first and capture when the goods ship, so the payment record’s state
is what tracks the money afterwards, and the pipeline stage tracks the fulfilment.
| Provider event | Crystallize |
|---|---|
| Authorized, capture later | createOrderOnce(…, 'unpaid', …) with meta state=authorized |
| Paid (captured immediately) | createOrderOnce(…, 'paid', …) with meta state=captured |
| Captured later (full or part) | updatePayment → state=captured, amount = captured amount |
| Refunded (part or full) | recordPayment with transactionId = refund id, amount, meta type=refund |
| Authorization cancelled/expired | updatePayment → state=cancelled; move the order to your cancelled stage |
| Pending (bank transfer, SEPA) | Nothing yet: the provider sends another event when it settles |
| Failed / declined | No order. The shopper retries: same session if the provider allows, else a new cart |
Do not patch paymentStatus or payments through the Core API’s updateOrder on these orders: a
Core write is pushed back to the Shop order as a whole and can overwrite payments you have just set
through the Shop API. Keep every write to a checkout order on the Shop API /order endpoint.
Capture on shipment, from fulfilment pipelines
Payment is the end of checkout and the start of the order’s life. Put orders in a
fulfilment pipeline at
creation (the last argument of createOrderOnce, passed to createFromCart as pipelines), and
let the stage drive the provider:
- Crystallize → Settings → Webhooks: concern Order, event pipeline stage change, POST to
/api/crystallize/order-stage, no GraphQL query. The body is then{ orderId, pipelineId, stageId, tenantId, webhookId }—orderIdis the Core order id. - Verify
X-Crystallize-Signaturewith the tenant’s signature secret. - Read the order’s payment records from Core, take the
cartId(= Shop order id),providerandtransactionId, call the provider’s capture (or cancel) with an idempotency key, thenupdatePayment.
import { createSignatureVerifier } from "@crystallize/js-api-client";import { api, updatePayment, withMeta } from "@/lib/crystallize-payments";// provider name → each reference's `capture(transactionId, amount, record)`: the captured amount, or null when// the provider captures asynchronously and reports the result in its own webhook (which then calls updatePayment).// `record` is the payment's stored properties (meta included), for providers that need more (Adyen: currency).import { captureByProvider } from "@/lib/payments";
const verify = createSignatureVerifier({ secret: process.env.CRYSTALLIZE_SIGNATURE_SECRET! });
export async function POST(req: Request) { const body = await req.text(); try { // url must be the URL Crystallize called — behind a proxy, rebuild it from your public host await verify(req.headers.get("x-crystallize-signature") ?? "", { url: req.url, method: "POST", body }); } catch { return new Response("bad signature", { status: 401 }); } const { orderId, stageId } = JSON.parse(body) as { orderId: string; pipelineId: string; stageId: string }; if (stageId !== process.env.CRYSTALLIZE_SHIPPED_STAGE_ID) return new Response("ignored");
const { order } = await api.nextPimApi<{ order: { payment?: { properties?: { property: string; value: string | null }[] }[] }; }>( `query($id: ID!) { order(id: $id) { ... on Order { payment { ... on CustomPayment { properties { property value } } } } } }`, { id: orderId }, ); const records = (order.payment ?? []).map((p) => Object.fromEntries((p.properties ?? []).map(({ property, value }) => [property, value ?? ""])), ); const authorized = records.find((r) => r.state === "authorized" && r.type !== "refund"); if (!authorized) return new Response("nothing to capture"); // a redelivery, or captured at checkout try { const capture = captureByProvider[authorized.provider]; const captured = await capture(authorized.transactionId, Number(authorized.amount), authorized); if (captured !== null) { await updatePayment(authorized.cartId, authorized.transactionId, (p) => withMeta(p, { state: "captured" }, captured), ); } return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // Crystallize retries failed webhooks }}Find the stage ids by logging one delivery. The same handler can cancel on a “Cancelled” stage. Where a
provider captures asynchronously (its capture() returns null), the record flips to captured from that
provider’s own webhook, never before the capture has succeeded. Check
how long each provider keeps an authorization alive (in its reference): made-to-order goods often ship
after it expires.
Choosing a provider
Crystallize works with any gateway; these have a reference here:
| Provider | Where it sells | Style | Capture |
|---|---|---|---|
| Stripe | Global; cards, wallets, Klarna, MobilePay, Vipps (preview) | Checkout Session + Payment Element | Auto (or manual) |
| Adyen | Global, enterprise onboarding | Sessions flow + Web Drop-in | Auto (or manual) |
| Klarna | 26 countries: Europe, US, CA, MX, AU, NZ | Klarna Payments, Hosted Payment Page | Manual |
| Qliro | Nordics (SEK, NOK, DKK, EUR) | Qliro Checkout, embedded | Manual (async) |
| Dintero | Nordics | Checkout session, redirect or embedded | Manual or auto |
| Vipps MobilePay | Norway, Denmark, Finland (NOK, DKK, EUR) | ePayment API, redirect / app switch / QR | Manual |
| Mollie | Merchants in the EEA, UK and Switzerland | Payments API, hosted checkout | Auto, or captureMode |
| Montonio | Baltics, Finland, Poland (EUR, PLN) | Order → payment URL, bank picker, parcel machines | None (paid) |
| QuickPay | Denmark and EU acquiring | Payment link, redirect | Manual |
| Two | B2B invoice: Nordics, UK, EU, US | Company search + hosted verification | On fulfilment (invoice) |
| Razorpay | Merchants in India, Malaysia/Singapore, US only | Standard Checkout + server Order | Auto (manual: ~3 days) |
Pick by where the merchant is incorporated and where its shoppers are, then by capture model: made-to-order goods want authorize now, capture on shipment. Payment details can also be stored on a subscription contract; recurring payments are not covered here — the references only point at each provider’s recurring API.
Common mistakes
- Charging an amount from a cart that is not placed, or from the browser — the two-tab attack.
- Verifying a signature over
JSON.stringify(parsedBody), or answering 200 (or{}) to a bad one. - Creating the order on the return page, or from a verification the browser sends (only the provider’s server-to-server notification, or a status you fetched yourself, counts).
- Creating an order on every callback, for refused or pending payments, or twice when two deliveries
overlap — use
createOrderOnce. - Reading the live cart in a callback instead of the placed one: lines and total must match what was charged.
gross * 100without rounding; sending0for VAT; mixing a per-unit price with a line discount.- Hardcoding currency, country or locale, or deriving the country from the currency.
- Letting the shopper pick shipping inside the provider’s checkout: the provider then charges more than
the placed cart, and the order has no shipping line. Choose shipping before
place. - Calling a provider’s old API family (eCom v2, Payments v1,
charges.data) — each reference names the current one. - Patching
paymentStatusor payments through CoreupdateOrderon a Shop API order. - Long polling a provider inside one request instead of handling its webhook.
References
Each reference opens with the provider at a glance, then: credentials and setup, creating the payment from the placed cart, the client, the webhook, capture/refund/cancel, the mapping, provider specifics, going further, and common mistakes.
- references/stripe.md
- references/adyen.md
- references/klarna.md
- references/qliro.md
- references/dintero.md
- references/vipps-mobilepay.md
- references/mollie.md
- references/montonio.md
- references/quickpay.md
- references/two.md
- references/razorpay.md
Related: [[mutation]] for the cart and order mutations, [[query]] for reading orders back, [[js-api-client]]
for the client and createSignatureVerifier, [[pricing]] for markets and currencies, [[bookable-resources]]
when the cart holds bookings (confirm them after createFromCart).
Reference Details
Adyen with Crystallize
Adyen is a Dutch global payment platform (cards, Apple Pay, Google Pay and local methods such as iDEAL, Klarna, Vipps,
MobilePay, Swish, Trustly) that onboards merchants per legal entity and sells in most currencies. The recommended
integration is the Sessions flow: one server-side /sessions call from the placed cart (Checkout API v72), the Web
Drop-in (@adyen/adyen-web v6) on the checkout page, and the order created from the HMAC-signed AUTHORISATION
webhook. Capture is automatic by default; for methods that support separate capture, manual capture (authorize now,
capture on shipment) or a delayed automatic capture is set per merchant account or per session.
Verification: Written from Adyen’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Sessions flow, Checkout API v72, Web v6, Handle webhooks, Verify HMAC, Capture, Refund, Cancel, Currency codes. Every page is served as Markdown (append
.md); the index is llms.txt.
At a glance
| Markets & currencies | Global; any currency in Adyen’s table. Payment methods are enabled per merchant account |
| Recommended integration | Sessions flow: POST /sessions on the server + Web Drop-in v6 on the page (it handles 3D Secure and redirects) |
| Alternative | Hosted Checkout: the same /sessions with mode: "hosted" + themeId, then redirect to the returned url (guide) |
| API version | Checkout API v72 (April 2026: validates reference, returnUrl, shopperEmail, postal codes; enhanced scheme data moved) |
| SDKs | Browser @adyen/adyen-web@6.46 (v6 needs API ≥ v69). Server @adyen/api-library@32 (v72, Node ≥ 18) or plain fetch, shown here |
| Amount units | Integer minor units by Adyen’s table: JPY, KRW, IDR, CVE… 0; BHD, KWD, JOD, OMR, TND… 3; ISK and CLP 2 (ISO says 0) |
| Capture | Auto by default; manual, or delayed (captureDelayHours ≤ 672). Adyen expires authorisations after 28 days; Visa e-commerce 10, Mastercard final 7, Amex 7 (validity) |
| Cart id | reference (≥ 3, ≤ 80 chars; a cart UUID is 36), echoed as the webhook’s merchantReference |
| One session per cart | Idempotency-Key: session-<cartId> (≤ 64 chars, remembered 7–14 days). Adyen does not refuse a repeated reference |
| Notification verification | Basic auth on the endpoint + HMAC-SHA256 of each item (8 fields, hex key), Base64 in additionalData.hmacSignature |
Credentials and setup
- Test account: sign up at adyen.com for a test Customer Area (https://ca-test.adyen.com). The crystallize.com
page names two values, both under Developers → API credentials → your credential (docs):
- API key (Server settings → Authentication → Generate API key): server only, sent as
X-API-Key; shown once. - Client key (Client settings → Authentication,
test_…/live_…): public, for Drop-in. Add allowed origins there (http://localhost:3000works in test; live needshttps) or Drop-in will not load.
- API key (Server settings → Authentication → Generate API key): server only, sent as
- Merchant account: its name goes in every request (
YourCompanyECOM). Add the payment methods you want to it. Capture mode: Settings → Account settings → Capture delay (immediate, 1–7 days, Manual). - HMAC key: Developers → Webhooks → Create new webhook → Standard webhook (company level, limited to your
merchant account), URL
https://<host>/api/payments/adyen/webhook, method JSON, Basic authentication user and password, HMAC key → Generate (hex). Live has its own webhook, HMAC key, API key and client key. - Test data (cards):
4111 1111 1111 1111,03/2030,737; 3DS2-enrolled4917 6100 0000 0000; holder nameDECLINEDforces a refusal (show the field withhasHolderName: true) (result codes). - Reaching localhost: the webhook URL must be public and must not redirect (test: HTTP on 80/8080/8888 or HTTPS on 443/8443/8843; live: HTTPS only). Use a tunnel (ngrok, cloudflared); Test configuration sends sample events.
ADYEN_API_KEY=AQE…ADYEN_CHECKOUT_URL=https://checkout-test.adyen.com/v72 # live: https://<prefix>-checkout-live.adyenpayments.com/…/v72ADYEN_MERCHANT_ACCOUNT=YourCompanyECOMADYEN_HMAC_KEY=44782DEF…ADYEN_WEBHOOK_USERNAME=…ADYEN_WEBHOOK_PASSWORD=…PUBLIC_URL=https://shop.example # the tunnel in developmentNEXT_PUBLIC_ADYEN_CLIENT_KEY=test_…NEXT_PUBLIC_ADYEN_ENVIRONMENT=test # live, live-us, live-au, live-nea, live-in: prefix's regionLive base URL: https://<prefix>-checkout-live.adyenpayments.com/checkout/v72, the prefix from the live Customer
Area’s Developers → API URLs (live endpoints).
Create the payment
Call it from the Pay route right after place, which makes the cart immutable
(SKILL.md). The amount is the placed total, the reference the cart id.
countryCode filters the payment methods: take it from the cart’s delivery address or the market the cart was priced
in, never from the currency. Klarna, Riverty, Afterpay, Affirm, Ratepay and Oney require lineItems that add up to
the amount exactly.
import type { PlacedCart } from "@/lib/crystallize-payments";
export const MERCHANT = process.env.ADYEN_MERCHANT_ACCOUNT!;/** Authorize at checkout, capture when the order ships. Keep it in step with the Customer Area's capture delay. */export const CAPTURE_ON_SHIPMENT = true;
// Adyen's decimals win over ISO 4217 (ISK and CLP have 2 at Adyen).const ZERO = ["CVE", "DJF", "GNF", "IDR", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF"];const THREE = ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"];const exp = (c: string) => (ZERO.includes(c.toUpperCase()) ? 0 : THREE.includes(c.toUpperCase()) ? 3 : 2);export const toMinor = (major: number, currency: string) => Math.round(major * 10 ** exp(currency));export const toMajor = (minor: number, currency: string) => minor / 10 ** exp(currency);
/** POST to the Checkout API. A repeated key returns the first result; "704 in progress" and transient errors retry. */export async function adyen<T>(path: string, body: object, idempotencyKey: string): Promise<T> { for (let attempt = 0; ; attempt++) { const res = await fetch(`${process.env.ADYEN_CHECKOUT_URL}${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": process.env.ADYEN_API_KEY!, "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(body), }); if (res.ok) return (await res.json()) as T; const text = await res.text(); const retry = res.headers.get("transient-error") === "true" || /"errorCode"\s*:\s*"704"/.test(text); if (!retry || attempt === 3) throw new Error(`Adyen ${path} ${res.status}: ${text}`); await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); }}
/** Unit amounts × quantity add up to the placed total; a line whose discount does not split per unit stays whole. */function lineItems(placed: PlacedCart) { const c = placed.total.currency; const lines = placed.items.map((item) => { const gross = toMinor(item.price.gross, c); // line totals const net = toMinor(item.price.net, c); const qty = gross % item.quantity === 0 && net % item.quantity === 0 ? item.quantity : 1; return { id: item.variant?.sku ?? item.lineId ?? item.name, description: qty === item.quantity ? item.name : `${item.quantity} × ${item.name}`, quantity: qty, amountIncludingTax: gross / qty, amountExcludingTax: net / qty, taxAmount: (gross - net) / qty, taxPercentage: Math.round(item.price.taxPercent * 100), // basis points: 2500 = 25 % }; }); const rest = toMinor(placed.total.gross, c) - lines.reduce((s, l) => s + l.amountIncludingTax * l.quantity, 0); if (rest !== 0) lines.push({ id: "adjustment", description: "Adjustment", quantity: 1, amountIncludingTax: rest, amountExcludingTax: rest, taxAmount: 0, taxPercentage: 0, }); return lines;}
type CartAddress = NonNullable<NonNullable<PlacedCart["customer"]>["addresses"]>[number];const toAddress = (a?: CartAddress) => // Adyen wants all five fields; postalCode ≤ 10 chars a?.street && a.city && a.postalCode && a.country ? { street: a.street, houseNumberOrName: a.streetNumber ?? "", postalCode: a.postalCode, city: a.city, country: a.country, } : undefined;
export async function createAdyenSession( placed: PlacedCart, market: { origin: string; country: string; locale: string }, // resolved on the server, not sent by the browser) { const currency = placed.total.currency.toUpperCase(); const c = placed.customer; const delivery = c?.addresses?.find((a) => a.type === "delivery"); const countryCode = (delivery?.country ?? market.country).toUpperCase(); const session = await adyen<{ id: string; sessionData: string }>( "/sessions", { merchantAccount: MERCHANT, amount: { currency, value: toMinor(placed.total.gross, currency) }, // the PLACED total reference: placed.id, returnUrl: `${market.origin}/order/cart/${placed.id}`, // ≤ 1024 chars, no PII countryCode, // Drop-in reads it, the amount and the locale from the session shopperLocale: market.locale, // e.g. nb-NO channel: "Web", shopperEmail: c?.email, // risk checks and 3DS; v72 rejects a malformed one lineItems: lineItems(placed), // with the four below, what Klarna, Riverty and Afterpay need shopperName: c?.firstName && c.lastName ? { firstName: c.firstName, lastName: c.lastName } : undefined, telephoneNumber: c?.phone?.startsWith("+") ? c.phone : undefined, deliveryAddress: toAddress(delivery), billingAddress: toAddress(c?.addresses?.find((a) => a.type === "billing")), ...(placed.meta?.paymentMethod ? { allowedPaymentMethods: [placed.meta.paymentMethod] } : {}), ...(CAPTURE_ON_SHIPMENT && { additionalData: { manualCapture: "true" } }), // a string, not a boolean }, `session-${placed.id}`, // one session per cart: a second tab gets the same session back ); return { id: session.id, sessionData: session.sessionData }; // all the page needs; nothing secret}The Pay route answers Response.json(await createAdyenSession(placed, { origin: process.env.PUBLIC_URL!, ...market })).
A session lives 1 hour (expiresAt, at most 24): a shopper who comes back later gets a new cart, as in
SKILL.md, never a second key for the same placed cart.
Client
Initialise Drop-in once, client side, on a ref (not with selectors, not in an iframe on another domain).
@adyen/adyen-web/auto shows every method enabled on the merchant account; import from @adyen/adyen-web and pass
paymentMethodComponents: [Card, …] to Dropin for a smaller bundle.
"use client";import { useEffect, useRef } from "react";import { AdyenCheckout, Dropin, type CoreConfiguration } from "@adyen/adyen-web/auto";import "@adyen/adyen-web/styles/adyen.css";
const config = { environment: (process.env.NEXT_PUBLIC_ADYEN_ENVIRONMENT ?? "test") as CoreConfiguration["environment"], clientKey: process.env.NEXT_PUBLIC_ADYEN_CLIENT_KEY!,};
export function AdyenDropin({ cartId, session }: { cartId: string; session: { id: string; sessionData: string } }) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { let dropin: Dropin | undefined; let gone = false; // React strict mode mounts twice AdyenCheckout({ ...config, session, // v6 requires a countryCode: with a session it comes from /sessions, like amount and locale onPaymentCompleted: () => location.assign(`/order/cart/${cartId}`), // Authorised, Pending, Received onPaymentFailed: (r) => console.warn(r?.resultCode), // Refused, Cancelled, Error: the shopper can retry onError: (e) => console.error(e.name, e.message), }).then((checkout) => { if (!gone && ref.current) dropin = new Dropin(checkout).mount(ref.current); }); return () => { gone = true; dropin?.unmount(); }; }, [cartId, session]); return <div ref={ref} />;}
/** On the return page, once, after a redirect (iDEAL, Vipps, MobilePay, some 3D Secure): completes the payment. */export async function finishAdyenRedirect(onResult: (resultCode?: string) => void) { const q = new URLSearchParams(location.search); const [sessionId, redirectResult] = [q.get("sessionId"), q.get("redirectResult")]; if (!sessionId || !redirectResult) return; history.replaceState(null, "", location.pathname); // a reload must not submit it twice const checkout = await AdyenCheckout({ ...config, session: { id: sessionId }, onPaymentCompleted: (r) => onResult(r.resultCode), onPaymentFailed: (r) => onResult(r?.resultCode), }); checkout.submitDetails({ details: { redirectResult } });}/order/cart/[cartId] is the return page: it takes the cart id from its own URL (a
redirect can land in another browser, without your cookie), only reads the cart and waits until it is ordered. It
calls finishAdyenRedirect in an effect, so a redirect method completes and a refusal shows at once (“try again”
leads back to the same placed cart and session) instead of waiting forever.
Webhook
Adyen signs each NotificationRequestItem, not the body: HMAC-SHA256 with the hex-decoded key over
pspReference:originalReference:merchantAccountCode:merchantReference:value:currency:eventCode:success (empty string
for a missing field), Base64 in additionalData.hmacSignature. It does not cover paymentMethod, eventDate or
additionalData; basic auth and TLS protect those. (hmacValidator in @adyen/api-library does the same check.)
Keep accepting the previous key for a while after rotating it.
// lib/adyen.ts (continued)import { createHmac, timingSafeEqual } from "node:crypto";
export type AdyenItem = { eventCode: string; success: "true" | "false"; pspReference: string; originalReference?: string; merchantAccountCode: string; merchantReference: string; amount: { value: number; currency: string }; eventDate: string; paymentMethod?: string; reason?: string; additionalData?: Record<string, string>;};const same = (a: Buffer, b: Buffer) => a.length === b.length && timingSafeEqual(a, b);
function validHmac(i: AdyenItem, hexKey: string) { const signed = [ i.pspReference, i.originalReference ?? "", i.merchantAccountCode, i.merchantReference, i.amount.value, i.amount.currency, i.eventCode, i.success, ].join(":"); const expected = createHmac("sha256", Buffer.from(hexKey, "hex")).update(signed, "utf8").digest(); return same(Buffer.from(i.additionalData?.hmacSignature ?? "", "base64"), expected);}
/** Basic auth, then the HMAC of EVERY item. null → answer 401. */export function verifyAdyen(authorization: string | null, raw: string): AdyenItem[] | null { const user = Buffer.from(`${process.env.ADYEN_WEBHOOK_USERNAME}:${process.env.ADYEN_WEBHOOK_PASSWORD}`); if (!same(Buffer.from(authorization ?? ""), Buffer.from(`Basic ${user.toString("base64")}`))) return null; let items: AdyenItem[]; try { const body = JSON.parse(raw) as { notificationItems?: { NotificationRequestItem: AdyenItem }[] }; items = (body.notificationItems ?? []).map((n) => n.NotificationRequestItem); } catch { return null; } const keys = [process.env.ADYEN_HMAC_KEY, process.env.ADYEN_HMAC_KEY_PREVIOUS].filter((k): k is string => !!k); return items.length > 0 && items.every((i) => keys.some((k) => validHmac(i, k))) ? items : null;}On a successful AUTHORISATION, createOrderOnce does what the crystallize.com page lists: it creates the customer in
Crystallize if missing, creates the order, and moves the cart from placed to ordered, which releases the waiting
order page.
import { createOrderOnce, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import { CAPTURE_ON_SHIPMENT, MERCHANT, toMajor, verifyAdyen, type AdyenItem } from "@/lib/adyen";
export const runtime = "nodejs";const CART_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;const NO_SEPARATE_CAPTURE = new Set(["ideal", "swish", "trustly"]); // captured at authorisation even when manual
export async function POST(req: Request) { const raw = await req.text(); const items = verifyAdyen(req.headers.get("authorization"), raw); if (!items) return new Response("unauthorized", { status: 401 }); try { for (const item of items) await handle(item); // every item, not only the first return new Response("[accepted]"); // any 2xx, within 10 seconds } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // Adyen retries, then queues for up to 30 days }}
async function handle(i: AdyenItem) { // A company-level webhook also carries other merchant accounts and POS payments with non-cart references. if (i.merchantAccountCode !== MERCHANT || !CART_ID.test(i.merchantReference)) return; const cartId = i.merchantReference; const ok = i.success === "true"; const amount = toMajor(i.amount.value, i.amount.currency); const payment = i.originalReference ?? i.pspReference; // the authorisation = the record's transactionId const set = (values: Record<string, string>, to?: number) => updatePayment(cartId, payment, (p) => withMeta(p, values, to)); const log = () => console.error(`[adyen] ${i.eventCode} ${i.success} ${payment} cart ${cartId}: ${i.reason}`); const action = i.additionalData?.["modification.action"]; // CANCEL_OR_REFUND (from /reversals): cancel | refund const event = i.eventCode === "CANCEL_OR_REFUND" ? `REVERSAL_${action}` : i.eventCode;
if (event === "AUTHORISATION" && ok) { const later = CAPTURE_ON_SHIPMENT && !NO_SEPARATE_CAPTURE.has(i.paymentMethod ?? ""); return createOrderOnce(cartId, later ? "unpaid" : "paid", { provider: "adyen", method: i.paymentMethod ?? "unknown", transactionId: i.pspReference, amount, createdAt: i.eventDate, meta: [ { key: "state", value: later ? "authorized" : "captured" }, { key: "cartId", value: cartId }, { key: "currency", value: i.amount.currency }, // capture() needs it ], }); } if (event === "CAPTURE" && ok) return set({ state: "captured" }, amount); if (event === "CAPTURE" || event === "CAPTURE_FAILED") { log(); // a refused capture: the money is still only authorized return set({ state: "authorized", attention: "capture-failed" }); } if (["CANCELLATION", "EXPIRE", "REVERSAL_cancel"].includes(event)) return ok ? set({ state: "cancelled" }) : log(); if (event === "REFUND" || event === "REVERSAL_refund") { if (!ok) return log(); return recordPayment(cartId, { provider: "adyen", method: i.paymentMethod, transactionId: i.pspReference, // the refund's own pspReference amount, createdAt: i.eventDate, meta: [ { key: "type", value: "refund" }, { key: "cartId", value: cartId }, { key: "paymentPspReference", value: payment }, ], }); } if (["REFUND_FAILED", "REFUNDED_REVERSED", "CHARGEBACK", "NOTIFICATION_OF_CHARGEBACK"].includes(event)) log(); // AUTHORISATION success=false: refused — no order, the placed cart stays, the shopper retries. Others: ignored.}Adyen eventCode |
Crystallize (SKILL.md) |
|---|---|
AUTHORISATION true, auto capture or iDEAL/Swish… |
createOrderOnce(…, 'paid', …), state=captured |
AUTHORISATION true, manual or delayed capture |
createOrderOnce(…, 'unpaid', …), state=authorized |
AUTHORISATION false, OFFER_CLOSED (opt-in) |
Nothing; answer 2xx (a non-2xx makes Adyen retry for 30 days) |
CAPTURE true |
updatePayment → state=captured, captured amount. Not sent for immediate auto-capture |
CAPTURE false, CAPTURE_FAILED |
updatePayment → state=authorized, attention=capture-failed; a human retries |
CANCELLATION, EXPIRE, CANCEL_OR_REFUND (cancel) |
updatePayment → state=cancelled; move the order to your cancelled stage |
REFUND true, CANCEL_OR_REFUND (refund) |
recordPayment with meta type=refund |
REFUND_FAILED, REFUNDED_REVERSED, CHARGEBACK… |
Log for a human; answer 2xx |
Duplicates share eventCode and pspReference and may arrive out of order (eventDate tells); every write above is
idempotent. JSON webhooks carry one item today (SOAP up to six): loop anyway. Past 10 seconds the event goes to the
retry queue — safe, for the same reason.
Capture, refund, cancel
Each call answers "status": "received"; the outcome arrives by webhook. Pass the cart id as reference: Adyen’s
refund example echoes a modification’s reference as the webhook’s merchantReference (for modifications made in the
Customer Area without a reference this is unconfirmed — the CART_ID check then skips them).
// lib/adyen.ts (continued)/** captureByProvider.adyen. Adyen needs the currency: pass the payment record (meta `currency`, `cartId`). */export async function capture(transactionId: string, amount: number, record?: Record<string, string>) { const currency = record?.currency; if (!currency) throw new Error(`Adyen capture ${transactionId}: the record has no currency`); const value = toMinor(amount, currency); const body = { merchantAccount: MERCHANT, amount: { currency, value }, reference: record?.cartId }; await adyen(`/payments/${transactionId}/captures`, body, `capture-${transactionId}`); return null; // asynchronous: the CAPTURE webhook writes state=captured, or flags capture-failed}
/** `n` numbers your refunds of this payment: a retry with the same n never refunds twice. */export async function refund(cartId: string, transactionId: string, amount: number, currency: string, n = 1) { const value = toMinor(amount, currency); const res = await adyen<{ pspReference: string }>( `/payments/${transactionId}/refunds`, { merchantAccount: MERCHANT, amount: { currency, value }, reference: cartId }, `refund-${transactionId}-${n}`, ); return res.pspReference; // the REFUND webhook records it}
/** Before capture (a "Cancelled" stage). Use /reversals when you do not know whether it was captured. */export async function cancel(cartId: string, transactionId: string) { await adyen( `/payments/${transactionId}/cancels`, { merchantAccount: MERCHANT, reference: cartId }, `cancel-${transactionId}`, ); // the CANCELLATION webhook sets state=cancelled}The pipeline handler must call
captureByProvider.adyen(authorized.transactionId, Number(authorized.amount), authorized): the third argument
carries the currency. capture returns null, so the handler writes nothing; the CAPTURE webhook sets
state=captured, and a redelivered stage webhook re-sends the same idempotency key, which never captures twice. A
partial capture cancels the rest of the authorisation unless Adyen Support enables multiple partial captures.
Refunds may be partial and repeated, never above the captured amount, and take up to 40 business days to reach the
shopper. For delayed automatic capture, send captureDelayHours (or set 1–7 days in the Customer Area) instead of
manualCapture, keep CAPTURE_ON_SHIPMENT so the order starts authorized, do not capture those orders from the
pipeline, and enable the CAPTURE event under Developers → Webhooks → Settings and on the Standard webhook: its
CAPTURE webhook flips the record.
Mapping
{ provider: 'adyen', method: item.paymentMethod, // visa, mc, amex, applepay, klarna, vipps, mobilepay, swish, ideal, … transactionId: item.pspReference, // the authorisation's pspReference: capture, refund and cancel take it amount: toMajor(item.amount.value, item.amount.currency), // MAJOR units createdAt: item.eventDate, meta: [{ key: 'state', value: 'authorized' }, // authorized | captured | cancelled { key: 'cartId', value: cartId }, { key: 'currency', value: 'NOK' }],}Refund record: transactionId = the REFUND webhook’s pspReference, amount = refunded major amount, meta
type=refund, cartId, paymentPspReference (the payment record).
Provider specifics
A payment method chosen before place. If the storefront has its own “Card / Vipps / Klarna” chooser, store the
choice on the cart before place; createAdyenSession turns it into allowedPaymentMethods. Values are Adyen’s
types: scheme (cards), applepay, googlepay, klarna, klarna_account, klarna_paynow, vipps,
mobilepay, swish, ideal, trustly.
await carts.setMeta(cartId, { meta: [{ key: "paymentMethod", value: "vipps" }], merge: true }); // before placeNordic and local methods run inside Drop-in (redirect, app switch or QR) once added to the merchant account: Vipps
needs NO/NOK, MobilePay DK or FI with DKK/EUR, Swish SE/SEK, iDEAL NL/EUR. Vipps, MobilePay and Klarna
support separate capture; iDEAL, Swish and Trustly do not, so the webhook records them as captured.
Klarna, Riverty, Afterpay refuse a session without lineItems, shopperName, telephoneNumber (with +) and
the addresses: createAdyenSession sends them from the customer set on the cart before place.
3D Secure runs natively in Drop-in. A strict Content-Security-Policy blocks its challenge: allow it, or send
authenticationData: { threeDSRequestData: { nativeThreeDS: "disabled" } } on /sessions to use a redirect.
Stored cards and recurring (after payment): /sessions with a non-PII shopperReference,
storePaymentMethodMode: "askForConsent" and recurringProcessingModel (CardOnFile, Subscription,
UnscheduledCardOnFile). The token comes in the Recurring tokens life cycle webhook, signed over the raw body in an
hmacsignature header; charge it with POST /payments, storedPaymentMethodId, shopperInteraction: "ContAuth"
(tokenization).
Going further
- Hosted Checkout:
/sessionswithmode: "hosted"and athemeId, redirect tourl; webhook, capture and refund stay the same (guide). Advanced flow (/paymentMethods,/payments,/payments/details) for control between steps (advanced flow). - Express checkout (Apple Pay, Google Pay, PayPal with shipping in the wallet sheet): the shopper picks shipping at
Adyen, so Adyen charges more than the placed cart and the order has no shipping line. Choose shipping in the
storefront before
placeunless you rebuild that flow deliberately. - Gift cards and partial payments: one cart is paid by several
AUTHORISATIONs and closed byORDER_CLOSED;createOrderOncewould flag the second as a duplicate. Create the order onORDER_CLOSEDsuccess=trueinstead (partial payments). - Changing the amount after the session (
payable: false, thenPATCH /sessions/{id}, v72): with Crystallize, place the final cart instead. Authorisation adjustment for goods shipped after the scheme’s validity (validity). - Pay by Link (
POST /paymentLinks) for call-centre orders, with a placed cart id asreference. - Webhook hardening: OAuth 2.0 instead of basic auth, Adyen’s IP ranges in an allowlist (secure webhooks).
- Go live: new API key, client key, webhook and HMAC key; the live URL prefix; a Drop-in
environmentmatching the prefix’s region, and every call of a session on that region. Disputes: dispute webhooks and the Disputes API.
Common mistakes
- Accepting the webhook without checking basic auth and every item’s
hmacSignature: anyone could POSTAUTHORISATION success=trueand get an order. - Handling only the first item of
notificationItems(areturninside the loop). - Answering 403 or another non-2xx to a refused payment (Adyen retries it for 30 days), answering with the created
order as the body, or working past 10 seconds: refusals get a 2xx and no order, everything gets
[accepted]fast. - Guessing
countryCodefrom the currency (NOK → NO, elseFR): it hides payment methods and breaks Vipps, Swish, Klarna. Use the delivery address or the market. cart.total.gross * 100withoutMath.round, and two decimals for every currency (JPY has 0, KWD 3).- Creating the session even though
placefailed, or a new session on every render or cart change. - Web v5 code (
import AdyenCheckout from '@adyen/adyen-web',checkout.create('dropin'), nocountryCode): v6 uses named imports,new Dropin(checkout)andonPaymentFailedfor failures. - A return page that never calls
submitDetailswithredirectResult, or that creates the order fromresultCode. manualCapture: trueas a boolean, or never capturing (authorisations expire); recording iDEAL, Swish or Trustly payments as “authorized” when they were captured at once.- Reading
operationsfrom theAUTHORISATIONwebhook to decide anything: Adyen marks it experimental.
Dintero with Crystallize
Dintero is a Norwegian payment company whose Dintero Checkout puts cards, Vipps, MobilePay, Swish, Apple Pay, Google
Pay, Klarna, Walley, Two and Kravia invoices behind one session API, for Nordic merchants selling in NOK, SEK, DKK and
EUR to consumers (B2C) and companies (B2B). The recommended integration creates a checkout session from a payment
profile on the server, from the placed cart, shows it with @dintero/checkout-web-sdk (embedded, or a redirect to the
hosted page), and creates the order from the signed callback_url after re-fetching the transaction. Payments are
authorized at checkout and captured when the goods ship (manual capture by default; auto-capture is optional).
Verification: Written from Dintero’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Quickstart, Create session API, Handling payment, Validating callbacks, Transaction management, Web SDK. Every docs page is also served as Markdown (append
.md); the index is llms.txt.
At a glance
| Markets & currencies | Nordics: NOK, SEK, DKK, EUR; Vipps (NO), Swish (SE), MobilePay (DK) (list unconfirmed) |
| Recommended | POST …/payments/sessions-profile (methods from a payment profile), embedded Web SDK |
| Alternative | Redirect to the hosted page: redirect({ sid }) or the session url |
| API version | api.dintero.com/v1/accounts/{aid}/payments/…; the old checkout.dintero.com/v1 works |
| SDKs | Browser @dintero/checkout-web-sdk@0.14; server @dintero/node-sdk@1 (optional) |
| Amount units | Integer minor units (29990 = 299.90 NOK); items[].amount = line total incl. VAT |
| Capture | Manual (default). Authorization: cards ~7 days, Vipps 5–30, Klarna 28+, Walley 90 days |
| Cart id | order.merchant_reference, on every transaction and callback (≤ 35 chars with Kravia) |
| One session per cart | No idempotency key: find the cart’s open session and reuse it |
| Verification | Signed GET callback_url (Dintero-Signature, HMAC-SHA256 of the URL) + re-fetch |
Credentials and setup
- Sign up at https://onboarding.dintero.com. The crystallize.com page names three values, all in Backoffice
(https://backoffice.dintero.com) → Settings: the client id and client secret (API clients → Create new
API client → Checkout client; the secret is shown once, API client) and the account id, which
differs between test and production:
T12345678is the sandbox,P12345678is live (Dintero’s quickstart creates new credentials on thePaccount to go live). - Profile id: Settings → Payment profiles; new accounts have
default. Payment methods, their order and the theme live on the profile, so enabling a method needs no deploy (payment profiles). - Signature secret, once per account:
POST https://checkout.dintero.com/v1/admin/signaturewith a bearer token returnssignature.secret(API); from then on every callback carriesDintero-Signature. - Test data (cards), any future expiry and CVC: Visa
4000 0000 0000 0002(no 3DS challenge),4000 1000 0000 0000(challenge), Mastercard5200 0000 0000 0007, declined4100 0000 0000 0076, capture time-out4100 0000 0000 0019. Klarna, Norway:customer@email.no,+4740123456(Klarna test data). - Reaching localhost:
callback_urlmust be public HTTPS (https://localhostis refused): use a tunnel asPUBLIC_URL. Backoffice shows each transaction’s callbacks and your answers. Callbacks come from34.241.230.119and34.242.13.162.
DINTERO_ACCOUNT_ID=T12345678 # P12345678 in productionDINTERO_CLIENT_ID=…DINTERO_CLIENT_SECRET=…DINTERO_PROFILE_ID=defaultDINTERO_SIGNATURE_SECRET=… # signature.secret from POST /v1/admin/signaturePUBLIC_URL=https://shop.example # the tunnel in development: Dintero signs this hostCreate the payment
Call it from the Pay route right after place (SKILL.md). The token
lasts 4 hours: cache it. With Dintero-Feature-Toggles: strict-session-amounts, Dintero refuses a session whose lines
do not add up to order.amount instead of failing at capture. A session yields at most one transaction, so reusing the
cart’s open session keeps two tabs on one payment. Only the older base lists sessions (search matches
merchant_reference); how soon a new session becomes searchable is unconfirmed, so two clicks in the same instant can still open two
sessions; the duplicate-payment flag in createOrderOnce covers that.
import { createHmac, timingSafeEqual } from "node:crypto";import { recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import type { Payment, PlacedCart } from "@/lib/crystallize-payments";
const AID = process.env.DINTERO_ACCOUNT_ID!;const API = `https://api.dintero.com/v1/accounts/${AID}`;const minor = (major: number) => Math.round(major * 100); // NOK, SEK, DKK, EUR: 2 decimalslet token: { value: string; expiresAt: number } | undefined;
async function accessToken() { if (token && token.expiresAt - Date.now() > 60_000) return token.value; const basic = btoa(`${process.env.DINTERO_CLIENT_ID}:${process.env.DINTERO_CLIENT_SECRET}`); const res = await fetch(`${API}/auth/token`, { method: "POST", headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "client_credentials", audience: API }), // audience = the account URL }); if (!res.ok) throw new Error(`Dintero auth ${res.status}`); const json = (await res.json()) as { access_token: string; expires_in: number }; token = { value: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 }; return token.value;}
/** `path` is under …/payments, or a full URL on the older base. A body makes it a POST. */export async function dintero<T>(path: string, body?: object, headers: Record<string, string> = {}): Promise<T> { const res = await fetch(path.startsWith("https://") ? path : `${API}/payments${path}`, { method: body ? "POST" : "GET", headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json", ...headers }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`Dintero ${path} ${res.status}: ${await res.text()}`); return (await res.json()) as T;}
export type DinteroTx = { id: string; status: string; amount: number; merchant_reference: string; payment_product_type: string; created_at: string; items?: { line_id: string; quantity: number; amount: number }[]; events?: { event: string; success: boolean; amount?: number; event_reference?: string }[];};type Session = { id: string; transaction_id?: string; expires_at?: string; cancelled_at?: string; order: Order };type Order = { amount: number; merchant_reference: string };const PAID = ["ON_HOLD", "AUTHORIZED", "CAPTURED", "PARTIALLY_CAPTURED"];
/** Lines add up exactly to what place returned (max 100 lines). */function dinteroOrder(placed: PlacedCart) { const items = placed.items.map((item, i) => ({ id: item.variant?.sku ?? `line-${i + 1}`, // external items (shipping, fees) may have no variant line_id: item.lineId ?? String(i + 1), // unique; capture and refund name lines by it type: item.type === "shipping" ? "shipping" : undefined, description: item.name, quantity: item.quantity, amount: minor(item.price.gross), // the line total, incl. VAT and discounts vat_amount: minor(item.price.taxAmount), // "display only", but never 0 on a taxed line vat: item.price.taxPercent, // 25, not 0.25 })); const amount = minor(placed.total.gross); items[items.length - 1].amount += amount - items.reduce((s, l) => s + l.amount, 0); // rounding cents const vat_amount = items.reduce((s, l) => s + l.vat_amount, 0); // order.vat_amount = Σ items.vat_amount return { amount, vat_amount, currency: placed.total.currency.toUpperCase(), items };}
export async function createDinteroSession( placed: PlacedCart, origin: string,): Promise<{ sid: string } | { paid: true }> { // No idempotency key on create: reuse the cart's live session (two tabs, double clicks). A click in the // same instant can still open a second session; the duplicate-payment flag in createOrderOnce covers it. const order = dinteroOrder(placed); const search = `https://checkout.dintero.com/v1/sessions?search=${encodeURIComponent(placed.id)}&limit=10`; for (const s of await dintero<Session[]>(search)) { if (s.order.merchant_reference !== placed.id || s.cancelled_at) continue; if (s.transaction_id) { const tx = await dintero<DinteroTx>(`/transactions/${s.transaction_id}`); if (PAID.includes(tx.status)) return { paid: true }; // send the shopper to the return page } else if (s.order.amount === order.amount && Date.parse(s.expires_at ?? "") > Date.now() + 120_000) { return { sid: s.id }; // the same session for every tab } } const parties = dinteroParties(placed); // customer, billing address, B2B or B2C: Provider specifics const session = await dintero<{ id: string; url: string }>( "/sessions-profile", { profile_id: process.env.DINTERO_PROFILE_ID ?? "default", url: { return_url: `${origin}/checkout/dintero/return?cart=${placed.id}`, // works in another browser callback_url: `${process.env.PUBLIC_URL}/api/payments/dintero/webhook`, // called with GET }, customer: parties.customer, order: { ...order, billing_address: parties.billing, merchant_reference: placed.id }, configuration: { default_customer_type: parties.type }, }, { "Dintero-Feature-Toggles": "strict-session-amounts" }, ); return { sid: session.id };}Client
Embed the session, or redirect to Dintero’s page; both need only the sid:
"use client";import { embed } from "@dintero/checkout-web-sdk";import { useEffect, useRef } from "react";
export function DinteroCheckout({ sid, language }: { sid: string; language: string }) { const container = useRef<HTMLDivElement>(null); useEffect(() => { // No onPayment* handlers: the SDK then sends the shopper to return_url itself. const checkout = embed({ container: container.current!, sid, language }); return () => void checkout.then((c) => c.destroy()); }, [sid, language]); return <div ref={container} />;}// Redirect instead: import { redirect } from "@dintero/checkout-web-sdk"; redirect({ sid });Iframe events are not guaranteed: after paying in the Vipps app the return_url can open in a new tab, or in
another browser. So the page takes the cart id from its own ?cart=; Dintero adds transaction_id,
merchant_reference and, on failure, error (cancelled, authorization, failed, capture). The page only reads
(SKILL.md): ordered → confirmation; placed without error → “confirming your
payment…” and refresh; error → say so and offer “Try again”, which calls the Pay route and gets the same open
session back.
Webhook
Dintero calls callback_url with GET and the query transaction_id, session_id, merchant_reference, time
(handling payment). There is no body to read: the signature covers the timestamp, account id, method, host,
path and sorted query of the URL it called (validating callbacks), so rebuild that URL on the public host
(behind a proxy req.url differs). Then re-fetch the transaction, as Dintero requires, and take the cart id from it.
Retries: 20 times on 1xx/5xx, a 10-second timeout or a connection error; any 4xx is final.
// lib/dintero.ts (continued)export function verifyDintero(req: Request): boolean { const header = req.headers.get("dintero-signature") ?? ""; // t=<unix>,v0-hmac-sha256=<hex> const t = /t=(\d+)/.exec(header)?.[1]; const given = /v0-hmac-sha256=([0-9a-f]+)/.exec(header)?.[1]; if (!t || !given || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay window: 5 minutes const called = new URL(req.url); const url = new URL(called.pathname + called.search, process.env.PUBLIC_URL); url.searchParams.sort(); // toString() then encodes spaces as "+", as Dintero does const payload = [t, AID, req.method, url.hostname, url.pathname, url.searchParams.toString()].join("\n"); const want = createHmac("sha256", process.env.DINTERO_SIGNATURE_SECRET!).update(payload, "utf8").digest(); const got = Buffer.from(given, "hex"); return got.length === want.length && timingSafeEqual(got, want);}// app/api/payments/dintero/webhook/route.ts — GET: a POST-only route never receives a callbackimport { createOrderOnce } from "@/lib/crystallize-payments";import { dintero, toPayment, verifyDintero, type DinteroTx } from "@/lib/dintero";
export async function GET(req: Request) { if (!verifyDintero(req)) return new Response("bad signature", { status: 401 }); const transactionId = new URL(req.url).searchParams.get("transaction_id"); if (!transactionId) return new Response("ignored"); try { const tx = await dintero<DinteroTx>(`/transactions/${encodeURIComponent(transactionId)}`); const cartId = tx.merchant_reference; // from Dintero, not from the query string if (tx.status === "AUTHORIZED") await createOrderOnce(cartId, "unpaid", toPayment(tx, "authorized")); if (tx.status === "CAPTURED") await createOrderOnce(cartId, "paid", toPayment(tx, "captured")); return new Response("ok"); // ON_HOLD: a second callback follows. FAILED, DECLINED: no order } catch (error) { console.error(error); return new Response("retry", { status: 503 }); // never 4xx for your own failure: Dintero would stop }}| Transaction status | Crystallize (paymentStatus) |
|---|---|
AUTHORIZED |
createOrderOnce(cartId, 'unpaid', …), state=authorized |
CAPTURED (auto-capture, Swish) |
createOrderOnce(cartId, 'paid', …), state=captured (no AUTHORIZED first) |
ON_HOLD (manual review) |
Nothing yet: another callback comes when it turns AUTHORIZED or FAILED |
FAILED, DECLINED |
No order. The cart stays placed; the shopper retries |
PARTIALLY_CAPTURED, REFUNDED … |
Only after your own calls below, which write Crystallize themselves |
A redelivery for a transaction already on the order is a no-op (recordPayment dedupes on transactionId). A second
paid session for the same cart is recorded with attention=duplicate-payment: void it.
Capture, refund, cancel
The calls answer with the updated transaction (transaction management); 202 means still processing. Dintero
documents no idempotency key, so each function re-reads the transaction first, and refunds carry your
refund_reference. “Captures might fail”: check the returned status.
// lib/dintero.ts (continued)const captured = (tx: DinteroTx) => (tx.events ?? []).filter((e) => e.event === "CAPTURE" && e.success).reduce((s, e) => s + (e.amount ?? 0), 0);
/** captureByProvider.dintero — the captured amount in major units. Never null: a `202` throws and is retried. */export async function capture(transactionId: string, amount: number): Promise<number | null> { const before = await dintero<DinteroTx>(`/transactions/${transactionId}`); if (before.status === "CAPTURED") return captured(before) / 100; // a retry after a capture that went through if (before.status !== "AUTHORIZED") throw new Error(`Dintero ${transactionId} is ${before.status}`); const lines = minor(amount) === before.amount ? before.items : undefined; // Instabank needs the lines const tx = await dintero<DinteroTx>(`/transactions/${transactionId}/capture`, { amount: minor(amount), items: lines?.map(({ line_id, quantity, amount }) => ({ line_id, quantity, amount })), }); if (!tx.status.endsWith("CAPTURED")) throw new Error(`Dintero capture: ${tx.status}`); // 202: retried later return captured(tx) / 100;}
/** A return. `refundId` is yours (return or credit-note id): a retry with it never refunds twice. */export async function refund(cartId: string, transactionId: string, amount: number, refundId: string) { const done = (tx: DinteroTx) => tx.events?.some((e) => e.event === "REFUND" && e.success && e.event_reference === refundId); let tx = await dintero<DinteroTx>(`/transactions/${transactionId}`); if (!done(tx)) { const body = { amount: minor(amount), refund_reference: refundId }; tx = await dintero<DinteroTx>(`/transactions/${transactionId}/refund`, body); if (!done(tx)) throw new Error(`Dintero refund ${refundId}: ${tx.status}`); } await recordPayment(cartId, { provider: "dintero", method: tx.payment_product_type, transactionId: `${transactionId}:${refundId}`, amount, createdAt: new Date().toISOString(), meta: [ { key: "type", value: "refund" }, { key: "cartId", value: cartId }, ], });}
/** Before capture only (a "Cancelled" stage): releases the reservation. */export async function cancel(cartId: string, transactionId: string) { let tx = await dintero<DinteroTx>(`/transactions/${transactionId}`); if (tx.status !== "AUTHORIZATION_VOIDED") tx = await dintero<DinteroTx>(`/transactions/${transactionId}/void`, {}); if (tx.status !== "AUTHORIZATION_VOIDED") throw new Error(`Dintero void: ${tx.status}`); await updatePayment(cartId, transactionId, (p) => withMeta(p, { state: "cancelled" }));}The pipeline handler calls capture before the
authorization lapses (durations). Void, never refund, an authorized transaction; to drop lines, capture less.
That a refund event carries refund_reference as event_reference is read from the API schema (unconfirmed).
Mapping
// lib/dintero.ts (continued)export const toPayment = (tx: DinteroTx, state: "authorized" | "captured"): Payment => ({ provider: "dintero", method: tx.payment_product_type, // dintero_psp.creditcard, vipps, swish.swish, klarna.klarna, two.invoice_b2b … transactionId: tx.id, // "T12345678.465Uf…": capture, refund and void take it amount: tx.amount / 100, // major units createdAt: tx.created_at, meta: [ { key: "state", value: state }, { key: "cartId", value: tx.merchant_reference }, ],});A refund is a second record: transactionId = <transaction id>:<your refund id>, the refunded amount, meta type=refund and cartId (the payment record). Lines stay on the transaction.
Provider specifics
B2B and B2C. One profile serves both. configuration.default_customer_type (b2c | b2b) preselects the type,
and the company the buyer gave your storefront — on the cart before place (setCustomer with type organization,
companyName, taxNumber, addresses) — prefills the billing address. Two (two.invoice_b2b) requires names,
address_line, postal_code, country, phone_number, email, business_name and organization_number (9 digits
in Norway, 10 or 12 in Sweden) (Two via Dintero).
// lib/dintero.ts (continued)function dinteroParties(placed: PlacedCart) { const c = placed.customer; const b2b = c?.type === "organization" || !!c?.companyName; const a = c?.addresses?.find((x) => x.type === "billing") ?? c?.addresses?.[0]; const phone = (a?.phone ?? c?.phone)?.startsWith("+") ? (a?.phone ?? c?.phone) : undefined; // E.123: +47… const billing = a && { first_name: a.firstName ?? c?.firstName, last_name: a.lastName ?? c?.lastName, address_line: [a.street, a.streetNumber].filter(Boolean).join(" "), postal_code: a.postalCode, postal_place: a.city, country: a.country, // ISO 3166 alpha-2 email: a.email ?? c?.email, phone_number: phone, ...(b2b ? { business_name: c?.companyName, organization_number: c?.taxNumber } : {}), }; return { type: b2b ? "b2b" : "b2c", customer: { email: c?.email, phone_number: phone }, billing };}Checkout Express for B2B. Dintero’s company lookup (registries in Norway and Denmark; Walley’s own for Walley
B2B, which needs it) runs in Checkout Express (express): add express: { shipping_options: [], shipping_mode: "shipping_not_required", customer_types: ["b2b"] } — no Dintero-side shipping — and, for Walley, send
only shipping_address.country and organization_number (Walley B2B). The confirmed company is then on the
transaction, while the order’s customer comes from the cart: compare organization_number in the webhook.
After payment (no code): set merchant_reference_2 to the Core order id for reconciliation
(PUT /transactions/{id}); raise a Klarna or Billie authorization before capture
(POST /transactions/{id}/authorization, new amount and all items). A Two or Walley CAPTURED means an invoice
was sent, not that money arrived.
Going further
- Dintero-side shipping (Express
shipping_options,shipping_address_callback_url, pick-up points) and Express discount codes — warning: Dintero then charges more (or less) than the placed cart, and the Crystallize order has no shipping line. Choose shipping in the storefront beforeplace(shipping options). - Account-wide webhooks: subscribe to
checkout_transaction(Backoffice → Settings → Webhooks, orPOST /v1/accounts/{aid}/hooks/subscriptions) to hear about captures, refunds and voids made in Backoffice. Deliveries carryevent-signature, HMAC-SHA1 of the raw body; trust an event’scorrection.statusover itssuccess(checkout webhook). Or addreport_event=CAPTURE(REFUND,VOID) tocallback_url. - Auto-capture (
configuration.auto_capture, or on the profile): only for goods handed over at once; Dintero retries it for 48 h, calls back only onceCAPTURED, and keeps the fee on refunds (auto-capture). - Cancel the old session when the shopper goes back and builds a new cart:
POST /sessions/{id}/cancel(optional: a paid old session still pays exactly the old placed cart). - Recurring payments: card tokens and merchant-initiated
POST /sessions/pay(tokenization). Also: payment links by SMS or e-mail, split payments for marketplaces, Kravia invoices, in-person terminals.
Common mistakes
- A callback route that only accepts POST: Dintero calls
callback_urlwith GET, so no order is ever created. - Trusting the callback’s query, or passing the whole request as the transaction id, instead of re-fetching
/transactions/{transaction_id}and readingmerchant_referencefrom it; or noDintero-Signaturecheck at all. - Creating the order only on
AUTHORIZED: with auto-capture or Swish the one callback saysCAPTURED. - Comparing against
AUTHORISED(British spelling): the API’s status isAUTHORIZED. - Answering a 4xx for your own problem (an unknown cart → 404): Dintero never retries a 4xx.
gross * 100without rounding,vat_amount: 0on taxed lines, or lines that do not add up toorder.amount.- Letting Express checkout pick (hard-coded) shipping: the shipping cost never reaches the Crystallize order.
- Overwriting the order’s customer with the transaction’s shipping address after payment.
- A new access token on every call (it lasts 4 hours), or a new session on every click.
- A 36-character cart UUID as
merchant_referencewith Kravia enabled: set a shortermerchant_reference_2. - Refunding an authorized transaction (void it), or never capturing: card authorizations lapse after about a week.
Klarna with Crystallize
Klarna is a Swedish bank whose pay later, pay in parts, financing and pay now (card, bank) options sell in 26
countries across Europe, North America and Oceania, each in its local currency. The recommended integration is
Klarna Payments with the Hosted Payment Page (HPP): create a Klarna Payments session from the placed cart,
redirect the shopper to Klarna’s page, let Klarna place the order (place_order_mode: PLACE_ORDER), and confirm it
server-side by re-reading the session from Klarna. The embedded Klarna Payments widget with a server-side
authorization callback is the alternative. Klarna only authorizes at checkout: capture with the Order Management
API when the goods ship, within 28 days by default.
Verification: Written from Klarna’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Klarna Payments, Hosted Payment Page, HPP status callbacks, JavaScript SDK, Authorization callback, Order Management, Tax handling, Payments API, Order Management API. Klarna’s docs now live under
docs.klarna.com/acquirer/klarna/. Crystallize page: Klarna.
At a glance
| Topic | Klarna |
|---|---|
| Markets & currencies | 26 countries (list): Europe, US, CA, MX, AU, NZ; local currency only; one agreement each |
| Recommended integration | Payments session → HPP session → redirect, place_order_mode: PLACE_ORDER (Klarna places it) |
| Alternative | Embedded widget (x.klarnacdn.net/kp/lib/v1/api.js) + authorization callback; you place it |
| API version | Payments v1, HPP v1, Order Management v1. Klarna Checkout (KCO) is now Kustom: not here |
| SDKs | No server SDK: fetch + HTTP Basic. The widget is a CDN script; HPP needs no client code |
| Amount units | Integer minor units (2500 = 25.00); tax_rate in basis points (2500 = 25 %) |
| Capture + auth lifetime | Manual, Order Management. 28 days (up to 180 by agreement); extendable within 180 days |
| Cart id field | merchant_reference1 (≤ 255 chars; the shopper sees it as the order number): the full cart id |
| One session per cart | No Klarna mechanism (no idempotency key on Payments, references not unique): see below |
| Notification verification | Unsigned callbacks: an HMAC token in each callback URL, then re-read session and order |
Credentials and setup
The crystallize.com page asks for a username and a password: Klarna’s API key ID and secret. Start with a test (playground) account; a live account comes with the merchant agreement.
- Playground account: on docs.klarna.com, Log in → your region → Playground → Sign up, activate
the e-mail, then open the playground Merchant portal (
portal.playground.klarna.com). - API key: Merchant portal → Payment settings → Klarna API keys → Generate new Klarna API key, and download the file: Key ID = username, Secret = password. The secret is shown once. Playground keys only work against playground URLs; the live portal issues live keys.
- Base URL per region (API URLs): Europe
https://api.klarna.com, North Americahttps://api-na.klarna.com, Oceaniahttps://api-oc.klarna.com; playground:api.playground.klarna.com,api-na.playground.klarna.com,api-oc.playground.klarna.com. Keys are per region. - Callbacks: every
merchant_urlsentry must match^https://and be reachable, so tunnel localhost (cloudflared, ngrok). Nothing is registered in the portal: each URL travels with its session.
KLARNA_API_URL=https://api.playground.klarna.com # https://api.klarna.com live (EU)KLARNA_USERNAME=... # API key IDKLARNA_PASSWORD=... # API key secretKLARNA_CALLBACK_SECRET=... # 32+ random bytes: signs the callback URLsPUBLIC_URL=https://shop.example # the tunnel URL in development- Test shoppers (sample customers):
customer+se@klarna.comis approved andcustomer+se+denied@klarna.comdeclined (use the purchase country’s code; the denied flow cannot be tested in every market, e.g. Sweden and Norway). New accounts take any 6-digit OTP except999999. - Test payments (sample payment data): card
4111 1111 1111 1111, CVC123, any future expiry;4687 3888 8888 8881triggers 3-D Secure; direct debit IBANDE11 5205 1373 5120 7101 31; bank transfer “Demo Bank”. - Debugging: every error body has a
correlation_id; find it under Logs in the Merchant portal (7 days).
Create the payment
Call this from the pay route in SKILL.md with the placed cart.
Klarna needs order lines with tax, and their total_amounts must add up to order_amount exactly, so the lines are
built from the placed cart and checked against placed.total.gross.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";import { carts, createOrderOnce, PLACED_CART, readOrder, recordPayment, updatePayment, withMeta,} from "@/lib/crystallize-payments";import type { Payment, PlacedCart } from "@/lib/crystallize-payments";
const KLARNA = process.env.KLARNA_API_URL!;const AUTH = `Basic ${btoa(`${process.env.KLARNA_USERNAME}:${process.env.KLARNA_PASSWORD}`)}`;export const minor = (major: number) => Math.round(major * 100); // every Klarna currency has 2 decimals
export async function call(path: string, init: RequestInit = {}) { const res = await fetch(KLARNA + path, { ...init, headers: { Authorization: AUTH, "Content-Type": "application/json", ...init.headers }, }); if (!res.ok) throw new Error(`Klarna ${init.method ?? "GET"} ${path} ${res.status}: ${await res.text()}`); return res;}export const klarna = async <T>(path: string, init?: RequestInit) => (await call(path, init)).json() as Promise<T>;
// Klarna callbacks are unsigned: an HMAC of (purpose, cart id) in the URL proves the URL is yoursconst sign = (purpose: string, cartId: string) => createHmac("sha256", process.env.KLARNA_CALLBACK_SECRET!).update(`${purpose}:${cartId}`).digest("hex");export const signedUrl = (path: string, purpose: string, cartId: string) => `${process.env.PUBLIC_URL}${path}?cart=${cartId}&token=${sign(purpose, cartId)}`;export function verifySignedUrl(url: string, purpose: string) { const q = new URL(url).searchParams; const cartId = q.get("cart") ?? ""; const [got, want] = [Buffer.from(q.get("token") ?? ""), Buffer.from(sign(purpose, cartId))]; return got.length === want.length && timingSafeEqual(got, want) ? cartId : null;}
// Deterministic, UUID-shaped Klarna-Idempotency-Key: a retry of the same operation reuses it (Klarna keeps it 24 h)export const keyFor = (operation: string) => { const h = createHash("sha256").update(operation).digest("hex"); return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;};
const LINE_TYPE: Record<string, string> = { shipping: "shipping_fee", fee: "surcharge", promotion: "discount", digital: "digital", // the rest: physical};
export function orderLines(placed: PlacedCart) { const lines = placed.items.map((item) => { const total = minor(item.price.gross); // the line total, discounts included const rate = Math.round(item.price.taxPercent * 100); // 25 % → 2500 const even = Math.ceil(total / item.quantity); const unit = total >= 0 && item.variant ? Math.max(minor(item.variant.price.gross), even) : even; // list price return { type: LINE_TYPE[item.type ?? ""] ?? "physical", reference: item.variant?.sku ?? item.lineId ?? undefined, name: item.name, quantity: item.quantity, unit_price: unit, total_discount_amount: unit * item.quantity - total, // never negative total_amount: total, tax_rate: rate, total_tax_amount: total - Math.round((total * 10000) / (10000 + rate)), // Klarna's formula, < 0 if discount }; }); const order_amount = minor(placed.total.gross); const drift = order_amount - lines.reduce((sum, l) => sum + l.total_amount, 0); if (Math.abs(drift) > lines.length) throw new Error(`Lines miss ${drift} of cart ${placed.id}: cart discount?`); if (drift !== 0) { const total_amount = drift; // cents lost to rounding: one untaxed line keeps the sum exact lines.push({ type: drift < 0 ? "discount" : "surcharge", reference: "rounding", name: "Rounding", quantity: 1, unit_price: total_amount, total_discount_amount: 0, total_amount, tax_rate: 0, total_tax_amount: 0, }); } const order_tax_amount = lines.reduce((sum, l) => sum + l.total_tax_amount, 0); return { order_amount, order_tax_amount, order_lines: lines };}
export type KlarnaMarket = { country: string; locale: string }; // from the Crystallize market, e.g. SE + sv-SEexport type KlarnaCategory = { identifier: string; name: string; asset_urls: { standard: string } };
export const createKpSession = (placed: PlacedCart, market: KlarnaMarket, merchant_urls?: object) => klarna<{ session_id: string; client_token: string; payment_method_categories: KlarnaCategory[] }>( "/payments/v1/sessions", { method: "POST", body: JSON.stringify({ acquiring_channel: "ECOMMERCE", intent: "buy", purchase_country: market.country, // the shopper's billing country purchase_currency: placed.total.currency.toUpperCase(), locale: market.locale, // a pair Klarna lists for that country ...orderLines(placed), merchant_reference1: placed.id, // the cart id = the Crystallize order id merchant_urls, }), }, );
/** Recommended: the Hosted Payment Page. Returns where to send the shopper. */export async function createKlarnaPayment(placed: PlacedCart, market: KlarnaMarket) { const kp = await createKpSession(placed, market); const checkout = `${process.env.PUBLIC_URL}/checkout`; const hpp = await klarna<{ session_id: string; redirect_url: string }>("/hpp/v1/sessions", { method: "POST", body: JSON.stringify({ payment_session_url: `${KLARNA}/payments/v1/sessions/${kp.session_id}`, merchant_urls: { success: `${checkout}/confirmation?cart=${placed.id}&sid={{session_id}}&order_id={{order_id}}`, cancel: checkout, back: checkout, failure: `${checkout}?payment=failed`, error: `${checkout}?payment=error`, status_update: signedUrl("/api/payments/klarna/webhook", "status", placed.id), }, options: { place_order_mode: "PLACE_ORDER" }, // Klarna places the order, even if the redirect fails }), }); console.info(`[klarna] cart ${placed.id} → HPP session ${hpp.session_id}`); // for reconciliation, see Webhook return hpp.redirect_url;}- One session per cart. Klarna offers nothing: the Payments API takes no idempotency key and
merchant_reference1is not unique, and the placed cart cannot store a session id. Two tabs get two sessions; if both are paid,createOrderOncerecords the second withattention=duplicate-paymentand someone cancels that Klarna order (it was never captured, so nothing to refund). The widget flow releases the second authorization before it becomes an order (Provider specifics). - US: no
tax_rateortotal_tax_amounton product lines; send line totals without tax plus onetype: "sales_tax"line named “Sales Tax”, andorder_tax_amount= that line’stotal_amount(tax). - Lifetimes: a Payments session lives 48 h (or until an order is placed); its HPP session expires 1 h earlier.
- Limits: at most 1000 lines;
unit_priceandtotal_amount≤ 200 000 000;tax_rate0–10000.
Client
The pay route answers with the redirect_url; the browser goes there (location.assign). Klarna hosts the login,
the method choice and 3-D Secure, and recommends this redirect over the widget’s pop-up on mobile browsers.
successis the return page from SKILL.md. It reads the cart named bycartin its URL (the shopper may come back in another browser, without your cookie):ordered→ thank you, clear the cart cookie; stillplaced→ “confirming your payment…” and refresh. While waiting it may read the HPP session (GET /hpp/v1/sessions/{sid}) to tell “still confirming” from aFAILEDorCANCELLEDsession. It never places or creates anything: theorder_idin its URL proves nothing.cancel,back,failureanderrorland on checkout with the cart still placed. Pay again creates a new session for the same placed cart; changing the cart means a new cart.
Webhook
HPP POSTs { event_id, session: { session_id, status, order_id?, klarna_reference? } } to status_update on every
status change. Nothing is signed: the route checks the URL’s token, re-reads the HPP session and the Klarna order,
and checks that the order is this cart’s, for this cart’s amount. HPP wants a 2xx within 3 seconds and makes at most
4 calls per event, a few seconds apart (status callbacks). A slower answer only means a retry, which
createOrderOnce absorbs.
// lib/klarna.ts (continued). Keep helpers here: a Next route file may only export HTTP methods.export type KlarnaOrder = { order_id: string; merchant_reference1: string; klarna_reference: string; status: "AUTHORIZED" | "PART_CAPTURED" | "CAPTURED" | "CANCELLED" | "EXPIRED" | "CLOSED"; fraud_status: "ACCEPTED" | "PENDING" | "REJECTED"; order_amount: number; captured_amount: number; remaining_authorized_amount: number; // minor units initial_payment_method?: { type: string };};
export async function handleHppSession(cartId: string, hppSessionId: string) { const hpp = await klarna<{ status: string; order_id?: string }>(`/hpp/v1/sessions/${hppSessionId}`); if (hpp.status !== "COMPLETED" || !hpp.order_id) return; // the body was only a hint const o = await klarna<KlarnaOrder>(`/ordermanagement/v1/orders/${hpp.order_id}`); const placed = (await carts.fetch(cartId, PLACED_CART)) as unknown as PlacedCart; if (o.merchant_reference1 !== cartId || o.order_amount !== minor(placed.total.gross)) { throw new Error(`Klarna order ${o.order_id} does not match cart ${cartId}`); // alert a human } const meta = { klarnaReference: o.klarna_reference, fraudStatus: o.fraud_status }; const payment = toPayment(cartId, o.order_id, o.order_amount, o.initial_payment_method?.type, meta); await createOrderOnce(cartId, "unpaid", payment);}// app/api/payments/klarna/webhook/route.ts — HPP status_updateimport { handleHppSession, verifySignedUrl } from "@/lib/klarna";
export async function POST(req: Request) { const body = await req.text(); const cartId = verifySignedUrl(req.url, "status"); if (!cartId) return new Response("bad token", { status: 401 }); const { session } = JSON.parse(body) as { event_id: string; session: { session_id: string } }; try { await handleHppSession(cartId, session.session_id); return new Response(null, { status: 204 }); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // RetryLater and real failures alike }}| Klarna | Crystallize (paymentStatus) |
|---|---|
HPP COMPLETED, order fraud_status: ACCEPTED |
createOrderOnce(cartId, 'unpaid', …), state=authorized |
fraud_status: PENDING (US/UK, when enabled) |
Same; capture() refuses until Klarna’s notification URL reports a decision |
IN_PROGRESS, WAITING, BACK, FAILED, ERROR |
Nothing: the session is still open and the shopper can retry |
CANCELLED, DISABLED, TIMEOUT |
No order. Pay again opens a new session for the same placed cart |
| Authorization expired or cancelled in the portal (no push) | capture() fails; cancel() below → state=cancelled, cancelled stage |
| Widget: authorization callback | Provider specifics: place the Klarna order, then the same |
If every call fails (your endpoint down for those seconds), Klarna has an order that Crystallize lacks. Re-run
handleHppSession(cartId, hppSessionId) from an admin action or a scheduled job with the ids logged by
createKlarnaPayment: it is idempotent. Never let the return page do it.
Capture, refund, cancel
Every Order Management POST takes a Klarna-Idempotency-Key; Klarna applies an operation once per key for 24 h and
ignores the body when it compares, so one key per operation, never one per order. The 201/204 is the
confirmation: no need to poll the order afterwards.
// lib/klarna.ts (continued) — captureByProvider.klarna = capture. Synchronous, so never null; `record` unused.export async function capture(transactionId: string, amount: number): Promise<number | null> { const o = await klarna<KlarnaOrder>(`/ordermanagement/v1/orders/${transactionId}`); if (o.status === "CANCELLED" || o.status === "EXPIRED" || o.fraud_status !== "ACCEPTED") { throw new Error(`Klarna order ${transactionId} is ${o.status}/${o.fraud_status}: not capturable`); } const todo = Math.min(minor(amount) - o.captured_amount, o.remaining_authorized_amount); if (todo > 0) { await call(`/ordermanagement/v1/orders/${transactionId}/captures`, { method: "POST", headers: { "Klarna-Idempotency-Key": keyFor(`capture:${transactionId}:${o.captured_amount}`) }, body: JSON.stringify({ captured_amount: todo }), // add order_lines and shipping_info (carrier, tracking) }); } return (o.captured_amount + Math.max(todo, 0)) / 100; // a retry after a capture that went through returns it}
/** `requestId` identifies this refund in your system (a return id): retries reuse it. */export async function refund(cartId: string, orderId: string, amount: number, requestId: string) { const res = await call(`/ordermanagement/v1/orders/${orderId}/refunds`, { method: "POST", headers: { "Klarna-Idempotency-Key": keyFor(`refund:${requestId}`) }, body: JSON.stringify({ refunded_amount: minor(amount) }), // order_lines let Klarna pick the right invoice }); await recordPayment(cartId, { provider: "klarna", method: "refund", transactionId: res.headers.get("Refund-Id")!, // 201 Created: the id is a header, the body is empty amount, createdAt: new Date().toISOString(), meta: [ { key: "type", value: "refund" }, { key: "cartId", value: cartId }, { key: "klarnaOrderId", value: orderId }, ], });}
/** Before any capture: releases the whole authorization. */export async function cancel(cartId: string, orderId: string) { await call(`/ordermanagement/v1/orders/${orderId}/cancel`, { method: "POST", headers: { "Klarna-Idempotency-Key": keyFor(`cancel:${orderId}`) }, }); await updatePayment(cartId, orderId, (p) => withMeta(p, { state: "cancelled" }));}- Partial capture: capture what shipped, then
POST …/release-remaining-authorization(204) once nothing more will ship.cancelfails once anything is captured (CANCEL_NOT_ALLOWED); refunds need a capture first. - Extend:
POST …/orders/{id}/extend-authorization-time(204) sets the expiry to today + your account’s period (28 days by default) (extension rules). Only within 180 days of the purchase, never after expiry, not for “Pay now” card orders nor US financing. Klarna asks for it only in exceptional delays; made-to-order shops agree a longer period (up to 180 days) at onboarding instead. - Retries: for
5xxretry with the same key (Klarna suggests 5 s, 5 min, 5 h);4xxand409need a human (escalation and retry policy).
Mapping
The order’s order_id is the transactionId: every Order Management call takes it.
// lib/klarna.ts (continued)export const toPayment = ( cartId: string, orderId: string, amountMinor: number, method: string | undefined, extra: Record<string, string>,): Payment => ({ provider: "klarna", method: method?.toLowerCase() ?? "klarna", // invoice, pay_in_x, card, direct_debit, … transactionId: orderId, amount: amountMinor / 100, // MAJOR units createdAt: new Date().toISOString(), meta: [ { key: "state", value: "authorized" }, { key: "cartId", value: cartId }, ...Object.entries(extra).map(([key, value]) => ({ key, value })), // klarnaReference: what support asks for ],});A refund is its own record: transactionId = the Refund-Id header, amount in major units, meta type=refund,
cartId and klarnaOrderId (see refund() above). Capture keeps the record and sets state=captured with the
captured amount, through the stage handler in SKILL.md.
Provider specifics
The embedded widget and its payment method categories
The session’s payment_method_categories (identifier, name, asset_urls.standard) drive the choice shown in
your checkout. Today most accounts get one category, klarna (“Pay with Klarna”), and the shopper picks the actual
method inside Klarna’s pop-up; older or configured accounts still get several (pay_later, pay_over_time,
pay_now, …). Render whatever comes back: one option per category, load() the chosen one, authorize() it. The
choice is made after place and never changes the amount, so it is not stored on the cart (and cannot be: the cart
is placed); Klarna reports the method actually used, which becomes the payment record’s method.
// lib/klarna.ts (continued) — the widget alternativeexport async function createKlarnaWidgetSession(placed: PlacedCart, market: KlarnaMarket) { const s = await createKpSession(placed, market, { authorization: signedUrl("/api/payments/klarna/authorization", "authorization", placed.id), }); return { clientToken: s.client_token, categories: s.payment_method_categories };}
export async function placeFromAuthorization(cartId: string, sessionId: string, token: string) { type KpRead = { merchant_reference1?: string; purchase_country: string; purchase_currency: string; locale: string }; const session = await klarna<KpRead>(`/payments/v1/sessions/${sessionId}`); // the callback body is unsigned if (session.merchant_reference1 !== cartId) throw new Error(`Session ${sessionId} is not cart ${cartId}'s`); // Lines from the PLACED cart, read by id, never the shopper's current cart: Klarna checks them against the session const placed = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: string; }; if (placed.state === "ordered") { const order = await readOrder(cartId); if (order?.payments?.some((p) => p.meta?.klarnaSessionId === sessionId)) return; // a redelivery await call(`/payments/v1/authorizations/${token}`, { method: "DELETE" }); // another tab paid: never charged return; } const { purchase_country, purchase_currency, locale } = session; type Placed = { order_id: string; fraud_status: string; authorized_payment_method?: { type: string } }; const o = await klarna<Placed>(`/payments/v1/authorizations/${token}/order`, { // the token lives 60 minutes method: "POST", body: JSON.stringify({ purchase_country, purchase_currency, locale, merchant_reference1: cartId, ...orderLines(placed), }), }); const meta = { fraudStatus: o.fraud_status, klarnaSessionId: sessionId }; await createOrderOnce( cartId, "unpaid", toPayment(cartId, o.order_id, minor(placed.total.gross), o.authorized_payment_method?.type, meta), ).catch((error) => { throw new Error(`Klarna order ${o.order_id} has no Crystallize order: ${error}`); // alert a human });}// app/api/payments/klarna/authorization/route.ts — merchant_urls.authorizationimport { after } from "next/server";import { placeFromAuthorization, verifySignedUrl } from "@/lib/klarna";
export async function POST(req: Request) { const body = await req.text(); const cartId = verifySignedUrl(req.url, "authorization"); if (!cartId) return new Response("bad token", { status: 401 }); const { authorization_token, session_id } = JSON.parse(body) as { authorization_token: string; session_id: string }; // Klarna gives this call 2 s and wants the order placed after the answer: answer first, work in after() after(() => placeFromAuthorization(cartId, session_id, authorization_token).catch((e) => console.error(e))); return new Response(null, { status: 204 }); // duplicates get 2xx too: a 409 makes Klarna retry}after() (Next.js 15.1+) is not durable: if your platform has a queue, enqueue the token there instead. The callback
is at-least-once and best effort (3 attempts, 2 s connect + 2 s read timeout each).
"use client";import { useEffect, useState } from "react";import type { KlarnaCategory } from "@/lib/klarna";
type Call = (o: object, data: object, cb: (r: { approved?: boolean; show_form: boolean }) => void) => void;type Sdk = { init(o: { client_token: string }): void; load: Call; authorize: Call };declare global { interface Window { Klarna?: { Payments: Sdk }; klarnaAsyncCallback?: () => void; }}
type Props = { clientToken: string; categories: KlarnaCategory[]; billing: object; done: string };export function KlarnaWidget(props: Props) { const [chosen, setChosen] = useState(props.categories[0]?.identifier); const [hidden, setHidden] = useState<string[]>([]); const [ready, setReady] = useState(false); useEffect(() => { const init = () => (window.Klarna!.Payments.init({ client_token: props.clientToken }), setReady(true)); if (window.Klarna) return init(); window.klarnaAsyncCallback = init; document.body.append( Object.assign(document.createElement("script"), { src: "https://x.klarnacdn.net/kp/lib/v1/api.js", async: true, }), ); }, [props.clientToken]); useEffect(() => { if (!ready || !chosen) return; window.Klarna!.Payments.load( { container: "#klarna-payments", payment_method_category: chosen }, {}, (r) => r.show_form || setHidden((h) => [...h, chosen]), ); }, [ready, chosen]); // No await between the click and authorize(): the browser only opens Klarna's pop-up on a user gesture const pay = () => window.Klarna!.Payments.authorize( { payment_method_category: chosen }, { billing_address: props.billing }, (r) => { if (r.approved) location.assign(props.done); // the return page; the callback creates the order else if (!r.show_form) setHidden((h) => [...h, chosen!]); }, ); return ( <fieldset> {props.categories .filter((c) => !hidden.includes(c.identifier)) .map((c) => ( <label key={c.identifier}> <input type="radio" checked={chosen === c.identifier} onChange={() => setChosen(c.identifier)} /> <img src={c.asset_urls.standard} alt="" height={24} /> {c.name} </label> ))} <div id="klarna-payments" /> <button type="button" disabled={!ready || !chosen} onClick={pay}> Pay with Klarna </button> </fieldset> );}- Create the session server-side after
placeand passclientTokenandcategoriesto the page; buildbillingfrom the placed cart’scustomerand itsbillingaddress:given_name,family_name,email,phone,street_address,postal_code,city,country. Klarna wants customer data atauthorize(), not in the session (GDPR). - Send
Cross-Origin-Opener-Policy: same-origin-allow-popups(Helmet’s defaultsame-origincuts the pop-up off) and allow Klarna’s hosts in your CSP (x.klarnacdn.net,js.klarna.com,*.klarna.com,*.klarnaevt.com). approved: truein the browser proves nothing; the page only goes to the return page and waits.
After payment (prose only)
- Pending orders (US and UK, enabled per account):
fraud_status: PENDINGfor up to 24 h; Klarna POSTs{ order_id, event_type: FRAUD_RISK_ACCEPTED | _REJECTED | _STOPPED }to the session’smerchant_urls.notification(unconfirmed: Klarna’s pending-orders page is unreachable today). It is unsigned: re-read the order; rejected →cancel(). - Order Management extras (paths under
/ordermanagement/v1/orders/{id}): tracking viashipping_infoon the capture orPOST …/captures/{capture_id}/shipping-info;…/trigger-send-outresends the invoice e-mail;PATCH …/authorizationchanges amount and lines before capture (new risk check);PATCH …/customer-detailsand…/merchant-references; atype: "return_fee"line in a refund; the paid due-date extension (…/captures/{capture_id}/extend-due-date-options, pay-later only). - Recurring:
intent: "tokenize"or"buy_and_tokenize", thenPOST /payments/v1/authorizations/{token}/customer-tokenandPOST /customer-token/v1/tokens/{token}/orderwith aKlarna-Idempotency-Key. Not covered here.
Going further
- Klarna Checkout (KCO v3) — the full checkout with Klarna’s address and shipping forms — is now Kustom, a
separate provider. Its docs,
/checkout/v3/ordersand theacknowledgestep do not apply to Klarna Payments. - Automatic capture for digital goods:
place_order_mode: "CAPTURE_ORDER"on the HPP session (orauto_capture: truewhen you place the order). Then create the order aspaidwithstate=captured. - HPP options:
payment_method_category(ies)to show only some categories (store a storefront choice on the cart beforeplace),payment_fallback, branding (customization), and distribution by SMS, e-mail or QR code (distribution_url,qr_code_url) for telesales and in-store. - Conversion boosters: On-site messaging, Express Checkout and Sign in with Klarna (Klarna Web SDK). Express
Checkout collects the shopper’s details at Klarna: keep shipping and the amount decided in your storefront before
place, or Klarna charges something the placed cart does not contain. - Extra merchant data (
attachment) for travel, tickets and marketplaces; the Mobile SDK (iOS, Android, React Native) or HPP in a web view for apps; settlement reports in the portal, by API or SFTP. - The crystallize.com page’s example — initiating the payment, handling success, the order confirmation and creating the order in Crystallize — is this reference’s flow.
Common mistakes
- Sending no
tax_rate,total_tax_amountororder_tax_amount(or0): Klarna rejects the lines or the invoice shows no VAT. Compute the tax fromtotal_amountas above, not from a rounded per-unit value. - Building
unit_priceas(gross / quantity + lineDiscount) * 100: the whole line discount lands on every unit and the line no longer adds up when quantity > 1.unit_priceis before discount; the discount is per line. gross * 100withoutMath.round; leaving outreference(the SKU) on lines.- Deriving the country and locale from the currency (e.g. every non-NOK cart sent as
FR/en-FR). Take both from the market;purchase_countrymust match the shopper’s billing country. - In the authorization callback, building the Klarna order from the shopper’s current cart (cookie, session) instead
of the placed cart named by the callback: the lines no longer match the session (
409), or the order ships something else than was paid. - An unsigned, guessable callback route (
…/klarna/{cartId}): anyone can make it run. Sign the URL; re-read Klarna. - Placing the Klarna order and creating the Crystallize order inside the authorization callback’s 2 seconds, or
answering a duplicate with
409: Klarna times out, retries, and duplicates follow. - Calling
authorize()after anawait(placing the cart in the click handler): the pop-up is blocked. Place the cart and create the session before rendering the widget. - Never capturing: authorizations expire after 28 days and nothing is paid.
- A random
Klarna-Idempotency-Keyper retry (no protection), or one key per order (Klarna ignores the body, so a second partial capture silently returns the first). - Trusting the HPP success redirect or the widget’s
approved: trueinstead of the re-read session and order.
Mollie with Crystallize
Mollie is a Dutch payment service provider for merchants incorporated in the EEA, the UK and Switzerland: cards,
Apple Pay, Google Pay, PayPal and local methods such as iDEAL, Bancontact, Klarna, Billie, Riverty, in3, TWINT, Vipps,
MobilePay and SEPA bank transfer, in EUR and 27 other currencies depending on the method. Integrate it by creating a
payment with the Payments API (POST /v2/payments) from the placed cart and redirecting the shopper to the hosted
Mollie Checkout (_links.checkout.href). The webhook receives only an unsigned payment id, so it fetches the payment
from Mollie before creating the order. Capture is automatic (paid) by default. With captureMode: "manual", cards,
Klarna, Billie, PayPal, Vipps and MobilePay stop at authorized and are captured on shipment; Riverty and Billink
always work that way.
Verification: Written from Mollie’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Accepting payments, Create payment, Webhooks, API idempotency, Payment status, Place a hold, Refunds, Testing, Multicurrency, Authentication. Every page is also served as Markdown (append
.md); index: llms.txt.
At a glance
| Topic | Mollie |
|---|---|
| Markets & currencies | Merchants in the EEA, UK, Switzerland; EUR + 27 currencies, per method |
| Recommended integration | Payments API POST /v2/payments, redirect to Mollie Checkout (_links.checkout.href) |
| Alternative | Mollie Components card form (cardToken). The Orders API is no longer recommended |
| API version | v2, https://api.mollie.com/v2 for test and live: the key’s prefix picks the mode |
| SDKs | fetch here. Node: mollie-api-typescript 1.12; legacy @mollie/api-client 4.6 |
| Amount units | { currency, value }, value a string in major units: "10.00", JPY/ISK "1000" |
| Capture | Automatic → paid; captureMode: "manual" → authorized (Riverty, Billink: always) |
| Authorization lifetime | Visa/Amex 7 d, Mastercard 30 d, Klarna/Billie/Billink 28 d, Riverty 30 d, PayPal 29 d |
| Cart id field | metadata: { cartId } (about 1 kB of JSON); description ≤ 255 chars |
| One session per cart | Idempotency-Key derived from the cart id — Mollie caches it for 1 hour only |
| Notification verification | Unsigned: webhookUrl receives id=tr_… → GET /v2/payments/{id} with your key |
The legacy @mollie/api-client goes maintenance-only: its README says migrate by 17 November 2026, the
new SDK’s README says 10 February 2027. MobilePay authorizations last 14 days, Vipps 180; every authorized payment
carries captureBefore, which cannot be extended.
Credentials and setup
The crystallize.com page names one credential, the apiKey:
- Sign up at my.mollie.com and create a website profile (your web store). Keys, payment methods and checkout branding belong to it.
- API key: Developers → API keys. Each profile has a Test API key (
test_…) and a Live API key (live_…); build with the test key first. A key is shown once, at creation. Server only. - Payment methods: activate at least one, or checkout has nothing to offer. The crystallize.com page says Settings → Website profiles → Payment methods; today’s Web app has it under Organization settings → Profiles → your web store → the method → Activate (troubleshooting).
- Currency: Mollie accepts ISO 4217 codes only. A Crystallize price variant’s currency can be named anything (€,
Euro, EURO, EUR): name it
EUR, sinceplaced.total.currencygoes to Mollie unchanged. Address countries are ISO 3166-1 alpha-2 (NL).
MOLLIE_API_KEY=test_... # live_... in productionMOLLIE_CAPTURE_MODE=manual # authorize now, capture on shipment; omit to capture at oncePUBLIC_BASE_URL=https://shop.example # redirect and webhook URLs; your tunnel URL in development- Test mode swaps the hosted pages for a test checkout where you pick the outcome (paid, authorized, failed,
canceled, expired), with real webhooks. EUR only. Cards: Visa
4543 4740 0224 9996, Mastercard2223 0000 1047 9399, Amex3782 822463 10005, any expiry and CVV; amounts €1,001.00 to €1,011.00 forced tofailedgive a chosenfailureReason. A paid test payment’s_links.changePaymentStatecreates a refund or chargeback. - Localhost: a
localhostwebhookUrlis refused (“The webhook location is invalid”). Run ngrok or cloudflared and pointPUBLIC_BASE_URLat the tunnel.
Create the payment
Call it from the “Pay” route right after place (SKILL.md). The body
comes from the placed cart only, so every call for one cart sends the same bytes: Mollie answers a reused
Idempotency-Key carrying a different body with 400.
import { type PlacedCart } from "@/lib/crystallize-payments";
type Money = { currency: string; value: string };export type MollieRefund = { id: string; amount: Money; createdAt: string; status?: string }; // re_… or chb_…export type MolliePayment = { id: string; // tr_… mode: "live" | "test"; status: "open" | "pending" | "authorized" | "paid" | "canceled" | "expired" | "failed"; amount: Money; amountCaptured?: Money; method: string | null; // ideal, creditcard, klarna, bancontact, banktransfer, … metadata: { cartId?: string } | null; createdAt: string; captureBefore?: string; _links: { checkout?: { href: string } }; _embedded?: { refunds?: MollieRefund[]; chargebacks?: MollieRefund[] };};
type Call = { method?: string; body?: unknown; idempotencyKey?: string };async function mollieFetch(path: string, call: Call = {}, attempt = 0): Promise<{ json: any; replayed: boolean }> { const res = await fetch(`https://api.mollie.com/v2${path}`, { method: call.method ?? (call.body ? "POST" : "GET"), headers: { Authorization: `Bearer ${process.env.MOLLIE_API_KEY}`, "Content-Type": "application/json", ...(call.idempotencyKey ? { "Idempotency-Key": call.idempotencyKey } : {}), }, body: call.body ? JSON.stringify(call.body) : undefined, }); if (res.status === 409 && attempt < 3) { // this Idempotency-Key is still being processed (two tabs at once): wait, then get the replay await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); return mollieFetch(path, call, attempt + 1); } const text = await res.text(); // release-authorization answers 202 without a body if (!res.ok) throw Object.assign(new Error(`Mollie ${path} ${res.status}: ${text}`), { status: res.status }); return { json: text ? JSON.parse(text) : undefined, replayed: res.headers.get("idempotent-replayed") === "true" };}export const mollie = async <T>(path: string, call?: Call) => (await mollieFetch(path, call)).json as T;
// A string in major units with the currency's decimals; JPY and ISK have none.const decimals = (currency: string) => (currency === "JPY" || currency === "ISK" ? 0 : 2);export const minor = (major: number, currency: string) => Math.round(major * 10 ** decimals(currency));export const money = (minorUnits: number, currency: string): Money => ({ currency, value: (minorUnits / 10 ** decimals(currency)).toFixed(decimals(currency)),});
/** Every tab and every click gets the same live payment for this cart (within Mollie's one-hour key cache). */export async function createMolliePayment(placed: PlacedCart): Promise<MolliePayment> { const body = await paymentBody(placed); let key = `cart-${placed.id}`; for (let attempt = 0; attempt < 5; attempt++) { const { json, replayed } = await mollieFetch("/payments", { body, idempotencyKey: key }); // A replay is the response cached at creation, still "open": read the payment as it is now. const payment: MolliePayment = replayed ? await mollie<MolliePayment>(`/payments/${json.id}`) : json; if (!["canceled", "expired", "failed"].includes(payment.status)) return payment; key = `cart-${placed.id}-after-${payment.id}`; // dead: every tab derives the same next key } throw new Error(`cart ${placed.id}: five dead Mollie payments in a row`);}
async function paymentBody(placed: PlacedCart) { const currency = placed.total.currency; // ISO 4217: "EUR", never "€" const base = process.env.PUBLIC_BASE_URL; // fixed: a varying Host header would change the body const customer = placed.customer; // customer and addresses were set with setCustomer before place const address = (type: string) => { const a = customer?.addresses?.find((x) => x.type === type); if (!a) return undefined; return clean({ givenName: a.firstName ?? customer?.firstName, familyName: a.lastName ?? customer?.lastName, organizationName: customer?.companyName, // required for Billie (B2B) streetAndNumber: [a.street, a.streetNumber].filter(Boolean).join(" "), streetAdditional: a.street2, postalCode: a.postalCode, city: a.city, country: a.country, // ISO 3166-1 alpha-2 email: a.email ?? customer?.email, }); }; return { amount: money(minor(placed.total.gross, currency), currency), // the PLACED total, never the browser's description: `Order ${placed.id}`, // the order id is the cart id redirectUrl: `${base}/checkout/mollie/return?cart=${placed.id}`, cancelUrl: `${base}/checkout?payment=cancelled`, webhookUrl: `${base}/api/payments/mollie/webhook`, metadata: { cartId: placed.id }, locale: placed.meta?.locale, // e.g. "nl_NL", put on the cart before place method: placed.meta?.mollieMethod, // optional preselection, see Provider specifics captureMode: process.env.MOLLIE_CAPTURE_MODE === "manual" ? "manual" : undefined, billingAddress: address("billing"), shippingAddress: address("delivery"), lines: mollieLines(placed), };}const clean = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null && v !== ""));
// Required for Klarna, Billie, in3, Riverty, Billink and vouchers; Mollie recommends them on every payment.function mollieLines(placed: PlacedCart) { const cur = placed.total.currency; let sum = 0; const lines: Record<string, unknown>[] = placed.items.map((item) => { const total = minor(item.price.gross, cur); // line total, VAT and discounts included const rate = item.price.taxPercent ?? 0; let [quantity, unit] = [ item.quantity, minor(item.variant?.price.gross ?? item.price.gross / item.quantity, cur), ]; if (unit * quantity < total) [quantity, unit] = [1, total]; // never a negative discount sum += total; return { type: item.type === "shipping" ? "shipping_fee" : item.type === "digital" ? "digital" : "physical", description: quantity === item.quantity ? item.name : `${item.quantity} × ${item.name}`, quantity, sku: item.variant?.sku?.slice(0, 64) || undefined, unitPrice: money(unit, cur), // VAT included ...(unit * quantity > total ? { discountAmount: money(unit * quantity - total, cur) } : {}), totalAmount: money(total, cur), // = unitPrice × quantity − discountAmount, exactly vatRate: rate.toFixed(2), // a string: "21.00" vatAmount: money(Math.round((total * rate) / (100 + rate)), cur), // Mollie checks this formula }; }); const rest = minor(placed.total.gross, cur) - sum; // Σ totalAmount must equal amount const adjust = { description: "Adjustment", quantity: 1, unitPrice: money(rest, cur), totalAmount: money(rest, cur), }; if (rest) lines.push({ type: rest < 0 ? "discount" : "surcharge", ...adjust }); return lines;}After the hour. Mollie forgets an Idempotency-Key after one hour, and GET /v2/payments has no
filter on metadata (it only pages through the profile’s payments, newest first). So the one-hour window is the
guarantee: a click after it creates a second payment while the first may still be open. If both get paid,
createOrderOnce records the second with attention=duplicate-payment for someone to refund
(SKILL.md). Keys are tied to the API key, so rotating it resets them too.
Client
Redirect the browser to the checkout URL with a GET (Mollie warns that POSTing to it breaks some methods):
// app/api/checkout/pay/route.ts — continues SKILL.md's route after `place`const payment = await createMolliePayment(placed);const url = payment._links.checkout?.href ?? // open: Mollie Checkout `${process.env.PUBLIC_BASE_URL}/checkout/mollie/return?cart=${placed.id}`; // already paid, authorized or pendingreturn Response.redirect(url, 303); // called with fetch(): return { url } and window.location.assign(url) instead- Mollie sends the shopper to
redirectUrlwhatever happened, without a status. The return page only reads the cart (SKILL.md) and shows nothing personal: anyone holding the URL can open it. - Without a preselected
method, a failed attempt returns the shopper to Mollie Checkout to retry, and the payment staysopen. Cancelling there leads tocancelUrl: offer “Pay again” (the same route; the canceled payment is dead, so the next key creates a fresh one) or “Change cart” (a new cart, as SKILL.md says). - SEPA bank transfer: the shopper returns before paying, and the payment stays
openfor 12 (+2) days. After a few seconds of “confirming”, say the order is confirmed when the transfer arrives.
Webhook
Mollie POSTs id=tr_… (form-encoded) to the payment’s webhookUrl on pending, authorized, paid, canceled,
expired and failed (never on open), when a refund reaches processing, refunded or failed, and on a
chargeback. Nothing is signed: the id is a hint, and the payment you GET with your own key is the proof.
import { createOrderOnce, readOrder, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import { mollie, molliePayment, mollieRefund, type MolliePayment } from "@/lib/mollie";
export async function POST(req: Request) { const id = new URLSearchParams(await req.text()).get("id") ?? ""; if (!/^tr_\w+$/.test(id)) return new Response("ignored"); // 200 even for junk: tell a prober nothing try { const p = await mollie<MolliePayment>(`/payments/${id}?embed=refunds,chargebacks`).catch((error) => { if (error.status === 404) return null; // not yours, or from the other mode throw error; }); const cartId = p?.metadata?.cartId; if (!p || !cartId) return new Response("ignored"); if (p.status === "paid" || p.status === "authorized") { await createOrderOnce(cartId, p.status === "paid" ? "paid" : "unpaid", molliePayment(p)); } await syncOrder(cartId, p); return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // Mollie retries: 10 attempts over 26 hours }}
/** Later changes to an existing order: a capture or release made anywhere, expiry, refunds, chargebacks. */async function syncOrder(cartId: string, p: MolliePayment) { const order = await readOrder(cartId); if (!order) return; // pending, or dead before paying: no order, nothing to do const state = (id: string) => order.payments?.find((r) => r.transactionId === id)?.meta?.state; if (state(p.id) === "authorized") { const captured = Number(p.amountCaptured?.value ?? (p.status === "paid" ? p.amount.value : 0)); if (captured > 0) await updatePayment(cartId, p.id, (r) => withMeta(r, { state: "captured" }, captured)); else if (p.status === "canceled" || p.status === "expired") { await updatePayment(cartId, p.id, (r) => withMeta(r, { state: "cancelled" })); // released or lapsed } } for (const r of [...(p._embedded?.refunds ?? []), ...(p._embedded?.chargebacks ?? [])]) { const known = state(r.id); const status = r.status ?? "chargeback"; if (!known && status !== "failed" && status !== "canceled") await recordPayment(cartId, mollieRefund(r, p.id)); else if (known && known !== status) await updatePayment(cartId, r.id, (x) => withMeta(x, { state: status })); }}Mollie payment status (from your GET) |
Crystallize (paymentStatus) |
|---|---|
paid |
createOrderOnce(cartId, 'paid', …), state=captured |
authorized (manual capture) |
createOrderOnce(cartId, 'unpaid', …), state=authorized |
paid after authorized |
updatePayment → state=captured, amount = amountCaptured |
open, pending |
Nothing: Mollie calls again when it settles |
failed, canceled, expired, no order |
Nothing; the cart stays placed and the next “Pay” makes a fresh payment |
canceled, expired after authorized |
updatePayment → state=cancelled; move the order to a cancelled stage |
a refund in _embedded.refunds |
recordPayment, transactionId = re_…, meta type=refund |
a chargeback in _embedded.chargebacks |
recordPayment, transactionId = chb_…, meta type=refund |
Answer within 15 seconds; later counts as failed. Retries follow after 1, 2, 4, 8, 16 and 29 minutes, then 1, 2
and 22 hours. A 301/302 turns the POST into a GET and loses the id: keep the route clear of trailing-slash,
locale and auth redirects. Don’t allowlist Mollie’s IPs (they change); your GET already authenticates the data.
Capture, refund, cancel
// lib/mollie.ts (continued). `capture` is captureByProvider.mollie in SKILL.md's pipeline-stage handler.import { recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";
export async function capture(transactionId: string, amount: number): Promise<number | null> { const p = await mollie<MolliePayment>(`/payments/${transactionId}`); if (p.status === "paid") return Number(p.amountCaptured?.value ?? p.amount.value); // captured already if (p.status !== "authorized") throw new Error(`Mollie ${transactionId} is ${p.status}: nothing to capture`); const cur = p.amount.currency; const [wanted, full] = [minor(amount, cur), minor(Number(p.amount.value), cur)]; if (wanted < full && ["riverty", "billink"].includes(p.method ?? "")) throw new Error(`${p.method}: full only`); const c = await mollie<{ id: string; status: string; amount?: Money }>(`/payments/${transactionId}/captures`, { body: wanted < full ? { amount: money(wanted, cur) } : {}, // no amount = the whole authorization idempotencyKey: `capture-${transactionId}-${wanted}`, }); if (c.status === "failed") throw new Error(`Mollie capture ${c.id} failed`); return null; // pending → succeeded is asynchronous: the webhook's "paid after authorized" row records it}
/** `refundId` is yours (a return number…): it makes the Idempotency-Key, so a retry never refunds twice. */export async function refund(cartId: string, paymentId: string, amount: number, currency: string, refundId: string) { const value = money(minor(amount, currency), currency); const r = await mollie<MollieRefund>(`/payments/${paymentId}/refunds`, { body: { amount: value, description: `Refund ${refundId}`, metadata: { cartId } }, idempotencyKey: `refund-${refundId}`, }); await recordPayment(cartId, mollieRefund(r, paymentId)); // the webhook then only updates its state}
/** Releases what is still authorized: always the whole remainder. */export async function cancel(cartId: string, transactionId: string) { const path = `/payments/${transactionId}/release-authorization`; await mollie(path, { body: {}, idempotencyKey: `release-${transactionId}` }); // 202, asynchronous await updatePayment(cartId, transactionId, (p) => withMeta(p, { state: "cancelled" }));}- A full capture moves the payment to
paidand calls the webhook. Partial: Klarna and Billie keep the rest authorized for later captures; cards release it (unless Mollie enables multicapture for you); Riverty and Billink capture in full only. Anauthorizedpayment cannot be refunded (release it); a captured one cannot be released. - Refunds run about two hours later and wait as
queuedwhen your Mollie balance is short. Mollie rejects a second refund of the same amount on a payment within an hour; the idempotency key makes a timed-out call safe to repeat in that hour. After it, checkGET /v2/payments/{id}/refundsbefore trying again.
Mapping
// lib/mollie.ts (continued)import type { Payment } from "@/lib/crystallize-payments";
export const molliePayment = (p: MolliePayment): Payment => ({ provider: "mollie", method: p.method ?? undefined, // ideal, creditcard, klarna, … transactionId: p.id, // tr_… — capture, refund and release need it amount: Number(p.amount.value), // already major units createdAt: p.createdAt, meta: [ { key: "state", value: p.status === "paid" ? "captured" : "authorized" }, { key: "cartId", value: p.metadata!.cartId! }, ...(p.captureBefore ? [{ key: "captureBefore", value: p.captureBefore }] : []), // capture by then { key: "mode", value: p.mode }, // "test" must never reach a production order ],});
/** Refunds (re_…) and chargebacks (chb_…) alike: money going back, never a second order. */export const mollieRefund = (r: MollieRefund, paymentId: string): Payment => ({ provider: "mollie", method: r.id.startsWith("chb_") ? "chargeback" : "refund", transactionId: r.id, amount: Number(r.amount.value), createdAt: r.createdAt, meta: [ { key: "type", value: "refund" }, { key: "state", value: r.status ?? "chargeback" }, // queued | pending | processing | refunded | … { key: "paymentId", value: paymentId }, ],});Mollie spells its status canceled; the record’s state uses SKILL.md’s cancelled.
Provider specifics
Choosing a method in your own checkout. GET /v2/methods lists what the profile accepts for an amount, sorted
and translated for a locale; billingCountry tells whether Klarna is offered. Store the choice on the cart before
place; paymentBody sends it as method, and the shopper skips Mollie’s method screen.
// lib/mollie.ts (continued)export async function mollieMethods(gross: number, currency: string, locale: string, billingCountry: string) { const q = new URLSearchParams({ locale, billingCountry, "amount[currency]": currency }); q.set("amount[value]", money(minor(gross, currency), currency).value); type Methods = { _embedded: { methods: { id: string; description: string; image: { size2x: string } }[] } }; return (await mollie<Methods>(`/methods?${q}`))._embedded.methods;}// shopper picks `id` → await carts.setMeta(cartId, { meta: [{ key: "mollieMethod", value: id }], merge: true });With a single method, a failed or canceled attempt returns the shopper to your site instead of Mollie Checkout; the next “Pay” makes a fresh payment.
Buy now, pay later. Klarna, Billie, in3, Riverty and Billink need lines and a billingAddress with
givenName, familyName, streetAndNumber, postalCode, city, country and email (Billie also
organizationName), so the customer and addresses must be on the cart before place. On the Payments API, Klarna
and Billie are captured at once (paid) unless captureMode is manual; Riverty and Billink require manual.
in3 is NL only (EUR 50 to 5,000); Riverty is NL, BE, DE, AT in EUR; Klarna takes EUR, DKK, SEK, NOK, CHF,
GBP, PLN, CZK, RON or HUF depending on the country.
Bank transfer stays open up to 12 (+2) days; with billingAddress.email set, Mollie emails the instructions.
The key lasts an hour, so a “Pay” days later makes a second payment: disable banktransfer if that is a problem.
After payment (prose only): capture before captureBefore, or the payment turns expired (paid if partly
captured). A refund can be cancelled while queued or pending (DELETE /v2/payments/{id}/refunds/{refundId}). A
chargeback shows in amountChargedBack and, if reversed, gets reversedAt. Paysafecard and gift cards cannot be
refunded.
Going further
- Mollie Components, an embedded card form: load
https://js.mollie.com/v1/mollie.js, callMollie(profileId, { locale, testmode })andcreateComponent('card');createToken()returns acardToken(valid 1 hour) that the server sends withmethod: 'creditcard', then redirects to_links.checkoutfor 3-D Secure. Each attempt has a new token, so key on cart id + token. The wider Components checkout and the Sessions API are in private beta. - Next-gen webhooks: organization-level subscriptions signed with
X-Mollie-Signature: sha256=<hex HMAC-SHA256 of the raw body>; Mollie still recommendswebhookUrlfor payments. - Recurring: create a Mollie customer once per Crystallize customer (
POST /v2/customers), take a first payment withcustomerIdandsequenceType: 'first', then read its mandate (GET /v2/customers/{id}/mandates; valid once that payment is paid). Store the customer and mandate ids on the Crystallize subscription contract. Charge withsequenceType: 'recurring'+mandateId(always with an Idempotency-Key), orPOST /v2/customers/{id}/subscriptions(amount,interval,descriptionunique per customer,startDateasYYYY-MM-DD,mandateId,webhookUrl). See Recurring payments. - Single-click cards: the same
customerIdon later payments lets Mollie Checkout offer saved cards. - QR codes:
?include=details.qrCodeon create, for iDEAL, Bancontact and bank transfer. - Cancel an open payment:
DELETE /v2/payments/{id}whileisCancelable, e.g. when the shopper starts a new cart. - Digital goods VAT:
restrictPaymentMethodsToCountry: 'NL'keeps methods to the customer’s country. - Express Component (private beta) collects the address during payment. That address, and any shipping cost, is
then not on the placed cart: Mollie charges more than the cart and the order lacks the shipping line. Choose
shipping in the storefront before
place. - Orders API integrations: shipments become captures, order cancel becomes release-authorization, order lines
become payment
lines(migration guide). - Advanced access tokens and OAuth must send
profileId(andtestmode: trueto test); an API key sends neither.
Common mistakes
- Creating the order when the shopper clicks “Pay”: every abandoned payment leaves an unpaid order. Create it from the
webhook once the payment is
paidorauthorized. - Creating a Mollie customer on every checkout: create one per Crystallize customer, for recurring or saved cards.
- Updating the order whatever status the fetched payment has:
open,pending,failed,canceledandexpiredcreate nothing. - Placeholder names and addresses in
billingAddress, or tax sent as0: take both from the cart. gross.toFixed(2)for every currency (JPY and ISK have no decimals), and unroundedgross * 100in line maths.- Taking the redirect to
redirectUrlas proof of payment, or looking for a signature on the classic webhook. - Answering
4xxfor unknown ids, or letting middleware redirect the webhook route. - Changing the create body under the same
Idempotency-Key(locale from the request, origin fromHost, a timestamp) →400; trusting a replayed response’sstatus; counting on the key after an hour. vatRateas a number, avatAmountthat is nottotalAmount × rate / (100 + rate), lines that do not sum toamount, or negativephysical/shipping_feelines.- A price variant currency named
€orEuro, or country names instead of ISO alpha-2 codes. - Expecting Klarna or Billie to stop at
authorizedwithoutcaptureMode: 'manual', or calling the Orders API. - A subscription
startDatewith a time in it (it isYYYY-MM-DD), or before the first payment is paid.
Montonio with Crystallize
Montonio is an Estonian payment and shipping platform for merchants in Estonia, Latvia, Lithuania, Finland and Poland:
bank payments from every major Baltic, Finnish and Polish bank, plus cards, Apple Pay, Google Pay, MobilePay, BLIK and
financing, in EUR and PLN only, with parcel-machine and courier shipping from the same account. The recommended
integration lets the shopper pick a bank (and a parcel machine) in your checkout, creates a Stargate order from the
placed cart with the bank preselected, redirects to its paymentUrl, and creates the Crystallize order from the signed
orderToken webhook. There is no capture: a PAID order is final, and money goes back only through refunds.
Verification: Written from Montonio’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Payments overview, API reference, Create and validate an Order, Display payment methods, Webhooks, Refunds, Shipping v2. Montonio asks agents to start from https://docs.montonio.com/llms.txt (all of it inlined:
/llms-full.txt).
At a glance
| Markets & currencies | Estonia, Latvia, Lithuania, Finland, Poland. EUR and PLN only (no NOK, SEK, DKK) |
| Recommended | Stargate POST /orders with the bank preselected, then redirect to paymentUrl |
| Alternative | Embedded cards (POST /sessions + @montonio/montonio-js), payment links |
| API version | Stargate (unversioned) https://stargate.montonio.com/api; Shipping …/api/v2 |
| SDKs | No server SDK: jsonwebtoken@9 + fetch. Browser @montonio/montonio-js@1 (cards only) |
| Amount units | Major units, 2 decimals (grandTotal: 99.99), like Crystallize — never × 100 |
| Capture | None: PENDING → PAID is final. Opt-in AUTHORIZED is a bank still settling, not a hold |
| Cart id | merchantReference (required, unique per store), echoed in every order token |
| One session per cart | Montonio enforces it: re-posting a merchantReference replaces the unpaid order |
| Verification | Webhook body { orderToken } / { refundToken }: an HS256 JWT signed with the Secret Key |
Credentials and setup
- Access Key (identifies your calls) and Secret Key (signs every request and verifies every webhook; keep it on the server), as the crystallize.com page names them. Register at montonio.com, then in the Partner System (https://partner.montonio.com) open Stores → your store → API Keys tab. Sandbox keys work at once, production keys after Montonio approves the business; generating new keys invalidates the old ones.
- Shipping uses the same store keys (both APIs point to the same API keys). Activate carriers in the Partner System; in sandbox, switch on test mode and activate carriers with dummy credentials — carrier calls and labels are mocked (sandbox).
- Test data (sandbox): cards
5577 0000 5577 0004(success) and5454 5454 5454 5454(3DS),03/30, CVC737;billingAddress.email = redirect-3ds-test@montonio.comforces the redirect 3DS flow. BLIK777 123succeeds. The sandbox bank list comes fromGET /stores/payment-methods; how its test banks behave is not documented (unconfirmed). - Webhooks need no registration: each order carries its
notificationUrl, and refund webhooks go to the same URL. They come from35.156.245.42and35.156.159.169with User-AgentMontonioWebhooks/1.0— allowlist them in a WAF or Cloudflare. Localhost: ngrok, or webhook.site to inspect payloads.
MONTONIO_ACCESS_KEY=…MONTONIO_SECRET_KEY=…MONTONIO_API_URL=https://sandbox-stargate.montonio.com/api # prod: https://stargate.montonio.com/apiMONTONIO_SHIPPING_URL=https://sandbox-shipping.montonio.com/api/v2 # prod: https://shipping.montonio.com/api/v2PUBLIC_URL=https://shop.example # the tunnel in developmentCreate the payment
Authentication is a JWT signed HS256 with the Secret Key and carrying accessKey. Stargate takes it as
Authorization: Bearer on GET, and on POST the payload itself is the JWT, sent as { data } (10-minute exp).
Shipping v2 takes a Bearer JWT on every call and plain JSON bodies. The bank and parcel machine were stored on the cart
before place (Provider specifics); the Pay route calls this after place
(SKILL.md).
import jwt from "jsonwebtoken";import type { Payment, PlacedCart } from "@/lib/crystallize-payments";
const ACCESS = process.env.MONTONIO_ACCESS_KEY!;const SECRET = process.env.MONTONIO_SECRET_KEY!;const round2 = (major: number) => Math.round(major * 100) / 100; // major units, 2 decimalsconst sign = (payload: object, expiresIn: "10m" | "1h") => jwt.sign({ ...payload, accessKey: ACCESS }, SECRET, { algorithm: "HS256", expiresIn }); // adds iat
/** Stargate. 401 STORE_NOT_FOUND = wrong access key or environment, 403 = wrong secret key. */export async function montonio<T>(path: string, payload?: object): Promise<T> { const init: RequestInit = payload ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: sign(payload, "10m") }), } : { headers: { Authorization: `Bearer ${sign({}, "1h")}` } }; const res = await fetch(`${process.env.MONTONIO_API_URL}${path}`, init); if (!res.ok) throw new Error(`Montonio ${path} ${res.status}: ${await res.text()}`); return (await res.json()) as T;}
/** Shipping v2. */export async function shipping<T>(path: string, body?: object): Promise<T> { const res = await fetch(`${process.env.MONTONIO_SHIPPING_URL}${path}`, { method: body ? "POST" : "GET", headers: { Authorization: `Bearer ${sign({}, "1h")}`, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`Montonio shipping ${path} ${res.status}: ${await res.text()}`); return (await res.json()) as T;}
type Options = { origin: string; locale: string; letShopperPickBank?: boolean };
export async function createMontonioOrder(placed: PlacedCart, { origin, locale, letShopperPickBank }: Options) { const m = placed.meta ?? {}; const c = placed.customer; const a = c?.addresses?.find((x) => x.type === "billing") ?? c?.addresses?.[0]; const grandTotal = round2(placed.total.gross); const method = m.montonioMethod || "paymentInitiation"; const order = await montonio<{ uuid: string; paymentUrl: string }>("/orders", { merchantReference: placed.id, // unique per store: a second POST replaces the unpaid order, never adds one returnUrl: `${origin}/checkout/montonio/return?cart=${placed.id}`, // + &order-token=<JWT>, paid or cancelled notificationUrl: `${process.env.PUBLIC_URL}/api/payments/montonio/webhook`, currency: placed.total.currency, // EUR or PLN grandTotal, locale, // de, en, et, fi, lt, lv, pl, ru billingAddress: { firstName: c?.firstName, lastName: c?.lastName, email: c?.email, addressLine1: a?.street, locality: a?.city, postalCode: a?.postalCode, country: a?.country, }, // unit price incl. tax; Montonio does not enforce that the lines add up to grandTotal lineItems: placed.items.map((i) => ({ name: i.name, quantity: i.quantity, finalPrice: round2(i.price.gross / i.quantity), })), payment: { method, // paymentInitiation, cardPayments, applePay, googlePay, mobilePay, blik, bnpl, hirePurchase amount: grandTotal, // must equal grandTotal currency: placed.total.currency, methodOptions: method === "paymentInitiation" ? { preferredProvider: letShopperPickBank ? undefined : m.montonioBank || undefined, // LHVBEE22 preferredCountry: m.montonioBankCountry || undefined, // the list the shopper saw } : undefined, }, expiresIn: 30, // minutes until ABANDONED (5 to 44 640) }); return { url: order.paymentUrl };}// app/api/checkout/pay/route.ts, after place: return Response.json(await createMontonioOrder(placed, opts));Re-posting a merchantReference replaces an unpaid order and returns a new paymentUrl; a paid one answers an
error (merchantReference). The placed cart cannot change, so the amount never does.
Client
Redirect to paymentUrl (location.href = url, or redirect() from a Server Action). With preferredProvider the
shopper goes straight to their bank. They come back to the returnUrl with order-token=<JWT> added, after success
or cancellation — maybe in another browser, so the page takes the cart id from its own ?cart=. It only reads
(SKILL.md), verifying the token on the server (verifyMontonio below) just to pick the
message: PAID → “thank you” while the cart turns ordered; AUTHORIZED → “your bank is processing the payment”;
anything else → “payment not completed” and “Try again”, which calls the Pay route with letShopperPickBank: true
(same order, new URL, Montonio’s own bank list). It never creates the order.
Webhook
Montonio POSTs { "orderToken": "<JWT>" } when an order’s paymentStatus changes and { "refundToken": "<JWT>" }
when a refund’s does, to the same notificationUrl (webhooks). The signature is inside the JWT, so the
body only has to be read and parsed; pin the algorithm and check accessKey. Use the camelCase fields: the snake_case
copies (payment_status, …) are being removed. Anything but 200/201 is retried 13 times over 48 hours.
// lib/montonio.ts (continued)export type Claims = Record<string, any>;
export function verifyMontonio(token: unknown): Claims { if (typeof token !== "string") throw new Error("no token"); const claims = jwt.verify(token, SECRET, { algorithms: ["HS256"] }) as Claims; // signature and exp if (claims.accessKey !== ACCESS) throw new Error("token for another store"); return claims;}import { createOrderOnce, readCart, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import { montonio, montonioPayment, verifyMontonio, type Claims } from "@/lib/montonio";
export async function POST(req: Request) { const raw = await req.text(); let body: { orderToken?: string; refundToken?: string }; let c: Claims; try { body = JSON.parse(raw); c = verifyMontonio(body.orderToken ?? body.refundToken); } catch { return Response.json({ error: "invalid token" }, { status: 401 }); } try { if (body.orderToken && c.paymentStatus === "PAID") { await createOrderOnce(c.merchantReference, "paid", montonioPayment(c)); } else if (body.orderToken && c.paymentStatus === "VOIDED") { // the bank rejected the payment; after PAID (rare, Montonio also e-mails you) the order must not ship if ((await readCart(c.merchantReference))?.state === "ordered") { await updatePayment(c.merchantReference, c.uuid, (p) => withMeta(p, { state: "cancelled" })); console.error(`[montonio] ${c.merchantReference} VOIDED after PAID: stop fulfilment`); } } else if (body.refundToken && c.refundStatus === "SUCCESSFUL") { // the refund token carries no merchantReference: read it from the Montonio order const order = await montonio<{ merchantReference: string; paymentMethodType: string }>( `/orders/${c.orderUuid}`, ); await recordPayment(order.merchantReference, { provider: "montonio", method: order.paymentMethodType, transactionId: c.refundUuid, amount: Number(c.refundAmount), // major units createdAt: new Date(c.iat * 1000).toISOString(), meta: [ { key: "type", value: "refund" }, { key: "cartId", value: order.merchantReference }, ], }); } else if (body.refundToken && c.refundStatus === "REJECTED") { console.error(`[montonio] refund ${c.refundUuid} rejected: ${c.refundStatusDescription}`); // a human acts } return Response.json({ ok: true }); // PENDING, AUTHORIZED, ABANDONED: nothing yet } catch (error) { console.error(error); return Response.json({ error: "retry" }, { status: 500 }); }}| Token status | Crystallize (paymentStatus) |
|---|---|
order PAID |
createOrderOnce(cartId, 'paid', …), state=captured |
order AUTHORIZED (opt-in) |
Nothing yet: the bank is still settling; PAID or VOIDED follows |
order VOIDED |
After PAID: updatePayment → state=cancelled, stop. Else no order |
order PENDING, ABANDONED |
No order (ABANDONED after expiresIn; on by default since 2023-08-29) |
order REFUNDED, PARTIALLY_… |
Nothing: the refund token records it |
refund SUCCESSFUL / REJECTED |
recordPayment (type=refund) / alert a human (refundStatusDescription) |
Redeliveries are harmless (createOrderOnce and recordPayment dedupe on the UUIDs). Whether late retries carry a
fresh token is unconfirmed: if they fail on exp, verify with ignoreExpiration: true — the handlers are idempotent.
Capture, refund, cancel
There is nothing to capture or void: bank payments, cards and wallets settle at PAID. Refunds go through
POST /refunds, and Crystallize is written only when the refundToken says SUCCESSFUL (a refund can wait in
PENDING, e.g. on insufficient settlement funds). Cancelling a paid order means refunding it in full
(availableForRefund from GET /orders/{uuid}).
// lib/montonio.ts (continued)/** captureByProvider.montonio — no-op: PAID is final, the whole amount is already captured. */export const capture = async (_orderUuid: string, amount: number): Promise<number | null> => amount;
/** `refundId` is yours (a return or credit-note id); Montonio refuses a second refund with the same key. */export async function refund(orderUuid: string, amount: number, refundId: string) { try { await montonio("/refunds", { orderUuid, amount: round2(amount), idempotencyKey: `refund-${refundId}` }); } catch (error) { if (!String(error).includes("same idempotency key")) throw error; // else: a retry of a refund already made }}Refund rules (refunds): to the original payer only, at least 0.05 €, in total at most grandTotal, and only
once the money reached the Montonio settlement account (about one business day). Bank-payment refunds are EUR only and
must be enabled in the Partner System; cards, wallets, BLIK and financing are refundable by default.
Mapping
// lib/montonio.ts (continued)export const montonioPayment = (c: Claims): Payment => ({ provider: "montonio", method: c.paymentMethod, // paymentInitiation, cardPayments, applePay, googlePay, mobilePay, blik, bnpl … transactionId: c.uuid, // the Montonio order UUID: refunds, GET /orders/{uuid} and shipments take it amount: Number(c.grandTotal), // already major units createdAt: new Date(c.iat * 1000).toISOString(), meta: [ { key: "state", value: "captured" }, { key: "cartId", value: c.merchantReference }, ...(c.paymentProviderName ? [{ key: "bank", value: String(c.paymentProviderName) }] : []), ],});A refund is a second record, from the webhook: transactionId = refund UUID, amount = refundAmount, meta type=refund and cartId (the payment record).
Provider specifics
Bank picker. Montonio wants the method, and for bank payments the bank, chosen in your checkout before the order
exists (methods). GET /stores/payment-methods lists the enabled methods, and the banks per country under
paymentInitiation.setup[country].paymentMethods (code, name, logoUrl, supportedCurrencies, uiPosition).
Revolut, N26 and Wise share one code across countries: send the country whose list the shopper saw as
preferredCountry. Montonio’s bank widget lives in its legacy SDK, retired in 2026: render the list yourself.
Pickup points. Shipping v2 lists the carriers per destination country (GET /shipping-methods) and their pickup
points (GET /shipping-methods/pickup-points?carrierCode=&countryCode=&type=, types parcelMachine, parcelShop,
postOffice) (shipping methods). The chosen point is required before Pay and goes on the cart with
the bank, next to the shipping line the storefront added as an external item.
// lib/montonio.ts (continued)export type Bank = { code: string; name: string; logoUrl: string; supportedCurrencies: string[]; uiPosition?: number };export type PickupPoint = { id: string; name: string; streetAddress: string; locality: string; carrierCode: string };type StoreMethods = { paymentMethods: { paymentInitiation?: { setup: Record<string, { paymentMethods: Bank[] }> } } };
const cache = new Map<string, { at: number; value: unknown }>();async function cached<T>(key: string, ttl: number, load: () => Promise<T>): Promise<T> { const hit = cache.get(key); if (hit && Date.now() - hit.at < ttl) return hit.value as T; const value = await load(); cache.set(key, { at: Date.now(), value }); return value;}
export async function listBanks(country: string, currency: string): Promise<Bank[]> { const store = await cached("methods", 3_600_000, () => montonio<StoreMethods>("/stores/payment-methods")); const banks = store.paymentMethods.paymentInitiation?.setup[country]?.paymentMethods ?? []; return banks .filter((b) => b.supportedCurrencies.includes(currency)) .sort((x, y) => (x.uiPosition ?? 99) - (y.uiPosition ?? 99));}
export async function listPickupPoints(carrier: string, country: string): Promise<PickupPoint[]> { const q = new URLSearchParams({ carrierCode: carrier, countryCode: country, type: "parcelMachine" }); const load = () => shipping<{ pickupPoints: PickupPoint[] }>(`/shipping-methods/pickup-points?${q}`); return cached(`points:${q}`, 86_400_000, async () => (await load()).pickupPoints);}// app/api/checkout/montonio/choice/route.ts — the bank and the parcel machine go on the cart BEFORE placeimport { carts, PLACED_CART, type PlacedCart } from "@/lib/crystallize-payments";import { listBanks, listPickupPoints } from "@/lib/montonio";
type Choice = { bankCountry: string; bank: string; shipTo: string; carrier: string; pickupPoint: string };
export async function POST(req: Request) { const cartId = getCartIdFromCookie(req); // your session handling const choice = (await req.json()) as Choice; const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: string; }; if (cart.state !== "cart") return Response.json({ error: "placed" }, { status: 409 }); // the write would be lost const banks = await listBanks(choice.bankCountry, cart.total.currency); const bank = banks.find((b) => b.code === choice.bank)?.code ?? ""; const point = (await listPickupPoints(choice.carrier, choice.shipTo)).find((p) => p.id === choice.pickupPoint); const meta = { montonioMethod: "paymentInitiation", montonioBank: bank, montonioBankCountry: choice.bankCountry, montonioCarrier: choice.carrier, montonioPickupPoint: point?.id ?? "", montonioPickupPointName: point ? `${point.name}, ${point.streetAddress}, ${point.locality}` : "", }; await carts.setMeta(cartId, { meta: Object.entries(meta).map(([key, value]) => ({ key, value })), merge: true }); return Response.json({ banks, ready: !!point }); // ready: Pay may be enabled}// app/api/checkout/pay/route.ts, before place: no parcel machine, no Pay// if (!cart.meta?.montonioPickupPoint) return Response.json({ error: "choose a parcel machine" }, { status: 400 });The checkout shows the banks this route returns as a logo grid under a country selector (EE, LV, LT, FI,
PL), and the pickup points (a GET route around listPickupPoints) as a searchable <select> grouped by locality;
it posts every change here and keeps Pay disabled until ready.
After payment: shipment and label (no code), from your “Ready to ship” stage in the pipeline-stage hook, never inside the payment webhook:
- Read the pickup point (
meta) and receiver (customer) from the placed cart (carts.fetch(cartId, PLACED_CART)). If the payment record already hasmeta montonioShipmentId, stop: the shipment exists. POST {MONTONIO_SHIPPING_URL}/shipmentswithmerchantReference(cart id),montonioOrderUuid(the payment’stransactionId),receiver(name,phoneCountryCodesuch as372,phoneNumberwithout it — both required — andemail),shippingMethod: { type: "pickupPoint", id },parcels: [{ weight }](kg; dimensions when the method’sconstraints.parcelDimensionsRequired), optionalproducts(sku,name,quantity,price) for pick lists and the tracking page, andsynchronous: true. Withoutsender, the store’s sender details are used.registered→updatePayment(cartId, orderUuid, (p) => withMeta(p, { montonioShipmentId: id }));registrationFailed(often a bad phone number) →PATCH /shipments/{id}with the fix registers it again.POST /label-fileswith{ shipmentIds: [id], pageSize: "A6", labelsPerPage: 1, synchronous: true }returnslabelFileUrl, a PDF;GET /label-files/{id}fetches it again later (labels).
At volume, keep the asynchronous default and register one shipping webhook (POST /webhooks with url and
enabledEvents such as shipment.registered, shipment.registrationFailed, labelFile.ready; 10 per store at most).
Its body is { "payload": "<JWT>" } signed with the Secret Key, with an eventType (webhooks).
Going further
- Embedded cards (Sessions flow):
POST /sessions→new MontonioCheckout({ sessionUuid, environment }),initialize(container),validateOrReject(), create the order withsessionUuid,submitPayment(); calldestroy()before re-initialising.cardPayments.processor === "stripe"means the store is still on the legacy embedded flow (embedded cards). Embedded BLIK exists for PLN. - Payment links (
POST /payment-links) for phone or e-mail orders, and financing (bnpl,hirePurchase, EUR) through the same order call (financing). - Shipping prices for the storefront’s shipping line:
POST /shipping-methods/rates(Montonio contracts only). Courier delivery:GET /shipping-methods/courier-services,shippingMethod.type: "courier"; cash on delivery and age verification asadditionalServiceswhere listed. Payout reports: payouts.
Common mistakes
- Multiplying by 100: Montonio takes major units, so the shopper is charged a hundred times the price.
- Building on the deprecated Payments V1 flow: a
payment_tokenappended to the gateway URL, snake_case fields (preselected_aspsp), a notification read from the query string withstatus === 'finalized', banks from/pis/v2/merchants/payment_methods, shipping onapi.shipping.montonio.com. - Verifying without pinning
algorithmsor checkingaccessKey, or creating the order from thereturnUrltoken. - Calling the order-creation step with its arguments swapped: nothing is created, and the webhook still answers 200.
- Treating every notification as a payment: refund webhooks reach the same URL, and one became a second order.
- Losing the pickup point between checkout and shipment, or letting the shopper pay without one.
- Creating the shipment and label inside the payment webhook with errors swallowed, reading the shipment id from a failed response, or a hard-coded dummy sender and phone number.
- Hard-wiring the bank list to Estonia and the carrier to one company; or offering Montonio in NOK, SEK or DKK.
Qliro with Crystallize
Qliro is a Swedish payment provider for the Nordics (Sweden, Norway, Finland, Denmark). Its embedded checkout,
Qliro Checkout (formerly Qliro One), puts pay later (invoice, part payment), card payments and other Nordic methods
such as Trustly in one iframe. The recommended integration is the embedded checkout: create a Qliro order from the
placed cart (a signed server-to-server call), render the OrderHtmlSnippet it returns, and create the Crystallize
order from Qliro’s checkout-status push — which is unsigned, so the server re-fetches the order before acting. The
purchase only reserves the money: MarkItemsAsShipped captures it when the goods ship, and Qliro reports the
outcome asynchronously on a separate order-management push.
Verification: Written from Qliro’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Qliro Checkout, Authorization, Load checkout, Notifications, Render thank-you page, Listeners, Order management, Testing, API reference (Merchant API v1 and Admin API v2 as OpenAPI). Crystallize page: Qliro.
At a glance
| Topic | Qliro |
|---|---|
| Markets & currencies | SE, NO, FI, DK (SEK, NOK, EUR, DKK); checkout in sv-se, nb-no, fi-fi, da-dk, en-us, de-de, fr-fr, nl-nl |
| Recommended integration | Qliro Checkout embedded: CreateOrder → GetOrder → inject OrderHtmlSnippet |
| Alternative | Payment link: redirect to the PaymentLink that CreateOrder returns (hosted by Qliro) |
| API version | Merchant API v1 (/checkout/merchantapi), Admin API v2 (/checkout/adminapi/v2) |
| SDKs | None: fetch + a signed header. The browser runs the snippet; q1Ready exposes the Frontend API |
| Amount units | Decimal major units, 0–2 decimals, per item; no order total is sent: Qliro sums the items |
| Capture + auth lifetime | Manual: MarkItemsAsShipped, result on the OM push. Session 90 min, order 48 h; reservation (unconfirmed) |
| Cart id field | MerchantReference (≤ 25, [A-Za-z0-9_|-]): first 25 chars of the cart id; full id in metadata |
| One session per cart | Deterministic MerchantReference; GET …/orders?merchantReference= before creating |
| Notification verification | None by design: an HMAC token in each push URL, then GetOrder / GetPaymentTransaction |
Credentials and setup
Contact Qliro to open a merchant account (your onboarding agent, or integration@qliro.com). You get a test account and environment first. The crystallize.com page names three settings:
- API key — identifies the store. It goes in the body of every request that has one (order creation, Admin
API calls) as
MerchantApiKey. - API secret — server-side only. It signs every request:
Authorization: Qliro <token>, where the token is Base64(SHA-256(JSON body + secret)) and aGEThashes the secret alone (authorization). Hash the exact string you send; re-serialising (key order, spaces) gives401. - Base URL —
https://pago.qit.nu(test),https://payments.qit.nu(production).
QLIRO_BASE_URL=https://pago.qit.nu # https://payments.qit.nu in productionQLIRO_API_KEY=... # MerchantApiKeyQLIRO_API_SECRET=... # signs requestsQLIRO_PUSH_SECRET=... # 32+ random bytes: signs your push URLsPUBLIC_URL=https://shop.example # the tunnel URL in development- URLs Qliro needs: the page embedding the iframe,
MerchantConfirmationUrl,MerchantTermsUrl,MerchantCheckoutStatusPushUrlandMerchantOrderManagementStatusPushUrl(both required, unless Qliro configures them for you). They travel with each order. Everything is HTTPS, and push URLs must be reachable from the internet: tunnel localhost (ngrok, cloudflared). - Test identities (testing) — personal / organisation numbers, Ok · OnHold · Denied:
| Country | B2C | B2B |
|---|---|---|
| Sweden | 790625-5307 · 770530-1773 · 750420-8104 | 556001-1982 · 556006-1912 · 556010-2005 |
| Norway | 22034149589 · 23034114714 · 23034114986 | 123456785 · 123123123 · 987654325 |
| Finland | 201042-9991 · 040842-922L · 030842-921X | 2678277-6 · 2194504-6 · 2392384-4 |
| Denmark | 0208429205 · 0408429226 · 0308429210 | 35168184 · 20578912 · 12655568 |
Create the payment
Call this from the pay route in SKILL.md with the placed cart.
Qliro takes a price per item with at most 2 decimals and sums the items itself, so each cart line is split into at
most two Qliro items whose totals equal the line exactly, and the sum is checked against placed.total.gross.
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";import { carts, createOrderOnce, PLACED_CART, readOrder, recordPayment, RetryLater, updatePayment, withMeta,} from "@/lib/crystallize-payments";import type { Payment, PlacedCart } from "@/lib/crystallize-payments";
const cents = (major: number) => Math.round(major * 100);
export async function qliro<T>(path: string, payload?: object, method = payload ? "POST" : "GET"): Promise<T> { const body = payload ? JSON.stringify({ MerchantApiKey: process.env.QLIRO_API_KEY, ...payload }) : ""; const token = createHash("sha256") .update(body + process.env.QLIRO_API_SECRET) .digest("base64"); // the bytes sent const res = await fetch(`${process.env.QLIRO_BASE_URL}/checkout/${path}`, { method, headers: { Authorization: `Qliro ${token}`, "Content-Type": "application/json" }, body: body || undefined, }); if (res.status === 404 && path.includes("merchantReference=")) return null as T; // no order for this cart yet if (!res.ok) throw new Error(`Qliro ${method} ${path} ${res.status}: ${await res.text()}`); // ErrorCode, … const text = await res.text(); return (text ? JSON.parse(text) : null) as T;}
// Pushes are unauthenticated: an HMAC of (push type, cart id) in each URL proves the URL is yoursconst sign = (type: string, cartId: string) => createHmac("sha256", process.env.QLIRO_PUSH_SECRET!).update(`${type}:${cartId}`).digest("hex");const pushUrl = (type: "checkout" | "om", cartId: string) => `${process.env.PUBLIC_URL}/api/payments/qliro/webhook?type=${type}&cart=${cartId}&token=${sign(type, cartId)}`;export function verifyPush(url: string) { const q = new URL(url).searchParams; const [type, cartId] = [q.get("type"), q.get("cart") ?? ""]; if (type !== "checkout" && type !== "om") return null; const [got, want] = [Buffer.from(q.get("token") ?? ""), Buffer.from(sign(type, cartId))]; return got.length === want.length && timingSafeEqual(got, want) ? { type, cartId } : null;}
const TYPE: Record<string, string> = { shipping: "Shipping", fee: "Fee", promotion: "Discount" }; // else Productconst reference = (s: string) => s.replace(/[^\p{L}\s(.)'\-_&,/–+0-9:|]/gu, "-").slice(0, 200); // Qliro's pattern
export function orderItems(placed: PlacedCart) { const items = placed.items.flatMap((item, i) => { const [total, q, rate] = [cents(item.price.gross), item.quantity, item.price.taxPercent]; const unit = Math.floor(total / q); const extra = total - unit * q; // 0 ≤ extra < q: that many units cost one cent more, so the line is exact const line = (Quantity: number, c: number) => ({ MerchantReference: reference(item.variant?.sku ?? item.lineId ?? `line-${i}`), Description: item.name, Type: TYPE[item.type ?? ""] ?? "Product", Quantity, PricePerItemIncVat: c / 100, PricePerItemExVat: Math.round(c / (1 + rate / 100)) / 100, VatRate: rate, // 25 = 25 % }); return extra ? [line(q - extra, unit), line(extra, unit + 1)] : [line(q, unit)]; }); const drift = cents(placed.total.gross) - items.reduce((s, l) => s + cents(l.PricePerItemIncVat) * l.Quantity, 0); if (Math.abs(drift) > placed.items.length) throw new Error(`Items miss ${drift} cents of cart ${placed.id}`); if (drift) { const price = drift / 100; // cents lost when line totals carry more than 2 decimals items.push({ MerchantReference: "rounding", Description: "Rounding", Type: drift < 0 ? "Discount" : "Fee", Quantity: 1, PricePerItemIncVat: price, PricePerItemExVat: price, VatRate: 0, }); } return items;}
export type QliroItem = { MerchantReference: string; Type: string; Quantity: number; PricePerItemIncVat: number };export type QliroOrder = { OrderId: number; MerchantReference: string; TotalPrice: number; Currency: string; CustomerCheckoutStatus: "InProcess" | "OnHold" | "Completed" | "Refused"; OrderHtmlSnippet: string; PaymentLink?: string; PaymentMethod?: { PaymentMethodName: string }; MerchantProvidedMetadata: { Key: string; Value: string }[]; OrderItems: QliroItem[]; // filled once Completed/OnHold};export type QliroMarket = { country: string; language: string }; // from the Crystallize market, e.g. SE + sv-se
export const merchantReference = (cartId: string) => cartId.slice(0, 25); // Qliro's maximum length
export async function createQliroCheckout(placed: PlacedCart, market: QliroMarket) { const ref = merchantReference(placed.id); // a second tab finds the first tab's order; a click in the same instant can still create a second one, // and the duplicate-payment flag in createOrderOnce covers it const found = await qliro<QliroOrder | null>(`merchantapi/orders?merchantReference=${ref}`); if (found) return found; const c = placed.customer; const a = c?.addresses?.find((x) => x.type === "billing") ?? c?.addresses?.[0]; const company = c?.type === "organization"; const { OrderId } = await qliro<{ OrderId: number; PaymentLink: string }>("merchantapi/orders", { MerchantReference: ref, MerchantProvidedMetadata: [{ Key: "cartId", Value: placed.id }], // the full id (Value ≤ 250) Country: market.country, Currency: placed.total.currency.toUpperCase(), Language: market.language, MerchantTermsUrl: `${process.env.PUBLIC_URL}/terms`, MerchantConfirmationUrl: `${process.env.PUBLIC_URL}/checkout/confirmation?cart=${placed.id}`, MerchantCheckoutStatusPushUrl: pushUrl("checkout", placed.id), MerchantOrderManagementStatusPushUrl: pushUrl("om", placed.id), OrderItems: orderItems(placed), CustomerInformation: { // prefill: the shopper does not type it again Email: c?.email, MobileNumber: c?.phone, JuridicalType: company ? "Company" : "Physical", Address: a && { FirstName: a.firstName, LastName: a.lastName, CompanyName: c?.companyName, Street: [a.street, a.streetNumber].filter(Boolean).join(" "), PostalCode: a.postalCode, City: a.city, }, }, ...(company && { EnforcedJuridicalType: "Company" }), // B2B chosen in the storefront, before place }); return qliro<QliroOrder>(`merchantapi/orders/${OrderId}`); // GetOrder carries the HTML snippet}The pay route returns OrderHtmlSnippet when the order is InProcess. A found order that is Completed or OnHold
is already submitted: send the shopper to the confirmation page. Refused cannot be paid again: start a new cart.
- Lifetimes: a checkout session lasts 90 minutes and a Qliro order 48 hours. When the session expires Qliro shows
a dialog and reloads the page; to resume an older
InProcessorder, renew it withUpdateOrder(PUT merchantapi/orders/{OrderId}with the sameOrderItems, throughqliro(path, body, "PUT")) beforeGetOrder. Past 48 hours, start a new cart. - Item rules (else
INVALID_INPUT):PricePerItemIncVat≥PricePerItemExVat;Product,FeeandShipping≥ 0;Discount≤ 0 (for a taxed discount the first rule presumably compares absolute values: unconfirmed). Qliro identifies an item byMerchantReference+PricePerItemIncVat.
Client
"use client";import { useEffect, useRef } from "react";
type Q1 = { onPaymentDeclined(cb: (reason: string, message?: string) => void): void };declare global { interface Window { q1Ready?: (q1: Q1) => void; }}
export function QliroCheckout({ snippet }: { snippet: string }) { const ref = useRef<HTMLDivElement>(null); useEffect(() => { window.q1Ready = (q1) => q1.onPaymentDeclined((reason) => console.warn("Qliro declined:", reason)); const el = ref.current!; el.innerHTML = snippet; // a <script> set through innerHTML never runs: re-create each one for (const old of Array.from(el.querySelectorAll("script"))) { const s = document.createElement("script"); for (const attr of Array.from(old.attributes)) s.setAttribute(attr.name, attr.value); s.text = old.text; old.replaceWith(s); } }, [snippet]); return <div ref={ref} />;}When the purchase is Completed or OnHold, Qliro redirects to MerchantConfirmationUrl — the return page from
SKILL.md. It reads the cart named by cart in its URL: ordered → confirmation and
clear the cart cookie; still placed → “confirming your payment…” and refresh. Qliro also wants a call from this
page: a new GetOrder (merchantapi/orders?merchantReference=…) returns Qliro’s thank-you page as a new
OrderHtmlSnippet; render it with the same component (render thank-you page), and say “under review” while
the status is OnHold. The page still never creates the order: the push does.
Webhook
Both push URLs point to one route, but the signed type in each URL keeps them apart: only a checkout-status
push can reach createOrderOnce, and an order-management push (capture, refund, cancel results) never creates an
order. Qliro signs nothing, so both re-read Qliro before acting. The answer must be the JSON
{ "CallbackResponse": "received" }; anything else is retried at once, then after 2 s, 5 s, 10 s, 30 s, 1 min, 2 min,
30 min, 1 h, 24 h and 3 days (notifications). The same push can also arrive several times.
// lib/qliro.ts (continued). Keep helpers here: a Next route file may only export HTTP methods.type CheckoutPush = { OrderId: number; Status: string; NotificationType: string; Timestamp: string };type OmPush = { OrderId: number; PaymentTransactionId: number; PaymentType: string; Status: string };export type QliroTx = { PaymentTransactionId: number; OrderId: number; Type: string; Status: string; Amount: number; Timestamp: string; ErrorCode?: string;};type AdminOrder = { PaymentTransactions: QliroTx[] }; // Admin API GetOrder
export async function onCheckoutStatus(cartId: string, push: CheckoutPush) { if (push.NotificationType !== "CustomerCheckoutStatus") return; // UpsellStatus pushes share this URL const order = await qliro<QliroOrder>(`merchantapi/orders/${push.OrderId}`); // the push is only a hint if (order.MerchantProvidedMetadata.find((m) => m.Key === "cartId")?.Value !== cartId) { throw new Error(`Qliro order ${push.OrderId} is not cart ${cartId}'s`); } if (order.CustomerCheckoutStatus !== "Completed") return; // InProcess; OnHold (another push follows); Refused // Qliro's items are what the shopper paid for: they must be the placed cart, or the order would ship something else const placed = (await carts.fetch(cartId, PLACED_CART)) as unknown as PlacedCart; if (cents(order.TotalPrice) !== cents(placed.total.gross)) { throw new Error(`Qliro order ${order.OrderId}: ${order.TotalPrice}, cart ${cartId}: ${placed.total.gross}`); } await createOrderOnce(cartId, "unpaid", toPayment(order, cartId)); // a repeated push is absorbed here}
export async function onOrderManagementStatus(cartId: string, push: OmPush) { const tx = await qliro<QliroTx>(`adminapi/v2/paymentTransactions/${push.PaymentTransactionId}`); // re-read if (!["Capture", "Refund", "Reversal"].includes(tx.Type)) return; // Preauthorization, Debit, UpdateInvoice, … if (["Created", "InProcess", "OnHold"].includes(tx.Status)) return; // another push follows const id = String(tx.OrderId); const order = await readOrder(cartId); if (!order?.payments?.some((p) => p.provider === "qliro" && p.transactionId === id)) { throw new RetryLater(`order ${cartId} has no Qliro payment ${id} yet`); } if (tx.Status !== "Success") { // Error or Cancelled: no money moved console.error(`[qliro] ${tx.Type} ${tx.PaymentTransactionId} on ${id}: ${tx.Status} ${tx.ErrorCode ?? ""}`); return updatePayment(cartId, id, (p) => withMeta(p, { attention: `${tx.Type.toLowerCase()}-failed` })); } if (tx.Type === "Capture") { const { PaymentTransactions: all } = await qliro<AdminOrder>(`adminapi/v2/orders/${id}`); const ok = all.filter((t) => t.Type === "Capture" && t.Status === "Success"); const captured = ok.reduce((sum, t) => sum + t.Amount, 0); // several partial captures add up const values = { state: "captured", captureTransactionId: String(tx.PaymentTransactionId) }; // for ReturnItems await updatePayment(cartId, id, (p) => withMeta(p, values, captured)); } else if (tx.Type === "Refund") { await recordPayment(cartId, { provider: "qliro", method: "refund", transactionId: String(tx.PaymentTransactionId), amount: tx.Amount, createdAt: tx.Timestamp, meta: [ { key: "type", value: "refund" }, { key: "cartId", value: cartId }, { key: "qliroOrderId", value: id }, ], }); } // Reversal = cancelorder went through: cancel() already set state=cancelled}// app/api/payments/qliro/webhook/route.ts — both push URLs, told apart by the signed `type`import { onCheckoutStatus, onOrderManagementStatus, verifyPush } from "@/lib/qliro";
export async function POST(req: Request) { const body = await req.text(); const push = verifyPush(req.url); if (!push) return new Response("bad token", { status: 401 }); try { if (push.type === "checkout") await onCheckoutStatus(push.cartId, JSON.parse(body)); else await onOrderManagementStatus(push.cartId, JSON.parse(body)); // never creates an order return Response.json({ CallbackResponse: "received" }); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // not "received": Qliro retries for up to 3 days }}| Qliro (re-read) | Crystallize (paymentStatus) |
|---|---|
Checkout push, CustomerCheckoutStatus: Completed |
createOrderOnce(cartId, 'unpaid', …), state=authorized |
InProcess |
Nothing: the shopper is still in the checkout |
OnHold |
Nothing yet: Qliro pushes again when it turns Completed or Refused |
Refused |
No order. The placed cart cannot be paid with this Qliro order: new cart |
OM push, Capture Success |
updatePayment → state=captured, amount = captured sum, captureTransactionId |
OM push, Refund Success |
recordPayment, meta type=refund (deduped by PaymentTransactionId) |
OM push, Reversal Success (cancelorder) |
Nothing more: cancel() set state=cancelled |
OM push, Error / Cancelled |
meta attention=<type>-failed and an alert; a failed capture stays authorized |
Things to keep in mind, from the crystallize.com page and Qliro’s docs:
- Push notifications are not authenticated. Always re-fetch (
GetOrder,GetPaymentTransaction) before acting. Qliro suggests a short-lived token in the push URL; the HMAC token here does not expire on purpose, because order-management pushes arrive days or weeks later. It proves the URL is yours; the re-fetch proves the content. - Duplicates and retries (up to 3 days): discard duplicates by
OrderId, status andTimestamp, or check that the cart has not already become an order.createOrderOnce,recordPayment(by transaction id) andupdatePaymentmake every push safe to repeat. GetOrder’s items are the source of truth for what the shopper paid for; here they must equal the placed cart.- Push URLs must be HTTPS and reachable from the internet; tunnel localhost while developing.
Capture, refund, cancel
Admin API calls answer at once with { PaymentTransactions: [{ PaymentTransactionId, Status: "Created" }] }; the
outcome arrives on the order-management push, so Crystallize is written in the webhook. RequestId is a GUID
that Qliro refuses to run twice within 7 days — a repeat is denied, not replayed — so check before you send.
capture() returns null: the stage handler in SKILL.md
then leaves the record alone, and the Capture push flips it to captured.
// lib/qliro.ts (continued) — captureByProvider.qliro = captureexport async function capture(transactionId: string): Promise<number | null> { const { PaymentTransactions } = await qliro<AdminOrder>(`adminapi/v2/orders/${transactionId}`); const pending = PaymentTransactions.some((t) => t.Type === "Capture" && !["Error", "Cancelled"].includes(t.Status)); if (pending) return null; // a redelivered stage webhook: the first capture is under way or done const order = await qliro<QliroOrder>(`merchantapi/orders/${transactionId}`); await qliro("adminapi/v2/markitemsasshipped", { RequestId: randomUUID(), OrderId: order.OrderId, Currency: order.Currency, Shipments: [ { OrderItems: order.OrderItems.map(({ MerchantReference, Type, Quantity, PricePerItemIncVat }) => ({ MerchantReference, Type, Quantity, PricePerItemIncVat, })), }, ], // a partial capture sends fewer }); return null; // asynchronous: the OM push sets state=captured}
/** After capture only (before it: cancel). `items` as GetOrder lists them; `requestId`: a GUID kept for this refund. */export async function refund(cartId: string, qliroOrderId: string, items: QliroItem[], requestId: string) { const record = (await readOrder(cartId))?.payments?.find((p) => p.transactionId === qliroOrderId); const captureTx = record?.meta?.captureTransactionId; if (!captureTx) throw new Error(`Qliro order ${qliroOrderId} is not captured: cancel it instead`); const { Currency } = await qliro<QliroOrder>(`merchantapi/orders/${qliroOrderId}`); await qliro("adminapi/v2/returnitems", { RequestId: requestId, OrderId: Number(qliroOrderId), Currency, Returns: [{ PaymentTransactionId: Number(captureTx), OrderItems: items }], // the capture's id, not the order's }); // the Refund push records it}
/** Before shipping: releases the reservation. */export async function cancel(cartId: string, qliroOrderId: string) { await qliro("adminapi/v2/cancelorder", { RequestId: randomUUID(), OrderId: Number(qliroOrderId) }); await updatePayment(cartId, qliroOrderId, (p) => withMeta(p, { state: "cancelled" })); // a failed Reversal flags it}- Admin API errors worth a retry later:
PAYMENT_ONHOLD, andOPERATION_NOT_SUPPORTEDwith “Another transaction is already in process”;INVALID_REQUEST_TOTAL_AMOUNT/EXCEEDING_QUANTITYmean the items do not match the order. ReturnItemsworks only after capture; return fees go inFees, extra discounts inDiscounts. Cancelling a Trustly payment creates an extraRefundtransaction, which the webhook records as a refund — correct, since Trustly had already moved the money.- One order can have many
PaymentTransactionIds (upsell, updates); Qliro advises using the latest successful one. After several partial captures, refund each item against the capture that shipped it. - How long a reservation stays capturable depends on the payment method (unconfirmed: Qliro does not publish it).
Mapping
// lib/qliro.ts (continued)export const toPayment = (o: QliroOrder, cartId: string): Payment => ({ provider: "qliro", method: o.PaymentMethod?.PaymentMethodName.toLowerCase() ?? "qliro", // e.g. CREDITCARDS → creditcards transactionId: String(o.OrderId), // every Admin API call takes the OrderId amount: o.TotalPrice, // already major units createdAt: new Date().toISOString(), meta: [ { key: "state", value: "authorized" }, { key: "cartId", value: cartId }, ], // the Capture push adds captureTransactionId});A refund is its own record: transactionId = the refund’s PaymentTransactionId, amount in major units,
meta type=refund, cartId, qliroOrderId (written by the webhook above).
Provider specifics
- B2B: a company chosen in the storefront is on the cart customer (
type: "organization") beforeplace; the create call then sendsJuridicalType: "Company"andEnforcedJuridicalType: "Company"(only companies may complete). For a company, Qliro readsCustomerInformation.PersonalNumberas the organisation number andVatNumberas the VAT number — send them if your cart customer carries them. - Prefill and lock:
CustomerInformation(Email,MobileNumber,PersonalNumber,Address,ShippingAddressfor B2B) plusLockCustomerEmail,LockCustomerMobileNumber,LockCustomerPersonalNumber,LockCustomerAddressorLockCustomerInformationkeep what the storefront collected; locking can disable Qliro’s own payment methods. - A method chosen before
place:POST merchantapi/PaymentOptionslists the payment ids; store the choice on the cart (carts.setMeta(id, { meta: [{ key: "qliroPaymentId", value }], merge: true })) and send it asPaymentIdto open the checkout on that method. - Frontend listeners (inside
q1Ready):onCheckoutLoaded,onCustomerInfoChanged,onPaymentMethodChanged,onShippingMethodChanged,onShippingPriceChanged,onPaymentDeclined,onPaymentProcess,onSessionExpired,onCustomerDeauthenticating, andlock()/unlock()/onOrderUpdated()around anUpdateOrder. The placed cart never changes, so you only need them to mirror Qliro’s state in your page. - After payment (prose only):
checkout/adminapi/v2/updateitems(change items before shipping),…/additemstoinvoice(discounts on an invoice after capture),…/updatemerchantreference,…/retryreversalpaymenttransaction,GET …/paymentTransactions/{id}; settlements under…/settlements; upsell on the thank-you page (POST merchantapi/Upsell, anUpsellStatuspush); saved cards and recurring orders (MerchantSavedCreditCardPushUrl,adminapi/v2/merchantpayment) for subscription contracts — not covered here.
Going further
Everything Qliro Checkout offers beyond the flow above, from the crystallize.com page and Qliro’s docs:
- Order management from Crystallize fulfilment pipelines: this reference’s
capture(),refund()andcancel(), driven by a stage webhook, with results onMerchantOrderManagementStatusPushUrl. The QliroOrderIdis the payment record’stransactionId. See order management. - Order validation:
MerchantOrderValidationUrlmakes Qliro POST the order (items, customer, addresses, payment method) when the shopper clicks “Complete purchase”. Answer200to accept, or400with{ "DeclineReason": "OutOfStock" }(orPostalCodeIsNotSupported,ShippingIsNotSupportedForPostalCode,CashOnDeliveryIsNotSupportedForShippingMethod,IdentityNotVerified,Other+DeclineReasonMessage≤ 150 chars). No answer within 5 s approves the order unless Qliro configures auto-reject. Protect it like a push URL. - Shipping in the checkout: a static
AvailableShippingMethodslist, a dynamicMerchantOrderAvailableShippingMethodsUrl(5 s to answer), or integrations such as Ingrid and Unifaun (nShift) throughShippingConfiguration, plusMerchantOrderAvailableShippingAddressesUrlandMerchantNotificationUrlfor the provider’s data. Warning: shipping chosen inside Qliro is charged by Qliro on top of the placed cart, so the shopper pays more than the cart and the Crystallize order has no shipping line — the webhook above refuses such orders. Choose shipping in the storefront, as an external item, beforeplace. - Thank-you page: after completion
GetOrderreturns Qliro’s thank-you snippet (Client);q1.excludeResultModules(["HEADER", "TOTAL_PRICE", "CUSTOMER_DETAILS", "SHIPPING_METHOD"])hides parts of it. - Customer and B2B options:
LockCustomerEmail,LockCustomerAddressand the other lock flags;EnforcedJuridicalTypefor companies only;RequireIdentityVerificationfor BankID in Sweden;MinimumCustomerAge. - Look and feel:
PrimaryColor,CallToActionColor,CallToActionHoverColor,BackgroundColor(saturation ≤ 10 %),CornerRadius,ButtonCornerRadius; alsoAskForNewsletterSignup,MerchantProvidedQuestion,ShippingAdditionalHeader,MerchantIntegrityPolicyUrl. - Payment link: instead of the iframe, redirect the shopper to
PaymentLink(in theCreateOrderandGetOrderresponses);MerchantCancelUrladds a cancel link. Pushes, confirmation page and webhook stay the same. - Frontend listeners: Qliro’s Frontend API keeps your page in sync with the iframe (listeners).
- Markets: country, currency and language come from the cart’s market and locale, never a fixed
NO/NOK/en-us.
Common mistakes
- Sending both push types to one handler that creates an order whenever
GetOrdersaysCompleted: every capture, refund or repeated push then creates another order. Keep the signedtype, checkNotificationType, and let only the checkout push callcreateOrderOnce. - Push URLs without a token, or trusting the push body instead of re-fetching.
- Recording the payment without Qliro’s
OrderId(e.g. a bare “custom” payment): nothing to capture or refund with. - Never calling the Admin API: reservations are never captured and nothing is paid.
- Hardcoding
Country: "NO",Currency: "NOK",Language: "en-us". - Leaving out shipping or discount lines, or rounding
line / quantityto 2 decimals: Qliro then charges a different amount than the placed cart. - Clearing the cart cookie when the Qliro order is created: an abandoned payment loses the cart. Clear it on the
confirmation page once the cart is
ordered. - Setting the snippet with
innerHTMLand stopping there: the scripts never run and the iframe never appears. - Hashing a re-serialised body (
401), or forgettingMerchantApiKeyin Admin API bodies. - Answering a push with anything but
{"CallbackResponse":"received"}: Qliro retries for 3 days. - Treating the Admin API’s
Createdas done; usingReturnItemsbefore capture; refunding against the order’s first transaction id instead of the capture’s. - Going live with
https://pago.qit.nu.
QuickPay with Crystallize
QuickPay (Quickpay) is a Danish payment gateway for merchants in Denmark and the rest of the Nordics and EU: cards
(Visa, Mastercard, Dankort, Amex) through the merchant’s acquirer, plus MobilePay, Vipps, Apple Pay, Google Pay,
Klarna, PayPal, ViaBill, Anyday, Swish and Trustly in one hosted payment window. The recommended integration is a
Quickpay Link: create a QuickPay payment for the placed cart, put a link on it, redirect the shopper, and create
the order from QuickPay’s checksum-signed callback. Payments are authorized only by default and captured later
through the API (capture on shipment); auto_capture captures at once for digital goods.
Verification: Written from QuickPay’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: API introduction, Callback, Quickpay Link, Payments guide, API services, Test data, Errors and codes, Payment methods, Acquirer details, Integration setup. Machine-readable spec: https://api.quickpay.net/docs/v10/merchant/api/payments.json. Crystallize page: QuickPay.
At a glance
| Topic | QuickPay |
|---|---|
| Markets & currencies | Danish PSP for DK, Nordic and EU merchants; currencies per acquirer (DKK, EUR, SEK, …) |
| Recommended integration | Quickpay Link: POST /payments → PUT /payments/{id}/link → redirect to its url |
| Alternative | The same link in an iframe (framed: true); Quickpay Form (legacy HTML POST) |
| API version | v10 in the Accept-Version header; only the two newest versions are served |
| SDKs | No Node SDK (official clients: Ruby, PHP, Python, .NET): use fetch |
| Amount units | Integer minor units (amount: 100 = 1.00 DKK) |
| Capture + auth lifetime | Manual by default (auto_capture: false); lifetime per acquirer; 40002 = expired |
| Cart id field | order_id (4–20 chars, unique per merchant) = hash of cart id; full id in variables |
| One session per cart | order_id from the cart id; GET /payments?order_id= before creating; link reusable |
| Notification verification | QuickPay-Checksum-Sha256: hex HMAC-SHA256 of the raw body, merchant private key |
Credentials and setup
The crystallize.com page names two credentials. Both are in the Quickpay Manager under Settings → Integration (the crystallize.com page says Settings → Merchant → Merchant Settings; the Manager now lists the Merchant ID, the private key and each user’s API key on the Integration page):
- API key — authenticates every API call (HTTP Basic, empty username, the key as password). Use the key of the API user, or of a dedicated system user (Settings → Users) allowed to create, capture, refund and cancel payments. The built-in “Payment Window” user is restricted.
- Merchant private key — “not an API key”: the account’s Private key that signs callbacks. Server-side only.
QUICKPAY_API_KEY=... # API user's key — Basic auth ":<key>"QUICKPAY_PRIVATE_KEY=... # merchant Private key — callback checksumQUICKPAY_CALLBACK_URL=https://shop.example/api/payments/quickpay/webhookQUICKPAY_ACCEPT_TEST=false # true only outside production- No sandbox host. Test cards work on the live account and the payment carries
test_mode: true; test transactions can be disabled per merchant (Settings → Integration) — do that, or filtertest_mode, in production. - Test cards (any plausible expiry and CVD; a CVD such as
752sets the issuing country): Visa1000 0000 0000 0008approved,…0016rejected,…0024expired,…0032capture rejected,…0040refund rejected,…0057cancel rejected,…00733-D Secure required (30100),…0099delayed 60 s; Mastercard1000 0100 0000 0007, Dankort1000 0200 0000 0006approved. - Localhost: QuickPay must reach the callback URL. Run a tunnel (cloudflared, ngrok) and pass its URL as the
link’s
callback_url— each link carries its own, so every developer can use their own tunnel.
Create the payment
Call this from the pay route in SKILL.md, with the placed cart.
order_id is unique per merchant and at most 20 characters, so it is a hash of the cart id; looking it up first is
what keeps two tabs on one QuickPay payment. A link can be reopened until the payment is authorized, and a declined
card can retry in the same window.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";import type { Payment, PlacedCart } from "@/lib/crystallize-payments";
export async function quickpay<T>(path: string, init: { method?: string; body?: unknown; headers?: object } = {}) { const res = await fetch(`https://api.quickpay.net${path}`, { method: init.method ?? "GET", headers: { Authorization: `Basic ${btoa(`:${process.env.QUICKPAY_API_KEY}`)}`, // empty username "Accept-Version": "v10", Accept: "application/json", "Content-Type": "application/json", ...init.headers, }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); if (!res.ok) throw new Error(`QuickPay ${init.method ?? "GET"} ${path} ${res.status}: ${await res.text()}`); return (await res.json()) as T;}
// 4–20 chars, unique per merchant; hex also fits Swish's a-zA-Z0-9- ruleexport const quickPayOrderId = (cartId: string) => createHash("sha256").update(cartId).digest("hex").slice(0, 20);const findPayment = async (orderId: string) => (await quickpay<QuickPayPayment[]>(`/payments?order_id=${orderId}`))[0];
export async function createQuickPayPayment(placed: PlacedCart, origin: string, language = "en") { const orderId = quickPayOrderId(placed.id); let payment = await findPayment(orderId); // one payment per cart: the other tab may have created it const returnUrl = `${origin}/checkout/confirmation?cart=${placed.id}`; // a wallet app may open another browser if (payment?.accepted) return { url: returnUrl }; // already paid: just wait for the webhook payment ??= await quickpay<QuickPayPayment>("/payments", { method: "POST", body: { order_id: orderId, currency: placed.total.currency, variables: { cartId: placed.id } }, }).catch(async (error) => (await findPayment(orderId)) ?? Promise.reject(error)); // lost the race: reuse const link = await quickpay<{ url: string }>(`/payments/${payment.id}/link`, { method: "PUT", body: { amount: Math.round(placed.total.gross * 100), // placed total, minor units, never from the browser continue_url: returnUrl, // not proof of payment cancel_url: `${origin}/checkout?payment=cancelled`, callback_url: process.env.QUICKPAY_CALLBACK_URL, language, // two-letter code from the storefront locale auto_capture: false, // physical goods: capture on shipment auto_fee: false, // never add the acquirer fee: charge exactly the placed total customer_email: placed.customer?.email, // PayPal requires it payment_methods: placed.meta?.quickpayMethods, // chosen before place, see Provider specifics }, }); return { url: link.url };}origin is your public URL from configuration, not the request’s Host header. QuickPay needs no order lines,
except for Klarna and Resurs (Provider specifics). QuickPay does not document zero-decimal
currencies (ISK, JPY): * 100 assumes two decimals, as for DKK, EUR, SEK and NOK (unconfirmed for others).
Client
Redirect the browser to the link (location.assign(url) after the pay route answers, or a 303 from a form POST).
Cards, 3-D Secure and the wallets run in QuickPay’s hosted window at payment.quickpay.net. continue_url is the
return page from SKILL.md: it reads the cart named in its URL (MobilePay or Vipps can
come back in another browser, without your cookie) and shows “confirming your payment…” until the cart is
ordered — it never creates the order. cancel_url lands on checkout with the cart still placed; Pay again
reuses the same payment and link, and changing the cart means a new cart.
Webhook
QuickPay POSTs the whole payment (the same body as GET /payments/{id}) after every operation: authorize,
capture, refund, cancel — from your code or from the Manager. Callbacks for one payment arrive in operation order.
// lib/quickpay.ts (continued)export type QuickPayOperation = { id: number; type: string; // authorize | capture | refund | cancel | renew | … amount: number; // minor units pending: boolean; qp_status_code: string; // "20000" approved; 40000 rejected (see aq_status_msg); 40002 authorization expired aq_status_msg: string | null; created_at: string;};export type QuickPayPayment = { id: number; order_id: string; type: string; // "Payment" accepted: boolean; currency: string; test_mode: boolean; variables: { cartId?: string }; metadata: { type?: string; brand?: string } | null; link: { auto_capture?: boolean | null } | null; operations: QuickPayOperation[];};export const isApproved = (op: QuickPayOperation) => !op.pending && op.qp_status_code === "20000";export const approved = (p: QuickPayPayment, type: string) => p.operations.filter((op) => op.type === type && isApproved(op));export const total = (ops: QuickPayOperation[]) => ops.reduce((sum, op) => sum + op.amount, 0);
export function verifyQuickPay(raw: string, checksum: string | null) { const expected = createHmac("sha256", process.env.QUICKPAY_PRIVATE_KEY!).update(raw).digest(); const received = Buffer.from(checksum ?? "", "hex"); return received.length === expected.length && timingSafeEqual(received, expected);}import { createOrderOnce, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import { approved, isApproved, quickPayPayment, quickPayRefund, total, verifyQuickPay } from "@/lib/quickpay";import type { QuickPayPayment } from "@/lib/quickpay";
export async function POST(req: Request) { const raw = await req.text(); // the exact bytes QuickPay signed if (!verifyQuickPay(raw, req.headers.get("quickpay-checksum-sha256"))) { return new Response("bad checksum", { status: 401 }); } const p = JSON.parse(raw) as QuickPayPayment; const cartId = p.variables?.cartId; if (p.type !== "Payment" || !cartId) return new Response("ignored"); if (p.test_mode && process.env.QUICKPAY_ACCEPT_TEST !== "true") return new Response("test payment ignored"); const auth = approved(p, "authorize").at(-1); // the latest approved authorize if (!p.accepted || !auth) return new Response("not authorized"); // declined or pending: no order const captured = total(approved(p, "capture")); if (p.link?.auto_capture && !captured) return new Response("waiting for the capture callback"); try { await createOrderOnce(cartId, captured ? "paid" : "unpaid", quickPayPayment(p)); // Operations after the order exists (capture() below, or the Manager); all of these are idempotent const last = p.operations.at(-1)!; const update = (state: string, amount?: number) => updatePayment(cartId, String(p.id), (r) => withMeta(r, { state }, amount ?? r.amount)); if (isApproved(last) && last.type === "capture") await update("captured", captured / 100); if (isApproved(last) && last.type === "cancel") await update("cancelled"); for (const op of approved(p, "refund")) await recordPayment(cartId, quickPayRefund(p, op)); return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // RetryLater and real failures alike }}QuickPay counts 2xx, 302 and 303 as delivered and follows 301/307 to the Location. A redirect from auth or i18n
middleware on this route therefore swallows the callback: exclude the route from that middleware. Anything else fails
and is retried — the docs say both “up to 24 times, with gradually increasing delays” and “after an hour”, so do not
rely on the timing. Never answer 2xx to a bad checksum.
| Payment in the callback | Crystallize (paymentStatus) |
|---|---|
accepted, latest authorize approved |
createOrderOnce(cartId, 'unpaid', …), state=authorized |
Approved capture, auto_capture: true |
createOrderOnce(cartId, 'paid', …), state=captured |
Latest operation an approved capture |
updatePayment → state=captured, amount = captured sum |
Approved refund operations |
recordPayment for each, meta type=refund (deduped by id) |
Latest operation an approved cancel |
updatePayment → state=cancelled; move to a cancelled stage |
Declined or pending authorize |
Nothing: the shopper retries in the same window |
test_mode: true in production |
200 and ignore (or disable test transactions) |
Capture, refund, cancel
The operation endpoints take a QuickPay-Callback-Url header (else the account’s default callback URL is used) and
a ?synchronized query flag that waits and returns the payment with the finished operation instead of 202 and a
later callback. capture() uses it, so it normally returns the captured amount; if the operation is still pending it
returns null and the capture callback flips the record (the webhook above). QuickPay documents no idempotency
key: read the payment and its operations before acting, so a retried stage webhook never captures twice.
// lib/quickpay.ts (continued) — captureByProvider.quickpay = captureasync function operate(id: string, op: "capture" | "refund" | "cancel", body?: object) { const headers = { "QuickPay-Callback-Url": process.env.QUICKPAY_CALLBACK_URL! }; const p = await quickpay<QuickPayPayment>(`/payments/${id}/${op}?synchronized`, { method: "POST", body, headers }); const last = p.operations.at(-1)!; // 40000: see aq_status_msg; 40002: authorization expired if (!last.pending && !isApproved(last)) throw new Error(`QuickPay ${op} ${last.qp_status_code}: ${last.aq_status_msg}`); return p;}
/** The captured total in major units, or null while QuickPay is still processing (its callback updates the record). */export async function capture(transactionId: string, amount: number): Promise<number | null> { let p = await quickpay<QuickPayPayment>(`/payments/${transactionId}`); const target = Math.round(amount * 100); const done = total(approved(p, "capture")); const pending = p.operations.some((o) => o.type === "capture" && o.pending); if (done < target && !pending) p = await operate(transactionId, "capture", { amount: target - done }); if (p.operations.some((o) => o.type === "capture" && o.pending)) return null; return total(approved(p, "capture")) / 100; // also covers a retry after a capture that already went through}
/** * `refundedBefore` = the refunds already recorded on the Crystallize order (major units). More at QuickPay means * this refund went through and its callback has not been recorded yet. The webhook records it (recordPayment), * so refunds made in the Manager are recorded too. */export async function refund(transactionId: string, amount: number, refundedBefore: number) { const p = await quickpay<QuickPayPayment>(`/payments/${transactionId}`); if (total(approved(p, "refund")) > Math.round(refundedBefore * 100)) return; await operate(transactionId, "refund", { amount: Math.round(amount * 100) }); // partial refunds allowed}
/** Voids an uncaptured authorization; its callback sets `state=cancelled` on the record (webhook above). */export const cancel = (transactionId: string) => operate(transactionId, "cancel");- Captures can be partial (several
captureoperations); the record’samountbecomes the captured sum. - An authorization lives as long as the acquirer and card scheme allow — commonly about 7 days for cards
(unconfirmed per acquirer).
POST /payments/{id}/renewrenews it; capture fails with40002once it has expired. - HTTP 429 means “Too Many 4XX Requests” and carries
Retry-After: a burst of bad calls throttles the account.
Mapping
// lib/quickpay.ts (continued)export function quickPayPayment(p: QuickPayPayment): Payment { const auth = approved(p, "authorize").at(-1)!; const captured = total(approved(p, "capture")); return { provider: "quickpay", method: p.metadata?.brand ?? p.metadata?.type ?? "card", // visa, dankort, mobilepay, … transactionId: String(p.id), // the QuickPay payment id: capture, refund and cancel use it amount: (captured || auth.amount) / 100, // MAJOR units createdAt: auth.created_at, meta: [ { key: "state", value: captured ? "captured" : "authorized" }, { key: "cartId", value: p.variables.cartId! }, { key: "quickpayOrderId", value: p.order_id }, ], };}
export const quickPayRefund = (p: QuickPayPayment, op: QuickPayOperation): Payment => ({ provider: "quickpay", method: p.metadata?.brand ?? p.metadata?.type ?? "card", transactionId: `${p.id}-${op.id}`, // payment id + operation id: unique per refund amount: op.amount / 100, createdAt: op.created_at, meta: [ { key: "type", value: "refund" }, { key: "cartId", value: p.variables.cartId! }, ],});Provider specifics
Payment method chosen in the storefront. payment_methods restricts the window (creditcard, dankort,
mobilepay, vipps, apple-pay, google-pay, klarna-payments, swish, …; !brand excludes; 3d- and a country
suffix narrow cards). Store the shopper’s choice on the cart before place —
carts.setMeta(id, { meta: [{ key: "quickpayMethods", value: "mobilepay" }], merge: true }) — and the link reads it
from placed.meta. Listing methods excludes every other one.
Klarna and Resurs need a basket whose total (plus shipping.amount) equals the payment amount; item_price is
per unit including VAT in minor units, vat_rate a fraction (0.25). Resurs also needs invoice_address (name,
street, city, zip, country_code as ISO 3166-1 alpha-3, email, phone) — map it from the billing entry of
placed.customer.addresses. Send both in the POST /payments body. Shipping is already a cart line (type: "shipping", an external item without a variant), so leave shipping.amount out:
// lib/quickpay.ts (continued)export function quickPayBasket(placed: PlacedCart) { const basket = placed.items.map((item) => { const line = Math.round(item.price.gross * 100); const unit = line / item.quantity; const base = { item_no: item.variant?.sku ?? item.lineId ?? item.name, vat_rate: item.price.taxPercent / 100 }; return Number.isInteger(unit) ? { ...base, qty: item.quantity, item_name: item.name, item_price: unit } : { ...base, qty: 1, item_name: `${item.quantity} × ${item.name}`, item_price: line }; // keep the sum exact }); const rest = Math.round(placed.total.gross * 100) - basket.reduce((s, l) => s + l.qty * l.item_price, 0); // a cart-level discount or rounding; whether QuickPay/Klarna accept a negative line is unconfirmed if (rest !== 0) { basket.push({ item_no: "adjustment", vat_rate: 0, qty: 1, item_name: "Adjustment", item_price: rest }); } return basket; // POST /payments body: { order_id, currency, variables, basket, invoice_address? }}Embedded window. framed: true on the link allows it in an iframe with sandbox="allow-same-origin allow-scripts allow-forms"; the flow and the webhook are unchanged.
After payment (prose only). PATCH /payments/{id} adds shipping[tracking_number] / shipping[tracking_url]
once the goods ship; POST /payments/{id}/renew renews an authorization; POST /payments/{id}/fraud-report reports
fraud; text_on_statement (Clearhaus only, 22 ASCII chars) and branding_id shape what the shopper sees.
Going further
- Subscriptions and saved cards:
POST /subscriptions,PUT /subscriptions/{id}/link,POST /subscriptions/{id}/recurring, and/cardsfor card-on-file tokens; MobilePay Subscriptions has a 600 s link deadline. Keep the agreement on a Crystallize subscription contract — recurring charges are not covered here. - Wallet addresses:
invoice_address_selection/shipping_address_selection(MobilePay, PayPal) put the address on the QuickPay payment, not on the cart. Collect addresses in the storefront beforeplace. auto_feeadds the acquirer fee to the amount: the shopper is then charged more than the placed cart. Leave it off, or add the fee as an external item beforeplace.- Acquirers: with several, QuickPay picks the cheapest; prioritise in Settings → Acquirers or force one with
acquirer. MobilePay Checkout through QuickPay ended on 2024-03-12. - Several shops on one callback URL: the
QuickPay-Account-IDheader tells them apart. - Payouts, Quickpay Form (legacy HTML POST) and the full API services list.
- The crystallize.com page’s flow — lock the cart, create the payment and link server-to-server, redirect, wait on the return page while the callback creates the customer (if missing) and the order — is this reference.
Common mistakes
- Computing the checksum over
JSON.stringify(await req.json())— QuickPay’s own Node sample does it. Hash the raw text; key order and unicode escaping break the re-serialised version. - Checking the checksum with the API key: callbacks are signed with the merchant private key.
- Answering a bad checksum with 200 (or
{}), or letting middleware answer 302/303: QuickPay treats both as delivered, so a forged callback is “accepted” and a real one is never retried. - Creating an order when
acceptedisfalse(stored as a refused order), for a pending authorize, or on every callback — capture and refund callbacks carry the same payment and created extra orders. - Ignoring
test_mode: test cards work on the live account, so a test payment ships real goods. - Creating a new QuickPay payment on every click: a second
POST /paymentswith the sameorder_idfails, and a randomorder_idlets one cart be paid twice. Look the payment up byorder_idfirst. cart.total.gross * 100withoutMath.round.- Never capturing:
auto_capturedefaults tofalse, so nothing is settled and authorizations expire (40002). - Retrying a capture or refund blindly — there is no idempotency key; read
operations(including pending ones) first. - Placing the cart and creating the link from the browser: do both in the server’s pay route, from the placed cart.
Razorpay with Crystallize
Razorpay is an Indian payment gateway (UPI, cards, netbanking, wallets, EMI, Pay Later, and international cards in
160+ currencies) that only onboards businesses incorporated in India (sign-up needs an Indian phone number, a PAN
and video KYC), Malaysia and Singapore (Razorpay Curlec) or the US; a Nordic or EU company cannot sign up,
and other foreign businesses can only use it to take payments from Indian customers. The recommended integration
creates a Razorpay Order on the server from the placed cart, opens Standard Checkout (the checkout.js
modal) on that order, and creates the Crystallize order from the signed order.paid / payment.captured webhook —
the signature Checkout hands the browser is for the UX only. Payments are auto-captured by default; manual
capture must happen within 3 days, after which authorized payments are refunded automatically.
Verification: Written from Razorpay’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Standard Checkout, Create an Order, Fetch orders, Capture, Capture settings, Per-order capture, Refunds, Validate webhooks, Webhook best practices, Payment events, Order events, Refund events, API keys, Test cards, International payments. Markdown mirrors of every page: https://razorpay.com/docs/llms.txt. Crystallize page: Razorpay.
At a glance
| Topic | Razorpay |
|---|---|
| Markets & currencies | Merchants in IN, MY/SG (Curlec), US. INR; 160+ currencies once international is on |
| Recommended integration | Server Order (POST /v1/orders) → Standard Checkout (checkout.js) → webhook |
| Alternative | Hosted Checkout (redirect), Payment Links |
| API version | REST https://api.razorpay.com/v1, no dated versions; Basic auth key_id:key_secret |
| SDKs | razorpay on npm (2.9.x, optional — the code uses fetch); script checkout.js |
| Amount units | Integer sub-units; 0 decimals (JPY, ISK, KRW…); 3 decimals ending in 0 (KWD, BHD…) |
| Capture + auth lifetime | Auto-capture by default; manual capture within 3 days, then auto-refunded |
| Cart id field | receipt (≤ 40 chars, unique) = the cart id; notes.cartId (15 keys × 256 chars) |
| One session per cart | receipt is idempotent (“Duplicate request”) → GET /v1/orders?receipt= and reuse |
| Notification verification | X-Razorpay-Signature: hex HMAC-SHA256 of the raw body with the webhook secret |
Credentials and setup
- Who can sign up: the crystallize.com page warns that sign-up needs an Indian phone number and a PAN; Razorpay also asks for business documents and a video KYC with Aadhaar and PAN. Malaysian and Singaporean companies go through Razorpay Curlec, US companies through Razorpay US. Check this before writing any code.
- Key ID and secret key (as the crystallize.com page names them): Dashboard → Test or Live mode →
Account & Settings → API Keys (under Website and app settings) → Generate Key. The secret is shown once; Live
keys need a verified website (up to 3 working days). Every call sends them as HTTP Basic auth, “a base64 encoded
string of
RAZORPAY_KEY_ID:RAZORPAY_KEY_SECRET” in the crystallize.com page’s words. - Webhook: Account & Settings → Webhooks → + Add New Webhook: URL, a secret you choose (not the key
secret), an alert email, and
order.paid,payment.captured,payment.authorized(manual capture only),payment.failed,refund.processed,refund.failed. Test and Live are set up separately; test-mode OTP754081. - Capture mode: Account & Settings → Payment Capture (account owner only).
RAZORPAY_KEY_ID=rzp_test_... # key ID — public, also handed to CheckoutRAZORPAY_KEY_SECRET=... # secret key — server only: API auth and the Checkout signatureRAZORPAY_WEBHOOK_SECRET=... # the webhook's own secretRAZORPAY_MANUAL_CAPTURE=false # true when Payment Capture is manual (capture on shipment)- Test mode uses the test keys and a mock bank page. Cards (any CVV, future expiry; an OTP of 4–10 digits
succeeds, shorter fails): Visa
4100 2800 0000 1007, Mastercard5500 6700 0000 1002, RuPay6527 6589 0000 1005; international Mastercard5555 5555 5555 4444, Visa4012 8888 8888 1881; declined Visa4100 2800 0006 0003. UPI:success@razorpay/failure@razorpay. - Localhost: webhooks need a public URL, and Razorpay blocks
localhost,.local,.internal,ngrok.io,loca.lt,webhook.site,requestbin.comand similar. It suggests azroktunnel; a preview deployment works too. Requests can be tried in Razorpay’s Postman workspace or with the Razorpay CLI.
Create the payment
A Razorpay Order fixes amount and currency; Checkout pays that order, and payments without an order_id cannot be
captured and are refunded. Create it from the placed cart in the pay route of
SKILL.md. The cart id is the receipt, which Razorpay treats as an
idempotency key, so two tabs share one order; a failed attempt is retried inside Checkout on the same order.
import { createHmac, timingSafeEqual } from "node:crypto";import type { Payment, PlacedCart } from "@/lib/crystallize-payments";
export async function razorpay<T>(path: string, init: { method?: string; body?: unknown; headers?: object } = {}) { const res = await fetch(`https://api.razorpay.com/v1${path}`, { method: init.method ?? "GET", headers: { Authorization: `Basic ${btoa(`${process.env.RAZORPAY_KEY_ID}:${process.env.RAZORPAY_KEY_SECRET}`)}`, "Content-Type": "application/json", ...init.headers, }, body: init.body === undefined ? undefined : JSON.stringify(init.body), }); const json = await res.json(); if (!res.ok) throw new Error(`Razorpay ${init.method ?? "GET"} ${path} ${res.status}: ${json.error?.description}`); return json as T;}
export function hmacMatches(secret: string, message: string, signature: string | null) { const expected = createHmac("sha256", secret).update(message).digest(); const received = Buffer.from(signature ?? "", "hex"); return received.length === expected.length && timingSafeEqual(received, expected);}
// Razorpay's currency table: these have 0 or 3 decimals, every other currency 2const ZERO = "CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX VND VUV XAF XOF XPF".split(" ");const THREE = "BHD IQD JOD KWD OMR TND".split(" ");const decimals = (currency: string) => (ZERO.includes(currency) ? 0 : THREE.includes(currency) ? 3 : 2);export function toMinor(major: number, currency: string) { const minor = Math.round(major * 10 ** decimals(currency)); return decimals(currency) === 3 ? Math.round(minor / 10) * 10 : minor; // 3 decimals: the last digit must be 0}export const toMajor = (minor: number, currency: string) => minor / 10 ** decimals(currency);
// Amounts in sub-units, `created_at` in unix seconds; `notes` is an empty array when there are nonetype Entity = { id: string; amount: number; currency: string; created_at: number };export type RzpOrder = Entity & { amount_paid: number; status: "created" | "attempted" | "paid"; notes: Record<string, string>;};// status: created | authorized | captured | refunded | failed; method: card | upi | netbanking | wallet | emi | …export type RzpPayment = Entity & { order_id: string; status: string; captured: boolean; method: string };export type RzpRefund = Entity & { payment_id: string };
export const findOrder = async (cartId: string) => (await razorpay<{ items: RzpOrder[] }>(`/orders?receipt=${encodeURIComponent(cartId)}`)).items[0];
export async function createRazorpayOrder(placed: PlacedCart) { const currency = placed.total.currency.toUpperCase(); // must be enabled on the account const amount = toMinor(placed.total.gross, currency); // the placed total, never from the browser const order = (await findOrder(placed.id)) ?? // one order per cart: the other tab may have created it (await razorpay<RzpOrder>("/orders", { method: "POST", body: { amount, currency, receipt: placed.id, // a UUID is 36 chars (max 40); a second create with it is refused notes: { cartId: placed.id }, // payment: { capture: "manual", … } captures this order on shipment, see Capture }, }).catch(async (error) => (await findOrder(placed.id)) ?? Promise.reject(error))); // lost the race: reuse if (order.amount !== amount || order.currency !== currency) throw new Error(`${order.id} does not match the cart`); const c = placed.customer; const prefill = { name: [c?.firstName, c?.lastName].filter(Boolean).join(" "), email: c?.email, contact: c?.phone, // "+<country code><number>", else +91 is assumed method: placed.meta?.razorpayMethod, // chosen before place, see Provider specifics }; const key = process.env.RAZORPAY_KEY_ID!; // the key ID only, never the secret // paid: the other tab already paid — go straight to the return page return { key, cartId: placed.id, orderId: order.id, amount, currency, paid: order.status === "paid", prefill };}The minimum amount is INR 1.00. Never set partial_payment: the order must be paid in full. Razorpay’s Orders page
also says to create a new order after a failed payment because reusing one “will cause an error”, while its order
states and late-authorization pages let several attempts share an order (unconfirmed which case errors). If Checkout
refuses a reopened attempted order, send the shopper back to a new cart, which gets a new order.
Client
Load checkout.js once and open Checkout only when it is ready. Use the handler, not callback_url (meant for
WebView and redirect flows; it bypasses the handler). The handler only moves the shopper on: the return page from
SKILL.md waits until the webhook has turned the cart into an order. Failed attempts
are retried inside the modal.
"use client";import Script from "next/script";import { useState } from "react";import type { createRazorpayOrder } from "@/lib/razorpay"; // type only: nothing server-side reaches the bundle
type Session = Awaited<ReturnType<typeof createRazorpayOrder>>;
export function RazorpayButton() { const [ready, setReady] = useState(false); async function pay() { const s = (await (await fetch("/api/checkout/pay", { method: "POST" })).json()) as Session; // place + order const returnUrl = `/checkout/confirmation?cart=${s.cartId}`; // the return page reads this cart if (s.paid) return location.assign(returnUrl); new (window as any).Razorpay({ key: s.key, order_id: s.orderId, amount: s.amount, // sub-units, from the server currency: s.currency, name: "Your store", image: "https://shop.example/logo.png", // a URL or a base64 string prefill: s.prefill, handler: async (r: { razorpay_payment_id: string; razorpay_signature: string }) => { const body = JSON.stringify({ ...r, cartId: s.cartId }); await fetch("/api/payments/razorpay/verify", { method: "POST", body }); // UX only location.assign(returnUrl); }, }).open(); } return ( <> <Script src="https://checkout.razorpay.com/v1/checkout.js" onReady={() => setReady(true)} /> <button type="button" disabled={!ready} onClick={pay}> Pay </button> </> );}The verify route lets the page say “payment received” at once. It checks the signature against the order id your
server created for this cart — never the razorpay_order_id the browser sends — and it never creates the order: a
closed tab would lose it, and a valid signature says nothing about capture.
import { findOrder, hmacMatches } from "@/lib/razorpay";
export async function POST(req: Request) { const r = (await req.json()) as { cartId: string; razorpay_payment_id: string; razorpay_signature: string }; const order = await findOrder(r.cartId); // the HMAC below ties the payment to this cart's order const signed = `${order?.id}|${r.razorpay_payment_id}`; // hex HMAC-SHA256 with the KEY secret const ok = !!order && hmacMatches(process.env.RAZORPAY_KEY_SECRET!, signed, r.razorpay_signature); return Response.json({ ok }, { status: ok ? 200 : 400 });}Webhook
import { createOrderOnce, readCart, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import { hmacMatches, razorpay, razorpayPayment, razorpayRefund } from "@/lib/razorpay";import type { RzpOrder, RzpPayment, RzpRefund } from "@/lib/razorpay";
type RzpEvent = { event: string; payload: { payment?: { entity: RzpPayment }; refund?: { entity: RzpRefund } } };const manualCapture = process.env.RAZORPAY_MANUAL_CAPTURE === "true";
export async function POST(req: Request) { const raw = await req.text(); // the exact bytes Razorpay signed if (!hmacMatches(process.env.RAZORPAY_WEBHOOK_SECRET!, raw, req.headers.get("x-razorpay-signature"))) { return new Response("bad signature", { status: 400 }); } const { event, payload } = JSON.parse(raw) as RzpEvent; const snapshot = payload.payment?.entity; if (!snapshot?.order_id) return new Response("ignored"); // not an Orders API payment try { // Payloads are snapshots and arrive in any order: act on the current order and payment const order = await razorpay<RzpOrder>(`/orders/${snapshot.order_id}`); const cartId = order.notes.cartId; if (!cartId) return new Response("not ours"); const payment = await razorpay<RzpPayment>(`/payments/${snapshot.id}`); if (event === "refund.processed") { if ((await readCart(cartId))?.state !== "ordered") return new Response("no order"); // e.g. a late auth if (!payment.captured) await updatePayment(cartId, payment.id, (r) => withMeta(r, { state: "cancelled" })); else await recordPayment(cartId, razorpayRefund(payload.refund!.entity, payment, cartId)); return new Response("ok"); } if (payment.amount !== order.amount || payment.currency !== order.currency) { console.error(`[razorpay] ${payment.id} does not match order ${order.id}: check by hand`); return new Response("mismatch logged"); // 2xx: retrying would not fix it } if (payment.status === "captured" && order.status === "paid" && order.amount_paid === order.amount) { await createOrderOnce(cartId, "paid", razorpayPayment(payment, cartId, "captured")); if (manualCapture) await updatePayment(cartId, payment.id, (r) => withMeta(r, { state: "captured" })); } else if (payment.status === "authorized" && manualCapture) { await createOrderOnce(cartId, "unpaid", razorpayPayment(payment, cartId, "authorized")); } // failed, created, or authorized under auto-capture (order.paid follows): nothing yet return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); }}Razorpay wants a 2xx within 5 seconds; anything else (a 3xx from middleware included) or a timeout fails and is
retried with backoff for 24 hours — then the webhook is disabled until re-enabled (the alert email says so).
Delivery is at least once and unordered (payment.authorized can follow payment.captured), hence the re-read and
createOrderOnce; x-razorpay-event-id identifies duplicates. Retries keep the secret they were signed with.
| Event, current state re-read | Crystallize (paymentStatus) |
|---|---|
order.paid / payment.captured, order paid |
createOrderOnce(…, 'paid', …), state=captured |
payment.captured after a manual capture |
updatePayment → state=captured |
payment.authorized, manual capture |
createOrderOnce(…, 'unpaid', …), state=authorized |
payment.authorized, auto-capture |
Nothing: order.paid follows |
payment.failed |
Nothing; Checkout offers a retry. A late authorization may follow |
refund.processed, captured payment |
recordPayment, meta type=refund |
refund.processed, never captured (unconfirmed) |
updatePayment → state=cancelled: the authorization lapsed |
refund.failed |
Alert a human |
Capture, refund, cancel
Capture mode is an account setting (auto-capture by default) and applies only to payments made through Orders.
Manual capture must happen within 3 days (the Dashboard maximum), then the payment is refunded automatically. A
per-order payment: { capture: "manual", capture_options: { manual_expiry_period, refund_speed: "normal" } } on
the order overrides the setting; manual_expiry_period is documented up to 7 200 minutes (5 days) — the docs
disagree, so plan for 3. Too short for made-to-order goods: keep auto-capture and refund instead.
// lib/razorpay.ts (continued) — captureByProvider.razorpay = capture// Razorpay captures synchronously, so this never returns null: the captured amount in major unitsexport async function capture(transactionId: string, amount: number): Promise<number | null> { const payment = await razorpay<RzpPayment>(`/payments/${transactionId}`); if (toMinor(amount, payment.currency) !== payment.amount) throw new Error("Razorpay captures the full amount only"); if (payment.status === "authorized") { // no idempotency key: reading the status first keeps a retried stage webhook from capturing twice const body = { amount: payment.amount, currency: payment.currency }; await razorpay(`/payments/${transactionId}/capture`, { method: "POST", body }); } else if (payment.status !== "captured") { throw new Error(`Razorpay payment ${transactionId} is ${payment.status}`); // refunded: the authorization lapsed } return toMajor(payment.amount, payment.currency);}
/** `key`: one per business refund (e.g. the return id), ≥ 10 chars of [A-Za-z0-9_-]. refund.processed records it. */export async function refund(transactionId: string, amount: number, key: string) { const payment = await razorpay<RzpPayment>(`/payments/${transactionId}`); return razorpay<RzpRefund>(`/payments/${transactionId}/refund`, { method: "POST", headers: { "X-Refund-Idempotency": key }, // same key + same body = the same refund; 409 = still running body: { amount: toMinor(amount, payment.currency), speed: "normal" }, // omit amount for a full refund });}- Refunds are recorded by the
refund.processedwebhook — Dashboard refunds included — never from here, so two writers never race.speed: "optimum"requests an instant refund (fee); normal takes 5–7 working days. - Cancel: Razorpay has no void. An uncaptured authorization is refunded at the capture timeout (the webhook then
marks it
cancelled); to release it at once, capture and refund. On an auto-captured payment, cancel = refund.
Mapping
// lib/razorpay.ts (continued)export const razorpayPayment = (payment: RzpPayment, cartId: string, state: "authorized" | "captured"): Payment => ({ provider: "razorpay", method: payment.method, // card, upi, netbanking, wallet, emi, paylater, … transactionId: payment.id, // pay_…: capture and refund use it amount: toMajor(payment.amount, payment.currency), // MAJOR units createdAt: new Date(payment.created_at * 1000).toISOString(), meta: [ { key: "state", value: state }, { key: "cartId", value: cartId }, { key: "razorpayOrderId", value: payment.order_id }, ],});
export const razorpayRefund = (refund: RzpRefund, payment: RzpPayment, cartId: string): Payment => ({ provider: "razorpay", method: payment.method, transactionId: refund.id, // rfnd_… amount: toMajor(refund.amount, refund.currency), createdAt: new Date(refund.created_at * 1000).toISOString(), meta: [ { key: "type", value: "refund" }, { key: "cartId", value: cartId }, { key: "paymentId", value: payment.id }, ],});Provider specifics
Payment method chosen in the storefront. prefill.method (card, netbanking, wallet, upi, emi) opens
Checkout on that method when email and contact are prefilled too. Store the choice on the cart before place
— carts.setMeta(id, { meta: [{ key: "razorpayMethod", value: "upi" }], merge: true }) — and the pay route reads it
from placed.meta. To hide or reorder methods for everyone, use Checkout’s config.display (methods).
International shoppers. International cards and every currency but INR must be activated (Account & Settings →
International payments), or order creation fails. Prefill contact with its country code (else +91 is assumed);
dummy email or phone values make international payments fail. Three-decimal currencies (KWD, BHD, OMR, …) must end
in 0, so toMinor can move the charge by up to 0.005 from the placed total: price those markets with two decimals.
After payment (prose only). GET /v1/payments/{id}/refunds lists refunds; Route transfers split a captured
payment between linked accounts; the Invoices API issues invoices; saved cards and recurring payments use tokens
(customer_id, recurring in Checkout).
Going further
- Hosted Checkout redirects to a Razorpay page for the same order and webhook. Payment Links (e.g. for
abandoned carts) are separate objects with their own
payment_link.*events. - Magic Checkout collects addresses, shipping, COD and coupons inside Razorpay: Razorpay then charges another
amount than the placed cart and the Crystallize order lacks the shipping line. Choose shipping before
place. - Offers (
offer_id) and Dynamic Currency Conversion change what the shopper pays; settle how the amount check and reconciliation treat them first. Subscriptions and recurring payments: not covered here. - Late authorization: a payment reported failed can be authorized days later; with Orders, a late payment on an already paid order is refunded at once.
- Tooling: Postman workspace, the Razorpay CLI and MCP server, webhook IP allowlists, event replay on request (Dashboard → Help, up to 15 days). The crystallize.com page’s flow (lock the cart, create a Razorpay order, open the modal, verify the signature, create the order) is this reference — with the webhook creating the order.
Common mistakes
- Creating the Crystallize order from the browser handler’s signature check: a closed tab after paying leaves a captured payment and no order.
- Checking the Checkout signature with the order id the browser sent, comparing with
!==, or answering a mismatch with 200{}. - Verifying the webhook over
JSON.stringify(body)(Razorpay’s own Node example) instead of the raw text; mixing up the key secret (Checkout signature) and the webhook secret. - Not checking the order status,
amount_paidand currency before creating the order. - A new Razorpay order on every click: a fixed
receiptthen fails (“Duplicate request”), a random one lets a cart be paid twice. Look the order up byreceiptfirst. - Answering 3xx (middleware), 4xx or 5xx, or taking over 5 seconds, for a day: Razorpay disables the webhook.
gross * 100without rounding, or ignoring zero- and three-decimal currencies.new Razorpay(…)beforecheckout.jshas loaded;imageas an object;contactwithout a country code.callback_urlin a web integration; paying without anorder_id(auto-refunded); going live with test keys (Checkout shows success, nothing is captured).- Manual capture for goods that ship after 3 days: the authorization is refunded before you capture.
Stripe with Crystallize
Stripe is a global payment platform — cards, Apple Pay, Google Pay, Link, Klarna and local methods such as iDEAL, SEPA
Direct Debit, MobilePay and Swish, in 135+ currencies. The recommended integration is a Checkout Session created on
the server from the placed cart and paid inline with the Payment Element (ui_mode: 'elements', or hosted_page to
redirect to Stripe), with the order created from the checkout.session.completed webhook. Capture is automatic by
default; capture_method: 'manual' authorizes at checkout (cards: 7 days) and captures from the Shipped pipeline stage.
Verification: Written from Stripe’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Accept a payment (Elements + Checkout Sessions), Create a Checkout Session, Fulfill orders, Webhooks, Place a hold, Refunds, Idempotency, Currencies, Endive changelog. Every docs page is served as Markdown (append
.md); Stripe’s own agent skills: https://docs.stripe.com/skills.
At a glance
| Topic | Stripe |
|---|---|
| Markets & currencies | Global, 135+ currencies. Nordics: cards, wallets, Klarna, MobilePay, Swish, Vipps (preview) |
| Recommended | Checkout Sessions API, ui_mode: 'elements' + Payment Element on your checkout page |
| Alternative | hosted_page (redirect, least code); also embedded_page, form (embedded form, preview) |
| API version | 2026-09-30.endive (Endive: breaking changes; the last Dahlia was 2026-08-26.dahlia) |
| SDKs | stripe@23 (pins 2026-09-30.endive, Node 20+), @stripe/stripe-js@10, react-stripe-js@7 |
| Amount units | Integer minor units, lowercase currency. Zero-decimal: JPY, KRW, …; ISK and UGX sent ×100 |
| Capture | Default automatic_async. Manual: cards 7 days (Visa MIT 4 d 18 h), Klarna 28 d, PayPal 20 |
| Cart id | client_reference_id (≤ 200 chars) + payment_intent_data.metadata (values ≤ 500 chars) |
| One session per cart | Idempotency key checkout-<cartId> + live status; expired → successor; > 24 h: Search |
| Verification | Stripe-Signature: t=…,v1=…: HMAC-SHA256 of ${t}.${rawBody} with whsec_…, 5-min window |
Credentials and setup
The crystallize.com page names three keys, all in the Stripe Dashboard once the account exists:
- Public key — the publishable key
pk_test_…/pk_live_…, Dashboard → API keys. Safe in the browser. - Secret key —
sk_…, same page, server only. Stripe now advises a restricted keyrk_…for new code, with write access to Checkout Sessions, PaymentIntents and Refunds (+ Customers if you reuse them; permission names unconfirmed). - Signing secret —
whsec_…, one per webhook endpoint and per mode: Workbench → Webhooks → Create an event destination → Your account → API version2026-09-30.endive(the SDK’s, so payloads match its types) → eventscheckout.session.completed,checkout.session.async_payment_succeeded,payment_intent.succeeded,payment_intent.canceled,refund.created,refund.failed→ Webhook endpointhttps://<host>/api/payments/stripe/webhook(snapshot payloads) → Reveal secret.
STRIPE_SECRET_KEY=sk_test_... # or rk_test_... (restricted key)STRIPE_WEBHOOK_SECRET=whsec_...NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...PUBLIC_STORE_URL=https://shop.example.com # fixed origin for return_url (idempotent retries need identical params)- Sandbox: account picker → Sandboxes. Agents can create an anonymous one with keys:
npm i -g @stripe/clithenstripe sandbox create --help. Enable payment methods in Settings → Payment methods (dynamic payment methods);payment_method_typesis gone from Checkout Sessions on Endive (400) — narrow withallowed_payment_method_types. - Test data (Testing):
4242 4242 4242 4242succeeds,4000 0025 0000 3155asks for 3D Secure,4000 0000 0000 9995is declined (any future expiry, any CVC). SEPA IBANAT321904300235473204staysprocessing~3 minutes then succeeds;AT861904300235473202fails. Redirect methods offer Complete / Fail test payment. - Localhost:
stripe listen --forward-to localhost:3000/api/payments/stripe/webhookprints its ownwhsec_…; use that one locally.stripe triggerfixtures carry no cart id (the handler ignores them): test a real checkout.
Create the payment
Call createStripeSession from the Pay route right after place
(SKILL.md). Put what the session needs on the cart before place,
so the parameters are identical on every retry: the email (setCustomer; a session needs one) and the shopper’s
locale as cart meta (carts.setMeta(id, { meta: [{ key: 'locale', value }], merge: true })). Leave Stripe Tax,
promotion codes, shipping options, adjustable quantities and Adaptive Pricing off: Stripe must charge exactly the
placed total.gross.
import Stripe from "stripe";import { recordPayment, updatePayment, withMeta, type Payment, type PlacedCart } from "@/lib/crystallize-payments";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // stripe@23 pins 2026-09-30.endive: no apiVersion
// docs.stripe.com/currencies#zero-decimal. ISK and UGX are zero-decimal but still sent ×100, so they are not listed.const ZERO_DECIMAL = new Set("bif clp djf gnf jpy kmf krw mga pyg rwf vnd vuv xaf xof xpf".split(" "));export const toMinor = (major: number, currency: string) => ZERO_DECIMAL.has(currency.toLowerCase()) ? Math.round(major) : Math.round(major * 100);export const toMajor = (minor: number, currency: string) => ZERO_DECIMAL.has(currency.toLowerCase()) ? minor : minor / 100;
function sessionParams(placed: PlacedCart): Stripe.Checkout.SessionCreateParams { const currency = placed.total.currency.toLowerCase(); // from the market, never hardcoded const unit_amount = toMinor(placed.total.gross, currency); const metadata = { crystallize_cart_id: placed.id }; const returnPage = `${process.env.PUBLIC_STORE_URL}/${placed.meta?.locale ?? "en"}/checkout/stripe/return`; return { mode: "payment", ui_mode: "elements", client_reference_id: placed.id, metadata, // Session metadata is NOT copied to the PaymentIntent; capture, cancel and refund events need the cart id. payment_intent_data: { metadata }, // + capture_method: "manual" → authorize now, capture on shipment // One line for the placed total. Per-item lines (max 100, shipping = the `type: 'shipping'` item) are // optional, but must then add up to it exactly. line_items: [{ quantity: 1, price_data: { currency, unit_amount, product_data: { name: "Your order" } } }], customer_email: placed.customer?.email, // read-only in the form; without it, render <ContactDetailsElement /> adaptive_pricing: { enabled: false }, // the Crystallize market owns the currency return_url: `${returnPage}?cart=${placed.id}&session_id={CHECKOUT_SESSION_ID}`, };}
/** One Checkout Session per placed cart: every tab, reload and retry gets the same one. */export async function createStripeSession(placed: PlacedCart): Promise<{ clientSecret: string } | { paid: true }> { // Idempotency keys may be pruned after 24 h: first look for a payment that already went through. // Search lags up to a minute (the key covers that window) and is not available to accounts in India. const { data } = await stripe.paymentIntents.search({ query: `metadata['crystallize_cart_id']:'${placed.id}'` }); if (data.some((pi) => ["succeeded", "processing", "requires_capture"].includes(pi.status))) return { paid: true };
const params = sessionParams(placed); // a reused key with other params fails with an idempotency_error let key = `checkout-${placed.id}`; for (let attempt = 0; attempt < 3; attempt++) { const { id } = await stripe.checkout.sessions.create(params, { idempotencyKey: key }); // A replayed create returns the original (stale) response: read the live status. const session = await stripe.checkout.sessions.retrieve(id, { expand: ["payment_intent"] }); const pi = session.payment_intent as Stripe.PaymentIntent | null; const failed = pi?.status === "requires_payment_method" || pi?.status === "canceled"; // e.g. a bounced debit if (session.status === "open") return { clientSecret: session.client_secret! }; if (session.status === "complete" && !failed) return { paid: true }; // paid, held or processing key = `checkout-${placed.id}-after-${session.id}`; // expired or failed: exactly one successor per session } throw new Error(`no open Checkout Session for cart ${placed.id}`);}The Pay route answers createStripeSession(placed) as JSON: { clientSecret } mounts the form, { paid: true } sends
the shopper to the return page. A session expires 24 h after creation by default (expires_at: 30 min to 24 h).
Client
"use client";import { useState } from "react";import { loadStripe } from "@stripe/stripe-js";import { CheckoutElementsProvider, PaymentElement, useCheckoutElements } from "@stripe/react-stripe-js/checkout";
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); // module scope, not per render
export function StripePay({ clientSecret }: { clientSecret: string }) { return ( <CheckoutElementsProvider stripe={stripePromise} options={{ clientSecret }}> <PayForm /> </CheckoutElementsProvider> );}
function PayForm() { const state = useCheckoutElements(); const [error, setError] = useState<string>(); if (state.type === "loading") return <p>Loading…</p>; if (state.type === "error") return <p>{state.error.message}</p>; const pay = async () => { const result = await state.checkout.confirm(); // on success Stripe sends the shopper to return_url if (result.type === "error") setError(result.error.message); // declined: retry in the same session }; return ( <> <PaymentElement /> <button disabled={!state.checkout.canConfirm} onClick={pay}> Pay {state.checkout.total.total.amount} </button> {error && <p role="alert">{error}</p>} </> );}confirm() redirects to return_url by default; confirm({ redirect: 'if_required' }) keeps card payers on the page.
The return page only reads. Take the cart id from the URL’s cart (a bank app may reopen the page in another
browser, without your cookie) and fetch the cart: ordered → confirmation (order id = cart id), clear the cookie.
Still placed → retrieve the session from session_id server-side and check its client_reference_id is that cart:
open → the payment failed or was cancelled, back to Pay (same session); complete → “Confirming your payment…”,
refresh every few seconds. Bank debits stay processing for days: say you will email.
Webhook
import type Stripe from "stripe";import { createOrderOnce, readOrder, recordPayment, updatePayment, withMeta } from "@/lib/crystallize-payments";import { refundRecord, stripe, toMajor } from "@/lib/stripe";
export const runtime = "nodejs";
/** HMAC-SHA256 over `${t}.${body}`, timing-safe; throws on a bad signature or a timestamp older than 5 minutes. */function verifyStripe(body: string, signature: string | null): Stripe.Event { return stripe.webhooks.constructEvent(body, signature ?? "", process.env.STRIPE_WEBHOOK_SECRET!);}
export async function POST(req: Request) { const body = await req.text(); // raw bytes first: never JSON.stringify(await req.json()) let event: Stripe.Event; try { event = verifyStripe(body, req.headers.get("stripe-signature")); } catch { return new Response("bad signature", { status: 400 }); } try { if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") { await onSessionPaid(event.data.object.id); } else if (event.type === "payment_intent.succeeded" || event.type === "payment_intent.canceled") { await onHoldSettled(event.data.object); } else if (event.type === "refund.created" || event.type === "refund.failed") { await onRefund(event.data.object.id); } return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // live: Stripe retries for up to 3 days; sandbox: 3 times }}
async function onSessionPaid(sessionId: string) { // Re-read: act on the PaymentIntent's live status, not on the event snapshot. const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["payment_intent.latest_charge"] }); const pi = session.payment_intent as Stripe.PaymentIntent | null; const cartId = session.client_reference_id; if (!cartId || !pi) return; // not one of ours (e.g. `stripe trigger` fixtures) const captured = pi.status === "succeeded"; if (!captured && pi.status !== "requires_capture") return; // processing: async_payment_succeeded follows const charge = pi.latest_charge as Stripe.Charge | null; await createOrderOnce(cartId, captured ? "paid" : "unpaid", { provider: "stripe", method: charge?.payment_method_details?.type ?? "card", transactionId: pi.id, amount: toMajor(captured ? pi.amount_received : pi.amount, pi.currency), createdAt: new Date(pi.created * 1000).toISOString(), meta: [ { key: "state", value: captured ? "captured" : "authorized" }, { key: "cartId", value: cartId }, { key: "checkoutSessionId", value: session.id }, ], });}
/** A hold captured or voided outside capture()/cancel(): in the Dashboard, or an authorization that expired. */async function onHoldSettled(pi: Stripe.PaymentIntent) { const cartId = pi.metadata.crystallize_cart_id; if (!cartId) return; const record = (await readOrder(cartId))?.payments?.find((p) => p.transactionId === pi.id); if (record?.meta?.state !== "authorized") return; // no order yet (onSessionPaid reads the live status) or done await updatePayment(cartId, pi.id, (p) => pi.status === "succeeded" ? withMeta(p, { state: "captured" }, toMajor(pi.amount_received, pi.currency)) : withMeta(p, { state: "cancelled" }), );}
async function onRefund(refundId: string) { const refund = await stripe.refunds.retrieve(refundId, { expand: ["payment_intent"] }); // events come in any order const pi = refund.payment_intent as Stripe.PaymentIntent | null; const cartId = pi?.metadata.crystallize_cart_id; if (!pi || !cartId) return; if (refund.status === "failed" || refund.status === "canceled") { console.error(`[payments] refund ${refund.id} on order ${cartId} is ${refund.status}: pay the shopper back`); return updatePayment(cartId, refund.id, (p) => withMeta(p, { state: "failed" })); // no-op if never recorded } await recordPayment(cartId, refundRecord(refund, cartId, pi.id)); // a refund made by refund() is already there}| Stripe event, live PaymentIntent status | Crystallize (SKILL.md) |
|---|---|
checkout.session.completed, succeeded |
createOrderOnce(…, 'paid'), state=captured |
checkout.session.completed, requires_capture |
createOrderOnce(…, 'unpaid'), state=authorized |
checkout.session.completed, processing |
Nothing yet (bank debits): async_payment_succeeded follows |
checkout.session.async_payment_succeeded |
Same as the first two rows |
checkout.session.async_payment_failed |
No order; the next Pay creates a successor session |
payment_intent.succeeded, record authorized |
updatePayment → state=captured, amount_received |
payment_intent.canceled, record authorized |
updatePayment → state=cancelled; cancelled stage |
refund.created |
recordPayment with the refund record (type=refund) |
refund.failed |
Alert a human; the refund record gets state=failed |
Stripe delivers at least once, sometimes concurrently and out of order; createOrderOnce and recordPayment absorb it.
Capture, refund, cancel
Register capture as captureByProvider.stripe for SKILL.md’s
pipeline-stage handler, which then calls updatePayment.
Stripe captures synchronously, so it always returns the captured amount, never null.
// lib/stripe.ts (continued)/** Captures a held PaymentIntent (at most `amount`, major units); returns the captured amount in major units. */export async function capture(transactionId: string, amount: number): Promise<number> { const pi = await stripe.paymentIntents.retrieve(transactionId); if (pi.status === "succeeded") return toMajor(pi.amount_received, pi.currency); // already captured if (pi.status !== "requires_capture") throw new Error(`${pi.id} is ${pi.status}: nothing to capture`); const amount_to_capture = Math.min(toMinor(amount, pi.currency), pi.amount_capturable); // less releases the rest const key = { idempotencyKey: `capture-${pi.id}` }; const done = await stripe.paymentIntents.capture(pi.id, { amount_to_capture }, key); return toMajor(done.amount_received, done.currency);}
/** Refunds a captured payment, fully or partly. `refundRef` is yours (return number): a retry never refunds twice. */export async function refund(cartId: string, transactionId: string, amount: number, refundRef: string) { const pi = await stripe.paymentIntents.retrieve(transactionId); const created = await stripe.refunds.create( { payment_intent: pi.id, amount: toMinor(amount, pi.currency), metadata: { crystallize_cart_id: cartId } }, { idempotencyKey: `refund-${pi.id}-${refundRef}` }, ); await recordPayment(cartId, refundRecord(created, cartId, pi.id));}
/** Voids a hold you will not capture. A captured payment needs refund() instead. */export async function cancel(cartId: string, transactionId: string) { const pi = await stripe.paymentIntents.retrieve(transactionId); if (pi.status === "requires_capture") { await stripe.paymentIntents.cancel(pi.id, {}, { idempotencyKey: `cancel-${pi.id}` }); } else if (pi.status !== "canceled") throw new Error(`${pi.id} is ${pi.status}: refund it instead`); await updatePayment(cartId, pi.id, (p) => withMeta(p, { state: "cancelled" }));}Most payments allow one capture (multicapture is opt-in, for some cards); an uncaptured PaymentIntent is cancelled
when the hold expires → payment_intent.canceled. Refunds need a captured payment, may be partial and repeated up to
the captured amount, are paid from your Stripe balance and reach the shopper in about 5–10 business days. A Checkout
Session’s PaymentIntent can only be cancelled in requires_capture; before that, expire the session.
Mapping
The payment record is the one onSessionPaid passes to createOrderOnce: provider: 'stripe', method from
latest_charge.payment_method_details.type (card, klarna, link, mobilepay, swish, sepa_debit, …),
transactionId = the PaymentIntent id (pi_…, used by capture, cancel and refund), amount in major units
(amount_received when captured, amount when held), and meta state (authorized | captured | cancelled),
cartId and checkoutSessionId (cs_…). The refund record:
// lib/stripe.ts (continued)export const refundRecord = (r: Stripe.Refund, cartId: string, paymentIntentId: string): Payment => ({ provider: "stripe", transactionId: r.id, // re_… amount: toMajor(r.amount, r.currency), createdAt: new Date(r.created * 1000).toISOString(), meta: [ { key: "type", value: "refund" }, { key: "cartId", value: cartId }, { key: "paymentIntentId", value: paymentIntentId }, ],});Provider specifics
Hold only what can be held. Cards, Klarna, PayPal and Affirm support manual capture; iDEAL, SEPA and ACH debits do
not. Hold per method so the others still capture at checkout (the webhook decides by the PaymentIntent status anyway):
payment_method_options: { card: { capture_method: 'manual' }, klarna: { capture_method: 'manual' } }. Eligible
online card holds can be extended to 30 days (extended authorization); see also multicapture,
overcapture and automatic_delayed capture (private preview).
Redirect instead of inline. ui_mode: 'hosted_page' takes success_url (same {CHECKOUT_SESSION_ID} template)
and an optional cancel_url instead of return_url; return session.url and send the browser there. Stripe waits up
to 10 seconds for your checkout.session.completed endpoint before redirecting the shopper — answer fast.
Reuse a Stripe Customer and save the card. Look the shopper up with customers.list({ email, limit: 1 }) (exact,
case-sensitive) or create one with an idempotency key such as customer-<cartId>, and pass customer: 'cus_…' instead
of customer_email; Checkout then prefills the last saved card (Stripe now recommends customer-configured Accounts,
customer_account, for new integrations). To keep the card for later, set setup_future_usage: 'off_session' in
payment_intent_data (Checkout shows a notice) or enable saved_payment_method_options.payment_method_save (a consent
checkbox), and record the shopper’s agreement. A renewal is then a server-side paymentIntents.create with customer,
payment_method, off_session: true, confirm: true and an idempotency key per period; an authentication_required
decline means bringing the shopper back on-session. Keep the customer and payment_method ids on the Crystallize
subscription contract — recurring payments are outside this skill (saving cards).
Expire an abandoned session. When the shopper goes back and you hydrate a new cart, the old session stays payable
until it expires — consistent, but a second purchase. Keep its id next to the cart id (cookie) and call
stripe.checkout.sessions.expire(id) (only open sessions can be expired).
Going further
- Embedded page or embedded form:
ui_mode: 'embedded_page'(createEmbeddedCheckoutPage) or'form'(public preview;CheckoutFormProvider/useCheckoutForm; Adaptive Pricing on by default — keep it disabled). - PaymentIntents API directly: create the PaymentIntent from the placed cart (
automatic_payment_methods,metadata.crystallize_cart_id, idempotency key per cart), confirm withconfirmPayment, create the order onpayment_intent.succeeded. Stripe advises Checkout Sessions unless you need it (guide). - Thin events (GA for v1 resources in Endive): small, version-independent payloads; fetch the object yourself, on a separate endpoint (event destinations).
- Receipts: Dashboard → Settings → Business → Customer emails, or
payment_intent_data.receipt_email;invoice_creationadds a paid invoice (priced separately). Disputes: alert a human oncharge.dispute.created. - Shipping, tax or promotion codes in Stripe (
shipping_options,automatic_tax,allow_promotion_codes): Stripe then charges more or less than the placed cart, and the order lacks those lines. Choose shipping in the storefront beforeplace, as an external item. A billing address typed into the Payment Element only reacheslatest_charge.billing_details: collect addresses withsetCustomerbeforeplace. - Express Checkout Element (Apple Pay / Google Pay buttons) is in
@stripe/react-stripe-js/checkout(the payment request button is deprecated in Endive); it still needs a placed cart and a session. Marketplaces: Connect viapayment_intent_data.application_fee_amount,transfer_data,on_behalf_of.
Common mistakes
- Verifying over
JSON.stringify(await req.json()), or passing the parsed object as the raw body: verification never passes — and switching it off “to make it work” lets anyone post a paid event. - A webhook handler that is never routed or registered: payments succeed and no order ever appears.
- Creating the order in the browser when
confirm()resolves, without a webhook or a status check: closed tabs lose orders and a forged call creates unpaid ones. - Reading
paymentIntent.charges.data[0](removed in2022-11-15) or pinning an oldapiVersionin code: uselatest_charge, and keep the SDK and the webhook endpoint on the same API version. cart.total.gross * 100withoutMath.round, or treating JPY or KRW as two-decimal.- A random idempotency key per request (two tabs, two sessions, two payments), or the cart’s key with parameters that
change between calls (request origin, locale, email) →
idempotency_error. - The cart id only on the session
metadata: PaymentIntent and refund events do not carry it. - Deciding by
session.payment_statusalone: the PaymentIntent status tells a hold (requires_capture) from a capture (succeeded) and a debit that is stillprocessing. return_urlbuilt as'http://' + host, or without the locale prefix.- Legacy
CardElement+confirmCardPayment,payment_method_types(400 on Endive),ui_modecustom/hosted/embedded(renamed in Dahlia) orinitCheckout(nowinitCheckoutElementsSdk). - Refunding a payment that is only held (cancel it instead), or never capturing before the hold expires.
- Auth or i18n middleware answering the webhook with a 3xx: Stripe counts it as a failed delivery.
Two with Crystallize
Two (two.inc, formerly Tillit) is a B2B “buy now, pay later” provider: a registered business buys on invoice, Two
takes the credit and fraud risk, sends the invoice (email, or e-invoice such as EHF in Norway), collects it and pays the
merchant. It sells to business buyers in the Nordics, the UK, other EU countries and the US. The recommended
integration is Two’s Order API with plain fetch: find the buyer company with the Company API, pre-check credit with an
order intent, create the Two order from the placed cart, send the buyer’s representative to Two’s hosted verification
page, and create the Crystallize order from the signed order.verified.v1 webhook. Capture is fulfilment:
POST /v1/order/{id}/fulfillments issues the invoice and triggers the payout, within a 21-day credit guarantee.
Verification: Written from Two’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: Order creation path, Create order, Company API, Order intent, Order validation, Order states, Webhooks (sent by Svix: verifying, retries), Sandbox, OpenAPI specs.
At a glance
| Markets & currencies | 19 markets (Nordics, UK, EU, US); currencies CHF CZK DKK EUR GBP INR MXN NOK PLN RON SEK USD |
| Recommended | Company API → order intent → POST /v1/order → hosted payment_url → order.verified.v1 |
| Alternative | No embedded checkout. POST /v1/order/{id}/notify texts or emails the verification link |
| API version | Order API /v1 (spec 1.0), Company API /companies/v2 (spec 0.9.0), events order.*.v1 |
| SDKs | None for Node: fetch + X-Api-Key. Svix check by hand, or the svix npm package (2.7) |
| Amounts | Decimal strings, major units, ≤ 2 decimals ("1499.00"); tax_rate a fraction ("0.25") |
| Capture | On fulfilment, async (order.fulfilled.v1). Credit guaranteed 21 days, then /renew |
| Authorization window | payment_url valid 24 h once opened; UNVERIFIED orders auto-cancel after 48 h |
| Cart id | merchant_order_id (string, no documented limit; the cart UUID fits), in every order webhook |
| One session per cart | No create idempotency: GET /v1/order/merchant-order-id/{cartId} first, reuse the live order |
| Notification check | Svix HMAC-SHA256 over svix-id, svix-timestamp, raw body; fresh timestamp; re-GET order |
Credentials and setup
- API keys. The crystallize.com page’s “test credentials” (emailed at sign-up, or in “Developer tools”) now
live in the Two Merchant Portal: Settings → Integration → Sandbox or Production tab → Create key
(guide). The
secret_test_…/secret_prod_…key is shown once. Production keys need an approved account: ask integration@two.inc. Admins create production keys, developers sandbox keys. Server-side only. - Webhook secret. Merchant Portal → Integrations → Manage webhook subscriptions opens the Svix portal: add
https://<host>/api/payments/two/webhook, subscribe toorder.verified.v1,order.fulfilled.v1,order.cancelled.v1,order.rejected.v1,order.refunded.v1, copy the signing secret.
TWO_API_URL=https://api.sandbox.two.inc # production https://api.two.inc (sandbox.api.two.inc does not resolve)TWO_API_KEY=secret_test_… # Merchant Portal → Settings → IntegrationTWO_WEBHOOK_SECRET=whsec_… # Svix portal → endpoint → signing secret- Sandbox companies (search returns real registry data; the org number sets the behaviour, tables for
SE, FI, DK, NL, US too). GB:
15717462credit never used up,10200123zero credit (intent declined),13333334fraud reject,14553414verification declined; < £5 000 skips verification, ≥ £30 000 needs open banking. NO:922934479credit never used up,922422508zero credit,983772102fraud,920245404verification declined; < 5 000 kr skips, ≥ 50 000 kr needs Vipps + legal representative. Test orders use up sandbox credit: cancel them. - Webhooks without an order: Svix portal → Endpoints → Testing → Send Example.
- Localhost:
merchant_urlsare browser redirects (localhost works). Svix needs a tunnel (ngrok, cloudflared).
Create the payment
Before place the storefront stored the company, the representative and the intent’s tracking_id on the cart
(Provider specifics). SKILL.md’s Pay route calls
createTwoOrder(placed, origin) and redirects to redirectUrl; on declined it offers another method for the same
placed cart.
import { createHmac, timingSafeEqual } from "node:crypto";import { carts, createOrderOnce, PLACED_CART, readCart, recordPayment } from "@/lib/crystallize-payments";import { updatePayment, withMeta, type Payment, type PlacedCart } from "@/lib/crystallize-payments";
export async function two<T>(path: string, init: RequestInit = {}): Promise<T> { const res = await fetch(process.env.TWO_API_URL + path, { ...init, headers: { "X-Api-Key": process.env.TWO_API_KEY!, "Content-Type": "application/json", ...init.headers }, }); const text = await res.text(); if (!res.ok) throw Object.assign(new Error(`Two ${path} ${res.status}: ${text}`), { status: res.status }); return (text ? JSON.parse(text) : null) as T;}
type Str<K extends string> = Record<K, string>;type Opt<K extends string> = Partial<Record<K, string | null>>;export type TwoRefund = Str<"id" | "total_amount" | "credit_note_number"> & Opt<"refund_date">;export type TwoOrder = Str<"id" | "merchant_order_id" | "currency" | "gross_amount" | "status" | "state"> & Opt<"root_order_id" | "payment_url" | "invoice_url" | "decline_reason" | "date_created"> & { refunds?: TwoRefund[]; };
const cents = (major: number) => Math.round(major * 100);const dec = (minor: number) => (minor / 100).toFixed(2);const TYPES: Record<string, string> = { shipping: "SHIPPING_FEE", service: "SERVICE", digital: "DIGITAL" };type Person = { firstName?: string; lastName?: string; email?: string; phone?: string };const rep = (p: Person) => ({ first_name: p.firstName, last_name: p.lastName, email: p.email, phone_number: p.phone });
/** Two's maths: net = qty × unit_price − discount, tax = net × rate, gross = net + tax; totals = sums of lines. */export function amounts(cart: PlacedCart) { const total = { net: 0, tax: 0, gross: 0 }; const brackets = new Map<string, { net: number; tax: number }>(); const line_items = cart.items.map(({ type, name, quantity, variant, price }) => { const [gross, net] = [cents(price.gross), cents(price.net)]; // line totals, discounts included const tax = gross - net; const rate = String(+(price.taxPercent / 100).toFixed(6)); // 25 → "0.25", never "25" const b = brackets.get(rate) ?? { net: 0, tax: 0 }; brackets.set(rate, { net: b.net + net, tax: b.tax + tax }); Object.assign(total, { net: total.net + net, tax: total.tax + tax, gross: total.gross + gross }); const sku = variant?.sku ?? ""; // external items (shipping, fee, promotion) may have no variant return { type: TYPES[type ?? ""] ?? (type && type !== "standard" ? "OTHER" : "PHYSICAL"), // fee, promotion → OTHER name, description: name, line_item_reference: /^[\w-]+$/.test(sku) ? sku : undefined, // Two accepts [A-Za-z0-9_-] only quantity: String(quantity), quantity_unit: "pcs", unit_price: (net / 100 / quantity).toFixed(6), // net unit price, so discount_amount stays 0 net_amount: dec(net), tax_amount: dec(tax), gross_amount: dec(gross), tax_rate: rate, tax_class_name: `VAT ${price.taxPercent}%`, }; }); if (total.gross !== cents(cart.total.gross)) throw new Error(`Two lines do not add up to cart ${cart.id}`); const subs = [...brackets].map(([r, b]) => ({ tax_rate: r, taxable_amount: dec(b.net), tax_amount: dec(b.tax) })); const sums = { net_amount: dec(total.net), tax_amount: dec(total.tax), gross_amount: dec(total.gross) }; return { currency: cart.total.currency.toUpperCase(), ...sums, line_items, tax_subtotals: subs };}
export const getTwoOrder = (id: string) => two<TwoOrder>(`/v1/order/${id}`);export const findTwoOrders = (cartId: string) => two<TwoOrder[]>(`/v1/order/merchant-order-id/${cartId}`).catch((e) => (e.status === 404 ? [] : Promise.reject(e)));
export async function createTwoOrder(placed: PlacedCart, origin: string) { const returnUrl = `${origin}/checkout/two/return?cart=${placed.id}`; // No idempotency key on create: look the cart up first (two tabs, double clicks). A click in the same instant // can still create a second Two order; the duplicate-payment flag in createOrderOnce covers it. const live = (await findTwoOrders(placed.id)).find((o) => o.state !== "CANCELLED"); if (live && live.status !== "APPROVED" && live.status !== "PARTIAL") return { declined: live.decline_reason }; if (live?.state === "UNVERIFIED") return { redirectUrl: (await getTwoOrder(live.id)).payment_url }; // fresh URL if (live) return { redirectUrl: returnUrl }; // verified already const { meta, customer } = placed; const billing = customer?.addresses?.find((a) => a.type === "billing"); if (!meta?.twoCompanyId || !customer?.companyName || !customer.phone || !customer.email || !billing) { throw new Error(`cart ${placed.id} was placed without a Two company or representative`); } const order = await two<TwoOrder>("/v1/order", { method: "POST", body: JSON.stringify({ merchant_order_id: placed.id, ...amounts(placed), // canonical id alone: that form forbids other company fields. All four representative fields required. buyer: { company: { company_canonical_id: meta.twoCompanyId }, representative: rep(customer) }, billing_address: { organization_name: customer.companyName, street_address: billing.street, // the registered address, from the Company API postal_code: billing.postalCode, city: billing.city, country: billing.country, }, buyer_purchase_order_number: meta.poNumber || undefined, // printed on the invoice tracking_id: meta.twoTrackingId || undefined, // links the order intent merchant_urls: { merchant_confirmation_url: returnUrl, merchant_cancel_order_url: `${origin}/checkout/two/cancel?cart=${placed.id}`, }, }), }); if (order.status !== "APPROVED") return { declined: order.decline_reason }; return { redirectUrl: order.state === "UNVERIFIED" ? order.payment_url : returnUrl };}- Two tolerates ±0.02 on a line’s net and ±1.00 on a tax subtotal (validation);
tax_subtotalsis required. Lines built from the placed line totals (shipping, fees and promotions included) keep the Two order equal toplaced.total.gross; if they do not add up,amountsthrows rather than invoice another amount. statusis the credit decision, made again here (the intent was only a signal): notAPPROVED→ no redirect.statestaysUNVERIFIEDuntil the representative verifies.
Client
Redirect with window.location.assign(redirectUrl); there is no embedded form. Two’s page picks the verification from
the buyer, the person and the amount: an email or SMS code, open banking, or an eID (Vipps, backed by BankID, in Norway;
BankID in Sweden, MitID in Denmark). When the shopper clicks Pay again, createTwoOrder re-reads the order, which
hands back a fresh payment_url.
The return page (merchant_confirmation_url) takes the cart id from its own URL (?cart=, set above), not only from
the cookie, and only reads: readCart(cartId) → ordered: show the confirmation and clear the cart cookie; still
placed: “We are confirming your order…” and refresh every few seconds. It may also call
POST /v1/order/{id}/confirm when findTwoOrders(cartId) shows VERIFIED: that moves the order to CONFIRMED,
which only tells Two the buyer is back (both states can be fulfilled). It never creates the order.
The cancel page (merchant_cancel_order_url) changes nothing at Two: the order stays UNVERIFIED and holds the
buyer’s credit until the 48-hour auto-cancel. Call POST /v1/order/{id}/cancel while it is UNVERIFIED, then offer
another payment method for the same placed cart, or a new cart if the shopper wants to change it.
Webhook
Svix sends a CloudEvents envelope { id, type, time, data: { order_id, root_order_id, merchant_order_id, state, … } }
at least once, and retries after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h unless it gets a 2xx within 15 s (a 3xx
is a failure).
// lib/two.ts (continued)export function verifyTwo(headers: Headers, body: string) { const [id, ts, signatures] = ["svix-id", "svix-timestamp", "svix-signature"].map((h) => headers.get(h) ?? ""); const t = Number(ts); if (!id || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 min, as Svix's libraries const key = Buffer.from(process.env.TWO_WEBHOOK_SECRET!.replace(/^whsec_/, ""), "base64"); const want = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest(); return signatures.split(" ").some((entry) => { const [version, sig = ""] = entry.split(","); // "v1,<base64> v1,<base64>" while a secret rotates const got = Buffer.from(sig, "base64"); return version === "v1" && got.length === want.length && timingSafeEqual(got, want); });}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const TWO_EVENTS = /^order\.(verified|fulfilled|cancelled|rejected|refunded)\.v1$/;
export async function handleTwoEvent(type: string, data: { order_id: string; root_order_id?: string | null }) { const order = await getTwoOrder(data.root_order_id ?? data.order_id); // act on Two's state, not on the event const cartId = order.merchant_order_id; if (!UUID.test(cartId)) return; // made in Two's Merchant Portal, not from a cart if (type === "order.verified.v1") { if (order.status !== "APPROVED" || !["VERIFIED", "CONFIRMED", "FULFILLING", "FULFILLED"].includes(order.state)) return; await createOrderOnce(cartId, "unpaid", toPayment(order, "authorized")); } else if (type === "order.fulfilled.v1" && ["FULFILLED", "REFUNDED"].includes(order.state)) { const invoice: Record<string, string> = order.invoice_url ? { invoiceUrl: order.invoice_url } : {}; await updatePayment(cartId, order.id, (p) => withMeta(p, { state: "captured", ...invoice }, Number(order.gross_amount)), ); } else if (type === "order.refunded.v1") { for (const r of order.refunds ?? []) await recordPayment(cartId, toRefund(order, r)); // skips known ids } else if (/cancelled|rejected/.test(type) && (await readCart(cartId))?.state === "ordered") { await updatePayment(cartId, order.id, (p) => withMeta(p, { state: "cancelled" })); } // not ordered (or a partial fulfilment): nothing to do, the cart stays as it is}
// app/api/payments/two/webhook/route.tsimport { handleTwoEvent, TWO_EVENTS, verifyTwo } from "@/lib/two";
export async function POST(req: Request) { const body = await req.text(); if (!verifyTwo(req.headers, body)) return new Response("bad signature", { status: 401 }); const event = JSON.parse(body) as { type: string; data: { order_id: string; root_order_id?: string | null } }; if (!TWO_EVENTS.test(event.type)) return new Response("ignored"); // reconciliation, customer events… try { await handleTwoEvent(event.type, event.data); return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); // Svix retries; createOrderOnce and recordPayment dedupe }}| Two (re-fetched order) | Crystallize (SKILL.md) |
|---|---|
order.verified.v1, APPROVED, VERIFIED |
createOrderOnce(cartId, 'unpaid', …state=authorized) |
UNVERIFIED (no event) |
Nothing: the return page waits; Two cancels it after 48 h |
order.fulfilled.v1, FULFILLED |
updatePayment → state=captured, invoiceUrl |
order.refunded.v1 |
recordPayment per order.refunds entry (type=refund) |
order.cancelled.v1, order.rejected.v1 |
Ordered: updatePayment → state=cancelled, cancelled stage |
| Verified, cart already ordered another way | createOrderOnce flags attention=duplicate-payment: cancel at Two |
Capture, refund, cancel
Fulfilment is the capture: it issues the invoice, starts the buyer’s payment term and the payout. Idempotency-Key
(≤ 100 chars) exists on fulfil and refund only; cancel answers 202 when already cancelled.
// lib/two.ts (continued). SKILL.md's stage handler calls captureByProvider["two"] = capture.export async function capture(transactionId: string, amount: number): Promise<number | null> { const { state } = await getTwoOrder(transactionId); if (state !== "FULFILLING" && state !== "FULFILLED") { // No body = the whole order; `amount` is unused (a partial fulfilment sends `partial` with lines). await two(`/v1/order/${transactionId}/fulfillments`, { method: "POST", headers: { "Idempotency-Key": `fulfil-${transactionId}` }, // a redelivered stage event cannot fulfil twice }); } return null; // asynchronous: FULFILLING now; order.fulfilled.v1 flips the record to captured}
type Part = { amount: number; tax_subtotals: { tax_rate: string; taxable_amount: string; tax_amount: string }[] };
/** A credit note: no `part` = refund the rest. `key`: one per refund, reused on retry. */export async function refund(cartId: string, twoOrderId: string, key: string, part?: Part) { const order = await getTwoOrder(twoOrderId); const body = part ? { amount: part.amount.toFixed(2), currency: order.currency, tax_subtotals: part.tax_subtotals } : {}; const r = await two<TwoRefund>(`/v1/order/${order.root_order_id ?? order.id}/refund`, { method: "POST", headers: { "Idempotency-Key": key }, body: JSON.stringify(body), }); await recordPayment(cartId, toRefund(order, r)); // order.refunded.v1 then finds the same id and skips it}
/** Before fulfilment only; after it, refund. */export async function cancel(cartId: string, twoOrderId: string) { await two(`/v1/order/${twoOrderId}/cancel`, { method: "POST" }); await updatePayment(cartId, twoOrderId, (p) => withMeta(p, { state: "cancelled" }));}- 21 days. Fulfil within 21 days of verification, or call
POST /v1/order/{id}/renewfirst (re-runs the credit check; 400 if refused). capturereturnsnull, so SKILL.md’s stage handler leaves the recordauthorized; the webhook setscaptured(and the invoice link) once Two reportsFULFILLED, usually within minutes.- Partial refund:
amount(gross),currency,tax_subtotalsrequired;line_items,reason,refund_referenceoptional. Refunds before the payout reduce it; later ones are netted from future payouts.
Mapping
// lib/two.ts (continued)export const toPayment = (o: TwoOrder, state: "authorized" | "captured" | "cancelled"): Payment => ({ provider: "two", method: "invoice", transactionId: o.id, // the root order id: GET, fulfil, cancel and refund use it amount: Number(o.gross_amount), // decimal string → major units createdAt: o.date_created ?? undefined, meta: [ { key: "state", value: state }, { key: "cartId", value: o.merchant_order_id }, ],});
export const toRefund = (o: TwoOrder, r: TwoRefund): Payment => ({ provider: "two", method: "credit_note", transactionId: r.id, amount: Number(r.total_amount), // payable + prepaid createdAt: r.refund_date ?? undefined, meta: [ { key: "type", value: "refund" }, { key: "cartId", value: o.merchant_order_id }, { key: "creditNote", value: r.credit_note_number }, ],});paymentStatus stays unpaid (set once by createFromCart): the buyer pays Two later, on the invoice terms. The
record’s state and the pipeline stage track the order; Two’s reconciliation webhooks track the invoice.
Provider specifics
At checkout, while the cart is still a cart: the shopper picks a country (from the market, never from the currency),
searches their company, picks it and enters the representative. chooseCompany stores it all on the cart and runs the
credit pre-check that decides whether to show “Pay by invoice with Two”.
// lib/two.ts (continued)type Address = Opt<"type" | "street_address" | "postal_code" | "city" | "country">;type Company = Str<"name" | "canonical_id"> & { national_identifier?: { id: string }; addresses?: Address[] };type Intent = { approved?: boolean; tracking_id?: string; decline_reason?: string };
/** No key needed and CORS-open (the browser may call it). `lookup_id` is temporary: never store it. */export const searchCompanies = (q: string, country: string) => two<{ items: { name: string; lookup_id: string; additional_information?: string }[]; degraded?: boolean }>( `/companies/v2/company?${new URLSearchParams({ q, country, limit: "10" })}`, // q: name or org number ); // `degraded: true` with no items = search is down, not "no such company"
export async function chooseCompany(cartId: string, lookupId: string, person: Required<Person>) { const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: string; }; if (cart.state !== "cart") throw new Error(`cart ${cartId} is ${cart.state}`); // writes to a placed cart are lost const co = await two<Company>(`/companies/v2/company/${encodeURIComponent(lookupId)}`); const a = co.addresses?.find((x) => x.type === "BUSINESS_ADDRESS"); // registered address = billing address const [street, postalCode, city, country] = [a?.street_address, a?.postal_code, a?.city, a?.country].map( (v) => v ?? undefined, ); await carts.setCustomer(cartId, { ...person, // firstName, lastName, email, phone: Two requires all four isGuest: false, // true skips the Core customer identifier: person.email, // or your signed-in customer's identifier type: "organization", companyName: co.name, taxNumber: co.national_identifier?.id, // the organisation number addresses: [{ type: "billing", street, postalCode, city, country }], // replaces all addresses: add delivery too }); const { tax_subtotals: _, ...money } = amounts(cart); // the current cart: a pre-check, not a charge const buyer = { company: { company_canonical_id: co.canonical_id }, representative: rep(person) }; const intent = await two<Intent>("/v1/order_intent", { method: "POST", body: JSON.stringify({ ...money, buyer }) }); const meta = { twoCompanyId: co.canonical_id, twoCountry: country ?? "", twoTrackingId: intent.tracking_id ?? "" }; await carts.setMeta(cartId, { merge: true, meta: Object.entries(meta).map(([key, value]) => ({ key, value })) }); return { approved: intent.approved === true, declineReason: intent.decline_reason }; // false → hide Two}
// app/api/payments/two/company/route.tsimport { chooseCompany, searchCompanies } from "@/lib/two";
export async function GET(req: Request) { const q = new URL(req.url).searchParams; return Response.json(await searchCompanies(q.get("q") ?? "", q.get("country") ?? ""));}export async function POST(req: Request) { const { lookupId, representative } = await req.json(); // + your validation return Response.json(await chooseCompany(getCartIdFromCookie(req), lookupId, representative));}- An approved intent is a strong signal for this checkout session, not a guarantee: run it again if the cart changes much, and show a decline reason message with other payment methods when it is refused.
- PO number (
poNumberabove),buyer_reference(≤ 140 chars),buyer_project,buyer_departmentprint on the invoice: collect them into cart meta beforeplace. Show your contract’s terms (“Pay by invoice, 30 days”). - E-invoicing (EHF in Norway, per the crystallize.com page) depends on your setup with Two;
electronic_invoice_recipientandpreferred_distribution_methodsteer it per order. - The crystallize.com page, updated. Two is no longer “UK and Norway, Sweden soon” (see markets above). The
per-country Search API (
no.search.two.inc/search,gb.…, withlimit,offset,q) is no longer in Two’s docs (it still answered on 2026-10-06): usesearchCompanies. The order example posts tosandbox.api.two.inc(does not resolve) and sendsinvoice_type(set by your contract now) and order-leveltax_rate/discount_*; its lines do not add up (unit_price: '0.00', 0.1 labelled “VAT 25%”, three 200.00 lines for 400),tax_subtotalsis missing and the GB company has a Norwegian address: usecreateTwoOrder. Its confirm call is optional; the Crystallize order comes fromorder.verified.v1, not from confirm’s answer.
Going further
- Partial fulfilment:
partial(lines, amounts,tax_subtotals) creates a fulfilled child order and sets the root’s status toPARTIAL;…/fulfillments/complete_partialcancels the rest (child orders). The webhook above then leaves the recordauthorized: sumGET …/fulfillmentsandupdatePaymentthe captured amount yourself. - Edit before fulfilment with
PUT /v1/order/{id}: lower amounts keepAPPROVED; a higher one after verification is likelyREJECTED(cancel, then a new cart and order). disallow_portal_mutation: trueon create keeps staff from fulfilling, cancelling or refunding in the portal.- PDFs:
invoice_url,credit_note_url,GET /v1/invoice/{order_id}/pdf.order.reconciliation.*webhooks report the buyer’s payments to Two. - Payment terms:
terms(NET_TERMSwithduration_days, beta;INSTALMENTS), billing accounts (terms). - Buyer fee:
POST /v1/pricing/order/fee→buyer_fee_share; add it as an external item beforeplace. - Trade accounts for one-click buyers (
merchant_user_id, guide); credit limitsGET /limits/v1/company/…; Norwegian branchesGET /companies/v2/company/{canonical_id}/branches. - Merchant Portal Order Creator and
POST /v1/order/{id}/notifyfor phone and email sales.
Common mistakes
- Creating the order on the confirmation page after a browser-triggered confirm, with no webhook: a closed tab loses
it, and anyone can load the page. Create it from
order.verified.v1. - Faking amounts to fit a currency (scaling totals by a “currency factor”), or hardcoding currency, country or
merchant_order_id: the invoice must be the placed cart. - No lookup before
POST /v1/order: a double click creates two Two orders and two credit reservations. - Never fulfilling: no invoice, no payout, and the guarantee lapses after 21 days.
- An unencoded search term; the old
tillit.aiorsearch.two.inchosts;sandbox.api.two.inc. tax_rateas a percentage, numbers instead of decimal strings,invoice_typein the body, notax_subtotals.- Company fields next to
company_canonical_id(that form forbids them), or storing the temporarylookup_id. - Setting the company or representative after
place(silently lost), or without phone and email. - Treating
merchant_cancel_order_urlas a cancellation: the order staysUNVERIFIEDand holds the buyer’s credit. - Verifying Svix over re-serialized JSON, only the first signature, or without the timestamp; acting on the event body.
- Refunding a child order id, or cancelling after fulfilment (refund instead).
- Older docs’ names:
/fulfilledand/refunds(spec:/fulfillments,/refund),original_order_id(root_order_id),merchant_short_name(merchant_id),due_in_days(terms), webhookgross_amount(payable_amount).
Vipps MobilePay with Crystallize
Vipps MobilePay is the Nordic mobile wallet: Vipps in Norway (also used by Swedes), MobilePay in Denmark and Finland.
Any Nordic user can pay any Nordic merchant, in the currency of the merchant’s sales unit (NOK, DKK or EUR). Integrate
it with the ePayment API: create a WALLET payment from the placed cart with the cart id as reference, open the
returned redirectUrl from the Widget SDK button (app switch on phones, landing page on desktop, QR on kiosk screens),
and create the order from the HMAC-signed authorized webhook. Every ePayment payment is reserve-capture: authorized
at checkout, captured when the goods ship.
Verification: Written from Vipps MobilePay’s official docs, checked 2026-10-06. Not run end-to-end. Official docs: ePayment API, Create payment, Concepts, Webhooks API, Webhook HMAC, Capture, Test environment, Widget SDK, OpenAPI 1.8.5. Vipps publishes an agent plugin (
claude plugin marketplace add vippsas/agent-toolkit, thenclaude plugin install vipps-developer@agent-toolkit; agent-toolkit) and serves every docs page as.md, indexed at https://developer.vippsmobilepay.com/llms.txt.
At a glance
| Topic | Vipps MobilePay |
|---|---|
| Markets & currencies | NO (Vipps, NOK), DK (MobilePay, DKK), FI (MobilePay, EUR); currency = the sales unit’s |
| Recommended integration | ePayment API, paymentMethod: WALLET, userFlow: WEB_REDIRECT, Widget SDK button |
| Alternative | QR for customer-facing screens; CARD (Vipps card page, WEB_REDIRECT, not in test) |
| Not for new work | eCom API v2 (Norway only, replaced); Checkout API v3 (sold to Kustom, May 2026) |
| API version | /epayment/v1 (spec 1.8.5), /webhooks/v1, POST /accesstoken/get |
| SDKs | Server: none maintained (@vippsmobilepay/sdk 2.4.2 is from 2024): fetch. Widget SDK |
| Amount units | Integer minor units. Min NOK 100, DKK 1, EUR 1; max 65 000 000 |
| Capture + auth lifetime | Always manual. NO 180 days, DK/FI 14; guaranteed until captureGuaranteedUntil |
| Cart id field (+ max) | reference = cart id (36 chars); ^[a-zA-Z0-9-]{8,64}$, unique per sales unit (MSN) |
| One session per cart | Vipps refuses a reused reference (4150); Idempotency-Key: create-<cartId> |
| Notification verification | Webhooks API HMAC-SHA256 over method, path, x-ms-date, host, x-ms-content-sha256 |
Credentials and setup
The crystallize.com page lists four credentials, all in the business portal → For developers → Test or Production → your sales unit → Show keys. Its “Vipps Express account” is outdated: order Payment integration (ePayment) on vippsmobilepay.com, which comes with a test sales unit. Test and production keys differ, and each country is its own sales unit: a shop selling in NOK and DKK has two MSNs and picks one by the placed cart’s currency.
- client id and client secret: exchanged at
POST /accesstoken/getfor a Bearer token (1 h in test, 24 h in production; cache it). The page calls the client id public: keep all four on the server anyway. - merchant serial number (MSN), sent as
Merchant-Serial-Number; subscription key,Ocp-Apim-Subscription-Key. - Test app on a phone: the orange MT app — iOS via TestFlight;
Android: join the Google group
vipps-mobilepay-test-app, then installno.dnb.vipps.mtwith the same account. Make a test user (phone + national identity number, the sales unit’s country) under For developers → Test users; OTP0000/000000, PIN1236. Push is flaky in MT: open Payments and pull to refresh. - Test data:
POST /epayment/v1/test/payments/{reference}/approveapproves without the app (MT only, once the test user has approved one payment by hand). Amounts in øre/cents:151insufficient funds,182refused,186expired card,201unknown result for 1 h; a refund of124fails. In production, smoke-test with 2 NOK, not 1. - Webhook: register once per environment, one registration for all events (Vipps orders deliveries per
registration), and store the
secret(shown once; lost → delete and register again). HTTPS, no redirects, a common port. Calls come fromcallback-mt-1/2.vipps.no(test) andcallback-[dr-]1..4.vipps.no(production): allowlist hostnames, not IPs. - Localhost: register a tunnel URL (cloudflared, ngrok) as an extra webhook (25 per event per MSN); delete it after.
# Register: vipps("POST", "/webhooks/v1/webhooks", { url: VIPPS_WEBHOOK_URL, events }) → { id, secret }, with events# "epayments.payment.<name>.v1" for authorized, aborted, expired, terminated, captured, cancelled, refundedVIPPS_BASE_URL=https://apitest.vipps.no # production: https://api.vipps.noVIPPS_CLIENT_ID=...VIPPS_CLIENT_SECRET=...VIPPS_SUBSCRIPTION_KEY=...VIPPS_MSN=...VIPPS_WEBHOOK_URL=https://shop.example/api/payments/vipps/webhookVIPPS_WEBHOOK_SECRET=...PUBLIC_URL=https://shop.exampleCreate the payment
Call it after place, with the placed cart (SKILL.md). The cart id is
the reference, so a cart gets one Vipps payment: a second tab gets the same live payment back. A declined card
is retried inside the app, in the same payment; once the payment is aborted, expired or terminated (going back from
the landing page cancels it), the shopper continues with a new cart holding the same items.
// lib/vipps.ts — server onlyimport type { PlacedCart } from "@/lib/crystallize-payments";
type Money = { currency: string; value: number }; // minor unitsexport type VippsPayment = { reference: string; state: "CREATED" | "AUTHORIZED" | "ABORTED" | "EXPIRED" | "TERMINATED"; // stays AUTHORIZED after capture amount: Money; aggregate: { authorizedAmount: Money; capturedAmount: Money; refundedAmount: Money; cancelledAmount: Money }; paymentMethod: { type: "WALLET" | "CARD" }; redirectUrl?: string; captureGuaranteedUntil?: string;};
let token = { value: "", expiresAt: 0 }; // 1 h in test, 24 h in production
/** One Vipps API call: null on 404, throws otherwise (never log the headers). */export async function vipps<T>(method: "GET" | "POST", path: string, body?: unknown, idempotencyKey?: string) { const base = process.env.VIPPS_BASE_URL; const headers = { "Ocp-Apim-Subscription-Key": process.env.VIPPS_SUBSCRIPTION_KEY!, "Merchant-Serial-Number": process.env.VIPPS_MSN!, "Vipps-System-Name": "crystallize", // with -Version, -Plugin-Name, -Plugin-Version; each ≤ 30 chars }; if (token.expiresAt - Date.now() < 60_000) { const secrets = { client_id: process.env.VIPPS_CLIENT_ID!, client_secret: process.env.VIPPS_CLIENT_SECRET! }; const res = await fetch(`${base}/accesstoken/get`, { method: "POST", headers: { ...headers, ...secrets } }); if (!res.ok) throw new Error(`Vipps access token: ${res.status}`); const json = (await res.json()) as { access_token: string; expires_on: string }; token = { value: json.access_token, expiresAt: Number(json.expires_on) * 1000 }; } const res = await fetch(`${base}${path}`, { method, headers: { ...headers, Authorization: `Bearer ${token.value}`, "Content-Type": "application/json", ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), // ≤ 50 chars }, body: body === undefined ? undefined : JSON.stringify(body), }); if (res.status === 404) return null; if (!res.ok) throw new Error(`Vipps ${method} ${path} ${res.status}: ${await res.text()}`); // see ErrorCode return (await res.json()) as T;}
export const getPayment = (reference: string) => vipps<VippsPayment>("GET", `/epayment/v1/payments/${reference}`);
export async function createVippsPayment( placed: PlacedCart, returnUrl: string, // carries the cart id, never a status userFlow: "WEB_REDIRECT" | "QR" | "NATIVE_REDIRECT" = "WEB_REDIRECT",): Promise<Pick<VippsPayment, "state" | "redirectUrl">> { const reference = placed.id; const existing = await getPayment(reference); if (existing) return existing; // the caller decides by its state const body = { amount: { currency: placed.total.currency, value: Math.round(placed.total.gross * 100) }, // must match the MSN paymentMethod: { type: "WALLET" }, // or "CARD" reference, userFlow, returnUrl, paymentDescription: `Order ${reference.slice(0, 8)}`, // 3–100 chars, shown in the app ...(userFlow === "QR" ? { qrFormat: { format: "IMAGE/SVG+XML" } } : {}), // receipt: { orderLines, bottomLine }: order lines in the app, see Provider specifics }; try { const res = await vipps<{ redirectUrl?: string }>("POST", "/epayment/v1/payments", body, `create-${reference}`); return { state: "CREATED", redirectUrl: res?.redirectUrl }; } catch (error) { const raced = await getPayment(reference); // another tab created it first (4150) if (raced) return raced; throw error; }}The storefront’s pay route is SKILL.md’s; after place it ends with:
const returnUrl = `${process.env.PUBLIC_URL}/checkout/vipps/return?cartId=${placed.id}`;const payment = await createVippsPayment(placed, returnUrl);if (payment.state === "CREATED") return Response.json({ redirectUrl: payment.redirectUrl });if (payment.state === "AUTHORIZED") return Response.json({ redirectUrl: returnUrl }); // already paidreturn Response.json({ error: "start-over" }, { status: 409 }); // spent: a new cart with the same itemsClient
Use the Widget SDK: app switch on phones, the landing page in a Vipps dialog on desktop. The switch only
works when redirectUrl opens unchanged from the shopper’s click, within about 3 seconds — never rewrite it, frame
it, open it from an effect or timer, or detect the app. The SDK’s success event proves nothing.
"use client";import Script from "next/script";
export function VippsButton({ brand }: { brand: "vipps" | "mobilepay" }) { // brand: the sales unit's country — "vipps" for NO, "mobilepay" for DK and FI const mount = () => { const vipps = (window as any).vipps; vipps.consent({ rememberMe: false, analytics: false }); // then follow your cookie banner vipps.host().start(); // desktop dialog instead of a full-page redirect const pay = async () => { const res = await fetch("/api/checkout/pay", { method: "POST" }); const { redirectUrl, error } = await res.json(); if (!res.ok) throw new Error(error); // "start-over": copy the items into a new cart, then pay again return redirectUrl; }; vipps.trigger(pay).button().brand(brand).mount("#vipps-button"); }; const src = "https://cdn.vippsmobilepay.com/js/widget-sdk/vipps-widget.js"; // not on npm; no SRI hash return ( <> <Script src={src} data-vipps-widget-sdk onReady={mount} /> <div id="vipps-button" /> </> );}Closing the desktop dialog cancels the payment (cancelPaymentOnClose: false keeps it). After paying, the phone opens
returnUrl in its default browser, possibly without your cookie: the return page
takes the cart id from the URL (check it against ^[a-zA-Z0-9-]{8,64}$) and polls a read-only endpoint every 2
seconds that answers paid when readCart says ordered, failed when getPayment says ABORTED, EXPIRED or
TERMINATED (offer a new cart), and pending otherwise (AUTHORIZED means the webhook is on its way).
Webhook
Verify the HMAC over the raw body, then act on the event (VippsEvent in Mapping). It has no
paymentMethod, so the AUTHORIZED branch fetches the payment.
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
const same = (a: string, b: string) => Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
export function verifyVippsWebhook(req: Request, raw: string): boolean { const date = req.headers.get("x-ms-date") ?? ""; const hash = req.headers.get("x-ms-content-sha256") ?? ""; if (!same(createHash("sha256").update(raw, "utf8").digest("base64"), hash)) return false; const url = new URL(process.env.VIPPS_WEBHOOK_URL!); // the REGISTERED host and path, not req.url behind a proxy const signed = `POST\n${url.pathname}${url.search}\n${date};${url.host};${hash}`; // \n, never \r\n const sig = createHmac("sha256", process.env.VIPPS_WEBHOOK_SECRET!).update(signed, "utf8").digest("base64"); const expected = `HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=${sig}`; return same(expected, req.headers.get("authorization") ?? "");}import { createOrderOnce, recordPayment } from "@/lib/crystallize-payments";import { getPayment, syncVippsRecord, vippsRecord, vippsRefund, type VippsEvent } from "@/lib/vipps";import { verifyVippsWebhook } from "@/lib/vipps-webhook";
export async function POST(req: Request) { const raw = await req.text(); if (!verifyVippsWebhook(req, raw)) return new Response("invalid signature", { status: 401 }); const event = JSON.parse(raw) as VippsEvent; const cartId = event.reference; try { if (!event.success) return new Response("ignored"); // a failed operation changed nothing if (event.name === "AUTHORIZED") { const payment = await getPayment(cartId); if (payment?.state !== "AUTHORIZED") throw new Error(`Vipps ${cartId} is ${payment?.state}`); await createOrderOnce(cartId, "unpaid", vippsRecord(payment)); } else if (event.name === "CAPTURED" || event.name === "CANCELLED") { await syncVippsRecord(cartId); // also catches captures and cancels made in the business portal } else if (event.name === "REFUNDED") { await recordPayment(cartId, vippsRefund(event)); // skips a refund already on the order } // ABORTED, EXPIRED, TERMINATED: no order return new Response("ok"); } catch (error) { console.error(error); return new Response("retry", { status: 500 }); }}Vipps retries any 4xx/5xx or an answer slower than 10 s, backing off for 7 days; delivery is ordered per payment
per registration (a failing AUTHORIZED holds back its CAPTURED); a registration failing for 2 weeks is deleted —
watch Webhook errors in the portal. Vipps also asks for polling as a backup: a scheduled job, not a request, that
calls getPayment for carts you sent to Vipps still placed after ~15 minutes and runs the AUTHORIZED branch. A
payment has 10 minutes to be approved: while it is CREATED, never cancel it or show “failed”.
epayments.payment.<name>.v1 |
Crystallize (paymentStatus) |
|---|---|
authorized |
createOrderOnce(cartId, 'unpaid', …) with state=authorized |
captured |
updatePayment → state=captured, amount = aggregate.capturedAmount |
cancelled |
updatePayment → state=cancelled (stays captured if part was captured) |
refunded |
recordPayment, type=refund, transactionId = event pspReference |
aborted, expired, terminated |
No order; the cart’s payment is spent: the shopper goes on with a new cart |
any event with success: false |
Nothing |
Capture, refund, cancel
The payment state stays AUTHORIZED; the money is in aggregate (minor units), returned by GET and by every
modification. Capture and refund take an Idempotency-Key, reused only to retry that same operation; cancel takes
none. Capture when the goods ship, and trust capturedAmount, not the HTTP status.
// lib/vipps.ts (continued)import { updatePayment, withMeta } from "@/lib/crystallize-payments";
async function modify(op: "capture" | "refund", reference: string, amount: number, key: string) { const { currency } = (await getPayment(reference))!.amount; const modificationAmount = { currency, value: Math.round(amount * 100) }; const path = `/epayment/v1/payments/${reference}/${op}`; return (await vipps<VippsPayment>("POST", path, { modificationAmount }, key))!;}
/** captureByProvider["vipps-mobilepay"]. Returns the captured total in major units. */export async function capture(transactionId: string, amount: number): Promise<number> { const key = `capture-${transactionId}`; // partial captures: one key per part, e.g. `capture-${transactionId}-2` const { aggregate } = await modify("capture", transactionId, amount, key); if (aggregate.capturedAmount.value < Math.round(amount * 100)) throw new Error("Vipps capture short: do not ship"); return aggregate.capturedAmount.value / 100;}
/** Up to the captured amount, within 365 days. `refundId` is yours (a return id, ≤ 43 chars). */export const refund = (transactionId: string, amount: number, refundId: string) => modify("refund", transactionId, amount, `refund-${refundId}`); // the `refunded` webhook records it
/** Releases everything not captured, as soon as you know you will not ship it. */export async function cancel(transactionId: string) { await vipps("POST", `/epayment/v1/payments/${transactionId}/cancel`, {}).catch(async (error) => { const p = await getPayment(transactionId); // repeating a cancel that went through is fine if (!p || (p.aggregate.cancelledAmount.value === 0 && p.state !== "TERMINATED")) throw error; }); await syncVippsRecord(transactionId);}
/** Copies Vipps' aggregate onto the order's record (order id = cart id = reference). */export async function syncVippsRecord(cartId: string) { const { aggregate } = (await getPayment(cartId))!; const captured = aggregate.capturedAmount.value; if (captured === 0 && aggregate.cancelledAmount.value === 0) return; // still only authorized await updatePayment(cartId, cartId, (p) => captured > 0 ? withMeta(p, { state: "captured" }, captured / 100) : withMeta(p, { state: "cancelled" }), );}The pipeline-stage handler calls capture, then
updatePayment; a “Cancelled” stage calls cancel. DK/FI sales units must ask for partial capture. Capture attempts
are allowed for 180 days (NO) or 14 (DK/FI; late capture on request) but only guaranteed until
captureGuaranteedUntil: Visa holds last 5–7 days, BankAxept 7 — capture the day you ship. A capture still failing
30 days after authorization will not succeed: contact the shopper.
Mapping
// lib/vipps.ts (continued)import type { Payment } from "@/lib/crystallize-payments";
export type VippsEvent = { reference: string; // = cart id name: "AUTHORIZED" | "CAPTURED" | "CANCELLED" | "REFUNDED" | "ABORTED" | "EXPIRED" | "TERMINATED" | "CREATED"; amount: Money; // of this operation pspReference: string; // unique per event timestamp: string; success: boolean;};
export const vippsRecord = (p: VippsPayment): Payment => ({ provider: "vipps-mobilepay", // the captureByProvider key method: p.paymentMethod.type === "CARD" ? "card" : "wallet", transactionId: p.reference, // = cart id; capture, refund and cancel are keyed on it amount: p.amount.value / 100, // major units (NOK, DKK and EUR have 2 decimals) createdAt: new Date().toISOString(), meta: [ { key: "state", value: "authorized" }, { key: "cartId", value: p.reference }, { key: "captureGuaranteedUntil", value: p.captureGuaranteedUntil ?? "" }, ],});
export const vippsRefund = (e: VippsEvent): Payment => ({ provider: "vipps-mobilepay", method: "refund", transactionId: e.pspReference, // per event; the refund call's response carries the payment's pspReference amount: e.amount.value / 100, createdAt: e.timestamp, meta: [ { key: "type", value: "refund" }, { key: "cartId", value: e.reference }, ],});Provider specifics
Choosing the user flow. WEB_REDIRECT fits every website. The pay route may take a flow from the storefront —
never an amount — instead of the fixed returnUrl above:
const site = process.env.PUBLIC_URL;const flows = { web: ["WEB_REDIRECT", `${site}/checkout/vipps/return`], qr: ["QR", `${site}/checkout/vipps/return`], // kiosk or in-store screen app: ["WEB_REDIRECT", `${site}/app/vipps-return`], // a universal link your app claims native: ["NATIVE_REDIRECT", "myshop://vipps-return"], // only for an app with no website} as const;const [userFlow, base] = flows[new URL(req.url).searchParams.get("flow") as keyof typeof flows] ?? flows.web;const payment = await createVippsPayment(placed, `${base}?cartId=${placed.id}`, userFlow);- QR:
redirectUrlis then a link to the QR image (IMAGE/SVG+XML;IMAGE/PNGwithsize100–2000;TEXT/TARGETURLto draw it yourself), dead with the payment after 10 minutes. Render<img src={redirectUrl} width={280} height={280} alt="Scan with Vipps or MobilePay" />and poll the status endpoint; in a store, also sendcustomerInteraction: "CUSTOMER_PRESENT". - Native app switch:
POST /api/checkout/pay?flow=app, thenawait Linking.openURL(redirectUrl)(React Native) — the operating system switches to Vipps or MobilePay; never a WebView.returnUrlbrings the shopper back: a universal link (preferred) or, for apps without a website, your custom scheme withNATIVE_REDIRECT. PUSH_MESSAGEskips the landing page and needscustomer.phoneNumber(4712345678). It requires approval and is only allowed on devices the shopper does not own (POS, vending) — not in a web shop.
After payment:
- Receipts (order lines in the app):
POST /order-management/v2/ecom/receipts/{reference}(ecomcovers ePayment; immutable once sent, 409), or the same object asreceiptin the create call (order details), which Vipps recommends. Body:orderLines[]withname,id,totalAmount,totalAmountExcludingTax,totalTaxAmount,taxRate(25 % →2500;taxPercentage0–100 is deprecated),unitInfo(unitPrice,quantityas a string),discount,isShipping(your shipping external item), andbottomLine(currency,receiptNumber). Every amount is in minor units, taken from the placed cart’s lines (price.gross,price.taxAmount); the lines must sum exactly to the payment amount — put the rounding drift on the last line — or the app shows none of them. A link button in the app:PUT /order-management/v2/ecom/categories/{reference}withcategory(ORDER_CONFIRMATION,DELIVERY,RECEIPT, …) andorderDetailsUrl. Read both back withGET /order-management/v2/ecom/{reference}. - Vipps MobilePay Login (OpenID Connect; enable Login on the sales unit and register the exact
redirect_uri): discovery at{VIPPS_BASE_URL}/access-management-1.0/access/.well-known/openid-configuration. Redirect to itsauthorization_endpointwithclient_id,response_type=code,scope=openid name email phoneNumber address,redirect_uri, PKCES256and a randomstateper login (≥ 8 chars, kept in an httpOnly cookie, compared on return — never a constant). Exchange the code at thetoken_endpoint(client_secret_basicby default), readGET /vipps-userinfo-api/userinfowith that token, and keepsubas the stable user id. Put the profile on the cart’s customer beforeplace.
Going further
- Vipps Checkout v3 — deprecated, sold to Kustom (May 2026):
POST /checkout/v3/sessionreturnstoken,checkoutFrontendUrlandpollingUrlforcheckout.vipps.no/vippsCheckoutSDK.js. Its callbacks carry yourcallbackAuthorizationTokenasAuthorization(compare it timing-safe); capture, refund and cancel use the ePayment API with the samereference. It collects shipping itself — same warning as Express. (Checkout API) - Express / buy now from the product page: ePayment
shipping.fixedOptions(ordynamicOptions, whose callback must checkcallbackAuthorizationToken) withaddressinprofile.scope; needs sales unit approval; the choice comes back asshippingDetails. Warning: the shopper picks shipping in the app, so Vipps authorizes more than the placed cart and the order has no shipping line — choose shipping in the storefront beforeplace. eCom v2 Express (/ecomm/v2/payments,staticShippingDetails) is deprecated. (Express) - More create-payment options (features):
paymentMethod.type: "CARD"(card page for shoppers without the app),profile.scope(profile sharing),minimumUserAge,expiresAt(long-living, 10 min–60 days, approval andreceiptrequired),merchantLegalLinks. GET /epayment/v1/payments/{reference}/eventsis the authoritative history — build support tools on it; the business portal is not meant for customer support. Subscriptions use the Recurring API, not ePayment.
Common mistakes
- Capturing, refunding or cancelling ePayment payments through eCom v2 (
/ecomm/v2/payments/…) withclient_id/client_secretheaders instead of a Bearer token: pipeline capture and refund never worked. - Polling
GET /payments/{reference}every 2 seconds for up to an hour inside one request; a webhook route that read the JSON twice and verified nothing. - The cart id as the
Idempotency-Keyof every call, so a second partial capture or refund is a “duplicate”. gross * 100unrounded (4040);NOKhardcoded (5040 on DK/FI sales units).- Receipt
unitPricein major units andtaxPercentage = tax / gross: amounts are minor units,taxRate= % × 100. - Checkout callbacks without the
callbackAuthorizationTokencheck; an unauthenticated Express shipping callback. - A constant
statein the Login redirect: no CSRF protection. - Recording refunds under the refund response’s
pspReference— the payment’s, identical for every refund, so the second one is dropped as a redelivery. - Ignoring
success: false; verifying the HMAC againstreq.urlbehind a proxy. - Rewriting, framing or effect-opening
redirectUrl; trusting the cart cookie on the return page. - Shipping before
capturedAmountconfirms the capture.
Crystallize AI