How to buildOctober 11, 202612 min read
APIs and integrationsStep 7 of 8

Payment integration: flow, statuses and webhooks

In short

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.

How-toLevel: IntermediatePayment IntegrationsPractice: ~2–4 h

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

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
  1. 01Who takes part and how a payment flows
  2. 02The order state machine
  3. 03Creating a payment with an idempotency key
  4. 04The webhook is a signal, the API is the source of truth
  5. 05Refunds, partial payments and reconciliation
  6. 06Test mode, a mock server and pre-launch checks
  7. 07Common mistakes and exercises

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.

Order on the serverPayment at the providerCustomer paysWebhook and checkOrder fulfilled
Money is confirmed not by the customer’s browser but by the “webhook + API request” pair.
  1. The server creates an order

    Amount and contents come from your database, not from the browser’s request. Status: new.

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

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

  4. The provider sends a webhook

    A signed HTTP notification to your endpoint, for example checkout.session.completed. The server verifies the signature and quickly answers 2xx.

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

  6. 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
—
src/orders.ts
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 };
}
Transition rules: from — where an event is allowed, done — where it has already been applied

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.

src/store.ts
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>;
In-memory storage and a per-order lock — stand-ins for database tables and row locks

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
  • GET and DELETE are 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.

bash
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
A temporary project: the Stripe SDK, TypeScript for type checking and Vitest for tests
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src", "mock", "test"]
}
Type checking only: Node 24 runs TypeScript itself, without a build step
src/stripe.ts
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',
    }),
  });
}
The SDK client: the key comes from the environment; the base URL is overridden only in tests
src/checkout.ts
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;
  });
}
Creating a payment: one open session per order, the idempotency key is saved before the call

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.

src/fulfill.ts
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;
    }
  });
}
Fulfilment: the status comes from the API, the amount is checked, the side effect runs once
src/webhook.ts
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
    }
  });
}
Webhook endpoint on node:http: signature, deduplication, dispatch by event type
src/main.ts
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`),
);
Entry point: node src/main.ts with STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET set
  • The Stripe-Signature header is an HMAC-SHA256 of the “timestamp.body” string keyed with the endpoint secret whsec_…. 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 2xx quickly, before heavy logic. For checkout.session.completed Stripe waits up to 10 seconds for your response before sending the customer to success_url
  • Event order is not guaranteed and duplicates happen: deduplicate by the event id, not by the created field
  • 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.

schema.sql
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()
);
Production version: an orders table and a processed events log
event.sql
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;
One webhook — one transaction. On a repeated delivery of the same event both INSERT and UPDATE affect 0 rows

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.

src/refund.ts
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 },
  );
}
A refund is started by a call; the order status changes from the webhook

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.

src/reconcile.ts
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;
}
Reconciliation: auto-pagination over sessions created since a given moment

Session paid, order still awaiting payment

What to do
The webhook was lost: call the same fulfilment function — which is what reconcile does

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 — fulfillCheckout checks 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.

mock/fake-stripe.ts
// 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())),
  };
}
A local test double: three endpoints, idempotency, a lost response and signed webhooks
test/flow.test.ts
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();
});
End-to-end scenarios against the mock: duplicates, forged signature, expiry, lost webhook, refunds
terminal
$ 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)
Real output of the check (timings removed); test/orders.test.ts is in the same project
  • 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: completed with payment_status: unpaid doesn’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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.