Cómo Integrar Stripe en tu SaaS en 2026 (Suscripciones Bien Hechas) — Cesar Ayala
← Todos los artículos

Cómo Integrar Stripe en tu SaaS en 2026 (Suscripciones Bien Hechas)

Integra Stripe tratando los webhooks—no el redirect de éxito—como la fuente de verdad. Usa Checkout alojado en modo suscripción para cobrar, Stripe Billing para gestionar la suscripción y el Customer Portal para autoservicio. Otorga acceso solo cuando un webhook idempotente y con firma verificada confirme que la suscripción está activa. Tu base de datos es una réplica sincronizada de Stripe.

La respuesta corta: ¿dónde otorgas el acceso realmente?

Si solo te llevas una idea de todo este post, que sea esta: Stripe es la fuente de verdad, no tu base de datos, y mucho menos el redirect de “pago exitoso”. Tu app no calcula el estado de una suscripción; lo espeja. Guardas un reflejo delgado de lo que Stripe ya sabe —stripe_customer_id, stripe_subscription_id, status, current_period_end, price_id— atado a tu usuario, y cada cambio en ese reflejo llega por un webhook con firma verificada.

¿Por qué tan terminante? Porque los pagos son asíncronos. Una tarjeta puede disparar un challenge de SCA/3DS, un pago ACH se liquida días después, un cargo se rechaza y se reintenta. La llamada a la API que haces y el redirect que el navegador sigue son solo una solicitud. La verdad llega después, por el stream de webhooks.

De ahí sale la regla que sostiene todo lo demás: nunca otorgues acceso en el success_url del cliente. Ese redirect dispara desde el navegador en el instante en que Checkout termina, pero el dinero puede no haberse movido todavía (3DS pendiente, ACH sin liquidar). Y peor: puede dispararse dos veces si el usuario recarga la página, nunca dispararse si cierra la pestaña, o ser reproducido por cualquiera con el link. El success_url es para la UX —un “Listo, estamos preparando tu cuenta”— y nada más.

El stack correcto en 2026 son tres piezas de Stripe que encajan: Checkout alojado para adquirir al cliente, Stripe Billing para correr la suscripción (la máquina de estados, los reintentos, el prorrateo), y el Customer Portal para que el cliente se autogestione. Tú escribes el handler de webhooks, el mapeo usuario↔customer y la lógica de acceso. Stripe hace el resto.

Te lo digo con cicatrices. Cada bug de pérdida silenciosa de clientes y de doble provisión que toqué construyendo el cobro de FinHOA —un SaaS fintech de recaudación de cuotas y contabilidad para condominios— y el billing B2B de Mercanto, se rastreó hasta lo mismo: confiar en el redirect o en mi propia base de datos en lugar de en el stream de webhooks. La versión aburrida y correcta es la que no te despierta a las 3 a.m.

El ciclo de vida real de una suscripción de Stripe

Usuario da clic en SuscribirseTu server crea la Checkout Session (modo subscription)
Redirect a StripeEl cliente paga en la página alojada de Stripe
success_url (solo UX)'Estamos preparando tu cuenta' — NO otorga acceso
Webhook verificado llegacheckout.session.completed + invoice.paid
Provisión en tu DBEspejas status + current_period_end
Renovacióninvoice.paid cada ciclo extiende el acceso
Falla de pagoinvoice.payment_failed → past_due (dunning)
Cancelaciónsubscription.deleted → revocas acceso
Provisionas en el webhook verificado, nunca en el redirect. El redirect es solo UX.

¿Cómo encajan los objetos principales de Stripe?

Antes del código, el modelo de objetos. Si esto te queda claro, el resto del ciclo de vida se explica solo.

  • Customer es la identidad de cobro de tu usuario; guarda sus métodos de pago. Un cus_....
  • Subscription conecta un Customer con uno o más Prices y tiene un status. Un sub_.... Esta es la pieza con estado.
  • Price es el monto recurrente más el intervalo (por ejemplo, $50 al mes). Pertenece a un Product, que es la cosa que vendes (“Plan Pro”).

Cada ciclo de cobro, Stripe genera una Invoice, y cada Invoice tiene un PaymentIntent que rastrea ese intento de pago. Un truco de API que uso siempre: expandir latest_invoice.payment_intent sobre la Subscription para leer el estado real del pago en una sola llamada.

