Lemon Squeezy — Módulo de Suscripciones
Un módulo autocontenido para pagos recurrentes con Lemon Squeezy en una
app de Next.js. Está separado del módulo de pagos únicos:
comparte las mismas LEMONSQUEEZY_API_KEY / LEMONSQUEEZY_STORE_ID, pero este
módulo tiene su propio webhook y su propio árbol de archivos.
Dependencia
El mismo SDK que el módulo de pagos: no hace falta instalar nada extra si ya lo agregaste:
@lemonsqueezy/lemonsqueezy.js
Mapa de archivos
lib/lemonsqueezy-subscriptions/
service.ts Internal logic (SDK calls, mapping, signature verification).
actions.ts Server-side functions, ready to import in Server Components
or Server Actions. Every function returns
{ success: true, data } | { success: false, error }.
types.ts Shared types (Subscription, ChangePlanParams, Webhook payload).
client.ts Client-side helpers ("use client"): redirect/open checkout,
redirect to the customer portal.
app/api/webhooks/lemonsqueezy-subscriptions/route.ts
Dedicated webhook receiver for subscription lifecycle events.
Variables de entorno
Reutiliza LEMONSQUEEZY_API_KEY y LEMONSQUEEZY_STORE_ID del módulo de
pagos. Agrega una propia:
| Variable | Dónde encontrarla |
|---|---|
LEMONSQUEEZY_SUBSCRIPTIONS_WEBHOOK_SECRET | Dashboard → Settings → Webhooks → creá un webhook separado para este módulo → su signing secret |
Registrar un webhook separado (en lugar de reutilizar el de pagos) mantiene a los dos módulos completamente independientes y te permite suscribir cada uno solo a los eventos que realmente maneja.
Notas de terminología (Lemon Squeezy vs. la lista de funcionalidades)
- Upgrade / Downgrade / Change plan son la misma llamada a la API
(
updateSubscriptioncon un nuevovariantId). Este módulo expone una únicachangeSubscriptionPlan(): tu UI decide cómo llamar a la acción, la API no las distingue. - Resume son dos cosas distintas en Lemon Squeezy:
resumeCancelledSubscription()— revierte una cancelación mientras todavía estás dentro del período de gracia (antes deendsAt).unpauseSubscription()— reanuda una suscripción que estaba pausada. Llamar a la equivocada va a fallar contra una suscripción que no está en el estado correspondiente.
- Renewal y Expiration no son acciones: no existe un endpoint de "renovar".
getRenewalInfo()/getExpirationInfo()solo leen los camposrenewsAt/endsAtque Lemon Squeezy ya mantiene. - Trial es un campo (
trialEndsAt), no un flujo separado. Definilo concreateSubscriptionCheckout({ skipTrial })al registrarse, o extendé el trial de una suscripción existente conextendTrial(). - Change quantity opera sobre el line item de la suscripción
(
firstSubscriptionItemIddegetSubscription()), no sobre la suscripción en sí: por esochangeSubscriptionQuantity()recibe unsubscriptionItemId. - Este módulo asume que el
variantId(el plan) ya existe en tu tienda de Lemon Squeezy: no gestiona productos/variantes.
Cómo encajan las piezas
1. Iniciar una suscripción (checkout)
Mismo patrón que los pagos únicos: no existe un endpoint de "crear suscripción"; una Subscription se crea cuando se completa el checkout.
import { createSubscriptionCheckout } from "@/lib/lemonsqueezy-subscriptions/actions";
const result = await createSubscriptionCheckout({
variantId: 123456,
email: "customer@example.com",
redirectUrl: "https://yourapp.com/subscription/result",
});
if (result.success) {
// result.data.url
}
import { redirectToSubscriptionCheckout } from "@/lib/lemonsqueezy-subscriptions/client";
redirectToSubscriptionCheckout(checkoutUrl);
2. Leer una suscripción
import {
getSubscription,
getSubscriptionStatus,
getRenewalInfo,
getExpirationInfo,
} from "@/lib/lemonsqueezy-subscriptions/actions";
const subscription = await getSubscription(subscriptionId);
const status = await getSubscriptionStatus(subscriptionId);
const renewal = await getRenewalInfo(subscriptionId); // { renewsAt, status }
const expiration = await getExpirationInfo(subscriptionId); // { endsAt, status }
3. Cancelar / reanudar
import {
cancelSubscription,
resumeCancelledSubscription,
} from "@/lib/lemonsqueezy-subscriptions/actions";
await cancelSubscription(subscriptionId); // enters grace period until endsAt
await resumeCancelledSubscription(subscriptionId); // only works during that grace period
4. Pausar / reanudar tras una pausa
import {
pauseSubscription,
unpauseSubscription,
} from "@/lib/lemonsqueezy-subscriptions/actions";
await pauseSubscription({ subscriptionId, mode: "void" }); // or mode: "free", resumesAt: isoDate
await unpauseSubscription(subscriptionId);
5. Cambiar de plan (upgrade / downgrade / change plan)
import { changeSubscriptionPlan } from "@/lib/lemonsqueezy-subscriptions/actions";
await changeSubscriptionPlan({
subscriptionId,
variantId: newVariantId,
invoiceImmediately: true, // charge the difference now instead of at renewal
});
6. Cambiar la cantidad
import { changeSubscriptionQuantity } from "@/lib/lemonsqueezy-subscriptions/actions";
await changeSubscriptionQuantity({ subscriptionItemId, quantity: 5 });
7. Trial
import { extendTrial } from "@/lib/lemonsqueezy-subscriptions/actions";
await extendTrial(subscriptionId, "2026-12-01T00:00:00Z");
8. Portal del cliente (gestión del método de pago)
Lemon Squeezy no expone una API para actualizar directamente el método de pago de una suscripción: se maneja a través de su customer portal alojado, cuya URL viene en el propio objeto Subscription:
import { redirectToCustomerPortal } from "@/lib/lemonsqueezy-subscriptions/client";
const subscription = await getSubscription(subscriptionId);
if (subscription.success && subscription.data.customerPortalUrl) {
redirectToCustomerPortal(subscription.data.customerPortalUrl);
}
9. Webhooks
Registrá https://yourapp.com/api/webhooks/lemonsqueezy-subscriptions como
un webhook propio en Dashboard → Settings → Webhooks, suscrito a:
subscription_createdsubscription_updatedsubscription_cancelledsubscription_resumedsubscription_pausedsubscription_unpausedsubscription_expiredsubscription_plan_changed
Los eventos de pago/factura (subscription_payment_success,
subscription_payment_failed, subscription_payment_recovered) intencionalmente
no están suscritos ni se manejan acá: pertenecen a un futuro módulo de
facturación, mantenido separado de la misma forma en que el módulo de billing de Stripe
está separado de las suscripciones de Stripe.
Manejo de errores
Misma convención que el módulo de pagos: service.ts lanza errores, actions.ts
los captura y devuelve { success: false, error }, y la route del webhook
devuelve 401 / 400 / 500 según corresponda para que la lógica de reintentos
de Lemon Squeezy se active ante fallos reales.