Надёжная онлайн-оплата у любого провайдера строится по одной схеме: сервер создаёт платёж с ключом идемпотентности, покупатель платит на странице провайдера, а заказ становится «оплаченным» только после подписанного вебхука и проверки статуса запросом к API. Статусы заказа задаёт машина состояний: повторное событие ничего не меняет, невозможный переход поднимает тревогу. Сверка по расписанию находит платежи, вебхук о которых потерялся.
После материала вы сможете
- спроектировать сценарий оплаты: создание платежа, страница провайдера, вебхук, подтверждение статуса запросом к API, выдача заказа
- описать статусы заказа машиной состояний, в которой повторное событие ничего не меняет, а невозможный переход поднимает тревогу
- создавать платёж с ключом идемпотентности так, чтобы потерянный ответ не приводил к двойной оплате
- проверить обработку дублей, поддельной подписи, потерянного вебхука и возвратов тестами против локального мок-сервера
Что знать заранее
Проверено на
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)
Сверено с документацией: 11 октября 2026 г.
Содержание
Кто участвует и как проходит оплата
Руководство для backend- и fullstack-разработчиков, которые подключают приём оплаты к магазину или сервису. Нужно понимать, что такое вебхук, и знать основы транзакций в базе данных. Результат — схема оплаты, в которой заказ не теряется и не оплачивается дважды, и код на Node.js 24, проверенный тестами против локального мок-сервера.
В оплате участвуют покупатель, ваш сервер, платёжный провайдер и банки — эмитент карты покупателя и банк-эквайер. С банками ваш сервер напрямую не общается: он работает только с API провайдера. Пример ниже построен по документации Stripe — у неё подробная открытая документация и полноценный тестовый режим. У казахстанских провайдеров — банков-эквайеров, Kaspi — поля и методы называются иначе, но этапы те же; о бизнес-стороне интеграции с Kaspi мы писали отдельно.
Сервер создаёт заказ
Сумма и состав берутся из вашей базы, а не из запроса браузера. Статус —
new.Сервер создаёт платёж у провайдера
Запрос к API с ключом идемпотентности. В ответ — идентификатор платежа (в Stripe это Checkout Session) и адрес страницы оплаты. Заказ переходит в
awaiting_payment.Покупатель платит
На странице провайдера, во встроенной форме или по ссылке. Данные карты вводятся у провайдера и не проходят через ваш сервер.
Провайдер присылает вебхук
Подписанное HTTP-уведомление на ваш адрес, например
checkout.session.completed. Сервер проверяет подпись и быстро отвечает2xx.Сервер подтверждает статус запросом к API
Тело вебхука — сигнал, а не доказательство: сервер запрашивает платёж у API и проверяет
payment_status, сумму и валюту.Заказ выдаётся ровно один раз
Доступ к услуге, отгрузка, чек. Статус —
fulfilled.
Редирект на страницу провайдера
- Как выглядит
- Покупатель уходит на страницу оплаты и возвращается на ваш
success_url - Что важно
- Самый простой путь и меньше обязательств по PCI DSS; возврат на сайт — не подтверждение оплаты
Встроенная форма (виджет)
- Как выглядит
- Форма провайдера внутри вашей страницы
- Что важно
- Данные карты всё равно уходят провайдеру; итог — тот же вебхук
Ссылка на оплату
- Как выглядит
- Ссылку отправляют в мессенджере или по почте
- Что важно
- Удобно для заявок без корзины; номер заказа передайте в метаданных
Машина состояний заказа
Статус заказа — не строка, которую любой код может перезаписать, а конечный автомат: для каждого события перечислено, из каких статусов оно допустимо и в какой статус ведёт. Тогда «оплачен» нельзя случайно получить из «отменён», а повторное событие не портит данные.
new
- Что значит
- Заказ создан, платежа ещё нет
- Куда может перейти
awaiting_payment,cancelled
awaiting_payment
- Что значит
- Есть открытая сессия оплаты
- Куда может перейти
paid,payment_failed,expired,cancelled
payment_failed, expired
- Что значит
- Попытка не удалась или истекла
- Куда может перейти
awaiting_payment(новая попытка),cancelled
paid
- Что значит
- Деньги подтверждены запросом к API
- Куда может перейти
fulfilled,partially_refunded,refunded
fulfilled
- Что значит
- Заказ выдан
- Куда может перейти
partially_refunded,refunded
partially_refunded
- Что значит
- Возвращена часть суммы
- Куда может перейти
refunded
refunded, cancelled
- Что значит
- Конечные статусы
- Куда может перейти
- —
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 };
}Ключевая деталь — поле done. Вебхуки приходят повторно, и событие «оплата прошла» для уже оплаченного заказа — норма, а не ошибка: функция возвращает тот же статус с changed: false. Исключение IllegalTransition бросается только для действительно невозможного — например, оплаты отменённого заказа. Такой случай повтором не исправить: нужен человек, который вернёт деньги или восстановит заказ.
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>;Создание платежа и ключ идемпотентности
Самый опасный момент — сетевой сбой при создании платежа: запрос дошёл до провайдера, сессия создана, а ответ потерялся. Если просто повторить запрос, у заказа появятся две сессии, и покупатель сможет заплатить дважды. Защита — ключ идемпотентности (idempotency key): уникальная строка в заголовке Idempotency-Key. Stripe сохраняет код и тело первого ответа на этот ключ и на повтор возвращает сохранённый результат.
- Ключ генерирует клиент; Stripe советует UUID v4 или другую случайную строку, длина — до 255 символов
- Персональные данные — e-mail, телефон — в ключ класть нельзя
- Параметры повтора должны совпадать с первым запросом, иначе API вернёт ошибку. Поэтому не вычисляйте в теле запроса значения «на лету», например время истечения
- Ключи могут удаляться через 24 часа: это защита от сетевых повторов, а не вечная дедупликация
GETиDELETEидемпотентны по определению, ключ им не нужен
Отсюда правило кода: ключ сохраняется в заказе до вызова API и стирается только после того, как идентификатор сессии записан. Если процесс упал посередине, следующая попытка возьмёт тот же ключ. А если у заказа уже есть открытая сессия, новая не создаётся — покупатель получает ту же ссылку.
npm init -y && npm pkg set type=module
npm install stripe@23
npm install -D typescript@7 vitest@5 @types/node
npx tsc -p . # проверка типов
npx vitest run # тесты против локального мока{
"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;
});
}Вебхук — сигнал, API — источник истины
Обработчик вебхука делает четыре вещи: проверяет подпись по сырому телу запроса, отбрасывает уже обработанные события, подтверждает статус запросом к API и меняет заказ через машину состояний. Начнём с функции выдачи: её вызывают вебхук, страница успеха и сверка, поэтому она обязана быть безопасной при повторных и одновременных вызовах — этого прямо требует руководство Stripe по выдаче заказов.
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`),
);- Подпись
Stripe-Signature— HMAC-SHA256 от строки «время.тело» с секретом конечной точкиwhsec_…. Проверяется по сырым байтам: если фреймворк уже разобрал JSON, подпись не сойдётся - Время в подписи защищает от повторной отправки перехваченного запроса: библиотеки Stripe по умолчанию допускают расхождение 5 минут, поэтому часы сервера синхронизируют по NTP
- Ответ
2xx— быстро, до тяжёлой логики. Дляcheckout.session.completedStripe ждёт ответа до 10 секунд, прежде чем вернуть покупателя наsuccess_url - Порядок событий не гарантирован, повторы возможны: дедупликация — по
idсобытия, а не по полюcreated - На ответ с ошибкой Stripe повторяет доставку: в рабочем режиме до трёх суток с экспоненциальной паузой, в песочнице — три раза за несколько часов
В примере повторы гасятся на двух уровнях: журнал обработанных event.id и машина состояний с блокировкой на заказ. При проверке мы отключали журнал — тесты всё равно проходили, потому что повтор упирается в done. Журнал экономит запросы к API и защищает побочные действия, которые машина состояний не покрывает. А без блокировки три одновременных вебхука выдали заказ трижды — тест это поймал.
Хранилище в памяти — только для примера. В рабочей системе журнал событий и переход статуса выполняются в одной транзакции базы: первичный ключ отсекает повторную доставку, а условный UPDATE переводит заказ только из ожидаемого статуса.
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;Возвраты, частичные оплаты и сверка
Возврат — отдельная операция у провайдера со своим жизненным циклом. По одному платежу можно сделать несколько частичных возвратов, но в сумме не больше исходного платежа. Возврат проходит не мгновенно и может не пройти вовсе: тогда Stripe присылает событие refund.failed, и покупателю нужно вернуть деньги другим способом.
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 },
);
}Статус заказа меняет не вызов refunds.create, а вебхук charge.refunded: его поле amount_refunded — накопленная сумма всех возвратов. Поэтому повторная доставка того же события безопасна: машина состояний сравнивает накопленную сумму с суммой платежа и ставит partially_refunded или refunded.
Частичная оплата — например, предоплата и остаток — означает несколько платежей на один заказ. Заводите для каждого отдельную запись со своим статусом и ключом, а заказ считайте оплаченным, когда сумма подтверждённых платежей равна сумме заказа. Суммы везде храните целыми числами в минимальных единицах валюты — центах, тиынах.
Сверка (reconciliation) — регулярное сравнение данных провайдера с вашими. Она ловит то, что пропустили вебхуки: потерянную доставку, сбой обработчика, деньги без заказа.
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;
}Сессия оплачена, заказ ждёт оплаты
- Что делать
- Вебхук потерян: вызвать ту же функцию выдачи — так и делает
reconcile
Сессия оплачена, заказа нет
- Что делать
- Разобрать вручную: деньги пришли, а выдавать нечего
Заказ оплачен, у провайдера оплаты нет
- Что делать
- Остановить выдачу и искать ошибку в коде или подмену данных (в примере не реализовано — см. упражнение)
Не совпадает сумма или валюта
- Что делать
- Не выдавать заказ и поднять тревогу — это проверяет
fulfillCheckout
Сверка денег с выплатами на расчётный счёт — отдельная задача: её делают по финансовым отчётам провайдера вместе с бухгалтерией.
Тестовый режим, мок-сервер и проверка до запуска
Тестовый режим Stripe — песочница (sandbox): отдельные ключи sk_test_…, тестовые карты, деньги не двигаются. Карта 4242 4242 4242 4242 с любым будущим сроком и любым CVC проходит, 4000 0000 0000 0002 даёт отказ card_declined. В серверном коде документация советует тестовые PaymentMethod вроде pm_card_visa, а не номера карт. Проверять боевой режим настоящими картами соглашение Stripe запрещает.
Для локальной отладки вебхуков есть Stripe CLI: команда stripe listen --forward-to localhost:4242/webhooks/stripe пересылает события песочницы на ваш сервер и печатает секрет whsec_… для проверки подписи.
Автотесты этого руководства не ходят даже в песочницу: SDK направлен на локальный мок-сервер, который имитирует три эндпоинта Stripe, ключи идемпотентности, потерю ответа и подписанные вебхуки. Мок — тестовый двойник, а не Stripe: он моделирует только поля, которые читает наш код.
// 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)- Сумма и валюта платежа берутся из базы, а не из запроса браузера
- Ключ идемпотентности сохраняется до вызова API
- Подпись вебхука проверяется по сырому телу, секрет — в переменных окружения
- Повторная и одновременная доставка события не выдаёт заказ дважды
- Отложенная оплата:
completedсpayment_status: unpaidне выдаёт заказ - После истёкшей или неуспешной попытки можно оплатить заново
- Сверка запускается по расписанию и сообщает о расхождениях
- Возвраты, включая неуспешные, отражаются в статусе и уведомлениях
- Боевые ключи выпущены на компанию и не попадают в репозиторий
PCI DSS — стандарт безопасности индустрии платёжных карт, обязательный для всех, кто хранит, обрабатывает или передаёт данные карт. Если номер карты попадает на ваш сервер, обязательств становится очень много: Stripe пишет о более чем 300 мерах контроля. Поэтому принимайте карты только на странице или в форме провайдера, никогда не храните номер карты и CVC и не пишите их в логи. Хранить можно то, что возвращает API: тип карты, последние четыре цифры, срок действия.
Частые ошибки и упражнения
Заказ «оплачен» по возврату на success_url
- Последствие
- Выдача без денег или потерянный оплаченный заказ
- Как правильно
- Вебхук плюс запрос статуса к API
Новый ключ идемпотентности на каждый повтор
- Последствие
- Две сессии и двойная оплата
- Как правильно
- Сохранить ключ до вызова и переиспользовать
Проверка подписи после JSON.parse
- Последствие
- Подпись не сходится, и проверку отключают
- Как правильно
- Проверять по сырым байтам тела
Тяжёлая работа до ответа вебхуку
- Последствие
- Тайм-ауты и повторные доставки
- Как правильно
- Быстрый
2xx, обработка в очереди
UPDATE статуса без условия
- Последствие
- Старое или повторное событие перезаписывает статус
- Как правильно
- Переход только из ожидаемого статуса
Суммы в числах с плавающей точкой
- Последствие
- Ошибки округления
- Как правильно
- Целые числа в минимальных единицах валюты
Следующий шаг — общая устойчивость интеграций: повторы с паузой, тайм-ауты и обработка ответа 429 разобраны в материале об ошибках внешних API.
Как проверено5 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
tsconfig.json, src/orders.ts, src/store.ts, src/stripe.ts, src/checkout.ts, src/fulfill.ts, src/webhook.ts, src/refund.ts, src/reconcile.ts, src/main.ts, mock/fake-stripe.ts, test/flow.test.ts (+ test/orders.test.ts вне статьи)
временный проект в scratchpad\kb\verify\integraciya-platezhej: stripe@23.0.0, typescript@7.0.2, vitest@5.0.3, @types/node@26.6.5, Node 24.18.0; tsc -p . без ошибок; vitest run — 15/15 тестов (5 машины состояний, 10 сквозных против локального мока: потеря ответа и повтор SDK с тем же Idempotency-Key → 1 сессия; собственный повтор с сохранённым ключом → 1 сессия; переиспользование открытой сессии; 3 одновременных дубля вебхука → выдача 1 раз; поддельная подпись и подпись часовой давности → 400; отложенная оплата; новая попытка после expired; сверка восстанавливает потерянный вебхук; частичный и полный возврат с дублями событий; оплата отменённого заказа → тревога)
- запущено, работает
мутационная проверка тестов
без блокировки в fulfillCheckout тест одновременных дублей падает (deliver вызван 3 раза); без поля done падают 2 теста; с новым ключом на каждую попытку падает тест повторного вызова; без журнала event.id тесты проходят (дубли гасятся машиной состояний) — это отражено в тексте статьи
- запущено, работает
src/main.ts под Node 24 без сборки
node src/main.ts с выдуманными sk_test_/whsec_ значениями: POST с неверной подписью → 400, GET / → 404
- запущено, работает
schema.sql, event.sql
PGlite 0.5.8 (PostgreSQL 18.3, WASM) во временном проекте: первая доставка — вставка события и UPDATE 1 строки (статус paid), повторная — 0 строк в обоих запросах, в webhook_events 1 запись. Docker Desktop был недоступен, поэтому не postgres:17-alpine
- запущено, работает
команды установки (bash) и вывод тестов (shellsession)
те же пакеты установлены в проверочном проекте (там версии закреплены точно: stripe@23.0.0 и т. д.); вывод — реальный verbose-вывод vitest без строк времени
- не запускалось
stripe listen --forward-to localhost:4242/webhooks/stripe и сценарии в песочнице Stripe
нет реальных ключей и аккаунта; реальные платежи и песочница Stripe не использовались — в статье сказано явно
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.
