Stripe Connect для двухстороннего маркетплейса: архитектура, код, подводные камни
Пошаговое внедрение Stripe Connect в маркетплейс: выбор модели аккаунтов, архитектура, код на Node.js/TypeScript, комиссии, возвраты и что ломается в продакшене.

Ошибки в платежах стоят дороже любой маркетинговой кампании: удержания не туда, зависшие выплаты и спор с банком — и конверсия падает вместе с доверием продавцов. Эта статья — практическая схема, как интегрировать Stripe Connect в двухсторонний маркетплейс и не устроить себе «кассовый разрыв» из багов.
Если кратко: начните с Express-аккаунтов и Destination Charges, храните stripe_account_id у продавца, принимайте платежи через Payment Intents с transfer_data, комиссию берите application_fee_amount, а все события ведите через подписанные вебхуки с идемпотентностью. Дальше — нюансы: онбординг, возвраты, споры, расписание выплат, локальные регуляции и устойчивость интеграции.
Когда Stripe Connect уместен
- Двухсторонний маркетплейс: покупатели платят, продавцы (мерчанты) получают вознаграждение, платформа берет комиссию.
- Нужна автоматическая KYC/онбординг мерчантов и соответствие регуляциям без построения собственного лицензируемого PSP.
- Есть требования к разделению потоков денег: возвраты, чаевые, промокоды, разбиение платежа между несколькими получателями.
Если вы просто продаете сами — базовый Stripe (без Connect) проще. Если вы хотите кастомный онбординг под полный контроль — возможен Custom, но это больше ответственности и development-часов.
Выбор модели аккаунтов Stripe: Standard vs Express vs Custom
Главный выбор влияет на UX, комплаенс и бюджет сопровождения.
| Модель | Контроль онбординга | PCI/комплаенс на платформе | Доступность фич | Саппорт/споры | Когда брать |
|---|---|---|---|---|---|
| Standard | Низкий (Stripe UI) | Минимальный | Базовые (продавец видит всё в своем Stripe) | Львиная доля у Stripe | Когда платформа — каталог/агрегатор |
| Express | Средний (встроенные Account Links) | Низкий/средний | Почти всё, белый лейбл частично | Совместно | Хороший баланс для 80% маркетплейсов |
| Custom | Высокий (полный контроль UI/данных) | Выше, особенно по поддержке, диспутам | Максимум гибкости | Почти всё на вас | Только если обязателен кастомный KYC/UX |
Практически: начинайте с Express. Перейти на Custom позже можно, но дороже. Express ускоряет валидацию KYC, уменьшает юридический след, при этом дает контроль над комиссиями и выплатами.
Базовая архитектура платежного контура
Компоненты, которые обычно используем в проде:
- Frontend: Next.js/React + Stripe Elements/Payment Element.
- Backend API: Node.js/TypeScript (NestJS/Express), SDK
stripe. - БД: Postgres для сущностей платформы (
users,sellers,orders,payout_rules), Redis для блокировок/кэша идемпотентности. - Вебхуки: прием событий в отдельном изолированном сервисе (worker), очередь (SQS/RabbitMQ) желательна.
- Безопасность: подписанные вебхуки, секреты в Vault/SSM, ограниченный доступ ролей.
- Отчеты и сверки: периодические джобы, выгрузки Balance Transactions из Stripe и сравнение с вашей книгой учета.
Поток данных (упрощенно):
- Продавец проходит онбординг (Express Account Link) → вы сохраняете
stripe_account_id. - Покупатель оформляет заказ → создается
PaymentIntentна платформенном аккаунте сtransfer_data.destination = <seller_account>иapplication_fee_amount. - Stripe удерживает средства → событие
payment_intent.succeeded→ вы фиксируете заказ в БД и, при необходимости, создаетеTransfer(если используете отдельную схему переводов). - Выплаты продавцу идут автоматически (балансы на его аккаунте) по его расписанию; вы контролируете hold-периоды политиками Connect и своими правилами.
Интеграция шаг за шагом (Node.js/TypeScript)
1) Создание аккаунта продавца и онбординг
Храним связку с продавцом в своей БД (seller.stripe_account_id).
// npm i stripe
import Stripe from 'stripe'
import { v4 as uuid } from 'uuid'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' })
export async function createOrGetConnectedAccount(sellerId: string) {
// 1. Ищем уже существующий
const seller = await db.seller.findUnique({ where: { id: sellerId } })
if (seller?.stripeAccountId) return seller.stripeAccountId
// 2. Создаем Express-аккаунт
const account = await stripe.accounts.create({
type: 'express',
capabilities: { card_payments: { requested: true }, transfers: { requested: true } }
})
await db.seller.update({ where: { id: sellerId }, data: { stripeAccountId: account.id } })
return account.id
}
export async function createAccountLink(accountId: string, returnUrl: string, refreshUrl: string) {
const link = await stripe.accountLinks.create({
account: accountId,
refresh_url: refreshUrl,
return_url: returnUrl,
type: 'account_onboarding'
})
return link.url
}
Важно: проверяйте account.capabilities.*.status. Пока active нет — не разрешайте продавцу принимать платежи.
2) Создание платежа с комиссией платформы (Destination Charges)
export async function createPaymentIntent(opts: {
amount: number // в минорных единицах валюты, например, центы
currency: string
sellerStripeAccountId: string
customerId?: string
metadata?: Record<string, string>
}) {
const idempotencyKey = `pi_${uuid()}`
const pi = await stripe.paymentIntents.create({
amount: opts.amount,
currency: opts.currency,
automatic_payment_methods: { enabled: true },
transfer_data: { destination: opts.sellerStripeAccountId },
application_fee_amount: calcPlatformFee(opts.amount),
metadata: opts.metadata
}, { idempotencyKey })
return { clientSecret: pi.client_secret }
}
На фронте используйте Payment Element и stripe.confirmPayment({ clientSecret }). Так вы проходите SCA/3DS без ручной гимнастики.
3) Обработка вебхуков и идемпотентность
Никаких «оверрайдов статуса» без подтверждения из вебхука. Все финальные статусы — только из событий Stripe. Защитите обработчик и не обрабатывайте один и тот же event дважды.
import crypto from 'crypto'
import express from 'express'
const app = express()
app.post('/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'] as string
let event
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
}
const alreadyHandled = await redis.setNX(`wh:${event.id}`, '1')
if (!alreadyHandled) return res.status(200).end() // идемпотентность
await redis.expire(`wh:${event.id}`, 60 * 60)
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent
await orders.markPaidByPaymentIntent(pi.id)
break
}
case 'charge.refunded': {
const ch = event.data.object as Stripe.Charge
await orders.markRefundedByCharge(ch.id)
break
}
case 'account.updated': {
const acc = event.data.object as Stripe.Account
await sellers.updateCapabilities(acc.id, acc.capabilities)
break
}
}
res.json({ received: true })
})
Для локальной отладки удобно stripe listen --forward-to localhost:3000/stripe/webhook.
4) Возвраты и частичные возвраты
Возвраты инициируйте с учетом модели списания.
export async function refund(orderId: string, amountMinor?: number) {
const order = await db.order.findUnique({ where: { id: orderId } })
if (!order) throw new Error('order not found')
// Найдите charge через paymentIntent
const pi = await stripe.paymentIntents.retrieve(order.paymentIntentId)
const chargeId = typeof pi.charges?.data?.[0]?.id === 'string' ? pi.charges.data[0].id : undefined
if (!chargeId) throw new Error('no charge')
const refund = await stripe.refunds.create({
charge: chargeId,
amount: amountMinor, // undefined = полный возврат
reverse_transfer: true, // вернет средства с connected account
refund_application_fee: true // вернет комиссию платформы
})
return refund
}
Замечание: reverse_transfer и refund_application_fee зависят от выбранной схемы (см. ниже). Не все сочетания валидны для Direct/Destination моделей.
Способы списания и переводов: что выбрать
Stripe предлагает несколько схем. Чаще всего для маркетплейса — Destination Charges или Separate Charges and Transfers. Разница — где «живет» платеж и как идут возвраты/споры.
| Схема | Где создается Charge | Как взять комиссию | Возвраты | Сложность | Когда уместно |
|---|---|---|---|---|---|
| Destination Charges | На аккаунте платформы | application_fee_amount + transfer_data.destination | Можно делать reverse_transfer, refund_application_fee | Низкая/средняя | Большинство маркетплейсов, один продавец на заказ |
| Separate Charges & Transfers | Charge на платформе, потом transfers на селлеров | Комиссия — разница между входом и трансферами | Гибкие, но требуют согласования сумм | Средняя/высокая | Сплит-платежи на нескольких селлеров |
| Direct Charges | На аккаунте селлера | Комиссия через платформенную подписку/приложение | Сложнее управлять возвратами централизованно | Средняя | Когда продавцы юридически «полные мерчанты» |
Если у вас 90% заказов — один продавец, берите Destination Charges: минимум кода и понятные возвраты. Для корзин с несколькими продавцами используйте Separate Charges & Transfers и аккуратные транзакции в своей БД.
Удержания, холды, споры и отчеты
Удержание средств и расписание выплат
- Express-аккаунты сами управляют расписанием выплат. Платформа может ставить задержки через политики (например, не создавать трансфер сразу).
- Если вам нужен «эскроу-подобный» сценарий — используйте
transfer_schedule, ручныеTransfersпосле наступления бизнес-события (доставки/подтверждения).
Споры (disputes)
- События:
charge.dispute.created,charge.dispute.closed. - Храните доказательства (скриншоты, доставку, переписку) заранее, чтобы быстро отвечать через API/дешборд.
- Разделите SLA: кто готовит ответ — вы или продавец. В Express типично участвует платформа.
Отчеты и сверка
- Снимайте
balance_transactionsи сводите с внутренней книгой. Нельзя полагаться на «только наши статусы в БД» — сеть карт и возвраты асинхронны. - Заводите ежедневную джобу сверки и алерты по рассинхронизации сумм > 0.
Небольшая инженерная ирония: одна хорошая сверка спасает от десяти «касаем» в чате поддержки.
Что ломается в продакшене
- Идемпотентность: повторные вебхуки, повторные клики на оплату, ретраи сети. Используйте
idempotency_keyиsetNXв Redis с TTL. Мы писали о высоконагруженных счетчиках и атомиках — контекст близкий: быстрые счетчики в Erlang. - Порядок событий:
payment_intent.succeededможет прийти до вашего ответа фронту. Не стройте логику «только после редиректа». Состояние — из вебхуков. - Capabilities: кардинг-провалы «вчера платили — сегодня нет» часто связаны с KYC или включением новых стран/валют. Проверяйте
requirements.disabled_reason. - Валюта и минорные единицы: перепутали
RUB/USD— получили комичную сумму. Все суммы всегда в минорных единицах (amountв центах/копейках). - Refund + transfers: для некоторых конфигураций
reverse_transferневозможен. Проектируйте схему списания заранее, иначе получите миграцию платежной модели «на живую». Миграции в финансах похожи на смену типа колонки в проде: больно и дорого — см. наш разбор почему Cassandra не даст поменять тип колонки. - Тест/прод ключи и вебхуки: не смешивайте. Разделяйте окружения, используйте отдельные endpoint secrets.
- 3DS/SCA: фронт должен уметь переинициировать подтверждение. Не гасите попап «просто перезагрузкой». Проверяйте
requires_actionстатусы.
Безопасность и комплаенс
- PCI DSS: с Payment Element и серверной интеграцией вы остаетесь в упрощенном SAQ-A/SAQ-A-EP, но не храните PAN/CSC. Не логируйте
client_secret. - Вебхуки: верификация подписи обязательна. Отдавайте 2xx быстро, тяжелую работу — в очередь/воркер.
- GDPR/PII: данные KYC — в Stripe. У себя храните минимум (только
account_idи необходимые зеркала статусов). - Ограничения бизнеса: Stripe ведет список запрещенных категорий; валидируйте мерчантов на входе, чтобы не тратить ресурсы впустую.
- Доступы: разделяйте ключи по ролям (reading, writing), ротуйте секреты, проверяйте транспорт (TLS 1.2+).
Бизнес-контекст: стоимость, сроки, ROI
На что тратятся деньги и время при запуске платежей через Connect:
- Интеграция и онбординг: разработка флоу продавца, Account Links, хранение статусов, UI ошибок KYC.
- Платежная логика: создание
PaymentIntent, удержание комиссии, возвраты, частичные возвраты, отмены, тестовые сценарии. - Вебхуки/воркеры: идемпотентность, очередь, ретраи, мониторинг.
- Отчеты и сверки: джобы, дашборды, экспорт в бухгалтерию.
- Комиссии Stripe: обработка платежей, Connect-компоненты (за аккаунт/за выплату), комиссии за споры и конвертацию валют — актуальные тарифы смотрите в документации Stripe для вашей страны.
- Юридические/политики: публичные условия для продавцов, процедура возвратов, политика споров, SLA ответов.
Практический расклад по этапам (ориентиры без «магических чисел»):
- MVP-интеграция для одного региона и одной валюты, Express + Destination Charges: недели разработки, если команда знакома с Stripe.
- Мультивалютность и корзина из нескольких продавцов: ощутимо сложнее, закладывайте дополнительные спринты.
- Автоматизация отчетности и сверки: обязательный этап для выхода за рамки пилота.
ROI складывается из конверсии оплаты, скорости вывода продавцов в оборот (онбординг) и снижения нагрузки на саппорт по спорам/возвратам. Connect позволяет не строить собственный платежный процессинг и комплаенс-команду — экономия времени вывода на рынок.
Чеклист перед релизом
- Продавец без
capabilities.card_payments=activeне может принимать платежи — UI должен это объяснять. - Все суммы — в минорных единицах; валюта закреплена в заказе.
- Вебхуки: идемпотентность + retry-safe обработчики.
- Тесты сценариев: полный и частичный возврат, отмена до захвата, спор, чарджбек, провал 3DS.
- Логи и трассировка: корелляция
order_id↔payment_intent.id↔charge.id. - Документация для саппорта: «как найти платеж», «как инициировать возврат», «как проверить статус онбординга селлера».
Пример конфигурации для мультиселлер-корзины (Separate Charges & Transfers)
Для корзины, где один заказ разбивается на нескольких продавцов, шаблон таков:
- Создаете один
PaymentIntentна полную сумму. - После
succeeded— распределяетеTransfersпо продавцам, сохраняя комиссию у себя.
export async function distributeTransfers(orderId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { items: true } })
if (!order) throw new Error('no order')
// Найдем charge
const pi = await stripe.paymentIntents.retrieve(order.paymentIntentId)
const chargeId = pi.charges.data[0].id
// Идемпотентность на уровне ордера
const lock = await redis.setNX(`dist:${orderId}`, '1')
if (!lock) return
await redis.expire(`dist:${orderId}`, 60)
for (const item of order.items) {
const seller = await db.seller.findUnique({ where: { id: item.sellerId } })
const netAmount = item.totalMinor - calcPlatformFee(item.totalMinor)
await stripe.transfers.create({
amount: netAmount,
currency: order.currency,
destination: seller!.stripeAccountId!,
source_transaction: chargeId,
metadata: { orderId, itemId: item.id }
})
}
}
Плюсы: гибкость и сплит. Минусы: сложнее возвраты — надо зеркалить логику на уровне каждой позиции.
Мониторинг и алерты
- Ошибки вебхуков > порог — алерт в Slack/PagerDuty.
- Несовпадение сумм (ваша книга vs Stripe Balance Transactions) — алерт.
- Процент
requires_actionпо странам/банкам — сигнал к тюнингу UI. - Пул неуспешных онбордингов (застрявшие
requirements) — отдельный отчет.
FAQ
Что выбрать для начала: Express или Custom?
Express. Он покрывает подавляющее большинство кейсов маркетплейса, снимает с вас львиную долю KYC/UI-обязанностей и ускоряет вывод на рынок. Custom — только если нужен полный контроль онбординга и нестандартные регуляторные требования.
Какая схема списания проще для возвратов?
Destination Charges. Она позволяет делать reverse_transfer и refund_application_fee в лоб, что упрощает возвраты и бухгалтерию. Separate Charges & Transfers дает гибкость при сплите на нескольких продавцов, но сложнее в обращении с возвратами.
Как правильно брать комиссию платформы?
В Destination Charges используйте application_fee_amount. В Separate Charges & Transfers — просто переводите продавцу нетто, удерживая свою комиссию до перевода. Не «перекидывайте» комиссию отдельным инвойсом — усложнит сверки и налоги.
Нужен ли собственный PCI DSS?
При использовании Stripe Elements/Payment Element и серверной интеграции вы попадаете в упрощенную область (SAQ-A/SAQ-A-EP). Не храните данные карт, не логируйте чувствительные токены и следуйте гайдлайнам Stripe.
Что с мультивалютностью?
Заранее закрепляйте валюту в заказе, храните суммы в минорных единицах и проверяйте, может ли селлер принимать эту валюту (capabilities + счета). Конвертацию и связанные комиссии берите из официальной документации Stripe, тестируйте отдельными кейсами.
Как тестировать вебхуки локально?
Используйте Stripe CLI: stripe listen --forward-to localhost:3000/stripe/webhook. Держите разные секрты для теста и продакшена, не смешивайте окружения, реализуйте идемпотентность в обработчике.
Ключевые выводы
- Для 80% маркетплейсов — Express-аккаунты и Destination Charges: быстрее в прод и проще возвраты.
- Все критичные статусы берите из вебхуков, а не из ответов клиентского SDK; без идемпотентности жить нельзя.
- Продумайте модель денежных потоков заранее — менять ее в бою тяжелее, чем схему БД.
- Сверка Stripe Balance Transactions с вашей книгой — обязательная ежедневная рутина.
- Учтите комплаенс: capabilities, ограничения по видам бизнеса, SCA/3DS — иначе получите «вчера работало, сегодня нет».
Если вы строите маркетплейс и нужно быстро и безопасно принять первые оплаты — напишите нам. В MTBYTE мы проектируем и внедряем платежные контуры под нагрузку и бизнес-ограничения. Связаться: /contact.
СЛЕДУЮЩИЙ ШАГ
Понравилось как мыслим?
Применяем те же принципы в клиентских проектах: AI, автоматизации, продукты, которые не умирают после релиза.