Blog Recursos Acerca de Buscar Temas
Tutoriales

Pagos con Polar.sh en Node.js: Guía completa del backend

Actualizado el 3 de enero de 2026

Categoría: Tutoriales
Compartir

Integración de pagos Polar.sh con backend Node.js

Este post amplía mi hilo de X sobre pagos con Polar.sh con ejemplos de código completos, manejo de errores y lecciones aprendidas del uso en producción.

Si estás construyendo un SaaS o vendiendo productos digitales, necesitas un proveedor de pagos. Polar.sh se ha convertido en mi opción preferida para proyectos independientes: aquí te muestro cómo integrarlo con un backend de Node.js.

¿Por qué Polar.sh? #why-polar-sh

Antes de sumergirnos en el código, aquí te explico por qué elegí Polar.sh frente a otras alternativas:

vs Stripe: Polar maneja más cosas por defecto. Obtienes páginas de checkout alojadas, un portal de cliente y gestión del ciclo de vida de suscripciones sin tener que construirlos tú mismo. Stripe te da más control pero requiere más código.

vs Paddle/Lemon Squeezy: Beneficios similares de ser el comerciante de registro (manejan el IVA/impuesto de ventas), pero Polar está construido específicamente para desarrolladores y proyectos de código abierto. La DX (experiencia de desarrollo) es notablemente mejor.

Qué maneja Polar por ti:

  • Páginas de checkout alojadas
  • Procesamiento de pagos
  • Portal de cliente
  • Ciclo de vida de suscripciones
  • Cumplimiento fiscal (como comerciante de registro)

Qué manejas tú:

  • Eventos de webhooks
  • Derechos de usuario (entitlements)
  • Seguimiento de uso

Configuración del proyecto #project-setup

Instala la SDK y la librería de verificación de webhooks:

Terminal
npm install @polar-sh/sdk standardwebhooks

Inicializa el cliente Polar:

src/lib/polar.ts
import { Polar } from '@polar-sh/sdk';

export const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN!,
    server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});

Variables de entorno necesarias:

.env
POLAR_ACCESS_TOKEN=your_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret

Obtén estas desde tu dashboard de Polar. Usa el modo sandbox durante el desarrollo: proporciona tarjetas de prueba y no cobrará dinero real.

Creación de sesiones de checkout #creating-checkout-sessions

Cuando un usuario hace clic en “Suscribirse” o “Comprar”, crea una sesión de checkout y redirígelos:

src/routes/checkout.ts
import { polar } from '../lib/polar';

export async function createCheckout(user: { id: string; email: string }, productId: string) {
    const checkout = await polar.checkouts.create({
        products: [productId],
        successUrl: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
        customerEmail: user.email,
        metadata: {
            userId: String(user.id),
        },
    });

    return checkout.url;
}

Puntos clave:

  • metadata.userId vincula el cliente de Polar a tu usuario. Lo necesitarás en los webhooks.
  • successUrl puede incluir {CHECKOUT_SESSION_ID} que Polar reemplaza con el ID real.
  • products toma un array: puedes agrupar múltiples productos en un solo checkout.

Ejemplo de ruta Express:

src/routes/api.ts
router.post('/create-checkout', async (req, res) => {
    const user = req.user; // de tu middleware de autenticación
    const { productId } = req.body;

    if (!productId) {
        return res.status(400).json({ error: 'Se requiere ID de producto' });
    }

    try {
        const checkoutUrl = await createCheckout(user, productId);
        res.json({ url: checkoutUrl });
    } catch (error) {
        console.error('Fallo en creación de checkout:', error);
        res.status(500).json({ error: 'Error al crear checkout' });
    }
});

Manejo de webhooks #webhook-handling

Los webhooks son cómo Polar informa a tu aplicación sobre eventos de pago. Esta es la parte más crítica de hacer correctamente.

Verificación de firmas

Polar usa la especificación Standard Webhooks. Debes verificar firmas para prevenir solicitudes falsificadas:

src/webhooks/polar.ts
import { Webhook } from 'standardwebhooks';

const secret = 'whsec_' + Buffer.from(
    process.env.POLAR_WEBHOOK_SECRET!
).toString('base64');