Modela los planes con Products y Prices, nunca con montos hardcodeados

Un Price es inmutable. No puedes editar su monto. Para subir el precio creas un Price nuevo y archivas el viejo; tus suscriptores actuales conservan su precio anterior (quedan grandfathered). Esto sorprende a muchos founders que creen que pueden “editar el plan”.

Otra cosa que aprendí a la mala: referencia los Prices por lookup_key, no por IDs hardcodeados. Un price_1abc... de test no existe en live, así que si lo clavas en el código, tu deploy a producción se rompe en silencio. Con un lookup_key como pro_monthly, el mismo código funciona en cualquier ambiente.

Los modelos de cobro se mapean a la config del Price: flat (un Price recurrente simple), por asiento (usage_type=licensed con quantity), por uso/metered (Billing Meters), y escalonado (billing_scheme=tiered). Elige uno desde el día uno; cambiarlo después implica migrar suscriptores.

El gotcha de 2026 que rompe código viejo

Bajo el modo de cobro flexible (API 2025-06-30 en adelante, necesario para suscripciones de intervalos mixtos), las fechas del periodo ya no viven en subscription.current_period_end de nivel superior. Ahora están en los items de la suscripción (subscription.items.data[].current_period_end) o, más seguro para tu lógica de expiración de acceso, en invoice.lines.data[].period.end cuando llega invoice.paid. Si tu código lee el campo viejo de nivel superior, se va a romper en APIs nuevas.

¿Qué eventos de webhook de Stripe necesitas manejar realmente?

Esta es la parte que los tutoriales saltan. No necesitas suscribirte a los 250 tipos de evento; necesitas un conjunto pequeño y correcto. Aquí está, con lo que significa cada uno y la acción que dispara.

Evento Qué significa Qué haces
checkout.session.completed El flujo de Checkout terminó Liga el customer/subscription a tu usuario vía client_reference_id. Verifica payment_status == 'paid' antes de otorgar
checkout.session.async_payment_succeeded / _failed Método async (ACH/transferencia) que liquidó después Otorga o cancela cuando de verdad se confirme
customer.subscription.created / updated / deleted Los caballos de batalla: transiciones de status, cambios de plan, cancelaciones Sincroniza el status local
invoice.paid El latido de la renovación: dispara en el primer pago Y en cada renovación Provisiona/extiende el acceso aquí
invoice.payment_failed Una renovación falló; la suscripción pasa a past_due Arranca el dunning (NO revoques aún)
customer.subscription.trial_will_end Faltan ~3 días para que termine la prueba Pide que agreguen un método de pago

Dos errores que se ven seguido. Primero: no confundas invoice.paid con payment_intent.succeeded. Para suscripciones, invoice.paid es la señal correcta y menos ruidosa para otorgar acceso; payment_intent.succeeded es de más bajo nivel y dispara para todo. Segundo: no asumas orden ni entrega única. Stripe re-entrega eventos y puede reordenarlos; un subscription.created puede llegar después de un evento de invoice. Si un objeto referenciado todavía no está en tu DB, vuelve a consultarlo por su ID en lugar de tronar.

¿Cómo es el ciclo de vida completo de la suscripción (la máquina de estados)?

Aquí está el artefacto más valioso del post: la tabla de status con la decisión de otorgar o revocar para cada uno. Los tutoriales casi nunca la muestran, y por eso casi nadie integra Stripe bien.

status Qué significa ¿Acceso?
trialing En periodo de prueba, sin cargo aún Otorga
incomplete El primer PaymentIntent no se confirmó (ventana de 23 h) NO provisiones
incomplete_expired El primer pago nunca se completó; invoice anulada (terminal) NO provisiones
active En buen estado, pagado Otorga
past_due Una renovación falló, pero los Smart Retries siguen corriendo MANTÉN acceso (ventana de gracia)
unpaid Reintentos agotados Revoca
canceled Cancelada (terminal) Revoca
paused La prueba terminó sin método de pago Sin cobro hasta reanudar

La forma limpia de gatear tu app: deriva un solo booleano has_access de status in {trialing, active, past_due} y guarda status + current_period_end + cancel_at_period_end localmente. Tu código de features checa ese booleano y ya.

El bug silencioso más común es hardcodear active == tiene acceso e ignorar dos estados. Si ignoras trialing, le niegas acceso a usuarios que están probando tu producto —los que más necesitas convertir. Si ignoras past_due, cortas instantáneamente a clientes cuya tarjeta solo tuvo un rechazo recuperable, y conviertes una falla temporal en churn real.

