Как делать11 октября 2026 г.10 мин чтения
API и интеграцииШаг 7 из 8

Интеграция платежей: сценарий, статусы и вебхуки

Коротко

Надёжная онлайн-оплата у любого провайдера строится по одной схеме: сервер создаёт платёж с ключом идемпотентности, покупатель платит на странице провайдера, а заказ становится «оплаченным» только после подписанного вебхука и проверки статуса запросом к API. Статусы заказа задаёт машина состояний: повторное событие ничего не меняет, невозможный переход поднимает тревогу. Сверка по расписанию находит платежи, вебхук о которых потерялся.

РуководствоУровень: СреднийПлатёжные интеграцииПрактика: ~2–4 ч

После материала вы сможете

  • спроектировать сценарий оплаты: создание платежа, страница провайдера, вебхук, подтверждение статуса запросом к 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 г.

Содержание
  1. 01Кто участвует и как проходит оплата
  2. 02Машина состояний заказа
  3. 03Создание платежа и ключ идемпотентности
  4. 04Вебхук — сигнал, API — источник истины
  5. 05Возвраты, частичные оплаты и сверка
  6. 06Тестовый режим, мок-сервер и проверка до запуска
  7. 07Частые ошибки и упражнения

Кто участвует и как проходит оплата

Руководство для backend- и fullstack-разработчиков, которые подключают приём оплаты к магазину или сервису. Нужно понимать, что такое вебхук, и знать основы транзакций в базе данных. Результат — схема оплаты, в которой заказ не теряется и не оплачивается дважды, и код на Node.js 24, проверенный тестами против локального мок-сервера.

В оплате участвуют покупатель, ваш сервер, платёжный провайдер и банки — эмитент карты покупателя и банк-эквайер. С банками ваш сервер напрямую не общается: он работает только с API провайдера. Пример ниже построен по документации Stripe — у неё подробная открытая документация и полноценный тестовый режим. У казахстанских провайдеров — банков-эквайеров, Kaspi — поля и методы называются иначе, но этапы те же; о бизнес-стороне интеграции с Kaspi мы писали отдельно.

Заказ на сервереПлатёж у провайдераОплата покупателемВебхук и проверкаВыдача заказа
Деньги подтверждает не браузер покупателя, а связка «вебхук + запрос к API».
  1. Сервер создаёт заказ

    Сумма и состав берутся из вашей базы, а не из запроса браузера. Статус — new.

  2. Сервер создаёт платёж у провайдера

    Запрос к API с ключом идемпотентности. В ответ — идентификатор платежа (в Stripe это Checkout Session) и адрес страницы оплаты. Заказ переходит в awaiting_payment.

  3. Покупатель платит

    На странице провайдера, во встроенной форме или по ссылке. Данные карты вводятся у провайдера и не проходят через ваш сервер.

  4. Провайдер присылает вебхук

    Подписанное HTTP-уведомление на ваш адрес, например checkout.session.completed. Сервер проверяет подпись и быстро отвечает 2xx.

  5. Сервер подтверждает статус запросом к API

    Тело вебхука — сигнал, а не доказательство: сервер запрашивает платёж у API и проверяет payment_status, сумму и валюту.

  6. Заказ выдаётся ровно один раз

    Доступ к услуге, отгрузка, чек. Статус — 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

Что значит
Конечные статусы
Куда может перейти
—
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 };
}
Правила переходов: from — где событие допустимо, done — где оно уже применено

Ключевая деталь — поле done. Вебхуки приходят повторно, и событие «оплата прошла» для уже оплаченного заказа — норма, а не ошибка: функция возвращает тот же статус с changed: false. Исключение IllegalTransition бросается только для действительно невозможного — например, оплаты отменённого заказа. Такой случай повтором не исправить: нужен человек, который вернёт деньги или восстановит заказ.

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>;
Хранилище в памяти и блокировка на заказ — заменители таблиц и блокировок строк в базе

Создание платежа и ключ идемпотентности

Самый опасный момент — сетевой сбой при создании платежа: запрос дошёл до провайдера, сессия создана, а ответ потерялся. Если просто повторить запрос, у заказа появятся две сессии, и покупатель сможет заплатить дважды. Защита — ключ идемпотентности (idempotency key): уникальная строка в заголовке Idempotency-Key. Stripe сохраняет код и тело первого ответа на этот ключ и на повтор возвращает сохранённый результат.

  • Ключ генерирует клиент; Stripe советует UUID v4 или другую случайную строку, длина — до 255 символов
  • Персональные данные — e-mail, телефон — в ключ класть нельзя
  • Параметры повтора должны совпадать с первым запросом, иначе API вернёт ошибку. Поэтому не вычисляйте в теле запроса значения «на лету», например время истечения
  • Ключи могут удаляться через 24 часа: это защита от сетевых повторов, а не вечная дедупликация
  • GET и DELETE идемпотентны по определению, ключ им не нужен

