A reliable online payment follows the same pattern with any provider: the server creates a payment with an idempotency key, the customer pays on the provider’s page, and the order becomes “paid” only after a signed webhook and a status check through the API. Order statuses are governed by a state machine in which a repeated event changes nothing and an impossible transition raises an alert. Scheduled reconciliation finds payments whose webhook got lost.
After this you will be able to
- Design a payment flow: payment creation, provider page, webhook, status confirmation through the API, fulfilment
- Describe order statuses as a state machine in which a repeated event changes nothing and an impossible transition raises an alert
- Create a payment with an idempotency key so that a lost response never leads to a double charge
- Test handling of duplicates, forged signatures, lost webhooks and refunds against a local mock server
Know beforehand
Verified with
Node.js 24 LTS (24.18)stripe-node 23.0 (API 2026-09-30.endive)TypeScript 7.0Vitest 5.0PostgreSQL 18.3 (PGlite 0.5.8)
Checked against docs: October 11, 2026
Contents
Who takes part and how a payment flows
This guide is for backend and fullstack developers who connect payment acceptance to a shop or a service. You should know what a webhook is and understand database transactions. The result is a payment flow in which an order is neither lost nor paid twice, plus Node.js 24 code verified by tests against a local mock server.
A payment involves the customer, your server, the payment provider and the banks — the issuer of the customer’s card and the acquiring bank. Your server never talks to the banks directly: it only works with the provider’s API. The example follows the Stripe documentation, which is detailed, public and has a full test mode. Kazakhstan providers — acquiring banks, Kaspi — name fields and methods differently, but the stages are the same; we covered the business side of a Kaspi integration separately.
The server creates an order
Amount and contents come from your database, not from the browser’s request. Status:
new.The server creates a payment at the provider
An API request with an idempotency key. The response contains the payment ID (a Checkout Session in Stripe) and the payment page URL. The order moves to
awaiting_payment.The customer pays
On the provider’s page, in an embedded form or via a link. Card details are entered at the provider and never pass through your server.
The provider sends a webhook
A signed HTTP notification to your endpoint, for example
checkout.session.completed. The server verifies the signature and quickly answers2xx.The server confirms the status through the API
The webhook body is a signal, not proof: the server retrieves the payment from the API and checks
payment_status, amount and currency.The order is fulfilled exactly once
Access to a service, a shipment, a receipt. Status:
fulfilled.
Redirect to the provider’s page
- What it looks like
- The customer goes to the payment page and returns to your
success_url - What matters
- The simplest path with fewer PCI DSS obligations; returning to the site is not a payment confirmation
Embedded form (widget)
- What it looks like
- The provider’s form inside your page
- What matters
- Card details still go to the provider; the outcome is the same webhook
Payment link
- What it looks like
- The link is sent in a messenger or by email
- What matters
- Handy for requests without a cart; pass the order number in metadata
The order state machine
An order status is not a string that any code may overwrite but a finite state machine: for each event it lists the statuses where the event is allowed and the status it leads to. Then “paid” can’t accidentally come out of “cancelled”, and a repeated event doesn’t corrupt data.
new
- Meaning
- Order created, no payment yet
- Can move to
awaiting_payment,cancelled
awaiting_payment
- Meaning
- An open payment session exists
- Can move to
paid,payment_failed,expired,cancelled
payment_failed, expired
- Meaning
- The attempt failed or expired
- Can move to
awaiting_payment(a new attempt),cancelled
paid
- Meaning
- Money confirmed through the API
- Can move to
fulfilled,partially_refunded,refunded
fulfilled
- Meaning
- Order delivered
- Can move to
partially_refunded,refunded
partially_refunded
- Meaning
- Part of the amount returned
- Can move to
refunded
refunded, cancelled
- Meaning
- Final statuses
- Can move to
- —
export type OrderStatus =
| 'new'
| 'awaiting_payment'
| 'payment_failed'
| 'expired'
| 'paid'
| 'fulfilled'
| 'partially_refunded'
| 'refunded'
| 'cancelled';
export type OrderEvent =
| { type: 'checkout_created' }
| { type: 'payment_succeeded' }
| { type: 'payment_failed' }
| { type: 'checkout_expired' }
| { type: 'fulfilled' }
| { type: 'refunded'; amountRefunded: number; amountTotal: number }
| { type: 'cancel' };
type Rule = {
from: OrderStatus[]; // where the event is allowed
done: OrderStatus[]; // where it has already been applied: a repeat is a no-op
to: OrderStatus | ((e: OrderEvent) => OrderStatus);
};
const RULES: Record<OrderEvent['type'], Rule> = {
checkout_created: {
from: ['new', 'payment_failed', 'expired'],
done: ['awaiting_payment'],
to: 'awaiting_payment',
},
payment_succeeded: {
from: ['awaiting_payment'],
done: ['paid', 'fulfilled', 'partially_refunded', 'refunded'],
to: 'paid',
},
payment_failed: { from: ['awaiting_payment'], done: ['payment_failed'], to: 'payment_failed' },
checkout_expired: { from: ['awaiting_payment'], done: ['expired'], to: 'expired' },
fulfilled: { from: ['paid'], done: ['fulfilled'], to: 'fulfilled' },
refunded: {
from: ['paid', 'fulfilled', 'partially_refunded'],
done: ['refunded'],
to: (e) =>
e.type === 'refunded' && e.amountRefunded >= e.amountTotal ? 'refunded' : 'partially_refunded',
},
cancel: {
from: ['new', 'awaiting_payment', 'payment_failed', 'expired'],
done: ['cancelled'],
to: 'cancelled',
},
};
export class IllegalTransition extends Error {
constructor(status: OrderStatus, event: OrderEvent['type']) {
super(`order in "${status}" cannot accept "${event}"`);
}
}
export function transition(status: OrderStatus, event: OrderEvent) {
const rule = RULES[event.type];
if (rule.done.includes(status)) return { status, changed: false };
if (!rule.from.includes(status)) throw new IllegalTransition(status, event.type);
const next = typeof rule.to === 'function' ? rule.to(event) : rule.to;
return { status: next, changed: next !== status };
}The key detail is the done field. Webhooks arrive more than once, and a “payment succeeded” event for an order that is already paid is normal, not an error: the function returns the same status with changed: false. IllegalTransition is thrown only for the truly impossible — for example, a payment for a cancelled order. A retry won’t fix that: a person has to refund the money or restore the order.
import type { OrderStatus } from './orders.ts';
export type Order = {
id: string;
title: string;
amount: number; // in the smallest currency unit: cents, tiyn
currency: string;
status: OrderStatus;
sessionId?: string; // current checkout attempt
checkoutKey?: string; // idempotency key of an attempt that has not finished yet
paymentIntentId?: string;
};
// In-memory storage keeps the example self-contained.
// In production these are tables: orders, processed webhook events, alerts.
export function createStore() {
const orders = new Map<string, Order>();
const processedEvents = new Set<string>();
const alerts: string[] = [];
const locks = new Map<string, Promise<unknown>>();
return {
orders,
processedEvents,
alerts,
findBySession: (id: string) => [...orders.values()].find((o) => o.sessionId === id),
findByPaymentIntent: (id: string) =>
[...orders.values()].find((o) => o.paymentIntentId === id),
// Runs fn for one order at a time: two webhooks for the same order never interleave.
// With a database this is SELECT ... FOR UPDATE or a conditional UPDATE.
async withLock<T>(orderId: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(orderId) ?? Promise.resolve();
const run = prev.then(fn, fn);
locks.set(orderId, run.catch(() => {}));
return run;
},
};
}
export type Store = ReturnType<typeof createStore>;Creating a payment with an idempotency key
The most dangerous moment is a network failure while creating a payment: the request reached the provider, the session was created, but the response was lost. If you simply repeat the request, the order gets two sessions and the customer can pay twice. The protection is an idempotency key: a unique string in the Idempotency-Key header. Stripe saves the status code and body of the first response for that key and returns the saved result on a retry.
- The client generates the key; Stripe suggests a V4 UUID or another random string, up to 255 characters
- Personal data — email, phone — must not be used in the key
- Retry parameters must match the original request, otherwise the API returns an error. So don’t compute values on the fly in the request body, such as an expiry time
- Keys may be pruned after 24 hours: this protects against network retries, not deduplication forever
GETandDELETEare idempotent by definition and don’t need a key
Hence the coding rule: the key is saved on the order before the API call and cleared only after the session ID is stored. If the process dies halfway, the next attempt picks up the same key. And if the order already has an open session, no new one is created — the customer gets the same link.
npm init -y && npm pkg set type=module
npm install stripe@23
npm install -D typescript@7 vitest@5 @types/node
npx tsc -p . # type check
npx vitest run # tests against the local mock{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src", "mock", "test"]
}import Stripe from 'stripe';
// Keys live only in server environment variables (sk_test_... in a sandbox).
// STRIPE_API_BASE is set by tests to point the SDK at a local mock;
// in production it is unset and the SDK talks to https://api.stripe.com.
export function createStripe(apiKey = process.env.STRIPE_SECRET_KEY) {
if (!apiKey) throw new Error('STRIPE_SECRET_KEY is not set');
const base = process.env.STRIPE_API_BASE ? new URL(process.env.STRIPE_API_BASE) : undefined;
return new Stripe(apiKey, {
maxNetworkRetries: 2, // the SDK retries network errors with an idempotency key
...(base && {
host: base.hostname,
port: Number(base.port),
protocol: base.protocol === 'http:' ? 'http' : 'https',
}),
});
}import { randomUUID } from 'node:crypto';
import type Stripe from 'stripe';
import { transition } from './orders.ts';
import type { Store } from './store.ts';
const SITE = process.env.SITE_URL ?? 'http://localhost:3000';
// Creates a payment for the order and returns the URL of the hosted payment page.
export async function startCheckout(stripe: Stripe, store: Store, orderId: string) {
const order = store.orders.get(orderId);
if (!order) throw new Error(`order ${orderId} not found`);
return store.withLock(order.id, async () => {
transition(order.status, { type: 'checkout_created' }); // throws if the order can't be paid
if (order.sessionId) {
const prev = await stripe.checkout.sessions.retrieve(order.sessionId);
if (prev.status === 'open' && prev.url) return prev.url; // reuse, don't create a second one
if (prev.payment_status === 'paid' || (prev.status === 'complete' && order.status === 'awaiting_payment')) {
throw new Error(`order ${order.id}: previous attempt is paid or still processing`);
}
}
// The key is saved BEFORE the call: if the response is lost, a retry reuses it
// and the provider returns the same session instead of creating a second one.
order.checkoutKey ??= randomUUID();
const session = await stripe.checkout.sessions.create(
{
mode: 'payment',
client_reference_id: order.id,
metadata: { orderId: order.id },
line_items: [
{
quantity: 1,
price_data: {
currency: order.currency,
unit_amount: order.amount, // the price comes from our database, never from the browser
product_data: { name: order.title },
},
},
],
success_url: `${SITE}/orders/${order.id}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${SITE}/orders/${order.id}`,
},
{ idempotencyKey: order.checkoutKey },
);
order.sessionId = session.id;
order.checkoutKey = undefined; // the attempt is recorded; the next one gets a new key
order.status = transition(order.status, { type: 'checkout_created' }).status;
if (!session.url) throw new Error('session has no url');
return session.url;
});
}The webhook is a signal, the API is the source of truth
The webhook handler does four things: verifies the signature over the raw request body, drops events it has already processed, confirms the status through the API and changes the order via the state machine. We start with the fulfilment function: the webhook, the success page and reconciliation all call it, so it must be safe under repeated and concurrent calls — Stripe’s fulfilment guide requires exactly that.
import type Stripe from 'stripe';
import { transition } from './orders.ts';
import type { Order, Store } from './store.ts';
export type Deliver = (order: Order) => Promise<void>;
// Safe to call many times, even concurrently, for the same session:
// from the webhook, from the success page and from reconciliation.
export async function fulfillCheckout(
stripe: Stripe,
store: Store,
deliver: Deliver,
sessionId: string,
) {
const order = store.findBySession(sessionId);
if (!order) {
store.alerts.push(`session ${sessionId}: no matching order`);
return;
}
await store.withLock(order.id, async () => {
// The webhook body is only a signal; the status is taken from the API.
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status !== 'paid') return; // e.g. a bank transfer still in progress
if (session.amount_total !== order.amount || session.currency !== order.currency) {
store.alerts.push(`order ${order.id}: paid ${session.amount_total} ${session.currency}`);
return;
}
order.paymentIntentId = typeof session.payment_intent === 'string' ? session.payment_intent : undefined;
order.status = transition(order.status, { type: 'payment_succeeded' }).status;
if (order.status === 'paid') {
await deliver(order); // grant access, create a shipment, send a receipt
order.status = transition(order.status, { type: 'fulfilled' }).status;
}
});
}import { createServer, type IncomingMessage } from 'node:http';
import type Stripe from 'stripe';
import { IllegalTransition, transition, type OrderEvent } from './orders.ts';
import type { Store } from './store.ts';
import { fulfillCheckout, type Deliver } from './fulfill.ts';
async function rawBody(req: IncomingMessage) {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks); // the signature is computed over these exact bytes
}
export function createWebhookServer(stripe: Stripe, store: Store, deliver: Deliver, secret: string) {
// Applies a status event to the order that owns the CURRENT session; older sessions are ignored.
const applyToSession = (sessionId: string, event: OrderEvent) => {
const order = store.findBySession(sessionId);
if (!order) return;
return store.withLock(order.id, async () => {
order.status = transition(order.status, event).status;
});
};
async function handle(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded':
return fulfillCheckout(stripe, store, deliver, event.data.object.id);
case 'checkout.session.async_payment_failed':
return applyToSession(event.data.object.id, { type: 'payment_failed' });
case 'checkout.session.expired':
return applyToSession(event.data.object.id, { type: 'checkout_expired' });
case 'charge.refunded': {
const charge = event.data.object;
const order = typeof charge.payment_intent === 'string' && store.findByPaymentIntent(charge.payment_intent);
if (!order) return;
return store.withLock(order.id, async () => {
const e = { type: 'refunded', amountRefunded: charge.amount_refunded, amountTotal: charge.amount } as const;
order.status = transition(order.status, e).status;
});
}
default:
return; // events we did not subscribe to
}
}
return createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks/stripe') {
res.writeHead(404).end();
return;
}
let event: Stripe.Event;
try {
const body = await rawBody(req);
event = stripe.webhooks.constructEvent(body, req.headers['stripe-signature'] ?? '', secret);
} catch {
res.writeHead(400).end('bad signature'); // forged, modified or too old
return;
}
if (store.processedEvents.has(event.id)) {
res.writeHead(200).end('duplicate');
return;
}
try {
await handle(event);
store.processedEvents.add(event.id); // only after successful processing
res.writeHead(200).end('ok');
} catch (err) {
if (err instanceof IllegalTransition) {
// e.g. money arrived for a cancelled order: a retry won't help, a human will
store.alerts.push(`${event.id}: ${err.message}`);
store.processedEvents.add(event.id);
res.writeHead(200).end('needs review');
return;
}
res.writeHead(500).end(); // temporary failure: the provider will retry the delivery
}
});
}import { createStore } from './store.ts';
import { createStripe } from './stripe.ts';
import { createWebhookServer } from './webhook.ts';
const secret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_... from the endpoint settings or the Stripe CLI
if (!secret) throw new Error('STRIPE_WEBHOOK_SECRET is not set');
const store = createStore();
const deliver = async (order: { id: string }) => console.log(`fulfil ${order.id}`);
const port = Number(process.env.PORT ?? 4242);
createWebhookServer(createStripe(), store, deliver, secret).listen(port, () =>
console.log(`webhooks: http://localhost:${port}/webhooks/stripe`),
);- The
Stripe-Signatureheader is an HMAC-SHA256 of the “timestamp.body” string keyed with the endpoint secretwhsec_…. It is checked over the raw bytes: if a framework has already parsed the JSON, the signature won’t match - The timestamp in the signature protects against replaying an intercepted request: Stripe libraries allow a 5-minute difference by default, so keep the server clock synced via NTP
- Answer
2xxquickly, before heavy logic. Forcheckout.session.completedStripe waits up to 10 seconds for your response before sending the customer tosuccess_url - Event order is not guaranteed and duplicates happen: deduplicate by the event
id, not by thecreatedfield - After an error response Stripe retries delivery: in live mode for up to three days with exponential backoff, in a sandbox three times over a few hours
In the example duplicates are absorbed at two levels: a log of processed event.id values and the state machine with a per-order lock. During verification we disabled the log and the tests still passed, because a repeat hits done. The log saves API calls and protects side effects the state machine doesn’t cover. Without the lock, though, three concurrent webhooks fulfilled the order three times — and the test caught it.
The in-memory store is for the example only. In a real system the event log and the status transition run in one database transaction: the primary key rejects a repeated delivery, and a conditional UPDATE moves the order only from the expected status.
CREATE TABLE orders (
id text PRIMARY KEY,
amount bigint NOT NULL CHECK (amount > 0), -- smallest currency unit
currency text NOT NULL,
status text NOT NULL DEFAULT 'new',
session_id text UNIQUE,
checkout_key uuid,
payment_intent_id text UNIQUE
);
CREATE TABLE webhook_events (
event_id text PRIMARY KEY, -- a repeated delivery hits the primary key
received_at timestamptz NOT NULL DEFAULT now()
);BEGIN;
-- INSERT 0 0 means "already processed": commit and answer 200
INSERT INTO webhook_events (event_id) VALUES ('evt_test_7') ON CONFLICT DO NOTHING;
-- the transition happens only from the expected status; 0 rows = duplicate or illegal
UPDATE orders SET status = 'paid'
WHERE session_id = 'cs_test_3' AND status = 'awaiting_payment'
RETURNING id, status;
COMMIT;Refunds, partial payments and reconciliation
A refund is a separate operation at the provider with its own lifecycle. You can issue several partial refunds against one payment, but not more than the original amount in total. A refund isn’t instant and may fail altogether: then Stripe sends a refund.failed event, and the customer has to be repaid some other way.
import { randomUUID } from 'node:crypto';
import type Stripe from 'stripe';
import type { Store } from './store.ts';
// Starts a refund. The order status changes later, from the charge.refunded webhook.
export async function refundOrder(
stripe: Stripe,
store: Store,
orderId: string,
amount: number,
key: string = randomUUID(), // pass the same key when retrying the same refund
) {
const order = store.orders.get(orderId);
if (!order?.paymentIntentId) throw new Error(`order ${orderId} has no payment`);
return stripe.refunds.create(
{ payment_intent: order.paymentIntentId, amount },
{ idempotencyKey: key },
);
}The order status is changed not by the refunds.create call but by the charge.refunded webhook: its amount_refunded field is the cumulative total of all refunds. That makes a repeated delivery of the same event safe: the state machine compares the cumulative amount with the payment amount and sets partially_refunded or refunded.
A partial payment — say, a deposit and the balance — means several payments for one order. Create a separate record for each with its own status and key, and treat the order as paid when the confirmed payments add up to the order total. Store amounts everywhere as integers in the smallest currency unit — cents, tiyn.
Reconciliation is a regular comparison of the provider’s data with yours. It catches what webhooks missed: a lost delivery, a handler failure, money without an order.
import type Stripe from 'stripe';
import { fulfillCheckout, type Deliver } from './fulfill.ts';
import type { Store } from './store.ts';
// Runs on a schedule: compares the provider's sessions with our orders.
export async function reconcile(stripe: Stripe, store: Store, deliver: Deliver, sinceUnix: number) {
const report = { checked: 0, recovered: [] as string[], unknown: [] as string[] };
for await (const session of stripe.checkout.sessions.list({ created: { gte: sinceUnix }, limit: 100 })) {
report.checked++;
if (session.payment_status !== 'paid') continue;
const order = store.findBySession(session.id);
if (!order) {
report.unknown.push(session.id); // money without an order: investigate by hand
} else if (order.status === 'awaiting_payment') {
await fulfillCheckout(stripe, store, deliver, session.id); // the webhook was lost
report.recovered.push(order.id);
}
}
return report;
}Session paid, order still awaiting payment
- What to do
- The webhook was lost: call the same fulfilment function — which is what
reconciledoes
Session paid, no order
- What to do
- Investigate by hand: money arrived but there is nothing to deliver
Order paid, no payment at the provider
- What to do
- Stop fulfilment and look for a code bug or tampered data (not implemented in the example — see the exercise)
Amount or currency mismatch
- What to do
- Don’t fulfil, raise an alert —
fulfillCheckoutchecks this
Reconciling money against payouts to the bank account is a separate task: it is done from the provider’s financial reports together with accounting.
Test mode, a mock server and pre-launch checks
Stripe’s test mode is a sandbox: separate sk_test_… keys, test cards, no money moves. Card 4242 4242 4242 4242 with any future expiry and any CVC succeeds, 4000 0000 0000 0002 is declined with card_declined. In server code the documentation recommends test PaymentMethod values such as pm_card_visa rather than card numbers. The Stripe agreement prohibits testing live mode with real cards.
For local webhook debugging there is the Stripe CLI: stripe listen --forward-to localhost:4242/webhooks/stripe forwards sandbox events to your server and prints the whsec_… secret for signature verification.
The automated tests in this guide don’t even touch the sandbox: the SDK points at a local mock server that imitates three Stripe endpoints, idempotency keys, a lost response and signed webhooks. The mock is a test double, not Stripe: it models only the fields our code reads.
// A tiny local imitation of a few Stripe endpoints and webhook deliveries.
// It is a test double, not Stripe: only the fields our code reads are modelled.
import { createHmac } from 'node:crypto';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
type Session = {
id: string; object: 'checkout.session'; created: number; status: 'open' | 'complete' | 'expired';
payment_status: 'paid' | 'unpaid'; amount_total: number; currency: string; url: string | null;
client_reference_id: string | null; metadata: Record<string, string>; payment_intent: string | null;
};
type Charge = { id: string; object: 'charge'; amount: number; amount_refunded: number; payment_intent: string };
type Event = { id: string; object: 'event'; type: string; created: number; livemode: false; data: { object: unknown } };
export async function startFakeStripe(webhookSecret: string) {
let webhookUrl = ''; // set by the test once our webhook server is listening
const sessions = new Map<string, Session>();
const charges = new Map<string, Charge>(); // by payment_intent
const idempotency = new Map<string, { body: string; status: number; response: string }>();
const outbox: Event[] = [];
const stats = { sessionsCreated: 0 };
let seq = 0;
let dropResponses = 0; // the request is processed, but the answer never reaches the client
const id = (prefix: string) => `${prefix}_test_${++seq}`;
const now = () => Math.floor(Date.now() / 1000);
const emit = (type: string, object: unknown) =>
outbox.push({ id: id('evt'), object: 'event', type, created: now(), livemode: false, data: { object: structuredClone(object) } });
const error = (status: number, message: string) => ({ status, body: { error: { type: 'invalid_request_error', message } } });
const addCharge = (s: Session) =>
charges.set(s.payment_intent!, { id: id('ch'), object: 'charge', amount: s.amount_total, amount_refunded: 0, payment_intent: s.payment_intent! });
function route(method: string, path: string, query: URLSearchParams, form: URLSearchParams) {
if (method === 'POST' && path === '/v1/checkout/sessions') {
const s: Session = {
id: id('cs'), object: 'checkout.session', created: now(), status: 'open', payment_status: 'unpaid',
amount_total: Number(form.get('line_items[0][price_data][unit_amount]')) * Number(form.get('line_items[0][quantity]')),
currency: form.get('line_items[0][price_data][currency]') ?? '', url: null,
client_reference_id: form.get('client_reference_id'), metadata: { orderId: form.get('metadata[orderId]') ?? '' },
payment_intent: null,
};
s.url = `https://checkout.example.test/pay/${s.id}`;
sessions.set(s.id, s);
stats.sessionsCreated++;
return { status: 200, body: s };
}
if (method === 'GET' && path === '/v1/checkout/sessions') {
const gte = Number(query.get('created[gte]') ?? 0);
const data = [...sessions.values()].filter((s) => s.created >= gte);
return { status: 200, body: { object: 'list', url: path, has_more: false, data } };
}
const m = path.match(/^\/v1\/checkout\/sessions\/(\w+)$/);
if (method === 'GET' && m) {
const s = sessions.get(m[1]);
return s ? { status: 200, body: s } : error(404, `No such checkout.session: '${m[1]}'`);
}
if (method === 'POST' && path === '/v1/refunds') {
const charge = charges.get(form.get('payment_intent') ?? '');
if (!charge) return error(404, 'No such payment_intent');
const amount = Number(form.get('amount') ?? charge.amount - charge.amount_refunded);
if (charge.amount_refunded + amount > charge.amount) return error(400, 'Refund exceeds the charge amount');
charge.amount_refunded += amount;
emit('charge.refunded', charge);
return { status: 200, body: { id: id('re'), object: 'refund', amount, payment_intent: charge.payment_intent, status: 'succeeded' } };
}
return error(404, `Unrecognized request URL (${method} ${path})`);
}
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const raw = Buffer.concat(chunks).toString();
const url = new URL(req.url ?? '/', 'http://localhost');
const key = req.headers['idempotency-key'];
const send = (status: number, response: string) =>
res.writeHead(status, { 'content-type': 'application/json' }).end(response);
if (req.method === 'POST' && typeof key === 'string') {
let saved = idempotency.get(key);
if (saved && saved.body !== raw) {
const e = error(400, 'Keys for idempotent requests can only be used with the same parameters');
return send(e.status, JSON.stringify(e.body));
}
if (!saved) {
const r = route(req.method, url.pathname, url.searchParams, new URLSearchParams(raw));
saved = { body: raw, status: r.status, response: JSON.stringify(r.body) };
idempotency.set(key, saved);
}
if (dropResponses > 0) {
dropResponses--;
return req.socket.destroy();
}
return send(saved.status, saved.response); // a repeat gets the first result
}
const r = route(req.method ?? 'GET', url.pathname, url.searchParams, new URLSearchParams(raw));
send(r.status, JSON.stringify(r.body));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
// Signs the payload as described in Stripe docs: HMAC-SHA256 of `${t}.${body}`.
const sign = (payload: string, secret: string, t: number) =>
`t=${t},v1=${createHmac('sha256', secret).update(`${t}.${payload}`).digest('hex')}`;
async function deliver(event: Event, opts: { secret?: string; t?: number } = {}) {
const payload = JSON.stringify(event);
const signature = sign(payload, opts.secret ?? webhookSecret, opts.t ?? now());
const r = await fetch(webhookUrl, {
method: 'POST',
headers: { 'content-type': 'application/json', 'stripe-signature': signature },
body: payload,
});
return r.status;
}
return {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
setWebhookUrl: (url: string) => void (webhookUrl = url),
sessions,
stats,
outbox,
/** The customer pays. async: true models a bank transfer that settles later. */
pay(sessionId: string, { async = false } = {}) {
const s = sessions.get(sessionId)!;
s.status = 'complete';
s.url = null;
s.payment_intent = id('pi');
if (!async) {
s.payment_status = 'paid';
addCharge(s);
}
emit('checkout.session.completed', s);
},
settleAsync(sessionId: string, ok: boolean) {
const s = sessions.get(sessionId)!;
if (ok) {
s.payment_status = 'paid';
addCharge(s);
}
emit(ok ? 'checkout.session.async_payment_succeeded' : 'checkout.session.async_payment_failed', s);
},
expire(sessionId: string) {
const s = sessions.get(sessionId)!;
s.status = 'expired';
s.url = null;
emit('checkout.session.expired', s);
},
deliver,
/** Delivers queued events `times` times each: duplicates are normal for webhooks. */
async flush(times = 1) {
const statuses: number[] = [];
for (const e of outbox.splice(0)) for (let i = 0; i < times; i++) statuses.push(await deliver(e));
return statuses;
},
dropNextResponses: (n: number) => void (dropResponses = n),
dropOutbox: () => outbox.splice(0).length, // simulates lost deliveries
close: () => new Promise<void>((r) => server.close(() => r())),
};
}import type { AddressInfo } from 'node:net';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { startFakeStripe } from '../mock/fake-stripe.ts';
import { startCheckout } from '../src/checkout.ts';
import { reconcile } from '../src/reconcile.ts';
import { refundOrder } from '../src/refund.ts';
import { createStore, type Store } from '../src/store.ts';
import { createStripe } from '../src/stripe.ts';
import { createWebhookServer } from '../src/webhook.ts';
const SECRET = 'whsec_test_local_only'; // a made-up value: no real keys in tests
let store: Store;
let fake: Awaited<ReturnType<typeof startFakeStripe>>;
let server: ReturnType<typeof createWebhookServer>;
const deliver = vi.fn(() => new Promise<void>((r) => setTimeout(r, 20))); // a slow side effect
beforeEach(async () => {
store = createStore();
store.orders.set('A-1001', { id: 'A-1001', title: 'Consultation', amount: 1500000, currency: 'kzt', status: 'new' });
deliver.mockClear();
fake = await startFakeStripe(SECRET);
process.env.STRIPE_API_BASE = fake.url; // the SDK now talks to the local fake
server = createWebhookServer(stripe(), store, deliver, SECRET);
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
fake.setWebhookUrl(`http://127.0.0.1:${(server.address() as AddressInfo).port}/webhooks/stripe`);
});
afterEach(async () => {
await fake.close();
await new Promise((r) => server.close(r));
});
const stripe = () => createStripe('sk_test_local_only');
const order = () => store.orders.get('A-1001')!;
it('creates one session even when the response is lost (idempotency key)', async () => {
fake.dropNextResponses(1); // the SDK retries with the same Idempotency-Key
const url = await startCheckout(stripe(), store, 'A-1001');
expect(url).toMatch(/^https:\/\/checkout\.example\.test\/pay\/cs_test_/);
expect(fake.stats.sessionsCreated).toBe(1);
expect(order().status).toBe('awaiting_payment');
});
it('reuses the saved key when our own retry happens later', async () => {
fake.dropNextResponses(10); // all SDK retries fail
await expect(startCheckout(stripe(), store, 'A-1001')).rejects.toThrow();
expect(order().checkoutKey).toBeDefined();
fake.dropNextResponses(0);
await startCheckout(stripe(), store, 'A-1001'); // e.g. the customer presses "Pay" again
expect(fake.stats.sessionsCreated).toBe(1);
});
it('returns the open session instead of creating a second one', async () => {
const first = await startCheckout(stripe(), store, 'A-1001');
const second = await startCheckout(stripe(), store, 'A-1001');
expect(second).toBe(first);
expect(fake.stats.sessionsCreated).toBe(1);
});
it('fulfils once despite duplicate and concurrent webhooks', async () => {
await startCheckout(stripe(), store, 'A-1001');
fake.pay(order().sessionId!);
const event = fake.outbox[0];
const statuses = await Promise.all([fake.deliver(event), fake.deliver(event), fake.deliver(event)]);
expect(statuses.every((s) => s === 200)).toBe(true);
expect(deliver).toHaveBeenCalledTimes(1);
expect(order().status).toBe('fulfilled');
});
it('rejects a forged or stale signature', async () => {
await startCheckout(stripe(), store, 'A-1001');
fake.pay(order().sessionId!);
const event = fake.outbox[0];
expect(await fake.deliver(event, { secret: 'whsec_attacker' })).toBe(400);
expect(await fake.deliver(event, { t: Math.floor(Date.now() / 1000) - 3600 })).toBe(400);
expect(order().status).toBe('awaiting_payment');
expect(deliver).not.toHaveBeenCalled();
});
it('waits for a delayed payment method', async () => {
await startCheckout(stripe(), store, 'A-1001');
fake.pay(order().sessionId!, { async: true });
await fake.flush();
expect(order().status).toBe('awaiting_payment'); // completed, but payment_status is unpaid
fake.settleAsync(order().sessionId!, true);
await fake.flush();
expect(order().status).toBe('fulfilled');
});
it('lets the customer retry after the session expired', async () => {
await startCheckout(stripe(), store, 'A-1001');
const firstSession = order().sessionId!;
fake.expire(firstSession);
await fake.flush();
expect(order().status).toBe('expired');
await startCheckout(stripe(), store, 'A-1001');
expect(order().sessionId).not.toBe(firstSession);
expect(fake.stats.sessionsCreated).toBe(2);
});
it('recovers a payment whose webhook was lost (reconciliation)', async () => {
const since = Math.floor(Date.now() / 1000) - 60;
await startCheckout(stripe(), store, 'A-1001');
fake.pay(order().sessionId!);
fake.dropOutbox();
expect(order().status).toBe('awaiting_payment');
const report = await reconcile(stripe(), store, deliver, since);
expect(report.recovered).toEqual(['A-1001']);
expect(order().status).toBe('fulfilled');
});
it('tracks partial and full refunds from webhooks', async () => {
await startCheckout(stripe(), store, 'A-1001');
fake.pay(order().sessionId!);
await fake.flush();
await refundOrder(stripe(), store, 'A-1001', 500000);
await fake.flush(2); // each refund event delivered twice
expect(order().status).toBe('partially_refunded');
await refundOrder(stripe(), store, 'A-1001', 1000000);
await fake.flush();
expect(order().status).toBe('refunded');
await expect(refundOrder(stripe(), store, 'A-1001', 1)).rejects.toThrow(/exceeds/);
});
it('flags money that arrived for a cancelled order', async () => {
await startCheckout(stripe(), store, 'A-1001');
fake.pay(order().sessionId!);
order().status = 'cancelled'; // e.g. a manager cancelled it while the customer was paying
expect(await fake.flush()).toEqual([200]);
expect(store.alerts[0]).toMatch(/cannot accept "payment_succeeded"/);
expect(deliver).not.toHaveBeenCalled();
});$ npx vitest run --reporter=verbose
✓ test/orders.test.ts > order state machine > walks the happy path
✓ test/orders.test.ts > order state machine > treats a repeated event as a no-op, not as an error
✓ test/orders.test.ts > order state machine > rejects impossible transitions
✓ test/orders.test.ts > order state machine > allows a new attempt after an expired or failed one
✓ test/orders.test.ts > order state machine > distinguishes partial and full refunds by the cumulative amount
✓ test/flow.test.ts > creates one session even when the response is lost (idempotency key)
✓ test/flow.test.ts > reuses the saved key when our own retry happens later
✓ test/flow.test.ts > returns the open session instead of creating a second one
✓ test/flow.test.ts > fulfils once despite duplicate and concurrent webhooks
✓ test/flow.test.ts > rejects a forged or stale signature
✓ test/flow.test.ts > waits for a delayed payment method
✓ test/flow.test.ts > lets the customer retry after the session expired
✓ test/flow.test.ts > recovers a payment whose webhook was lost (reconciliation)
✓ test/flow.test.ts > tracks partial and full refunds from webhooks
✓ test/flow.test.ts > flags money that arrived for a cancelled order
Test Files 2 passed (2)
Tests 15 passed (15)- Payment amount and currency come from the database, not from the browser’s request
- The idempotency key is saved before the API call
- The webhook signature is checked over the raw body; the secret lives in environment variables
- Repeated and concurrent delivery of an event doesn’t fulfil the order twice
- Delayed payment:
completedwithpayment_status: unpaiddoesn’t fulfil the order - After an expired or failed attempt the customer can pay again
- Reconciliation runs on a schedule and reports mismatches
- Refunds, including failed ones, are reflected in status and notifications
- Live keys are issued to the company account and never reach the repository
PCI DSS is the payment card industry’s security standard, mandatory for everyone who stores, processes or transmits card data. If card numbers reach your server, the obligations become extensive: Stripe mentions more than 300 security controls. So accept cards only on the provider’s page or form, never store the card number or CVC, and never write them to logs. What you can store is what the API returns: card type, last four digits, expiry date.
Common mistakes and exercises
Order marked “paid” on return to success_url
- Consequence
- Fulfilment without money, or a paid order gets lost
- Do this instead
- A webhook plus a status request to the API
A new idempotency key on every retry
- Consequence
- Two sessions and a double charge
- Do this instead
- Save the key before the call and reuse it
Signature check after JSON.parse
- Consequence
- The signature never matches, and the check gets disabled
- Do this instead
- Verify over the raw body bytes
Heavy work before answering the webhook
- Consequence
- Timeouts and repeated deliveries
- Do this instead
- A quick
2xx, processing in a queue
A status UPDATE without a condition
- Consequence
- An old or repeated event overwrites the status
- Do this instead
- Transition only from the expected status
Amounts as floating-point numbers
- Consequence
- Rounding errors
- Do this instead
- Integers in the smallest currency unit
Next step — general integration resilience: retries with backoff, timeouts and handling 429 responses are covered in the article on external API errors.
How this was verified5 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.