Cada fase del ciclo se mapea a su evento: el signup es checkout.session.completed + subscription.created; la renovación es invoice.paid; la falla es invoice.payment_failed; la cancelación al final del periodo es subscription.updated con cancel_at_period_end=true; la cancelación inmediata es subscription.deleted.

La máquina de estados como guía de otorgar/revocar

OTORGAtrialing · active · past_due
NO aúnincomplete (ventana de 23 h)
REVOCAunpaid · canceled · incomplete_expired
1 booleanohas_access = status en {trialing, active, past_due}
Deriva un solo booleano has_access de tres estados. Lo demás revoca o espera.

¿Cómo escribes un manejador de webhooks que no provisione dos veces? (el código)

Aquí está el patrón único que separa la producción de los tutoriales. Dos cosas que casi todos rompen: leer el body crudo para verificar la firma, y ser idempotente antes de cualquier cambio de estado.

Primero, crear la Checkout Session en modo suscripción. Fíjate que el success_url es solo para la UX —no otorga nada— y que paso un client_reference_id para poder mapear el customer de Stripe de vuelta a mi usuario en el webhook:

import stripe
from fastapi import FastAPI, Request, HTTPException

stripe.api_key = STRIPE_SECRET_KEY   # sk_test_... en local, sk_live_... en prod
app = FastAPI()

@app.post("/create-checkout-session")
async def create_checkout_session(user, plan_id):
    # Referencia el Price por lookup_key, nunca por un ID hardcodeado
    prices = stripe.Price.list(lookup_keys=["pro_monthly"], expand=["data.product"])

    params = {
        "mode": "subscription",
        "line_items": [{"price": prices.data[0].id, "quantity": 1}],
        "client_reference_id": str(user.id),     # liga Stripe -> tu usuario
        "subscription_data": {"trial_period_days": 14},
        "success_url": f"{APP}/welcome?session_id={{CHECKOUT_SESSION_ID}}",
        "cancel_url": f"{APP}/pricing",
        "idempotency_key": f"checkout:{user.id}:{plan_id}",  # evita suscripciones dobles
    }
    # Reutiliza el Customer si ya existe; en el PRIMER alta NO pases customer=None:
    # omite el parametro (o pasa customer_email) y deja que Checkout cree el cus_.
    if user.stripe_customer_id:
        params["customer"] = user.stripe_customer_id
    else:
        params["customer_email"] = user.email

    session = stripe.checkout.Session.create(**params)
    return {"url": session.url}   # redirige aqui; NO provisiones todavia

Ahora la fuente de verdad: el webhook con firma verificada. El detalle crítico es await request.body() —los bytes crudos. Si parseas el JSON primero (await request.json()), el body re-serializado ya no coincide con la firma y la verificación falla. Ese es el bug número uno de “mis webhooks no jalan”.

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.body()                  # BYTES CRUDOS, nunca request.json()
    sig = request.headers.get("stripe-signature")
    try:
        event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
    except (ValueError, stripe.SignatureVerificationError):   # alias legacy: stripe.error.*
        raise HTTPException(status_code=400)        # firma invalida o body alterado

    # Idempotencia de entrada: Stripe reintenta y puede duplicar/reordenar
    if already_processed(event["id"]):
        return {"received": True}

    # Una sola funcion de sync, llamada desde TODOS los eventos relevantes.
    # Sin logica de provision por-evento que se desincronice.
    SYNC = {
        "checkout.session.completed",
        "customer.subscription.created",
        "customer.subscription.updated",
        "customer.subscription.deleted",
        "invoice.paid",
        "invoice.payment_failed",
    }
    if event["type"] in SYNC:
        obj = event["data"]["object"]
        customer_id = obj.get("customer")
        sync_stripe_to_db(customer_id)              # consulta Stripe, espeja tu DB

    mark_processed(event["id"])
    return {"received": True}                        # 2xx rapido; trabajo pesado async


def sync_stripe_to_db(customer_id):
    subs = stripe.Subscription.list(customer=customer_id, status="all", limit=1)
    if not subs.data:
        return clear_access(customer_id)
    sub = subs.data[0]
    ACTIVE = {"trialing", "active", "past_due"}      # past_due = ventana de gracia
    db_upsert(
        customer_id=customer_id,
        subscription_id=sub.id,
        status=sub.status,
        cancel_at_period_end=sub.cancel_at_period_end,
        has_access=sub.status in ACTIVE,             # el unico booleano que gatea tu app
    )

