Resumen: Monta express.raw({ type: 'application/json' }) en tu ruta /webhook antes de cualquier middleware JSON; esta decisión determina si cada verificación de firma de Stripe tiene éxito o falla. Elimina el '+' inicial del teléfono del suscriptor y agrega @s.whatsapp.net. Llama a POST en /groups/{id}/participants con el evento checkout.session.completed; DELETE con customer.subscription.deleted. Google Sheets gestiona hasta 50 miembros. SQLite gestiona el resto. Todo el código está en esta guía.
Quién usa grupos de WhatsApp de pago en 2026 (y por qué la gestión manual falla)
Entrenadores de fitness, proveedores de señales de criptomonedas, buscadores de oportunidades en el mercado inmobiliario y tutores para exámenes están operando grupos de WhatsApp de pago a gran escala; casi todos gestionan sus miembros en una hoja de cálculo que inevitablemente falla a partir de los 50 suscriptores.
El mercado está establecido en cuatro segmentos: fitness ($9,99--$140/mes), señales de criptomonedas ($49--$149/mes en safetrading.today), tutores para exámenes en Nigeria y Sudáfrica (₦1.500--R149/mes) y grupos de comercio de lujo como el minorista británico Love Luxe, donde las filtraciones del enlace de invitación son igual de frecuentes. Proyectos de código abierto como dhryvyad/wahooks (MIT, 2026) confirman el interés de los desarrolladores en soluciones autogestionadas más allá del SaaS comercial.
El coste operativo está documentado. CommuniPass, que cobra entre $29 y $299/mes como SaaS para exactamente este problema, documenta errores sistemáticos a partir de unos 50 miembros, donde cruzar las fechas de pago con los nombres de pantalla de WhatsApp supera la tolerancia de error humana. El coste de tiempo es aproximadamente 8 horas semanales, o $20.800 al año a $50 la hora.
El problema de la pérdida de ingresos es otro asunto y, en la práctica, más grave. Los grupos pierden entre un 15 y un 20% de los ingresos por suscripción debido a miembros con membresía expirada que permanecen en el grupo después de que su pago caduca. El motivo persiste por el componente social: WhatsApp envía una notificación visible a todo el grupo cuando se elimina a alguien a través de la app. Como lo expresó directamente un operador: "Los miembros se quedan en el grupo después de que caduca su suscripción porque resulta incómodo expulsarlos manualmente". La llamada DELETE a nivel de API que se describe en esta guía elimina a los miembros sin que los demás reciban ninguna notificación.
Por qué el enfoque de Zapier con enlace de invitación falla en cuanto alguien lo comparte
Puede que primero recurras a Zapier o Make: evento de pago de Stripe → obtener enlace de invitación del grupo → enviar por correo electrónico o SMS. Esto funciona hasta que un suscriptor comparte el enlace.
Como NothingApps documentó este modo de fallo exacto: "Vendes un lugar en tu grupo VIP. En menos de una hora, cinco miembros fantasma se han unido porque el comprador compartió el enlace en un foro público." Un enlace de invitación estático enviado a todos los suscriptores genera una fuga acumulativa sin ningún mecanismo para detenerla. El flujo de Zapier tampoco ofrece ninguna forma de eliminar miembros cuando las suscripciones caducan: reacciona al pago exitoso pero no tiene capacidad de DELETE sobre la membresía del grupo, ni un activador para la cancelación de la suscripción.
El enfoque de esta guía agrega y elimina miembros individuales por número de teléfono, no mediante un enlace compartible. La rotación del enlace de invitación sigue disponible como alternativa solo para el caso extremo en que la configuración de privacidad de un suscriptor bloquee la adición directa por API, lo que se gestiona en la sección de enlaces de invitación más adelante.
El sistema completo: Stripe Checkout → Webhook → Whapi.Cloud
Cinco componentes, dos eventos de Stripe, un token de Whapi.Cloud. Todo el ciclo de vida de la membresía cabe en un único archivo Express con un módulo de base de datos y dos rutas de API.
Flujo en tiempo de ejecución:
-
Creación de la sesión de Checkout: El suscriptor completa la página de pago alojada por Stripe. Su número de teléfono de WhatsApp se captura en
metadata.whatsapp_phoneal crear la sesión, no al confirmar el pago. -
checkout.session.completed: Stripe entrega este evento en segundos tras el pago exitoso. Tu handler lee el teléfono de los metadatos, lo convierte en un JID de WhatsApp y llama aPOST /groups/{id}/participants. El suscriptor queda en el grupo. -
Membresía activa: Tu base de datos registra el teléfono, el ID de suscripción de Stripe y el ID del grupo. Un cron diario verifica los registros que el webhook pudo haber omitido.
-
customer.subscription.deleted: Se dispara al cancelar o cuando Stripe agota los reintentos de pago. Tu handler llama aDELETE /groups/{id}/participantscon el JID del miembro. -
Eliminación silenciosa: La llamada DELETE elimina al miembro sin generar ninguna notificación en el grupo de WhatsApp. El problema de fricción social desaparece.
customer.subscription.deleted es el único evento de Stripe que garantiza la eliminación al vencer. Un error habitual de configuración es enrutar la eliminación a invoice.payment_failed. Ese evento se dispara en cada intento fallido (hasta 4 por defecto), no cuando Stripe cancela definitivamente la suscripción. La suscripción puede recuperarse tras un reintento. Usa customer.subscription.deleted.
Cómo capturar el teléfono de WhatsApp del suscriptor en Stripe Checkout
Captura el número de teléfono de WhatsApp del suscriptor al crear la sesión. No podrás recuperarlo de forma fiable desde Stripe después sin crear un sistema de búsqueda de perfil de cliente independiente.
Agrega un paso de entrada del teléfono en tu frontend antes de la redirección a Stripe. Pasa el valor a tu backend al crear la sesión de Checkout mediante el campo metadata de Stripe:
// 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 });
});
Si whatsapp_phone no está en los metadatos de la sesión cuando se dispara checkout.session.completed, tu handler no podrá agregar al miembro. Valida este campo en tu formulario de checkout. Rechaza envíos con menos de 10 dígitos y exige el prefijo internacional para que la conversión de E.164 a JID funcione correctamente en el siguiente paso.
El objeto de sesión al completarse también contiene el ID de subscription. Guárdalo junto al número de teléfono en tu base de datos; es la clave de búsqueda cuando se dispare customer.subscription.deleted más adelante. Sin esta relación, el evento de eliminación no podrá identificar qué número de teléfono eliminar.
Por qué fallan las firmas de webhook de Stripe y cómo solucionarlo
Toda verificación de firma de Stripe falla silenciosamente si express.json() se ejecuta antes que tu handler del webhook. La solución es una línea en la posición correcta; pero suele llevar horas diagnosticarlo la primera vez.
Los equipos que registran express.json() de forma global y luego agregan una ruta /webhook descubren que stripe.webhooks.constructEvent() lanza "No signatures found matching the expected signature for payload" en cada llamada, incluso con el secreto correcto. El error no da ninguna pista. La causa: express.json() ya ha re-serializado el cuerpo de la solicitud, modificando su representación en bytes lo suficiente para invalidar la firma HMAC-SHA256 que Stripe calculó sobre los bytes originales en bruto.
La regla del raw-body primero: monta express.raw({ type: 'application/json' }) en la ruta /webhook antes de registrar express.json() globalmente. El middleware específico de ruta en Express se ejecuta primero, por lo que la ruta del webhook recibe un Buffer antes de que el parser global lo toque.
// 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);
Con el orden de rutas correcto, el handler del webhook verifica la firma y enruta los eventos a la lógica de negocio correspondiente:
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');
}
La verificación de idempotencia depende de una tabla processed_events que registra cada ID de evento de Stripe tras su procesamiento exitoso. El esquema de esta tabla se incluye en la sección de base de datos más adelante.
Normalización del número de teléfono: dos pasos que evitan el 90% de los fallos
Elimina el '+', agrega '@s.whatsapp.net'. Dos operaciones de cadena, cero ambigüedad. Todos los demás fallos en las integraciones de Stripe + Whapi.Cloud se originan en omitir uno de estos pasos.
WhatsApp identifica los contactos por JID (Jabber ID), no por número de teléfono. El formato es {country_code}{national_number}@s.whatsapp.net. Stripe almacena los números en formato E.164 con un '+' inicial. La conversión es determinista:
// 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);
}
Los equipos pasan horas depurando por qué todas las llamadas a addGroupParticipant fallan con un error genérico, solo para descubrir que el JID contenía un '+' o un espacio de un campo de entrada sin limpiar. Este es el fallo silencioso más frecuente en las nuevas integraciones de Stripe + Whapi.Cloud. Llama a isValidJID() antes de cada llamada a la API y registra un error explícito si devuelve false.
Dos casos extremos a gestionar en el formulario de checkout: (1) números con espacios o guiones: elimina todos los caracteres no numéricos antes de quitar el '+'; (2) números sin código de país: rechaza los que tengan menos de 10 dígitos y solicita el formato internacional.
Agregar al pagar, eliminar al vencer
Dos handlers de eventos de Stripe, cuatro llamadas HTTP a Whapi.Cloud. La ruta de adición al pagar se ejecuta al hacer checkout; la de eliminación al vencer, cuando la suscripción termina.
El handler de checkout lee el teléfono de los metadatos de la sesión, agrega al miembro y guarda el registro para el seguimiento de vencimientos:
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})`);
}
Las llamadas a la API de Whapi.Cloud son solicitudes HTTP directas a la API de Grupos. La llamada de adición publica el JID del suscriptor como participante:
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();
}
La llamada DELETE de removeGroupMember no genera ninguna notificación en el grupo. El mensaje "X fue eliminado por el administrador" en WhatsApp solo aparece cuando un administrador usa la app de WhatsApp manualmente. La eliminación mediante la API con DELETE /groups/{id}/participants es invisible para los demás miembros. El suscriptor simplemente desaparece de la lista de participantes. El problema de fricción social —la incomodidad de expulsar a alguien manualmente— desaparece con él.
Límites de velocidad, idempotencia y el fallback 403
Tres modos de fallo que encontrará cualquier despliegue en producción, cada uno con una solución determinista. Incluye los tres en el código antes de tu primer pago de suscriptor en vivo.
IQErrorRateOverlimit es el error del servidor de WhatsApp que se dispara cuando llamas a addParticipants demasiado rápido. Un desarrollador documentó el problema en un issue de GitHub con precisión: "Error desconocido (internamente IQErrorRateOverlimit) al llamar a group.addParticipants() — ocurre típicamente después de 4 miembros agregados con éxito con los valores de espera predeterminados." La solución en addGroupMember() es await sleep(300) después de cada adición individual. Para migraciones masivas donde agregas 50 o más suscriptores existentes a la vez, aumenta el sleep a 500 ms y procesa en lotes de 10 con una pausa de 3 segundos entre lotes.
El módulo de base de datos siguiente implementa tanto la verificación de idempotencia como el almacenamiento de registros de miembros. Usa better-sqlite3 para acceso síncrono a SQLite, lo que simplifica el flujo asíncrono:
// 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),
};
Un 403 de addGroupParticipant significa que la privacidad de WhatsApp del suscriptor bloquea las adiciones a grupos desde el servidor. La única salida: enviarle directamente un enlace de invitación rotado. Se trata de una configuración del dispositivo que no puedes modificar. Consulta la referencia de la API para agregar un nuevo miembro al grupo para ver la forma exacta del error. El fallback lo gestiona sendInviteLink() en la siguiente sección.
Rotación del enlace de invitación: cómo cerrar el acceso de miembros no pagados
Cada vez que envíes a alguien un enlace de invitación, revócalo de inmediato y genera uno nuevo. Un enlace no rotado puede llenar tu grupo de miembros que no pagan en menos de una hora.
El mecanismo: DELETE /groups/{id}/invite invalida el código de invitación actual al instante. GET /groups/{id}/invite genera y devuelve un nuevo código. El nuevo enlace se envía únicamente al suscriptor, por mensaje directo de WhatsApp. Cualquier código anterior al DELETE (de una prueba anterior, un suscriptor pasado o una captura de pantalla filtrada) deja de funcionar de inmediato.
// 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}`);
}
Hay una compensación: cada rotación invalida todos los códigos anteriores, incluidos los enviados a suscriptores que aún no los han usado. Mantén la adición directa por API como ruta principal. Usa la rotación solo como fallback para el error 403, no en la incorporación habitual.
Los proyectos que omiten la rotación y entregan una URL de invitación estática registran la aparición de miembros fantasma a los pocos días de su primera promoción pública. Cada miembro que no paga consume contenido, ocupa uno de los 1.024 lugares del grupo y reduce la exclusividad percibida por los suscriptores de pago. La pérdida de ingresos es permanente: no hay forma de identificar retroactivamente qué miembro se unió mediante un enlace compartido frente a una suscripción de pago sin un seguimiento de participantes a nivel de API.
Qué almacenar en tu base de datos (y a qué escala)
Google Sheets gestiona 50 miembros; SQLite gestiona 50.000. Elige antes del lanzamiento, no después de que la fila 51 de tu hoja de cálculo empiece a producir errores de búsqueda.
Tabla de decisión:
| Número de miembros | Almacenamiento | Tiempo de configuración | Idempotencia | Migrar cuando |
|---|---|---|---|---|
| ≤50 miembros | Google Sheets + Apps Script | ~30 min | Verificación manual de duplicados | Aparecen errores en las búsquedas |
| 51--500 miembros | SQLite (basado en archivo, servidor único) | ~2 horas | Tabla processed_events |
Despliegas en varios servidores |
| 500+ miembros | PostgreSQL o MySQL | ~4 horas + alojamiento | processed_events + indexado |
Nunca — gestiona sin problemas grupos con los 1.024 miembros completos |
Para el nivel de Google Sheets, una aplicación web de Google Apps Script recibe solicitudes POST de tu servidor webhook y agrega o actualiza filas. Despliégala como una aplicación web pública a la que tu servidor Express llama en lugar de a una base de datos local:
// 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');
}
El esquema de SQLite de la sección de límites de velocidad cubre el nivel de 51 a 500 sin modificaciones. Para el nivel de 500+, migra a PostgreSQL: los nombres de columna se transfieren directamente, SERIAL sustituye a INTEGER PRIMARY KEY AUTOINCREMENT y unixepoch() se convierte en EXTRACT(EPOCH FROM NOW()). Cuando tu grupo llegue al límite de 1.024 miembros, agrega una consulta GROUP BY group_id a tu lógica de enrutamiento de miembros para abarcar grupos paralelos. Más de 3.000 empresas ejecutan Whapi.Cloud en producción hoy en día, incluyendo operadores de comunidades de pago que han escalado más allá de los 1.024 miembros. Cuando tus suscripciones alcancen cientos de registros, lo último que necesitas es una actualización del protocolo de WhatsApp que interrumpa la gestión de grupos a las 3 de la madrugada: Whapi.Cloud absorbe esos cambios de protocolo, manteniendo estable /groups/{id}/participants mientras la capa de sesión subyacente se actualiza de forma transparente.
La opción sin código: trigger de Stripe en n8n y el nodo HTTP de Whapi.Cloud
n8n elimina por completo el servidor Express: un nodo Stripe Trigger, un nodo Set para el formato del teléfono y un nodo HTTP Request hacia Whapi.Cloud mediante n8n. Cero código de backend personalizado para el camino habitual.
Flujo para checkout.session.completed:
-
Nodo Stripe Trigger: configúralo para recibir
checkout.session.completed. n8n proporciona una URL de webhook para registrar en tu panel de Stripe. -
Nodo Set (transformación del teléfono): campo de expresión:
{{ $json.data.object.metadata.whatsapp_phone.replace(/^\+/, '') + '@s.whatsapp.net' }}. Esta es la normalización completa en una sola expresión. -
Nodo HTTP Request: Método: POST, URL:
https://gate.whapi.cloud/groups/YOUR_GROUP_ID/participants, Autenticación: credencial genérica (Bearer token), Cuerpo (JSON):{"participants": ["{{ $json.jid }}"]}.
Para la ruta de eliminación, duplica el flujo y cambia el Stripe Trigger a customer.subscription.deleted y el método HTTP a DELETE. La compensación: n8n no tiene idempotencia nativa. Un reintento de Stripe entrega el evento dos veces, agregando o eliminando al miembro por duplicado. Para comunidades con menos de 50 suscriptores activos donde el volumen de reintentos es insignificante, esto es aceptable. Por encima de ese umbral, la ruta de Node.js + SQLite gestiona la deduplicación de forma fiable mediante la tabla processed_events.
Lo que este sistema no contempla (y qué hacer a continuación)
Las suscripciones multi-nivel quedan fuera del alcance de esta guía. Enrutar a los suscriptores Silver y Gold a grupos distintos requiere una tabla de búsqueda de IDs de producto de Stripe y IDs de grupo por nivel que dependen completamente de tu estructura de precios.
También quedan fuera: los períodos de gracia tras la cancelación, la gestión de los períodos de prueba en los que los miembros se unen antes de su primer cobro, y el límite de 8 miembros en grupos de la API oficial de WhatsApp Business (que requiere más de 100.000 mensajes diarios para acceso a la gestión de grupos y no aplica a la implementación de sesión web de Whapi.Cloud). Para la lógica de gracia en las suscripciones, agrega una columna grace_days a tu tabla de miembros y ajusta la verificación en el cron de vencimiento en consecuencia.









