@dariomvg/create
lemonsqueezysuscripcionespagos-recurrenteswebhooksintegracion

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:

VariableDónde encontrarla
LEMONSQUEEZY_SUBSCRIPTIONS_WEBHOOK_SECRETDashboard → 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 (updateSubscription con un nuevo variantId). Este módulo expone una única changeSubscriptionPlan(): 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 de endsAt).
    • 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 campos renewsAt / endsAt que Lemon Squeezy ya mantiene.
  • Trial es un campo (trialEndsAt), no un flujo separado. Definilo con createSubscriptionCheckout({ skipTrial }) al registrarse, o extendé el trial de una suscripción existente con extendTrial().
  • Change quantity opera sobre el line item de la suscripción (firstSubscriptionItemId de getSubscription()), no sobre la suscripción en sí: por eso changeSubscriptionQuantity() recibe un subscriptionItemId.
  • 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_created
  • subscription_updated
  • subscription_cancelled
  • subscription_resumed
  • subscription_paused
  • subscription_unpaused
  • subscription_expired
  • subscription_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.