La arquitectura limpia es esa única función sync_stripe_to_db(customer_id), llamada desde cada webhook, desde el redirect de éxito (de forma optimista, sin riesgo porque es idempotente) y desde un cron de reconciliación. Un solo camino de código, cero deriva entre eventos.

Dos detalles más. Idempotencia de salida: pasa un idempotency_key en tus llamadas de creación (como en la Checkout Session de arriba) para que un reintento por timeout de red no cree dos suscripciones. Y la red de seguridad de reconciliación: los webhooks se pierden —tu endpoint estuvo caído, un deploy tiró requests, Stripe se rinde después de 3 días. Corre un job nocturno que vuelva a sincronizar tu espejo contra la verdad de la API de Stripe. En FinHOA, ese cron atrapó más de un caso de un cliente que pagó pero cuyo webhook nunca llegó.

Los 5 pasos de un webhook handler correcto

  1. 1. Lee el body CRUDOawait request.body() — nunca request.json() antes de verificar
  2. 2. Verifica la firmaconstruct_event con el whsec_ del endpoint
  3. 3. Deduplica por event.idalready_processed() — Stripe reintenta y reordena
  4. 4. Sincroniza con una sola funciónsync_stripe_to_db(customer_id) desde todo evento
  5. 5. Marca procesado + 2xx rápidoTrabajo pesado async, o Stripe reintenta
Cada paso previene un bug real: firma falsa, doble provisión, o reintentos de Stripe.

¿Qué pasa cuando un pago falla (y cómo detienes la pérdida silenciosa)?

Esta es la pieza que más se salta y la que más dinero cuesta. Entre el 20% y el 40% del churn (la pérdida de clientes) es involuntario: tarjetas vencidas, rechazadas, sin fondos. No es gente que decidió irse; es gente cuyo pago simplemente falló y nadie reaccionó.

Cuando una renovación falla, Stripe no cancela. Pone el status en past_due y corre los Smart Retries —reintentos con timing calculado por ML, por defecto hasta 8 intentos a lo largo de ~2 semanas. La invoice carga attempt_count y next_payment_attempt. Configuras el estado final (cancelar o marcar unpaid) en los ajustes de Billing del Dashboard.

Mi opinión, y no es negociable: mantén el acceso durante past_due y corre tus propios correos de dunning. NO revoques en el primer invoice.payment_failed. Los Smart Retries recuperan alrededor del 55–57% de los pagos recurrentes fallidos (Stripe reporta ~55% en promedio) —pero solo si dejas que Stripe lo intente y tú reaccionas a los eventos en lugar de cortar al cliente de golpe.

Lo aprendí directamente en FinHOA. Al principio cortábamos a los usuarios past_due de inmediato, en cuánto llegaba la primera falla. El resultado fue que rechazos de tarjeta perfectamente recuperables —una tarjeta que se renovó con nueva fecha, un saldo que entraba dos días después— se convertían en churn real y en administradores de condominio molestos llamando a soporte. Cuando movimos la lógica a “mantén acceso durante toda la ventana de Smart Retries + un correo con link al Customer Portal para actualizar la tarjeta”, recuperamos ingresos que antes se nos iban por pura inercia.

Empareja los Smart Retries con tus propios correos y maneja customer.subscription.trial_will_end (dispara 3 días antes) para confirmar que existe un método de pago antes de que la prueba se convierta en una suscripción de pago. El cliente que no agregó tarjeta no debería enterarse de que su acceso se acabó por una pantalla de error.

¿Cómo manejas pruebas, prorrateo y cancelación de autoservicio?

Estos son los detalles del ciclo de vida que los tutoriales pasan por encima. Aquí va la versión correcta.

Pruebas. Pones trial_period_days en los datos de la suscripción; el status es trialing, sin cargo. trial_will_end dispara 3 días antes para que pidas la tarjeta. Si configuras trial_settings.end_behavior.missing_payment_method=pause, una suscripción sin método de pago al terminar la prueba pasa a paused en lugar de cobrar y fallar.

