Кратко: Подключите express.raw({ type: 'application/json' }) к маршруту /webhook до любого JSON-middleware — именно это определяет, пройдут ли проверки подписи Stripe. Уберите '+' из телефона подписчика и добавьте @s.whatsapp.net. POST на /groups/{id}/participants при checkout.session.completed; DELETE при customer.subscription.deleted. Google Sheets справится до 50 участников. SQLite — со всем остальным. Весь код — в этом руководстве.
Кто создаёт платные WhatsApp-группы в 2026 году — и почему ручная модерация не работает
Фитнес-тренеры, поставщики криптосигналов, агенты по недвижимости и репетиторы ведут платные WhatsApp-группы в промышленных масштабах. Почти все управляют участниками через таблицу, которая неизбежно ломается после 50 подписчиков.
Рынок устоялся в четырёх нишах: фитнес ($9,99--$140 в месяц), криптосигналы ($49--$149 в месяц на safetrading.today), репетиторы для поступающих в Нигерии и ЮАР (₦1.500--R149 в месяц) и группы люкс-ретейла — например, британского Love Luxe, где утечки ссылок-приглашений не менее активны. Open-source-проекты вроде dhryvyad/wahooks (MIT, 2026) подтверждают: разработчики хотят самостоятельных решений, а не очередного коммерческого SaaS.
Операционные потери посчитаны. CommuniPass, который берёт $29--$299 в месяц как SaaS именно за решение этой проблемы, фиксирует стабильные ошибки начиная примерно с 50 участников: сверять даты платежей с именами в WhatsApp уже выходит за рамки человеческой погрешности. Временные затраты — около 8 часов в неделю, или $20 800 в год при ставке $50 в час.
Потеря выручки — отдельная и на практике более серьёзная проблема. Группы теряют 15--20% дохода от подписок из-за участников с истёкшим сроком, которые остаются в группе после прекращения оплаты. Причина — социальная: WhatsApp показывает всей группе уведомление, когда кого-то удаляют через приложение. Один оператор сформулировал точно: «Участники остаются после истечения подписки, потому что выгонять их вручную неловко». DELETE-вызов на уровне API, описанный в этом руководстве, удаляет участника без каких-либо уведомлений для остальных.
Почему схема с Zapier и статической ссылкой рушится при первом же репосте
Первый порыв — взять Zapier или Make: событие оплаты Stripe → получить ссылку-приглашение → отправить по email или SMS. Работает ровно до тех пор, пока один подписчик не поделится ссылкой.
NothingApps описали этот сценарий провала точно: «Вы продаёте одно место в своей VIP-группе. Через час пять призрачных участников уже внутри — покупатель выложил ссылку на публичный форум». Статическая ссылка, разосланная всем подписчикам, создаёт нарастающую утечку без механизма её закрыть. К тому же поток Zapier не умеет удалять участников при истечении подписки: он реагирует на успешный платёж, но DELETE над членством в группе для него недоступен, а триггера на завершение подписки просто нет.
Подход этого руководства — добавлять и удалять участников по номеру телефона, а не через общую ссылку. Ротация ссылки-приглашения остаётся доступным fallback лишь для крайнего случая, когда настройки приватности подписчика блокируют прямое добавление через API; это разбирается в соответствующем разделе ниже.
Полная схема: Stripe Checkout → Webhook → Whapi.Cloud
Пять компонентов, два события Stripe, один токен Whapi.Cloud. Весь жизненный цикл членства вписывается в один файл Express с модулем базы данных и двумя маршрутами API.
Поток выполнения:
-
Создание Checkout-сессии: Подписчик завершает оплату на странице Stripe. Его номер WhatsApp записывается в
metadata.whatsapp_phoneпри создании сессии — не при подтверждении платежа. -
checkout.session.completed: Stripe доставляет событие через секунды после успешного платежа. Обработчик читает телефон из метаданных, конвертирует в WhatsApp JID и вызываетPOST /groups/{id}/participants. Подписчик в группе. -
Активное членство: База данных фиксирует телефон, ID подписки Stripe и ID группы. Ежедневный cron проверяет записи, которые вебхук мог пропустить.
-
customer.subscription.deleted: Срабатывает при отмене или когда Stripe исчерпывает попытки оплаты. Обработчик вызываетDELETE /groups/{id}/participantsс JID участника. -
Тихое удаление: DELETE удаляет участника без уведомлений в группе WhatsApp. Проблема социального дискомфорта исчезает.
customer.subscription.deleted — единственное событие Stripe, которое гарантирует удаление по истечении срока. Частая ошибка — привязать удаление к invoice.payment_failed. Это событие срабатывает при каждой неудачной попытке (до 4 по умолчанию), а не при окончательном завершении подписки. Подписка может восстановиться после повторной попытки. Используйте customer.subscription.deleted.
Как передать телефон подписчика в Stripe Checkout
Телефон нужно получить при создании сессии. Достать его потом из Stripe надёжно не получится — придётся отдельно строить поиск по профилю клиента.
Добавьте шаг ввода телефона во фронтенд до редиректа на Stripe. Передайте значение в бэкенд при создании Checkout Session через поле metadata:
// server.js -- create a Stripe Checkout Session with phone metadata
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/create-checkout', async (req, res) => {
const { phone } = req.body; // e.g. "+12025551234" from your pre-checkout form
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
// Store WhatsApp phone here -- webhook reads it from event.data.object.metadata.whatsapp_phone
metadata: { whatsapp_phone: phone },
success_url: `${process.env.BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/cancel`,
});
res.json({ url: session.url });
});
Если whatsapp_phone отсутствует в метаданных сессии к моменту срабатывания checkout.session.completed, вебхук не сможет добавить участника. Проверяйте это поле на форме checkout. Отклоняйте номера короче 10 цифр и требуйте международный префикс — иначе конвертация E.164 → JID сломается на следующем шаге.
Объект сессии при завершении также содержит ID subscription. Сохраните его вместе с телефоном в базе — это ключ поиска, когда позже придёт customer.subscription.deleted. Без этой пары событие удаления не поймёт, какой номер удалять.
Почему вебхуки Stripe падают — и как решить это одной строкой
Любая проверка подписи Stripe молча провалится, если express.json() выполнится раньше обработчика вебхука. Исправление — одна строка в нужном месте, но обычно диагностика занимает несколько часов.
Команды, которые регистрируют express.json() глобально, а потом добавляют маршрут /webhook, видят: stripe.webhooks.constructEvent() бросает "No signatures found matching the expected signature for payload" при каждом вызове — даже с правильным секретом. Ошибка не даёт никакой подсказки. Причина: express.json() уже пересериализовал тело запроса, изменив его байтовое представление ровно настолько, чтобы сломать HMAC-SHA256-подпись, которую Stripe вычислил по исходным сырым байтам.
Правило «raw-body первым»: подключите express.raw({ type: 'application/json' }) к маршруту /webhook до глобальной регистрации express.json(). Middleware на уровне маршрута в Express выполняется первым — вебхук-маршрут получит Buffer раньше, чем до него доберётся глобальный парсер.
// server.js -- middleware registration order is non-negotiable
// without express.raw() here, stripe.webhooks.constructEvent throws 400
// even when STRIPE_WEBHOOK_SECRET is correct and matches your dashboard
app.use('/webhook', express.raw({ type: 'application/json' }), webhookHandler);
// All other routes get parsed JSON normally
app.use(express.json());
app.use('/create-checkout', checkoutRouter);
При правильном порядке маршрутов обработчик вебхука проверяет подпись и направляет события в нужную бизнес-логику:
async function webhookHandler(req, res) {
const sig = req.headers['stripe-signature'];
let event;
try {
// req.body is a raw Buffer here -- not a parsed object
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency gate: Stripe delivers each event at least once.
// A duplicate checkout.session.completed adds the member twice; a duplicate
// customer.subscription.deleted removes a still-active subscriber.
if (await db.hasProcessedEvent(event.id)) {
return res.status(200).send('Already processed');
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
await db.markEventProcessed(event.id);
res.status(200).send('OK');
}
Проверка идемпотентности опирается на таблицу processed_events, куда записывается ID каждого события Stripe после успешной обработки. Схема этой таблицы — в разделе про базу данных ниже.
Нормализация номера телефона: две операции, которые устраняют 90% ошибок
Уберите '+', добавьте '@s.whatsapp.net'. Две строковые операции — никакой двусмысленности. Все прочие сбои в интеграциях Stripe + Whapi.Cloud начинаются с пропуска одного из этих шагов.
WhatsApp адресует контакты по JID (Jabber ID), а не по номеру телефона. Формат — {country_code}{national_number}@s.whatsapp.net. Stripe хранит номера в формате E.164 с ведущим '+'. Конвертация детерминирована:
// Converts E.164 phone → WhatsApp JID
// Input: "+12025551234" → Output: "[email protected]"
// Input: "12025551234" → Output: "[email protected]" (no + present)
function toWhatsAppJID(e164Phone) {
const digits = String(e164Phone).replace(/^\+/, '');
return `${digits}@s.whatsapp.net`;
}
// Optional: validate before using
function isValidJID(jid) {
return /^\d{7,15}@s\.whatsapp\.net$/.test(jid);
}
Команды часами отлаживают, почему каждый вызов addGroupParticipant падает с общей ошибкой — и в итоге обнаруживают, что в JID был '+' или пробел из неочищенного поля ввода. Это самая частая скрытая ошибка в новых интеграциях Stripe + Whapi.Cloud. Вызывайте isValidJID() перед каждым API-вызовом и явно логируйте ошибку при false.
Два краевых случая для формы checkout: (1) номера с пробелами или дефисами — уберите все нецифровые символы до удаления '+'; (2) номера без кода страны — отклоняйте короче 10 цифр и просите международный формат.
Добавить при оплате, удалить при отмене
Два обработчика событий Stripe, четыре HTTP-вызова к Whapi.Cloud. Путь добавления при оплате — при checkout, путь удаления — при завершении подписки.
Обработчик checkout читает телефон из метаданных сессии, добавляет участника и сохраняет запись для отслеживания истечения срока:
async function handleCheckoutCompleted(session) {
const phone = session.metadata?.whatsapp_phone;
if (!phone) {
console.error('No whatsapp_phone in session metadata -- member cannot be added automatically');
return;
}
const groupId = process.env.WHAPI_GROUP_ID;
await addGroupMember(groupId, phone);
await db.saveMember({
phone,
stripeSubscriptionId: session.subscription,
stripeCustomerId: session.customer,
groupId,
});
console.log(`Member added: ${phone} (subscription: ${session.subscription})`);
}
async function handleSubscriptionDeleted(subscription) {
const member = await db.getMemberBySubscriptionId(subscription.id);
if (!member) {
// Already removed manually, or was never successfully added
console.warn(`No member found for subscription ${subscription.id}`);
return;
}
await removeGroupMember(member.groupId, member.phone);
await db.removeMember(subscription.id);
console.log(`Member removed: ${member.phone} (subscription: ${subscription.id})`);
}
Вызовы API Whapi.Cloud — прямые HTTP-запросы к Groups API. Запрос на добавление передаёт JID подписчика как участника:
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// POST https://gate.whapi.cloud/groups/{GroupID}/participants
async function addGroupMember(groupId, phone) {
const jid = toWhatsAppJID(phone);
const response = await fetch(
`https://gate.whapi.cloud/groups/${groupId}/participants`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WHAPI_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ participants: [jid] }),
}
);
const data = await response.json();
if (!response.ok) {
// 403 means the subscriber's privacy settings block direct group addition
// Fall back to sending them a rotated invite link via DM
if (response.status === 403) {
console.warn(`Direct add blocked for ${phone}, sending invite link`);
return sendInviteLink(groupId, phone);
}
throw new Error(`addGroupMember failed (${response.status}): ${JSON.stringify(data)}`);
}
// Sleep >250ms between consecutive adds -- prevents IQErrorRateOverlimit
// which fires after ~4 rapid additions and requires a channel-level retry
await sleep(300);
return data;
}
// DELETE https://gate.whapi.cloud/groups/{GroupID}/participants
async function removeGroupMember(groupId, phone) {
const jid = toWhatsAppJID(phone);
const response = await fetch(
`https://gate.whapi.cloud/groups/${groupId}/participants`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${process.env.WHAPI_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ participants: [jid] }),
}
);
// This DELETE is silent: no WhatsApp group notification is sent to other members
return response.json();
}
DELETE-вызов в removeGroupMember не создаёт никаких уведомлений в группе. Сообщение «X был удалён администратором» в WhatsApp появляется только когда живой администратор действует через приложение. Удаление через API — DELETE /groups/{id}/participants — невидимо для остальных участников. Подписчик просто исчезает из списка. Вся социальная неловкость ручного удаления уходит вместе с ним.
Ограничения частоты запросов, идемпотентность и fallback на 403
Три режима сбоя, с которыми столкнётся любой продакшн-деплой. У каждого — детерминированное решение. Заложите все три в код до первого реального платежа подписчика.
IQErrorRateOverlimit — серверная ошибка WhatsApp при слишком частых вызовах addParticipants. Разработчик описал её точно в GitHub-issue: «Неизвестная ошибка (внутренне — IQErrorRateOverlimit) при вызове group.addParticipants() — обычно возникает после 4 успешно добавленных участников при стандартных значениях задержки». Решение в addGroupMember() — await sleep(300) после каждого добавления. При пакетной миграции 50+ существующих подписчиков увеличьте sleep до 500 мс и обрабатывайте по 10 человек с паузой 3 секунды между батчами.
Модуль базы данных ниже реализует и проверку идемпотентности, и хранение записей участников. Используется better-sqlite3 для синхронного доступа к SQLite — это упрощает асинхронный поток:
// db.js -- SQLite member store with idempotency
const Database = require('better-sqlite3');
const db = new Database('members.db');
db.exec(`
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
stripe_subscription_id TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT NOT NULL,
group_id TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS processed_events (
event_id TEXT PRIMARY KEY,
processed_at INTEGER DEFAULT (unixepoch())
);
`);
module.exports = {
hasProcessedEvent: (id) =>
!!db.prepare('SELECT 1 FROM processed_events WHERE event_id = ?').get(id),
markEventProcessed: (id) =>
db.prepare('INSERT OR IGNORE INTO processed_events (event_id) VALUES (?)').run(id),
saveMember: ({ phone, stripeSubscriptionId, stripeCustomerId, groupId }) =>
db.prepare(`
INSERT OR REPLACE INTO members (phone, stripe_subscription_id, stripe_customer_id, group_id)
VALUES (?, ?, ?, ?)
`).run(phone, stripeSubscriptionId, stripeCustomerId, groupId),
getMemberBySubscriptionId: (sid) =>
db.prepare('SELECT * FROM members WHERE stripe_subscription_id = ?').get(sid),
removeMember: (sid) =>
db.prepare('DELETE FROM members WHERE stripe_subscription_id = ?').run(sid),
};
403 от addGroupParticipant означает: настройки приватности WhatsApp подписчика блокируют добавление в группы с сервера. Единственный выход — отправить ему свежую ротированную ссылку-приглашение. Это настройка устройства, которую вы не можете обойти. Форму ошибки смотрите в справочнике API по добавлению нового участника в группу. Fallback реализован в функции sendInviteLink() в следующем разделе.
Ротация ссылки-приглашения: как закрыть вход незарегистрированным участникам
Каждый раз, когда отправляете кому-то ссылку-приглашение, сразу отзывайте её и генерируйте новую. Неротированная ссылка может заполнить группу неплатящими участниками меньше чем за час.
Механизм: DELETE /groups/{id}/invite мгновенно аннулирует текущий код приглашения. GET /groups/{id}/invite генерирует и возвращает новый. Новую ссылку получает только этот подписчик — личным сообщением WhatsApp. Любой код, существовавший до DELETE (от предыдущего теста, прошлого подписчика или слитого скриншота), перестаёт работать немедленно.
// DELETE https://gate.whapi.cloud/groups/{GroupID}/invite (revoke current code)
// GET https://gate.whapi.cloud/groups/{GroupID}/invite (get new code)
// POST https://gate.whapi.cloud/messages/text (send link via DM)
async function sendInviteLink(groupId, phone) {
// Step 1: Revoke -- any previously distributed link stops working now
await fetch(`https://gate.whapi.cloud/groups/${groupId}/invite`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${process.env.WHAPI_TOKEN}` },
});
// Step 2: Get fresh invite code
const res = await fetch(`https://gate.whapi.cloud/groups/${groupId}/invite`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${process.env.WHAPI_TOKEN}` },
});
const { invite_code } = await res.json();
const inviteLink = `https://chat.whatsapp.com/${invite_code}`;
// Step 3: Send the unique link to this subscriber only via DM
await fetch('https://gate.whapi.cloud/messages/text', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.WHAPI_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: toWhatsAppJID(phone),
body: `Your subscription is confirmed! Join your private group here:\n${inviteLink}\n\nThis link is personalized -- please do not share it.`,
}),
});
console.log(`Invite link rotated and sent to ${phone}`);
}
Есть компромисс: каждая ротация аннулирует все предыдущие коды — включая отправленные подписчикам, которые ещё не кликнули. Держите прямое добавление через API как основной путь. Ротацию используйте только как fallback для ошибки 403, а не при стандартном онбординге.
Проекты, которые пропускают ротацию и раздают статический URL приглашения, получают призрачных участников уже через несколько дней после первой публичной промоакции. Каждый неплатящий потребляет контент, занимает одно из 1024 мест и снижает ощущение эксклюзивности для платящих подписчиков. Потеря выручки постоянна: без отслеживания участников на уровне API нет способа задним числом определить, кто вступил по расшаренной ссылке, а кто — по платной подписке.
Что хранить в базе данных — и при каком масштабе
Google Sheets справится с 50 участниками; SQLite — с 50 000. Выбирайте до запуска, а не когда 51-я строка таблицы начнёт давать ошибки поиска.
Таблица выбора:
| Число участников | Хранилище | Время настройки | Идемпотентность | Мигрировать когда |
|---|---|---|---|---|
| ≤50 участников | Google Sheets + Apps Script | ~30 мин | Ручная проверка дублей | Появляются ошибки в поиске |
| 51--500 участников | SQLite (файловая БД, один сервер) | ~2 часа | Таблица processed_events |
Деплой на несколько серверов |
| 500+ участников | PostgreSQL или MySQL | ~4 часа + хостинг | processed_events + индексы |
Никогда — легко справится с полными 1024 участниками |
Для уровня Google Sheets веб-приложение Google Apps Script принимает POST-запросы от вашего вебхук-сервера и добавляет или обновляет строки. Разверните его как публичное веб-приложение, которое ваш Express-сервер вызывает вместо локальной базы:
// Google Apps Script -- deploy as a web app at script.google.com
// Your Express server calls this URL with { action, phone, subscriptionId }
function doPost(e) {
const data = JSON.parse(e.postData.contents);
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Members');
if (data.action === 'add') {
sheet.appendRow([
data.phone,
data.subscriptionId,
data.customerId,
'active',
new Date().toISOString(),
]);
} else if (data.action === 'remove') {
const rows = sheet.getDataRange().getValues();
for (let i = 1; i < rows.length; i++) {
if (rows[i][1] === data.subscriptionId) {
sheet.getRange(i + 1, 4).setValue('removed');
break;
}
}
}
return ContentService.createTextOutput('OK');
}
Схема SQLite из раздела про ограничения частоты покрывает уровень 51--500 без изменений. Для 500+: переходите на PostgreSQL. Названия колонок переносятся напрямую, SERIAL заменяет INTEGER PRIMARY KEY AUTOINCREMENT, unixepoch() заменяется на EXTRACT(EPOCH FROM NOW()). Когда группа достигнет лимита в 1024 участника, добавьте запрос GROUP BY group_id в логику маршрутизации — и охватывайте параллельные группы. Сегодня более 3000 компаний работают на Whapi.Cloud в продакшне, включая операторов платных сообществ, преодолевших порог 1024 участников. Когда подписок станет сотни, последнее, что вам нужно — обновление протокола WhatsApp, которое сломает управление группами в 3 часа ночи. Whapi.Cloud берёт эти изменения протокола на себя: /groups/{id}/participants остаётся стабильным, пока нижележащий слой сессий обновляется прозрачно.
Вариант без кода: триггер Stripe в n8n и HTTP-узел Whapi.Cloud
n8n полностью устраняет Express-сервер: один узел Stripe Trigger, один Set-узел для форматирования телефона и один HTTP Request к Whapi.Cloud через n8n. Никакого кастомного бэкенда для основного сценария.
Поток для checkout.session.completed:
-
Узел Stripe Trigger: настройте на получение
checkout.session.completed. n8n выдаст URL вебхука для регистрации в панели Stripe. -
Узел Set (трансформация телефона): поле выражения:
{{ $json.data.object.metadata.whatsapp_phone.replace(/^\+/, '') + '@s.whatsapp.net' }}. Вся нормализация — в одном выражении. -
Узел HTTP Request: Метод: POST, URL:
https://gate.whapi.cloud/groups/YOUR_GROUP_ID/participants, Аутентификация: Generic Credential (Bearer token), Тело (JSON):{"participants": ["{{ $json.jid }}"]}.
Для пути удаления: дублируйте поток, смените Stripe Trigger на customer.subscription.deleted и метод HTTP на DELETE. Компромисс: у n8n нет встроенной идемпотентности. Повторная доставка Stripe добавит или удалит участника дважды. Для сообществ до 50 активных подписчиков, где объём повторных доставок незначителен, это приемлемо. При большем масштабе путь Node.js + SQLite надёжно обеспечивает дедупликацию через таблицу processed_events.
Что не входит в эту систему — и куда идти дальше
Многоуровневые подписки в этом руководстве не рассматриваются. Маршрутизация подписчиков Silver и Gold в разные группы требует таблицы соответствия Product ID Stripe и ID группы по уровню — а это полностью зависит от вашей структуры цен.
Также за рамками: периоды льготного доступа после отмены, обработка триальных периодов, когда участники вступают до первого списания, и лимит в 8 участников групп официального WhatsApp Business API (он требует более 100 000 сообщений в день для доступа к управлению группами и к реализации Whapi.Cloud на основе веб-сессии не применяется). Для логики льготного периода добавьте колонку grace_days в таблицу участников и скорректируйте проверку в cron истечения срока.