const wh = new Webhook(secret);

export function verifyWebhook(rawBody: string, headers: Record<string, string>) {
    try {
        return wh.verify(rawBody, headers);
    } catch (error) {
        console.error('Verificación de webhook fallida:', error);
        throw new Error('Firma de webhook inválida');
    }
}

Configuración de ruta (¡Crítico!)

La ruta de webhook necesita el cuerpo de la solicitud sin procesar para la verificación de firmas. Regístrala ANTES de tu middleware JSON:

src/app.ts
import express from 'express';
import { webhookRouter } from './routes/webhooks';

const app = express();

// Rutas de webhook PRIMERO - necesitan cuerpo sin procesar
app.use('/api/webhooks', webhookRouter);

// Parsing JSON DESPUÉS de webhooks
app.use(express.json());

// Otras rutas...
app.use('/api', apiRouter);
src/routes/webhooks.ts
import { Router } from 'express';
import express from 'express';
import { verifyWebhook } from '../webhooks/polar';
import { handlePolarEvent } from '../services/polar-events';

const router = Router();

router.post('/polar',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
        try {
            const event = verifyWebhook(
                req.body.toString(),
                req.headers as Record<string, string>
            );

            await handlePolarEvent(event);
            res.status(200).json({ received: true });
        } catch (error) {
            console.error('Error de webhook:', error);
            res.status(400).json({ error: 'Procesamiento de webhook fallido' });
        }
    }
);

export { router as webhookRouter };

Manejadores de eventos

Así es como manejar los eventos clave de suscripción:

src/services/polar-events.ts
import { db } from '../lib/db';

interface PolarEvent {
    type: string;
    data: {
        id: string;
        status: string;
        customer_id: string;
        metadata?: { userId?: string };
        current_period_end?: string;
    };
}

export async function handlePolarEvent(event: PolarEvent) {
    const { type, data } = event;

    switch (type) {
        case 'subscription.created':
            // Vincular cliente de Polar a tu usuario
            await db.user.update({
                where: { id: data.metadata?.userId },
                data: {
                    polarCustomerId: data.customer_id,
                    subscriptionId: data.id,
                    subscriptionStatus: 'pending',
                },
            });
            break;

        case 'subscription.active':
            // Pago exitoso - activar la suscripción
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'active',
                    currentPeriodEnd: new Date(data.current_period_end!),
                    // Establecer límites específicos del plan
                    apiCallsLimit: 10000,
                    storageLimit: 5 * 1024 * 1024 * 1024, // 5GB
                },
            });
            break;

        case 'subscription.updated':
            // Cambio de plan, renovación, etc.
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: data.status,
                    currentPeriodEnd: new Date(data.current_period_end!),
                },
            });
            break;

        case 'subscription.canceled':
            // Usuario cancelado - sigue activo hasta fin de periodo
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'canceled',
                    canceledAt: new Date(),
                },
            });
            break;

        case 'subscription.revoked':
            // Revocación inmediata (pago fallido, reembolso, etc.)
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'revoked',
                    apiCallsLimit: 0,
                    storageLimit: 0,
                },
            });
            break;

        case 'order.paid':
            // Compra única completada
            await handleOneTimePurchase(data);
            break;

        default:
            console.log(`Tipo de evento no manejado: ${type}`);
    }
}

async function handleOneTimePurchase(data: PolarEvent['data']) {
    // Conceder acceso vitalicio, créditos, etc.
    await db.user.update({
        where: { id: data.metadata?.userId },
        data: {
            lifetimeAccess: true,
        },
    });
}

Gestión de suscripciones #subscription-management

Cancelar suscripción

Permite que los usuarios cancelen desde tu aplicación (mantienen acceso hasta fin de periodo):

src/services/subscriptions.ts
import { polar } from '../lib/polar';

export async function cancelSubscription(subscriptionId: string) {
    await polar.subscriptions.update({
        id: subscriptionId,
        subscriptionUpdate: {
            cancelAtPeriodEnd: true,
        },
    });
}

Portal de cliente

Polar proporciona un portal alojado donde los clientes pueden gestionar facturación, actualizar métodos de pago y ver facturas:

src/services/subscriptions.ts
export async function getCustomerPortalUrl(polarCustomerId: string) {
    const session = await polar.customerSessions.create({
        customerId: polarCustomerId,
    });

    return session.customerPortalUrl;
}

Ruta Express:

src/routes/api.ts
router.post('/billing-portal', async (req, res) => {
    const user = req.user;

    if (!user.polarCustomerId) {
        return res.status(400).json({ error: 'No se encontró suscripción' });
    }

    try {
        const portalUrl = await getCustomerPortalUrl(user.polarCustomerId);
        res.json({ url: portalUrl });
    } catch (error) {
        console.error('Fallo en creación de portal:', error);
        res.status(500).json({ error: 'Error al crear sesión de portal' });
    }
});

Errores comunes #common-pitfalls

Después de integrar Polar en producción, aquí están los errores que he visto (y cometido):

1. Middleware JSON antes de webhooks

Si ves errores de verificación de firmas, esta es casi siempre la causa. El cuerpo sin procesar es analizado por express.json() antes de que tu manejador de webhook lo vea.

Solución: Registra rutas de webhook antes de express.json().

2. No manejar todos los estados de suscripción

Una suscripción puede ser: active, canceled, past_due, incomplete, trialing, o revoked. No solo verifiques active—maneja el ciclo de vida completo.

3. Faltan metadatos

Si olvidas pasar userId en los metadatos del checkout, no puedes vincular el cliente de Polar a tu usuario. Siempre incluye información de identificación.

4. Webhooks no idempotentes

Polar puede reintentar webhooks. Tus manejadores deben ser idempotentes—procesar el mismo evento dos veces no debería romper nada.

// Mal - crea registros duplicados
await db.payment.create({ data: { orderId: event.data.id, ... } });

// Bien - upsert basado en ID único
await db.payment.upsert({
    where: { polarOrderId: event.data.id },
    create: { polarOrderId: event.data.id, ... },
    update: { ... },
});

5. Bloquear respuestas de webhooks

Los endpoints de webhook deben responder rápidamente. Si necesitas hacer procesamiento pesado, confirma inmediatamente y procesa asíncronamente:

router.post('/polar', express.raw({ type: 'application/json' }), async (req, res) => {
    const event = verifyWebhook(req.body.toString(), req.headers);

    // Responder inmediatamente
    res.status(200).json({ received: true });

    // Procesar asíncrono (usa una cola de trabajos adecuada en producción)
    setImmediate(() => handlePolarEvent(event));
});

Probando tu integración #testing-your-integration

Usa modo Sandbox

El entorno sandbox de Polar te permite probar el flujo completo sin cobros reales:

const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN,
    server: 'sandbox', // Modo de prueba
});

Tarjetas de prueba

En sandbox, usa estos números de tarjeta de prueba:

  • 4242 4242 4242 4242 - Pago exitoso
  • 4000 0000 0000 0002 - Tarjeta rechazada

Pruebas locales de webhooks

Usa ngrok o similar para exponer tu servidor local:

ngrok http 3000

Luego establece tu URL de webhook en el dashboard de Polar a: https://your-ngrok-url.ngrok.io/api/webhooks/polar

Verifica tu configuración

Lista de verificación antes de ir a producción:

  • El checkout se crea exitosamente y redirige a Polar
  • La URL de éxito redirige de vuelta a tu aplicación
  • La verificación de firma de webhook pasa
  • El evento subscription.active activa el acceso del usuario
  • El portal de cliente carga correctamente
  • La cancelación funciona y establece cancelAtPeriodEnd

Esa es la integración completa de pagos con Polar.sh. La idea clave: deja que Polar maneje lo que sabe hacer bien (UI de checkout, procesamiento de pagos, portal de cliente) y enfoca tu código en el manejo de webhooks y derechos de usuario.

Para más detalles, consulta la documentación oficial de Polar.

Categoría Tutoriales
Compartir

Recibe los últimos conocimientos sobre IA directamente en tu bandeja de entrada

Manténgase actualizado con las últimas tendencias, tutoriales e insights de la industria. Únase a la comunidad de desarrolladores que confían en nuestro boletín.

Solo cuentas nuevas. Al enviar tu correo electrónico aceptas nuestro Política de Privacidad