Prorrateo. En un upgrade o downgrade a mitad de ciclo, Stripe prorratea automáticamente: acredita el tiempo no usado del precio viejo y cobra la parte proporcional del nuevo. Lo controlas con proration_behavior (create_prorations | none | always_invoice). Para mostrarle al cliente el cargo exacto antes de aplicarlo, usa Invoice.create_preview —ojo, el endpoint legacy /v1/invoices/upcoming se deprecó en la API Basil de 2025 y ahora necesita pasar subscription_details explícito.

Cambia de plan actualizando los items de la suscripción con el nuevo price, nunca borrando y recreando la suscripción. Borrar y recrear pierde el estado de la prueba, el ancla de cobro y el historial de prorrateo.

Cancelación. Con cancel_at_period_end=true el cliente conserva acceso hasta el final del periodo pagado (el status sigue active con la bandera, y subscription.deleted dispara al final). Tu gate de acceso debe respetar current_period_end, no el momento del clic. La cancelación inmediata borra ya.

Y mi recomendación más fuerte de esta sección: no construyas tu propia UI de billing. El Customer Portal (stripe.billing_portal.Session.create) maneja actualización de tarjeta, cambios de plan, cancelaciones y facturas. Todos esos cambios regresan a ti como los mismos webhooks subscription.updated/deleted, así que escribes la lógica de provisión una sola vez y el Portal la reutiliza gratis. Construir esos formularios a mano es trabajo que Stripe ya hizo, con más superficie de bugs y más alcance de PCI.

Checkout + Portal alojados vs. Elements personalizado — ¿cuál usar?

La decisión de build-vs-buy. En 2026, para casi cualquier SaaS que lance suscripciones, lo alojado es la respuesta correcta.

Por defecto, usa lo alojado. Checkout + Billing + Customer Portal te dan SCA/3DS, impuestos, Apple/Google Pay, UI de dunning y flujos de autoservicio por casi nada de código, y te mantienen en PCI SAQ-A —el alcance más ligero. Construye con Payment Element / PaymentIntents solo cuando de verdad necesitas una UX de checkout totalmente custom dentro de tu app; ahí te toca a ti manejar SCA, la UI de reintentos y más alcance de PCI.

La regla dura: los datos de tarjeta nunca tocan tu servidor, tus logs ni tu DB. Guardas solo IDs de Stripe (cus_, sub_, pm_). El momento en que un PAN crudo pasa por tu backend, heredas obligaciones de PCI enormes.

El recap de seguridad no negociable: verifica firmas con el body crudo, deduplica por event.id, regresa 2xx rápido, pasa idempotency_key en las escrituras, y mapea customer→usuario vía client_reference_id/metadata. Y cuidado con la trampa clásica de test/live: las llaves, endpoints, signing secrets, products, prices y customers están todos duplicados por modo. Subir a producción con el whsec_ de test rompe la verificación de firma en silencio —nadie se provisiona y no hay error obvio.

Sé honesto sobre qué hace Stripe y qué construyes tú: el Portal y Billing te quitan muchísimo, pero sigues siendo dueño del handler de webhooks, del mapeo usuario↔customer y de la lógica de otorgar acceso. Eso no lo automatiza nadie.

Checkout + Portal alojados vs. Elements custom

Alojado (Checkout + Portal)

  • Casi cero código de pago
  • SCA/3DS, impuestos, Apple/Google Pay incluidos
  • UI de dunning y autoservicio gratis
  • PCI SAQ-A (alcance mínimo)
  • El caso correcto el 90% de las veces

Elements / Payment Element custom

  • UX totalmente dentro de tu app
  • Tú manejas SCA y la UI de reintentos
  • Más alcance de PCI
  • Mucho más código que mantener
  • Solo si la UX custom es el producto
Para un SaaS que lanza suscripciones, lo alojado gana casi siempre en 2026.

¿Cuánto cuesta Stripe realmente en 2026?

Hablemos de números reales, porque este es el dato que los founders modelan tarde. Te lo enmarco para un founder en Puebla o en Latinoamérica, que es donde estoy.

Procesamiento de tarjeta en EE. UU.: 2.9% + $0.30 por cargo exitoso. Stripe Billing suma un 0.7% plano del volumen de cobro (las tarifas viejas de 0.5%/0.8% de los planes Starter/Scale ya no existen; la promo legacy de 0.5% terminó el 30 de junio de 2025).