Отсюда правило кода: ключ сохраняется в заказе до вызова API и стирается только после того, как идентификатор сессии записан. Если процесс упал посередине, следующая попытка возьмёт тот же ключ. А если у заказа уже есть открытая сессия, новая не создаётся — покупатель получает ту же ссылку.

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 .     # проверка типов
npx vitest run   # тесты против локального мока
Временный проект: SDK Stripe, TypeScript для проверки типов и Vitest для тестов
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src", "mock", "test"]
}
Настройки только для проверки типов: Node 24 сам исполняет TypeScript без сборки
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',
    }),
  });
}
Клиент SDK: ключ — из окружения, адрес API подменяется только в тестах
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;
  });
}
Создание платежа: одна открытая сессия на заказ, ключ идемпотентности сохраняется до вызова

Вебхук — сигнал, API — источник истины

Обработчик вебхука делает четыре вещи: проверяет подпись по сырому телу запроса, отбрасывает уже обработанные события, подтверждает статус запросом к API и меняет заказ через машину состояний. Начнём с функции выдачи: её вызывают вебхук, страница успеха и сверка, поэтому она обязана быть безопасной при повторных и одновременных вызовах — этого прямо требует руководство Stripe по выдаче заказов.

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;
    }
  });
}
Выдача заказа: статус — из API, сумма сверяется, побочное действие — один раз
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
    }
  });
}
Обработчик вебхука на node:http: подпись, дедупликация, разбор по типу события
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`),
);
Точка входа: node src/main.ts с переменными STRIPE_SECRET_KEY и STRIPE_WEBHOOK_SECRET
  • Подпись Stripe-Signature — HMAC-SHA256 от строки «время.тело» с секретом конечной точки whsec_…. Проверяется по сырым байтам: если фреймворк уже разобрал JSON, подпись не сойдётся
  • Время в подписи защищает от повторной отправки перехваченного запроса: библиотеки Stripe по умолчанию допускают расхождение 5 минут, поэтому часы сервера синхронизируют по NTP
  • Ответ 2xx — быстро, до тяжёлой логики. Для checkout.session.completed Stripe ждёт ответа до 10 секунд, прежде чем вернуть покупателя на success_url
  • Порядок событий не гарантирован, повторы возможны: дедупликация — по id события, а не по полю created
  • На ответ с ошибкой Stripe повторяет доставку: в рабочем режиме до трёх суток с экспоненциальной паузой, в песочнице — три раза за несколько часов

В примере повторы гасятся на двух уровнях: журнал обработанных event.id и машина состояний с блокировкой на заказ. При проверке мы отключали журнал — тесты всё равно проходили, потому что повтор упирается в done. Журнал экономит запросы к API и защищает побочные действия, которые машина состояний не покрывает. А без блокировки три одновременных вебхука выдали заказ трижды — тест это поймал.

Хранилище в памяти — только для примера. В рабочей системе журнал событий и переход статуса выполняются в одной транзакции базы: первичный ключ отсекает повторную доставку, а условный UPDATE переводит заказ только из ожидаемого статуса.

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()
);
Рабочий вариант: таблица заказов и журнал обработанных событий
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;
Один вебхук — одна транзакция. При повторной доставке того же события INSERT и UPDATE затрагивают 0 строк

Возвраты, частичные оплаты и сверка

Возврат — отдельная операция у провайдера со своим жизненным циклом. По одному платежу можно сделать несколько частичных возвратов, но в сумме не больше исходного платежа. Возврат проходит не мгновенно и может не пройти вовсе: тогда Stripe присылает событие refund.failed, и покупателю нужно вернуть деньги другим способом.

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 },
  );
}
Возврат запускается вызовом, а статус заказа меняет вебхук

Статус заказа меняет не вызов refunds.create, а вебхук charge.refunded: его поле amount_refunded — накопленная сумма всех возвратов. Поэтому повторная доставка того же события безопасна: машина состояний сравнивает накопленную сумму с суммой платежа и ставит partially_refunded или refunded.

Частичная оплата — например, предоплата и остаток — означает несколько платежей на один заказ. Заводите для каждого отдельную запись со своим статусом и ключом, а заказ считайте оплаченным, когда сумма подтверждённых платежей равна сумме заказа. Суммы везде храните целыми числами в минимальных единицах валюты — центах, тиынах.

Сверка (reconciliation) — регулярное сравнение данных провайдера с вашими. Она ловит то, что пропустили вебхуки: потерянную доставку, сбой обработчика, деньги без заказа.

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;
}
Сверка: автопагинация по сессиям, созданным после заданного момента

Сессия оплачена, заказ ждёт оплаты

Что делать
Вебхук потерян: вызвать ту же функцию выдачи — так и делает 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: он моделирует только поля, которые читает наш код.

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())),
  };
}
Локальный тестовый двойник: три эндпоинта, идемпотентность, потеря ответа и подписанные вебхуки
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();
});
Сквозные сценарии против мока: дубли, поддельная подпись, истечение, потерянный вебхук, возвраты
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)
Реальный вывод проверки (время выполнения убрано); test/orders.test.ts — в том же проекте
  • Сумма и валюта платежа берутся из базы, а не из запроса браузера
  • Ключ идемпотентности сохраняется до вызова 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.