
Entitlements y límites de uso en SaaS: convierte un plan de Stripe en features y cuotas que de verdad se aplican (2026)
Un entitlement convierte una suscripción en acceso aplicado. Divídelo en dos: feature gates booleanos (¿el plan incluye X?) con Stripe Entitlements, sincronizados desde el webhook active_entitlement_summary.updated hacia un caché; y cuotas numéricas (¿cuánto en el periodo?) con Billing Meters más tus propios contadores atómicos. Aplica ambos del lado del servidor, por petición.
Dos problemas que los tutoriales de billing confunden: feature gates vs cuotas de uso
Casi todos los tutoriales de Stripe terminan en el checkout.session.completed: cobraste, hay una suscripción activa, fin. Pero ahí no termina nada. Empieza la parte que de verdad sostiene el producto: convertir esa suscripción en acceso aplicado. Y aquí es donde la mayoría mete todo en una sola caja cuando en realidad son dos mecanismos distintos.
Cuando construí el billing de FinHOA aprendí esto a golpes: hay dos preguntas que un SaaS tiene que responder en cada petición, y no se contestan igual.
- Feature gate (booleano): ¿el plan de este cliente incluye la feature X? Exportar a API, SSO, analytics avanzado, white-label. La respuesta es sí o no.
- Cuota de uso (numérica): ¿cuánto de X lleva consumido este periodo? Llamadas a la API, seats, proyectos, tokens. La respuesta es un número contra un límite.
La mayoría de los SaaS reales necesitan las dos, y son maquinarias diferentes. Un feature gate lo resuelves con un flag derivado de la suscripción. Una cuota la resuelves con un contador tuyo que cuentas y comparas contra el límite del plan, en tiempo real.
El punto clave que casi nadie dice: una decisión de entitlement se basa en la suscripción más las reglas de empaquetado, no solo en la identidad del usuario. Autenticar te dice quién es; el entitlement te dice qué puede hacer según lo que paga. Son capas distintas.
Y la disciplina que lo amarra todo: el estado de billing debe coincidir con el acceso al producto en tiempo real. Se aplica dentro del producto, en la petición. No se reconcilia en finanzas tres semanas después cuando alguien nota que un cliente con downgrade seguía usando la feature premium.
Esto cierra mi serie de billing: las suscripciones arman el plan, la facturación por uso mide los números, y este post los aplica.
Feature gates vs cuotas de uso
Feature gate (booleano)
- ¿El plan incluye la feature X?
- Stripe Entitlements: feature → product → active entitlement
- Sincronizado por webhook hacia un cache
- Ejemplos: SSO, export API, analytics avanzado
Cuota de uso (numerica)
- ¿Cuanto de X lleva el periodo?
- Billing Meters para facturar + tu contador atómico para aplicar
- Incremento atómico contra el limite del plan
- Ejemplos: llamadas API, seats, proyectos, tokens
¿Cómo resuelve Stripe Entitlements la mitad de los feature gates?
Stripe Entitlements mapea las features de tu servicio a tus products. Una Feature es una capacidad monetizable: tiene id, name, un lookup_key (de máximo 80 caracteres, único, es el identificador con el que tu código pregunta) y metadata.
El flujo es directo. Creas la feature, la enganchas a un product (eso se llama product feature), y cuando un cliente se suscribe a ese product, Stripe crea automáticamente un active entitlement de esa feature para ese customer.
Crear una feature y engancharla a un product:
# 1) Crear la feature (lookup_key es como tu codigo la pregunta).
# El POST devuelve un id con prefijo feat_ (formato real: feat_test_...).
curl https://api.stripe.com/v1/entitlements/features \
-u "$STRIPE_SECRET_KEY:" \
-d name="Advanced Analytics" \
-d lookup_key=advanced_analytics
# 2) Engancharla a un product (product feature).
# entitlement_feature = el id (feat_...) que devolvio el paso 1.
curl https://api.stripe.com/v1/products/prod_123/features \
-u "$STRIPE_SECRET_KEY:" \
-d entitlement_feature=feat_456
Para leer qué tiene un cliente, pides sus active entitlements. Cada uno carga el lookup_key de la feature:
curl -G https://api.stripe.com/v1/entitlements/active_entitlements \
-u "$STRIPE_SECRET_KEY:" \
-d customer=cus_789
Eso es todo lo que hace Entitlements, y lo hace bien: contesta el booleano “¿este customer tiene la feature X?” derivado directamente del billing. La fuente de verdad de qué incluye cada plan vive en Stripe, no hardcodeada en tu app.
El límite duro que tienes que tener clarísimo: Stripe Entitlements NO mide cuotas de uso. Solo acceso a features. No sabe cuántas llamadas a la API llevas; no es para eso. Esa mitad la cubres con Billing Meters más tus propios contadores, que vemos más abajo.
Documentación oficial: Stripe Entitlements y la Active Entitlement API.
Sincronizar entitlements: el webhook hacia un caché (y por qué nunca llamas a Stripe por petición)
Regla número uno: nunca llamas a Stripe en la ruta de la petición. La latencia te mata y los rate limits te tumban. La suscripción de un cliente no cambia mil veces por segundo, pero tu endpoint sí se llama mil veces por segundo. Llamar a la API de Stripe por request es un no rotundo.
La solución es sincronizar por evento. Stripe dispara entitlements.active_entitlement_summary.updated (el active entitlement summary, o resumen de entitlements activos) cada que los entitlements de un customer cambian. Escuchas ese evento, traes los active entitlements del customer, y guardas el set de lookup_key en tu caché, indexado por tenant.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// El webhook ya viene con la firma verificada (ver el post de verificacion de firma).
export async function handleEntitlementWebhook(event, { redis, tenantByCustomer }) {
if (event.type !== "entitlements.active_entitlement_summary.updated") return;
const customerId = event.data.object.customer;
const tenantId = await tenantByCustomer(customerId);
// Trae TODOS los active entitlements del customer (maneja paginacion).
const keys = [];
for await (const ent of stripe.entitlements.activeEntitlements.list({
customer: customerId,
})) {
keys.push(ent.lookup_key); // cada active entitlement carga su feature lookup_key
}
// Reescribe el cache del tenant: una sola fuente de verdad para los feature gates.
const cacheKey = `entitlements:${tenantId}`;
await redis.del(cacheKey);
if (keys.length) await redis.sadd(cacheKey, ...keys);
}
Verifica siempre la firma del webhook antes de confiar en el payload; si te saltas eso, cualquiera puede regalarse features con un POST falso. Lo explico a detalle en verificar la firma del webhook de Stripe.
Un detalle de timing importante: los cambios de feature sobre suscripciones existentes aplican al inicio del siguiente periodo de facturación, así que el summary se dispara en ese momento, no instantáneamente al editar el product. Eso es por diseño y tiene implicaciones para downgrades, que veremos.
El bug número uno de toda esta capa: un caché que llenas pero nunca invalidas. Si no reaccionas al webhook, un cliente que canceló sigue con acceso premium hasta que el caché expire por TTL, o para siempre si no le pusiste TTL. Caché viejo = acceso equivocado.
Construir la capa de entitlements en 4 pasos
- Define featuresCrea cada Feature con su lookup_key (maximo 80 caracteres) en Stripe Entitlements.
- Mapea a productsEngancha cada feature a su product (product feature) via POST /v1/products/{id}/features.
- Sincroniza por webhookEscucha entitlements.active_entitlement_summary.updated y escribe los lookup_key en tu cache por tenant.
- Aplica en el middlewarerequireEntitlement lee el cache; enforceQuota compara el contador atómico contra el limite del plan.
Cuotas de uso: Billing Meters más tus propios contadores atómicos
Ahora la mitad numérica. Stripe Billing Meters factura los números: acumula los eventos de uso para que al cierre del periodo el cliente pague por lo consumido. Esto lo cubrí en el post de facturación por uso.
Pero los meters tienen una limitación que tienes que entender: no bloquean peticiones en tiempo real. Son para facturar, no para hacer cumplir un límite en el momento. Si tu plan dice “10,000 llamadas al mes”, el meter no va a bloquear la llamada 10,001: solo registra que fueron 10,001 y las cobra.
Para aplicar la cuota necesitas tu propio contador, por tenant, por periodo, revisado en cada petición contra el límite del plan. Y aquí hay una sola regla que no se negocia: el incremento tiene que ser atómico.
Si haces “leer contador, ¿menor que el límite?, escribir contador + 1” en tres pasos, dos peticiones concurrentes leen el mismo valor, las dos pasan, y te rebasaste el límite. Un INCR de Redis es atómico y resuelve esto de un solo golpe: incrementa y te devuelve el valor nuevo en una operación.
// Contador atomico por tenant, por meter, por periodo.
async function incrAndCheck(redis, tenantId, meterKey, limit, periodId) {
const key = `usage:${tenantId}:${meterKey}:${periodId}`;
const count = await redis.incr(key); // atomico: incrementa y devuelve
if (count === 1) await redis.expireat(key, periodEndUnix(periodId)); // resetea en el corte
return { count, allowed: count <= limit, soft: count >= limit * 0.8 };
}
Fíjate en las llaves: usage:{tenant}:{meter}:{periodo}. El periodId hace que el contador se resetee solo en el corte de facturación; no necesitas un cron que limpie nada, el contador nuevo nace en cero. Y al permitir la petición, haces dos cosas: incrementas el contador local y reportas el meter event a Stripe para el cobro. El value del meter event debe representar la misma unidad que incrementas en el contador local (aquí, 1 por llamada) para que lo que facturas y lo que aplicas no se desincronicen.
Por último, límites suaves vs duros: avisa alrededor del 80% (“vas al 80% de tu cuota”) y bloquea al 100%. Que nadie se estrelle con un 429 sin haber visto venir el límite.
El patrón de middleware: requireEntitlement + enforceQuota
Toda esta disciplina se materializa en dos guards que aplicas por ruta o por acción. Uno para la mitad booleana, otro para la numérica. Ambos se evalúan del lado del servidor, siempre. Los permission flags (el tipo de feature flag que es exactamente este caso de uso) jamás se evalúan en el cliente: el navegador miente, el servidor decide.
// Guard 1: feature gate booleano contra el cache de entitlements.
function requireEntitlement(featureKey) {
return async (req, res, next) => {
try {
const has = await req.redis.sismember(
`entitlements:${req.tenantId}`,
featureKey
);
if (!has) return res.status(403).json({ error: `feature_required: ${featureKey}` });
next();
} catch (err) {
// Decision deliberada: fail-open para no romper a clientes que pagan.
req.log.error({ err }, "entitlement store unreachable");
next();
}
};
}
// Guard 2: cuota numerica con incremento atomico + meter event.
// limit puede ser un numero o un resolver (req) => numero, porque el limite
// del plan suele depender del tenant y no se conoce al cargar el modulo.
function enforceQuota(meterKey, limit) {
return async (req, res, next) => {
const lim = typeof limit === "function" ? limit(req) : limit;
const period = currentPeriodId(req.tenantId);
const { count, allowed, soft } = await incrAndCheck(
req.redis, req.tenantId, meterKey, lim, period
);
if (!allowed) {
return res.status(429).json({ error: "quota_exceeded", meter: meterKey, limit: lim });
}
if (soft) res.set("X-Usage-Warning", `${count}/${lim}`); // aviso ~80%
// Reporta a Stripe Billing Meters para el cobro (no bloquea esta peticion).
req.stripe.billing.meterEvents.create({
event_name: meterKey,
payload: { stripe_customer_id: req.customerId, value: "1" },
}).catch((err) => req.log.error({ err }, "meter report failed"));
next();
};
}
// Aplicados por ruta. El limite se resuelve dentro del guard via req.
app.post("/api/export",
authenticate,
requireEntitlement("advanced_analytics"),
enforceQuota("api_calls", (req) => req.plan.apiLimit),
exportHandler
);
El flujo en runtime es siempre el mismo: autentica → evalúa plan más uso → permite o niega → registra el uso para los chequeos futuros. Ese “registra el uso” es lo que mantiene correcto el siguiente chequeo; si permites pero no incrementas, la cuota nunca se acerca al límite.
Ciclo de vida de una petición con entitlements
Trampas de producción que te muerden: invalidación de caché, fail-open vs fail-closed, downgrades
Las decisiones que separan un demo de algo que aguanta producción:
Invalida el caché en el webhook. Cachear entitlements está perfecto; el problema es no invalidar. Si no reaccionas a active_entitlement_summary.updated, sirves acceso viejo. Este es el bug más común de toda la capa, sin discusión.
Incrementos atómicos, siempre. Ya lo dije pero vale repetirlo: bajo concurrencia, un check-then-set deja pasar peticiones de más. INCR o equivalente. No hay atajo.
Elige fail-open vs fail-closed deliberadamente, por gate. Si tu store de entitlements no responde: los chequeos de feature/billing normalmente van fail-open, porque prefieres dar acceso de más un minuto que tumbarle el producto a un cliente que paga. Pero los gates sensibles a seguridad van fail-closed: ante la duda, niega. No es una política global, es una decisión por gate.
Gracia en downgrade/cancel. Como los cambios de feature aplican al siguiente periodo, tienes que decidir conscientemente: ¿revocas de inmediato o al final del periodo? Si alguien pagó el mes, lo justo suele ser dejarlo hasta el corte. Pero esa es una decisión de producto que debes tomar a propósito, no dejar que el timing de Stripe la tome por ti.
Aplica por tenant. En multi-tenant, jamás dejes que el caché o el contador de un tenant se filtre a otro. Las llaves siempre llevan el tenantId. Un bug aquí es una fuga de datos, no un detalle de billing.
Checklist de gotchas de produccion
Construir vs comprar: ¿lo haces tú, Stripe Entitlements, o un vendor?
Hay tres caminos, y ninguno es “el correcto” en abstracto:
- Roll your own: un servicio de entitlements propio. Máximo control, más código que mantener. Tiene sentido cuando tu empaquetado es raro o cambia rápido y necesitas lógica que ningún producto te da.
- Stripe Entitlements para la mitad de feature-access. Si ya cobras con Stripe, te resuelve el booleano sin infraestructura nueva, con la fuente de verdad pegada al billing.
- Un vendor: Schematic, Stigg, LaunchDarkly, Lago, Flexprice. Te dan UI de empaquetado, flags y a veces meters listos. Ahorras tiempo a cambio de otra dependencia y otra factura.
El split pragmático que más veo (y el que yo elegiría para la mayoría de los equipos): Stripe Entitlements para los feature gates + tus propios contadores atómicos para las cuotas. Aprovechas que el billing ya vive en Stripe y te quedas con el control de la parte que necesita latencia baja y lógica de límites.
Lo que de verdad perdura no es la herramienta: es el patrón de ingeniería. Caché sincronizado por webhook, enforcement del lado del servidor, incrementos atómicos, fail-open/closed decidido por gate. Eso se mantiene igual sin importar qué cajita uses debajo. Elige el split según el tamaño de tu equipo y qué tan seguido cambias el empaquetado.
Preguntas frecuentes
¿Stripe Entitlements mide límites de uso? No. Solo acceso booleano a features. Para cuotas numéricas lo emparejas con Billing Meters (para facturar) más tus propios contadores atómicos (para aplicar en tiempo real).
¿Qué tan rápido aplican los cambios de entitlement?
Los cambios de feature sobre suscripciones existentes aplican al inicio del siguiente periodo de facturación; el webhook active_entitlement_summary.updated se dispara en ese momento y tu caché se actualiza ahí.
¿Por qué no chequear el objeto de la suscripción directo en cada petición? Latencia y rate limits. Llamar a Stripe en la ruta de la petición no escala. Sincroniza por webhook hacia un caché y lee del caché.
¿El chequeo de entitlement debe fallar abierto o cerrado? Los de billing/feature normalmente fail-open para no romper a clientes que pagan; los gates sensibles a seguridad van fail-closed. Decídelo por gate, no global.
¿Cuál es el bug más común?
Un caché que llenas pero nunca invalidas cuando llega entitlements.active_entitlement_summary.updated. Caché viejo = acceso equivocado.
Lanza la capa que la mayoría de tutoriales se salta
Recapitulando: feature gates (Stripe Entitlements, caché sincronizado por webhook) más cuotas de uso (Billing Meters más contadores atómicos), aplicados del lado del servidor en cada petición. Dos mecanismos, una capa de enforcement.
Esto completa la historia de billing: suscripciones → medición por uso → métricas de facturación → enforcement.
Mi opinión, sin rodeos: aplica el acceso dentro del producto, en tiempo real. Nunca dejes que finanzas reconcilie el acceso semanas después. Para cuando alguien nota la discrepancia en una hoja de cálculo, ya regalaste features premium a quien no las paga. Elige tu split de construir vs comprar y lanza el middleware.
¿Quieres más guías de este estilo? Están en mis integraciones de billing.