Skip to content

payments

Terminal window
npx skills add https://github.com/crystallizeapi/ai --skill payments

Crystallize 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:

  1. Towards the end of checkout, the shopper has a cart and wants to pay.
  2. The checkout page loads the gateway’s form, or links to a page hosted by the gateway.
  3. The shopper enters their payment details.
  4. The gateway hands the shopper back to your site (often a redirect) and the page updates.
  5. 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 cart
Shop /cart place cart frozen; place's total = what you charge
Server create provider session / intent amount from place, cart id as the reference
Browser pay provider's hosted page or embedded component
Provider ───► your webhook verify → customer → createFromCart once
Browser return page read-only: wait until the cart is `ordered`
… later …
Crystallize order enters the "Shipped" stage ───► your hook provider capture → setPayments

When 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:

  • place freezes the cart. A placed cart cannot be hydrated or edited, and there is no way back to the cart state. createFromCart refuses a cart that is not placed (“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 total that place returns: place re-prices the cart from the catalogue one last time, so it can differ from the last hydrate. 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 (cart meta). 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, setMeta and item changes answer with a cart that shows your change — but nothing is saved. Read the cart’s state before editing it.
  • Back from the payment page = a new cart. If the shopper wants to change anything, hydrate a 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, isStale turns true after 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" button
import { 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:

  1. 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 HPP status_update, Mollie, Qliro) prove nothing on their own: re-fetch the payment from the provider’s API and act on what the provider returns.
  2. 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.
  3. Decide by the provider’s status (paid, authorized, pending, failed — the reference has the table). Pending and failed create nothing.
  4. createOrderOnce: reads the cart, creates the Core customer if needed, and calls createFromCart once. Deliveries that collide in the same instant are a trade-off: see Serialising order writes.
  5. 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 placed catches 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.

lib/crystallize-payments.ts
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: updatePayment reads, 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 lock
import { 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:

  1. 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 } — orderId is the Core order id.
  2. Verify X-Crystallize-Signature with the tenant’s signature secret.
  3. Read the order’s payment records from Core, take the cartId (= Shop order id), provider and transactionId, call the provider’s capture (or cancel) with an idempotency key, then updatePayment.
app/api/crystallize/order-stage/route.ts
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 * 100 without rounding; sending 0 for 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 paymentStatus or payments through Core updateOrder on 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.

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:3000 works in test; live needs https) or Drop-in will not load.
  • 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-enrolled 4917 6100 0000 0000; holder name DECLINED forces a refusal (show the field with hasHolderName: 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.
Terminal window
ADYEN_API_KEY=AQE…
ADYEN_CHECKOUT_URL=https://checkout-test.adyen.com/v72 # live: https://<prefix>-checkout-live.adyenpayments.com/…/v72
ADYEN_MERCHANT_ACCOUNT=YourCompanyECOM
ADYEN_HMAC_KEY=44782DEF…
ADYEN_WEBHOOK_USERNAME=…
ADYEN_WEBHOOK_PASSWORD=…
PUBLIC_URL=https://shop.example # the tunnel in development
NEXT_PUBLIC_ADYEN_CLIENT_KEY=test_…
NEXT_PUBLIC_ADYEN_ENVIRONMENT=test # live, live-us, live-au, live-nea, live-in: prefix's region

Live 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.

lib/adyen.ts
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.

app/checkout/adyen.tsx
"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.

app/api/payments/adyen/webhook/route.ts
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 place

Nordic 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: /sessions with mode: "hosted" and a themeId, redirect to url; 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 place unless you rebuild that flow deliberately.
  • Gift cards and partial payments: one cart is paid by several AUTHORISATIONs and closed by ORDER_CLOSED; createOrderOnce would flag the second as a duplicate. Create the order on ORDER_CLOSED success=true instead (partial payments).
  • Changing the amount after the session (payable: false, then PATCH /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 as reference.
  • 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 environment matching 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 POST AUTHORISATION success=true and get an order.
  • Handling only the first item of notificationItems (a return inside 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 countryCode from the currency (NOK → NO, else FR): it hides payment methods and breaks Vipps, Swish, Klarna. Use the delivery address or the market.
  • cart.total.gross * 100 without Math.round, and two decimals for every currency (JPY has 0, KWD 3).
  • Creating the session even though place failed, or a new session on every render or cart change.
  • Web v5 code (import AdyenCheckout from '@adyen/adyen-web', checkout.create('dropin'), no countryCode): v6 uses named imports, new Dropin(checkout) and onPaymentFailed for failures.
  • A return page that never calls submitDetails with redirectResult, or that creates the order from resultCode.
  • manualCapture: true as a boolean, or never capturing (authorisations expire); recording iDEAL, Swish or Trustly payments as “authorized” when they were captured at once.
  • Reading operations from the AUTHORISATION webhook 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: T12345678 is the sandbox, P12345678 is live (Dintero’s quickstart creates new credentials on the P account 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/signature with a bearer token returns signature.secret (API); from then on every callback carries Dintero-Signature.
  • Test data (cards), any future expiry and CVC: Visa 4000 0000 0000 0002 (no 3DS challenge), 4000 1000 0000 0000 (challenge), Mastercard 5200 0000 0000 0007, declined 4100 0000 0000 0076, capture time-out 4100 0000 0000 0019. Klarna, Norway: customer@email.no, +4740123456 (Klarna test data).
  • Reaching localhost: callback_url must be public HTTPS (https://localhost is refused): use a tunnel as PUBLIC_URL. Backoffice shows each transaction’s callbacks and your answers. Callbacks come from 34.241.230.119 and 34.242.13.162.
Terminal window
DINTERO_ACCOUNT_ID=T12345678 # P12345678 in production
DINTERO_CLIENT_ID=…
DINTERO_CLIENT_SECRET=…
DINTERO_PROFILE_ID=default
DINTERO_SIGNATURE_SECRET=… # signature.secret from POST /v1/admin/signature
PUBLIC_URL=https://shop.example # the tunnel in development: Dintero signs this host

Create 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.

lib/dintero.ts
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 decimals
let 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:

app/checkout/dintero/checkout.tsx
"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 callback
import { 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 before place (shipping options).
  • Account-wide webhooks: subscribe to checkout_transaction (Backoffice → Settings → Webhooks, or POST /v1/accounts/{aid}/hooks/subscriptions) to hear about captures, refunds and voids made in Backoffice. Deliveries carry event-signature, HMAC-SHA1 of the raw body; trust an event’s correction.status over its success (checkout webhook). Or add report_event=CAPTURE (REFUND, VOID) to callback_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 once CAPTURED, 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_url with 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 reading merchant_reference from it; or no Dintero-Signature check at all.
  • Creating the order only on AUTHORIZED: with auto-capture or Swish the one callback says CAPTURED.
  • Comparing against AUTHORISED (British spelling): the API’s status is AUTHORIZED.
  • Answering a 4xx for your own problem (an unknown cart → 404): Dintero never retries a 4xx.
  • gross * 100 without rounding, vat_amount: 0 on taxed lines, or lines that do not add up to order.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_reference with Kravia enabled: set a shorter merchant_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 America https://api-na.klarna.com, Oceania https://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_urls entry must match ^https:// and be reachable, so tunnel localhost (cloudflared, ngrok). Nothing is registered in the portal: each URL travels with its session.
Terminal window
KLARNA_API_URL=https://api.playground.klarna.com # https://api.klarna.com live (EU)
KLARNA_USERNAME=... # API key ID
KLARNA_PASSWORD=... # API key secret
KLARNA_CALLBACK_SECRET=... # 32+ random bytes: signs the callback URLs
PUBLIC_URL=https://shop.example # the tunnel URL in development
  • Test shoppers (sample customers): customer+se@klarna.com is approved and customer+se+denied@klarna.com declined (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 except 999999.
  • Test payments (sample payment data): card 4111 1111 1111 1111, CVC 123, any future expiry; 4687 3888 8888 8881 triggers 3-D Secure; direct debit IBAN DE11 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.

lib/klarna.ts
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 yours
const 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-SE
export 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_reference1 is not unique, and the placed cart cannot store a session id. Two tabs get two sessions; if both are paid, createOrderOnce records the second with attention=duplicate-payment and 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_rate or total_tax_amount on product lines; send line totals without tax plus one type: "sales_tax" line named “Sales Tax”, and order_tax_amount = that line’s total_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_price and total_amount ≤ 200 000 000; tax_rate 0–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.

  • success is the return page from SKILL.md. It reads the cart named by cart in its URL (the shopper may come back in another browser, without your cookie): ordered → thank you, clear the cart cookie; still placed → “confirming your payment…” and refresh. While waiting it may read the HPP session (GET /hpp/v1/sessions/{sid}) to tell “still confirming” from a FAILED or CANCELLED session. It never places or creates anything: the order_id in its URL proves nothing.
  • cancel, back, failure and error land 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_update
import { 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. cancel fails 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 5xx retry with the same key (Klarna suggests 5 s, 5 min, 5 h); 4xx and 409 need 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 alternative
export 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.authorization
import { 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).

app/checkout/klarna-widget.tsx
"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 place and pass clientToken and categories to the page; build billing from the placed cart’s customer and its billing address: given_name, family_name, email, phone, street_address, postal_code, city, country. Klarna wants customer data at authorize(), not in the session (GDPR).
  • Send Cross-Origin-Opener-Policy: same-origin-allow-popups (Helmet’s default same-origin cuts the pop-up off) and allow Klarna’s hosts in your CSP (x.klarnacdn.net, js.klarna.com, *.klarna.com, *.klarnaevt.com).
  • approved: true in 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: PENDING for up to 24 h; Klarna POSTs { order_id, event_type: FRAUD_RISK_ACCEPTED | _REJECTED | _STOPPED } to the session’s merchant_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 via shipping_info on the capture or POST …/captures/{capture_id}/shipping-info; …/trigger-send-out resends the invoice e-mail; PATCH …/authorization changes amount and lines before capture (new risk check); PATCH …/customer-details and …/merchant-references; a type: "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", then POST /payments/v1/authorizations/{token}/customer-token and POST /customer-token/v1/tokens/{token}/order with a Klarna-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/orders and the acknowledge step do not apply to Klarna Payments.
  • Automatic capture for digital goods: place_order_mode: "CAPTURE_ORDER" on the HPP session (or auto_capture: true when you place the order). Then create the order as paid with state=captured.
  • HPP options: payment_method_category(ies) to show only some categories (store a storefront choice on the cart before place), 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_amount or order_tax_amount (or 0): Klarna rejects the lines or the invoice shows no VAT. Compute the tax from total_amount as above, not from a rounded per-unit value.
  • Building unit_price as (gross / quantity + lineDiscount) * 100: the whole line discount lands on every unit and the line no longer adds up when quantity > 1. unit_price is before discount; the discount is per line.
  • gross * 100 without Math.round; leaving out reference (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_country must 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 an await (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-Key per 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: true instead 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:

  1. Sign up at my.mollie.com and create a website profile (your web store). Keys, payment methods and checkout branding belong to it.
  2. 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.
  3. 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).
  4. Currency: Mollie accepts ISO 4217 codes only. A Crystallize price variant’s currency can be named anything (€, Euro, EURO, EUR): name it EUR, since placed.total.currency goes to Mollie unchanged. Address countries are ISO 3166-1 alpha-2 (NL).
Terminal window
MOLLIE_API_KEY=test_... # live_... in production
MOLLIE_CAPTURE_MODE=manual # authorize now, capture on shipment; omit to capture at once
PUBLIC_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, Mastercard 2223 0000 1047 9399, Amex 3782 822463 10005, any expiry and CVV; amounts €1,001.00 to €1,011.00 forced to failed give a chosen failureReason. A paid test payment’s _links.changePaymentState creates a refund or chargeback.
  • Localhost: a localhost webhookUrl is refused (“The webhook location is invalid”). Run ngrok or cloudflared and point PUBLIC_BASE_URL at 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.

lib/mollie.ts
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 pending
return Response.redirect(url, 303); // called with fetch(): return { url } and window.location.assign(url) instead
  • Mollie sends the shopper to redirectUrl whatever 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 stays open. Cancelling there leads to cancelUrl: 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 open for 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.

app/api/payments/mollie/webhook/route.ts
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 paid and 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. An authorized payment cannot be refunded (release it); a captured one cannot be released.
  • Refunds run about two hours later and wait as queued when 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, check GET /v2/payments/{id}/refunds before 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, call Mollie(profileId, { locale, testmode }) and createComponent('card'); createToken() returns a cardToken (valid 1 hour) that the server sends with method: 'creditcard', then redirects to _links.checkout for 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 recommends webhookUrl for payments.
  • Recurring: create a Mollie customer once per Crystallize customer (POST /v2/customers), take a first payment with customerId and sequenceType: '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 with sequenceType: 'recurring' + mandateId (always with an Idempotency-Key), or POST /v2/customers/{id}/subscriptions (amount, interval, description unique per customer, startDate as YYYY-MM-DD, mandateId, webhookUrl). See Recurring payments.
  • Single-click cards: the same customerId on later payments lets Mollie Checkout offer saved cards.
  • QR codes: ?include=details.qrCode on create, for iDEAL, Bancontact and bank transfer.
  • Cancel an open payment: DELETE /v2/payments/{id} while isCancelable, 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 (and testmode: true to 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 paid or authorized.
  • 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, canceled and expired create nothing.
  • Placeholder names and addresses in billingAddress, or tax sent as 0: take both from the cart.
  • gross.toFixed(2) for every currency (JPY and ISK have no decimals), and unrounded gross * 100 in line maths.
  • Taking the redirect to redirectUrl as proof of payment, or looking for a signature on the classic webhook.
  • Answering 4xx for unknown ids, or letting middleware redirect the webhook route.
  • Changing the create body under the same Idempotency-Key (locale from the request, origin from Host, a timestamp) → 400; trusting a replayed response’s status; counting on the key after an hour.
  • vatRate as a number, a vatAmount that is not totalAmount × rate / (100 + rate), lines that do not sum to amount, or negative physical / shipping_fee lines.
  • A price variant currency named € or Euro, or country names instead of ISO alpha-2 codes.
  • Expecting Klarna or Billie to stop at authorized without captureMode: 'manual', or calling the Orders API.
  • A subscription startDate with a time in it (it is YYYY-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) and 5454 5454 5454 5454 (3DS), 03/30, CVC 737; billingAddress.email = redirect-3ds-test@montonio.com forces the redirect 3DS flow. BLIK 777 123 succeeds. The sandbox bank list comes from GET /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 from 35.156.245.42 and 35.156.159.169 with User-Agent MontonioWebhooks/1.0 — allowlist them in a WAF or Cloudflare. Localhost: ngrok, or webhook.site to inspect payloads.
Terminal window
MONTONIO_ACCESS_KEY=…
MONTONIO_SECRET_KEY=…
MONTONIO_API_URL=https://sandbox-stargate.montonio.com/api # prod: https://stargate.montonio.com/api
MONTONIO_SHIPPING_URL=https://sandbox-shipping.montonio.com/api/v2 # prod: https://shipping.montonio.com/api/v2
PUBLIC_URL=https://shop.example # the tunnel in development

Create 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).

lib/montonio.ts
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 decimals
const 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;
}
app/api/payments/montonio/webhook/route.ts
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 place
import { 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:

  1. Read the pickup point (meta) and receiver (customer) from the placed cart (carts.fetch(cartId, PLACED_CART)). If the payment record already has meta montonioShipmentId, stop: the shipment exists.
  2. POST {MONTONIO_SHIPPING_URL}/shipments with merchantReference (cart id), montonioOrderUuid (the payment’s transactionId), receiver (name, phoneCountryCode such as 372, phoneNumber without it — both required — and email), shippingMethod: { type: "pickupPoint", id }, parcels: [{ weight }] (kg; dimensions when the method’s constraints.parcelDimensionsRequired), optional products (sku, name, quantity, price) for pick lists and the tracking page, and synchronous: true. Without sender, the store’s sender details are used.
  3. registered → updatePayment(cartId, orderUuid, (p) => withMeta(p, { montonioShipmentId: id })); registrationFailed (often a bad phone number) → PATCH /shipments/{id} with the fix registers it again.
  4. POST /label-files with { shipmentIds: [id], pageSize: "A6", labelsPerPage: 1, synchronous: true } returns labelFileUrl, 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 with sessionUuid, submitPayment(); call destroy() 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 as additionalServices where 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_token appended to the gateway URL, snake_case fields (preselected_aspsp), a notification read from the query string with status === 'finalized', banks from /pis/v2/merchants/payment_methods, shipping on api.shipping.montonio.com.
  • Verifying without pinning algorithms or checking accessKey, or creating the order from the returnUrl token.
  • 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 a GET hashes the secret alone (authorization). Hash the exact string you send; re-serialising (key order, spaces) gives 401.
  • Base URL — https://pago.qit.nu (test), https://payments.qit.nu (production).
Terminal window
QLIRO_BASE_URL=https://pago.qit.nu # https://payments.qit.nu in production
QLIRO_API_KEY=... # MerchantApiKey
QLIRO_API_SECRET=... # signs requests
QLIRO_PUSH_SECRET=... # 32+ random bytes: signs your push URLs
PUBLIC_URL=https://shop.example # the tunnel URL in development
  • URLs Qliro needs: the page embedding the iframe, MerchantConfirmationUrl, MerchantTermsUrl, MerchantCheckoutStatusPushUrl and MerchantOrderManagementStatusPushUrl (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.

lib/qliro.ts
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 yours
const 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 Product
const 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 InProcess order, renew it with UpdateOrder (PUT merchantapi/orders/{OrderId} with the same OrderItems, through qliro(path, body, "PUT")) before GetOrder. Past 48 hours, start a new cart.
  • Item rules (else INVALID_INPUT): PricePerItemIncVat ≥ PricePerItemExVat; Product, Fee and Shipping ≥ 0; Discount ≤ 0 (for a taxed discount the first rule presumably compares absolute values: unconfirmed). Qliro identifies an item by MerchantReference + PricePerItemIncVat.

Client

app/checkout/qliro-checkout.tsx
"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 and Timestamp, or check that the cart has not already become an order. createOrderOnce, recordPayment (by transaction id) and updatePayment make 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 = capture
export 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, and OPERATION_NOT_SUPPORTED with “Another transaction is already in process”; INVALID_REQUEST_TOTAL_AMOUNT / EXCEEDING_QUANTITY mean the items do not match the order.
  • ReturnItems works only after capture; return fees go in Fees, extra discounts in Discounts. Cancelling a Trustly payment creates an extra Refund transaction, 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") before place; the create call then sends JuridicalType: "Company" and EnforcedJuridicalType: "Company" (only companies may complete). For a company, Qliro reads CustomerInformation.PersonalNumber as the organisation number and VatNumber as the VAT number — send them if your cart customer carries them.
  • Prefill and lock: CustomerInformation (Email, MobileNumber, PersonalNumber, Address, ShippingAddress for B2B) plus LockCustomerEmail, LockCustomerMobileNumber, LockCustomerPersonalNumber, LockCustomerAddress or LockCustomerInformation keep what the storefront collected; locking can disable Qliro’s own payment methods.
  • A method chosen before place: POST merchantapi/PaymentOptions lists the payment ids; store the choice on the cart (carts.setMeta(id, { meta: [{ key: "qliroPaymentId", value }], merge: true })) and send it as PaymentId to open the checkout on that method.
  • Frontend listeners (inside q1Ready): onCheckoutLoaded, onCustomerInfoChanged, onPaymentMethodChanged, onShippingMethodChanged, onShippingPriceChanged, onPaymentDeclined, onPaymentProcess, onSessionExpired, onCustomerDeauthenticating, and lock() / unlock() / onOrderUpdated() around an UpdateOrder. 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, an UpsellStatus push); 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() and cancel(), driven by a stage webhook, with results on MerchantOrderManagementStatusPushUrl. The Qliro OrderId is the payment record’s transactionId. See order management.
  • Order validation: MerchantOrderValidationUrl makes Qliro POST the order (items, customer, addresses, payment method) when the shopper clicks “Complete purchase”. Answer 200 to accept, or 400 with { "DeclineReason": "OutOfStock" } (or PostalCodeIsNotSupported, 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 AvailableShippingMethods list, a dynamic MerchantOrderAvailableShippingMethodsUrl (5 s to answer), or integrations such as Ingrid and Unifaun (nShift) through ShippingConfiguration, plus MerchantOrderAvailableShippingAddressesUrl and MerchantNotificationUrl for 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, before place.
  • Thank-you page: after completion GetOrder returns Qliro’s thank-you snippet (Client); q1.excludeResultModules(["HEADER", "TOTAL_PRICE", "CUSTOMER_DETAILS", "SHIPPING_METHOD"]) hides parts of it.
  • Customer and B2B options: LockCustomerEmail, LockCustomerAddress and the other lock flags; EnforcedJuridicalType for companies only; RequireIdentityVerification for BankID in Sweden; MinimumCustomerAge.
  • Look and feel: PrimaryColor, CallToActionColor, CallToActionHoverColor, BackgroundColor (saturation ≤ 10 %), CornerRadius, ButtonCornerRadius; also AskForNewsletterSignup, MerchantProvidedQuestion, ShippingAdditionalHeader, MerchantIntegrityPolicyUrl.
  • Payment link: instead of the iframe, redirect the shopper to PaymentLink (in the CreateOrder and GetOrder responses); MerchantCancelUrl adds 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 GetOrder says Completed: every capture, refund or repeated push then creates another order. Keep the signed type, check NotificationType, and let only the checkout push call createOrderOnce.
  • 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 / quantity to 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 innerHTML and stopping there: the scripts never run and the iframe never appears.
  • Hashing a re-serialised body (401), or forgetting MerchantApiKey in Admin API bodies.
  • Answering a push with anything but {"CallbackResponse":"received"}: Qliro retries for 3 days.
  • Treating the Admin API’s Created as done; using ReturnItems before 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.
Terminal window
QUICKPAY_API_KEY=... # API user's key — Basic auth ":<key>"
QUICKPAY_PRIVATE_KEY=... # merchant Private key — callback checksum
QUICKPAY_CALLBACK_URL=https://shop.example/api/payments/quickpay/webhook
QUICKPAY_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 filter test_mode, in production.
  • Test cards (any plausible expiry and CVD; a CVD such as 752 sets the issuing country): Visa 1000 0000 0000 0008 approved, …0016 rejected, …0024 expired, …0032 capture rejected, …0040 refund rejected, …0057 cancel rejected, …0073 3-D Secure required (30100), …0099 delayed 60 s; Mastercard 1000 0100 0000 0007, Dankort 1000 0200 0000 0006 approved.
  • 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.

lib/quickpay.ts
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- rule
export 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);
}
app/api/payments/quickpay/webhook/route.ts
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 = capture
async 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 capture operations); the record’s amount becomes 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}/renew renews it; capture fails with 40002 once 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 /cards for 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 before place.
  • auto_fee adds 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 before place.
  • 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-ID header 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 accepted is false (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 /payments with the same order_id fails, and a random order_id lets one cart be paid twice. Look the payment up by order_id first.
  • cart.total.gross * 100 without Math.round.
  • Never capturing: auto_capture defaults to false, 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 OTP 754081.
  • Capture mode: Account & Settings → Payment Capture (account owner only).
Terminal window
RAZORPAY_KEY_ID=rzp_test_... # key ID — public, also handed to Checkout
RAZORPAY_KEY_SECRET=... # secret key — server only: API auth and the Checkout signature
RAZORPAY_WEBHOOK_SECRET=... # the webhook's own secret
RAZORPAY_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, Mastercard 5500 6700 0000 1002, RuPay 6527 6589 0000 1005; international Mastercard 5555 5555 5555 4444, Visa 4012 8888 8888 1881; declined Visa 4100 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.com and similar. It suggests a zrok tunnel; 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.

lib/razorpay.ts
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 2
const 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 none
type 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.

app/checkout/razorpay-button.tsx
"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.

app/api/payments/razorpay/verify/route.ts
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

app/api/payments/razorpay/webhook/route.ts
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 units
export 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.processed webhook — 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_paid and currency before creating the order.
  • A new Razorpay order on every click: a fixed receipt then fails (“Duplicate request”), a random one lets a cart be paid twice. Look the order up by receipt first.
  • Answering 3xx (middleware), 4xx or 5xx, or taking over 5 seconds, for a day: Razorpay disables the webhook.
  • gross * 100 without rounding, or ignoring zero- and three-decimal currencies.
  • new Razorpay(…) before checkout.js has loaded; image as an object; contact without a country code.
  • callback_url in a web integration; paying without an order_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 key rk_… 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 version 2026-09-30.endive (the SDK’s, so payloads match its types) → events checkout.session.completed, checkout.session.async_payment_succeeded, payment_intent.succeeded, payment_intent.canceled, refund.created, refund.failed → Webhook endpoint https://<host>/api/payments/stripe/webhook (snapshot payloads) → Reveal secret.
Terminal window
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/cli then stripe sandbox create --help. Enable payment methods in Settings → Payment methods (dynamic payment methods); payment_method_types is gone from Checkout Sessions on Endive (400) — narrow with allowed_payment_method_types.
  • Test data (Testing): 4242 4242 4242 4242 succeeds, 4000 0025 0000 3155 asks for 3D Secure, 4000 0000 0000 9995 is declined (any future expiry, any CVC). SEPA IBAN AT321904300235473204 stays processing ~3 minutes then succeeds; AT861904300235473202 fails. Redirect methods offer Complete / Fail test payment.
  • Localhost: stripe listen --forward-to localhost:3000/api/payments/stripe/webhook prints its own whsec_…; use that one locally. stripe trigger fixtures 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.

lib/stripe.ts
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

app/[locale]/checkout/stripe/stripe-pay.tsx
"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

app/api/payments/stripe/webhook/route.ts
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 with confirmPayment, create the order on payment_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_creation adds a paid invoice (priced separately). Disputes: alert a human on charge.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 before place, as an external item. A billing address typed into the Payment Element only reaches latest_charge.billing_details: collect addresses with setCustomer before place.
  • 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 via payment_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 in 2022-11-15) or pinning an old apiVersion in code: use latest_charge, and keep the SDK and the webhook endpoint on the same API version.
  • cart.total.gross * 100 without Math.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_status alone: the PaymentIntent status tells a hold (requires_capture) from a capture (succeeded) and a debit that is still processing.
  • return_url built as 'http://' + host, or without the locale prefix.
  • Legacy CardElement + confirmCardPayment, payment_method_types (400 on Endive), ui_mode custom / hosted / embedded (renamed in Dahlia) or initCheckout (now initCheckoutElementsSdk).
  • 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 to order.verified.v1, order.fulfilled.v1, order.cancelled.v1, order.rejected.v1, order.refunded.v1, copy the signing secret.
Terminal window
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 → Integration
TWO_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: 15717462 credit never used up, 10200123 zero credit (intent declined), 13333334 fraud reject, 14553414 verification declined; < £5 000 skips verification, ≥ £30 000 needs open banking. NO: 922934479 credit never used up, 922422508 zero credit, 983772102 fraud, 920245404 verification 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_urls are 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.

lib/two.ts
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_subtotals is required. Lines built from the placed line totals (shipping, fees and promotions included) keep the Two order equal to placed.total.gross; if they do not add up, amounts throws rather than invoice another amount.
  • status is the credit decision, made again here (the intent was only a signal): not APPROVED → no redirect. state stays UNVERIFIED until 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.ts
import { 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}/renew first (re-runs the credit check; 400 if refused).
  • capture returns null, so SKILL.md’s stage handler leaves the record authorized; the webhook sets captured (and the invoice link) once Two reports FULFILLED, usually within minutes.
  • Partial refund: amount (gross), currency, tax_subtotals required; line_items, reason, refund_reference optional. 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.ts
import { 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 (poNumber above), buyer_reference (≤ 140 chars), buyer_project, buyer_department print on the invoice: collect them into cart meta before place. 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_recipient and preferred_distribution_method steer 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.…, with limit, offset, q) is no longer in Two’s docs (it still answered on 2026-10-06): use searchCompanies. The order example posts to sandbox.api.two.inc (does not resolve) and sends invoice_type (set by your contract now) and order-level tax_rate/discount_*; its lines do not add up (unit_price: '0.00', 0.1 labelled “VAT 25%”, three 200.00 lines for 400), tax_subtotals is missing and the GB company has a Norwegian address: use createTwoOrder. Its confirm call is optional; the Crystallize order comes from order.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 to PARTIAL; …/fulfillments/complete_partial cancels the rest (child orders). The webhook above then leaves the record authorized: sum GET …/fulfillments and updatePayment the captured amount yourself.
  • Edit before fulfilment with PUT /v1/order/{id}: lower amounts keep APPROVED; a higher one after verification is likely REJECTED (cancel, then a new cart and order).
  • disallow_portal_mutation: true on 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_TERMS with duration_days, beta; INSTALMENTS), billing accounts (terms).
  • Buyer fee: POST /v1/pricing/order/fee → buyer_fee_share; add it as an external item before place.
  • Trade accounts for one-click buyers (merchant_user_id, guide); credit limits GET /limits/v1/company/…; Norwegian branches GET /companies/v2/company/{canonical_id}/branches.
  • Merchant Portal Order Creator and POST /v1/order/{id}/notify for 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.ai or search.two.inc hosts; sandbox.api.two.inc.
  • tax_rate as a percentage, numbers instead of decimal strings, invoice_type in the body, no tax_subtotals.
  • Company fields next to company_canonical_id (that form forbids them), or storing the temporary lookup_id.
  • Setting the company or representative after place (silently lost), or without phone and email.
  • Treating merchant_cancel_order_url as a cancellation: the order stays UNVERIFIED and 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: /fulfilled and /refunds (spec: /fulfillments, /refund), original_order_id (root_order_id), merchant_short_name (merchant_id), due_in_days (terms), webhook gross_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, then claude 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/get for 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 install no.dnb.vipps.mt with the same account. Make a test user (phone + national identity number, the sales unit’s country) under For developers → Test users; OTP 0000/000000, PIN 1236. Push is flaky in MT: open Payments and pull to refresh.
  • Test data: POST /epayment/v1/test/payments/{reference}/approve approves without the app (MT only, once the test user has approved one payment by hand). Amounts in øre/cents: 151 insufficient funds, 182 refused, 186 expired card, 201 unknown result for 1 h; a refund of 124 fails. 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 from callback-mt-1/2.vipps.no (test) and callback-[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.
Terminal window
# 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, refunded
VIPPS_BASE_URL=https://apitest.vipps.no # production: https://api.vipps.no
VIPPS_CLIENT_ID=...
VIPPS_CLIENT_SECRET=...
VIPPS_SUBSCRIPTION_KEY=...
VIPPS_MSN=...
VIPPS_WEBHOOK_URL=https://shop.example/api/payments/vipps/webhook
VIPPS_WEBHOOK_SECRET=...
PUBLIC_URL=https://shop.example

Create 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 only
import type { PlacedCart } from "@/lib/crystallize-payments";
type Money = { currency: string; value: number }; // minor units
export 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:

app/api/checkout/pay/route.ts
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 paid
return Response.json({ error: "start-over" }, { status: 409 }); // spent: a new cart with the same items

Client

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.

lib/vipps-webhook.ts
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") ?? "");
}
app/api/payments/vipps/webhook/route.ts
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: redirectUrl is then a link to the QR image (IMAGE/SVG+XML; IMAGE/PNG with size 100–2000; TEXT/TARGETURL to 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 send customerInteraction: "CUSTOMER_PRESENT".
  • Native app switch: POST /api/checkout/pay?flow=app, then await Linking.openURL(redirectUrl) (React Native) — the operating system switches to Vipps or MobilePay; never a WebView. returnUrl brings the shopper back: a universal link (preferred) or, for apps without a website, your custom scheme with NATIVE_REDIRECT.
  • PUSH_MESSAGE skips the landing page and needs customer.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} (ecom covers ePayment; immutable once sent, 409), or the same object as receipt in the create call (order details), which Vipps recommends. Body: orderLines[] with name, id, totalAmount, totalAmountExcludingTax, totalTaxAmount, taxRate (25 % → 2500; taxPercentage 0–100 is deprecated), unitInfo (unitPrice, quantity as a string), discount, isShipping (your shipping external item), and bottomLine (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} with category (ORDER_CONFIRMATION, DELIVERY, RECEIPT, …) and orderDetailsUrl. Read both back with GET /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 its authorization_endpoint with client_id, response_type=code, scope=openid name email phoneNumber address, redirect_uri, PKCE S256 and a random state per login (≥ 8 chars, kept in an httpOnly cookie, compared on return — never a constant). Exchange the code at the token_endpoint (client_secret_basic by default), read GET /vipps-userinfo-api/userinfo with that token, and keep sub as the stable user id. Put the profile on the cart’s customer before place.

Going further

  • Vipps Checkout v3 — deprecated, sold to Kustom (May 2026): POST /checkout/v3/session returns token, checkoutFrontendUrl and pollingUrl for checkout.vipps.no/vippsCheckoutSDK.js. Its callbacks carry your callbackAuthorizationToken as Authorization (compare it timing-safe); capture, refund and cancel use the ePayment API with the same reference. It collects shipping itself — same warning as Express. (Checkout API)
  • Express / buy now from the product page: ePayment shipping.fixedOptions (or dynamicOptions, whose callback must check callbackAuthorizationToken) with address in profile.scope; needs sales unit approval; the choice comes back as shippingDetails. 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 before place. 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 and receipt required), merchantLegalLinks.
  • GET /epayment/v1/payments/{reference}/events is 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/…) with client_id/client_secret headers 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-Key of every call, so a second partial capture or refund is a “duplicate”.
  • gross * 100 unrounded (4040); NOK hardcoded (5040 on DK/FI sales units).
  • Receipt unitPrice in major units and taxPercentage = tax / gross: amounts are minor units, taxRate = % × 100.
  • Checkout callbacks without the callbackAuthorizationToken check; an unauthenticated Express shipping callback.
  • A constant state in 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 against req.url behind a proxy.
  • Rewriting, framing or effect-opening redirectUrl; trusting the cart cookie on the return page.
  • Shipping before capturedAmount confirms the capture.


Crystallize AI