Монетизация SaaS: Как безболезненно настроить Stripe, подписки и биллинг

Интеграция рекуррентных платежей в SaaS-продукты обманчиво проста на словах. Но если разовое списание средств с карты реализуется быстро, то управление жизненным циклом подписок вскрывает десятки подводных камней: отслеживание истечения срока действия карт, интервалы повторных попыток списания, апгрейды и даунгрейды тарифных планов, льготные периоды (grace periods) и обработка вебхуков.
Если вебхук от платежной системы обработан некорректно или не в том порядке, вы рискуете заблокировать платящих клиентов либо предоставить бесплатный доступ тем, кто уже отменил подписку.
В этом практическом руководстве мы настроим отказоустойчивый обработчик Stripe Webhooks с проверкой подписи на Node.js/TypeScript и опишем процесс синхронизации данных.
1. Анатомия жизненного цикла подписки
Ваша база данных должна точно отслеживать статус подписки клиента. Stripe использует несколько ключевых статусов, которые вы должны синхронизировать со своей БД:
+--------------------------------------+
| trialing |
+------------------+-------------------+
| (Триал завершен или оформлена оплата)
v
+-------------------> +----------------------+
| | active | <------------------+
| +----------+-----------+ |
| (Карта обновлена, | |
| списание успешно) | (Списание не прошло, Stripe | (Продление
| | повторяет попытки) | до окончания
| v | периода)
| +----------+-----------+ |
+---------------------+| past_due | |
+----------+-----------+ |
| (Все попытки списания провалились) |
v |
+----------+-----------+ |
| canceled | -------------------+
+----------------------+
trialing: Бесплатный ознакомительный период. Доступ открыт.active: Платеж прошел успешно. Полный доступ.past_due: Очередной платеж не прошел. Не блокируйте пользователя сразу. Переведите аккаунт в предупредительный статус (льготный период от 3 до 7 дней), отключите ресурсоемкие API-методы и отправьте напоминание об обновлении платежных данных.canceled: Подписка полностью аннулирована. Доступ закрыт. Для возобновления клиент должен заново привязать карту.
2. Реализация обработчика Stripe Webhooks с верификацией подписи
Stripe отправляет асинхронные уведомления об изменениях статусов через HTTP POST запросы. Поскольку любой злоумышленник может отправить поддельный запрос на ваш эндпоинт /api/billing/webhook, вы обязаны валидировать криптографическую подпись Stripe в заголовке stripe-signature.
Пример реализации обработчика на Node.js/TypeScript и Express:
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
const app = express();
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// ВАЖНО: Проверка подписи Stripe требует сырого буфера тела запроса (raw body).
// Не применяйте bodyParser.json() к этому маршруту до проверки подписи.
app.post(
'/api/billing/webhook',
express.raw({ type: 'application/json' }),
async (req: express.Request, res: express.Response) => {
const sig = req.headers['stripe-signature'];
if (!sig) {
return res.status(400).send('Отсутствует заголовок stripe-signature.');
}
let event: Stripe.Event;
try {
// Проверяем подпись вебхука, используя секрет из кабинета Stripe
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err: any) {
console.error(`[Security Warning] Ошибка проверки подписи: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
try {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdate(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeletion(subscription);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailure(invoice);
break;
}
default:
console.log(`[Stripe Webhook] Пропущено событие: ${event.type}`);
}
res.json({ received: true });
} catch (err: any) {
console.error(`[Webhook Process Error] Ошибка обработки события: ${err.message}`);
res.status(500).send('Internal Server Error');
}
}
);
async function handleSubscriptionUpdate(sub: Stripe.Subscription) {
const tenantId = sub.metadata.tenantId;
if (!tenantId) {
throw new Error(`В метаданных подписки ${sub.id} отсутствует ID клиента (tenantId).`);
}
const status = sub.status; // 'active', 'trialing', 'past_due' и др.
const priceId = sub.items.data[0].price.id;
const currentPeriodEnd = new Date(sub.current_period_end * 1000);
// Синхронизируем статус в нашей базе данных
await db.query(
`UPDATE tenants
SET subscription_status = $1, stripe_price_id = $2, subscription_period_end = $3
WHERE id = $4`,
[status, priceId, currentPeriodEnd, tenantId]
);
console.log(`[Billing Sync] Статус клиента ${tenantId} обновлен на: ${status}`);
}
async function handleSubscriptionDeletion(sub: Stripe.Subscription) {
const tenantId = sub.metadata.tenantId;
if (!tenantId) return;
await db.query(
`UPDATE tenants
SET subscription_status = 'canceled', stripe_price_id = NULL
WHERE id = $1`,
[tenantId]
);
console.log(`[Billing Sync] Подписка клиента ${tenantId} аннулирована.`);
}
async function handlePaymentFailure(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
// Находим пользователя по customerId и отправляем предупреждающее письмо
console.warn(`[Billing Alert] Ошибка оплаты счета для клиента ${customerId}`);
}
3. Рекомендации по интеграции биллинга в SaaS
- Stripe Checkout и Customer Portal спасают недели работы: Не тратьте ресурсы на создание дашбордов привязки карт, просмотра истории инвойсов и отмены услуг. Готовые решения Stripe безопасны, соответствуют PCI DSS и переведены на десятки языков.
- Используйте Metadata: Всегда передавайте ID сущностей вашей базы данных в поле
metadataпри создании платежных сессий. Это единственный надежный способ сопоставить событие вебхука с конкретной строкой в вашей БД. - Защита от дублирования (Идемпотентность): Вебхуки могут приходить повторно. Убедитесь, что ваш обработчик проверяет, не был ли статус уже записан, чтобы исключить лишние операции отправки писем или пересчета лимитов.
Например, в проекте TeleGo.io мы внедрили именно эту архитектуру верификации вебхуков для безопасного управления подписками. Мы также совместили её с микротранзакциями Telegram Stars, о чем вы можете прочитать в моей статье Разработка SaaS внутри Telegram Web Apps.
Источники и документация
- Stripe Webhooks — доставка, подпись и повторные попытки событий
- Stripe: жизненный цикл подписок — все статусы из схемы выше
- Stripe CLI — локальное тестирование вебхуков
Профессиональная платежная архитектура защищает доходы вашего SaaS-бизнеса и гарантирует высокий уровень лояльности платящих клиентов. Чтобы узнать, как биллинг встраивается в общий цикл быстрого запуска стартапа, ознакомьтесь с моим руководством Как собрать B2B SaaS за 30 дней.
Если вы планируете настроить прием платежей на своем проекте или провести рефакторинг текущей платежной системы, изучите мою услугу Разработки SaaS-платформ или запишитесь на Консультацию, чтобы разработать надежный биллинг.
Частые вопросы
Почему нельзя выдавать доступ сразу после редиректа с оплаты?
Редирект на success-страницу не гарантирует, что деньги списаны: пользователь мог закрыть вкладку, а платёж — не пройти антифрод. Единственный источник истины — вебхуки Stripe: доступ выдаётся по событию checkout.session.completed, а продлевается по invoice.paid.
Что делать, если вебхук от Stripe не дошёл?
Stripe автоматически повторяет доставку событий до трёх суток, поэтому обработчик обязан быть идемпотентным — повторное событие не должно дублировать письма или продления. Для страховки добавляют фоновую сверку статусов подписок по расписанию.
Как тестировать вебхуки локально?
Через Stripe CLI: команда stripe listen пробрасывает события на localhost, а stripe trigger генерирует тестовые события любого типа — от успешной оплаты до провала списания. Это позволяет отладить все ветки обработчика до продакшена.