Un ejemplo trabajado para una suscripción de $50 al mes: ~$0.30 fijo + ~$1.45 (2.9%) + ~$0.35 (0.7% de Billing) = alrededor de $2.10 antes de extras. Pero el “all-in” real para muchos SaaS aterriza entre 4.5% y 6.5% una vez que sumas cross-border (~1.5%) y conversión de moneda (~1%).

Específico de México (cifras aproximadas, al momento de escribir): Stripe MX cobra ~3.6% + MXN 3.00 doméstico, +0.5% por tarjetas internacionales, +2% por conversión de moneda. Es distinto a EE. UU., así que tenlo separado en tu modelo. Y las disputas/chargebacks cuestan $15 no reembolsables cada una; Stripe Tax suma ~0.5% por transacción donde estés registrado (calcula y cobra, no presenta declaraciones por ti).

Mi opinión de primera mano para una audiencia poblana: muchos founders de Latinoamérica incorporan y cobran en USD vía una entidad en EE. UU. —por ejemplo una C-corp de Delaware con Stripe Atlas— para cobrar en dólares y evitar el 2% de FX en cada cargo. El costo es lidiar con payouts y banca en USD. No es para todos, pero si vendes global, ese 2% por transacción se acumula rápido. Modela las tarifas en tu pricing desde el día uno; es el número que se olvida. (Si estás dimensionando todo el costo de construir tu SaaS, el billing es una parte real de ese presupuesto.) Y siempre revisa la página de precios actual de Stripe, porque esto cambia.

Preguntas frecuentes: integración de suscripciones con Stripe

¿Necesito Stripe Billing o solo Payments? Billing, para suscripciones recurrentes. Te da la máquina de estados, los Smart Retries, el prorrateo y el Portal. Payments solo no corre suscripciones bien —terminarías reimplementando media máquina de estados a mano.

¿Cómo pruebo webhooks en local? Corre stripe login, luego stripe listen --forward-to localhost:8000/webhook —imprime un whsec_ local y tuneliza eventos reales a tu máquina. Dispara eventos con stripe trigger invoice.payment_failed. Tarjetas de prueba: 4242 4242 4242 4242 (ok), 4000 0000 0000 0341 (se adjunta pero falla al cobrar), 4000 0027 6000 3184 (requiere 3DS).

¿Necesito un backend y cumplimiento PCI? Sí necesitas backend, para los webhooks. Checkout alojado te mantiene en SAQ-A —el alcance más ligero— y nunca guardas datos de tarjeta.

¿Los clientes pueden cancelar solos? Sí, con el Customer Portal. Los cambios regresan como los mismos webhooks subscription.updated/deleted, así que tu lógica de provisión los maneja sin código extra.

¿invoice.paid o checkout.session.completed para otorgar acceso? Usa invoice.paid como la señal de “el dinero llegó”, que dispara en el primer pago y en cada renovación. checkout.session.completed liga al customer y es la primera señal para periodos de prueba (cuando aún no hay cargo).

¿Y los impuestos / Stripe en México? Enciende Stripe Tax temprano. Recuerda que las tarifas de MX difieren de las de EE. UU. y considera la opción de la entidad en USD si vendes global.

Cierre: lanza la versión aburrida y correcta

La columna vertebral, una vez más: Stripe es la fuente de verdad; tu app es una réplica de lectura sincronizada por webhooks verificados e idempotentes. Provisionas en el webhook, nunca en el redirect.

Tu checklist de “bien hecho”:

  • Verifica firmas con el body crudo.
  • Idempotencia en ambas direcciones (event.id de entrada, idempotency_key de salida).
  • Nunca provisiones en el success_url.
  • Maneja invoice.payment_failed y mantén acceso durante past_due.
  • Nunca toques datos de tarjeta crudos.
  • Respeta el split test/live de llaves y signing secrets.
  • Loguea y reconcilia con un cron nocturno.

No reinventes lo que Stripe ya te da: subscription.status es tu gate de acceso, y el Customer Portal es dueño del autoservicio. Esta es exactamente la arquitectura detrás del billing real que lancé en FinHOA y Mercanto —no es teoría de un tutorial, es lo que aguantó en producción con dinero de verdad de por medio. Si estás construyendo un SaaS y el cobro es el corazón del producto, así es como lo haría yo. Y si prefieres no pelearte con la máquina de estados de Stripe tú solo, así trabajo el desarrollo de SaaS y puedes contratarme para construirlo.