Pagos con Polar.sh en Node.js: Guía completa del backend
Actualizado el 3 de enero de 2026
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:
npm install @polar-sh/sdk standardwebhooks
Inicializa el cliente Polar:
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:
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:
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.userIdvincula el cliente de Polar a tu usuario. Lo necesitarás en los webhooks.successUrlpuede incluir{CHECKOUT_SESSION_ID}que Polar reemplaza con el ID real.productstoma un array: puedes agrupar múltiples productos en un solo checkout.
Ejemplo de ruta Express:
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:
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:
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);
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:
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):
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:
export async function getCustomerPortalUrl(polarCustomerId: string) {
const session = await polar.customerSessions.create({
customerId: polarCustomerId,
});
return session.customerPortalUrl;
}
Ruta Express:
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 exitoso4000 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.activeactiva 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.