Webhooks de PayPal con verificación RSA
Guía de implementación para recibir y verificar webhooks de PayPal usando firma RSA-SHA256 con certificado por petición, con ejemplos completos en Express y FastAPI. Cubre pagos, suscripciones, reembolsos y checkout.
Descarga abierta · sin registro · para Node.js, Python, Express
// Generated with: paypal-webhooks skill // https://github.com/hookdeck/webhook-skills // // Cliente: CursoHub — plataforma SaaS B2B de cursos online // Skill: webhooks-paypal-verificacion (CULTIVA IA) // // Implementación: Express 5 + TypeScript // Verificación: RSA-SHA256 offline (sin llamada OAuth extra) // Idempotencia: Redis SET NX con TTL de 24h
import express, { Request, Response, NextFunction } from "express"; import crypto from "crypto"; import zlib from "zlib"; import https from "https"; import { createClient } from "redis"; import { Pool } from "pg";
// ─── Config ──────────────────────────────────────────────────────────────────
const PAYPAL_WEBHOOK_ID = process.env.PAYPAL_WEBHOOK_ID!; // WH-82P29738BA6736726-4JH86294D6297351H
const db = new Pool({ connectionString: process.env.DATABASE_URL }); const redis = createClient({ url: process.env.REDIS_URL ?? "redis://localhost:6379" }); redis.connect().catch(console.error);
// ─── Cert cache (in-memory; en producción usar Redis con TTL ~1h) ─────────────
const certCache = new Map<string, string>();
function fetchCert(certUrl: string): Promise {
// SECURITY: rechazar cualquier URL que no sea *.paypal.com
const host = new URL(certUrl).hostname;
if (host !== "paypal.com" && !host.endsWith(".paypal.com")) {
return Promise.reject(new Error(Cert URL host no es paypal.com: ${host}));
}
if (certCache.has(certUrl)) return Promise.resolve(certCache.get(certUrl)!);
return new Promise((resolve, reject) => {
https
.get(certUrl, (res) => {
if (res.statusCode !== 200) {
return reject(new Error(Cert fetch failed: ${res.statusCode}));
}
let pem = "";
res.on("data", (c: Buffer) => (pem += c.toString()));
res.on("end", () => {
certCache.set(certUrl, pem);
resolve(pem);
});
})
.on("error", reject);
});
}
// ─── Firma RSA-SHA256 offline ─────────────────────────────────────────────────
async function verifyPayPalWebhook( headers: Record<string, string | string[] | undefined>, rawBody: Buffer, webhookId: string ): Promise { const get = (h: string) => Array.isArray(headers[h]) ? (headers[h] as string[])[0] : (headers[h] as string);
const transmissionId = get("paypal-transmission-id"); const transmissionTime = get("paypal-transmission-time"); const transmissionSig = get("paypal-transmission-sig"); const certUrl = get("paypal-cert-url"); const authAlgo = get("paypal-auth-algo");
if (!transmissionId || !transmissionTime || !transmissionSig || !certUrl) { console.warn("[PayPal] Faltan headers de verificación"); return false; }
if (authAlgo && !authAlgo.toLowerCase().includes("sha256")) { console.warn("[PayPal] Algoritmo no soportado:", authAlgo); return false; }
// CRC32 del body crudo como decimal sin signo
const crc = (zlib.crc32 as (buf: Buffer) => number)(rawBody);
const message = ${transmissionId}|${transmissionTime}|${webhookId}|${crc};
try { const cert = await fetchCert(certUrl); const verifier = crypto.createVerify("SHA256"); verifier.update(message); verifier.end(); return verifier.verify(cert, transmissionSig, "base64"); } catch (err) { console.error("[PayPal] Error en verificación:", err); return false; } }
// ─── Idempotencia via Redis ───────────────────────────────────────────────────
async function isAlreadyProcessed(eventId: string): Promise {
const key = paypal:processed:${eventId};
// SET NX EX 86400 → sólo se escribe si la clave no existe (24h TTL)
const result = await redis.set(key, "1", { NX: true, EX: 86400 });
return result === null; // null = clave ya existía → ya procesado
}
// ─── Handlers de negocio (CursoHub) ──────────────────────────────────────────
interface PayPalEvent { id: string; event_type: string; resource: Record<string, unknown>; create_time: string; }
async function handlePaymentCaptureCompleted(resource: Record<string, unknown>) { const captureId = resource.id as string; const orderId = (resource as any).supplementary_data?.related_ids?.order_id as string; const amount = (resource as any).amount?.value as string; const currency = (resource as any).amount?.currency_code as string; const payerId = (resource as any).payer?.payer_id as string;
// Activar acceso al curso comprado
await db.query(
UPDATE user_purchases SET status = 'active', activated_at = NOW() WHERE paypal_order_id = $1,
[orderId]
);
// Registrar transacción
await db.query(
INSERT INTO transactions (capture_id, order_id, payer_id, amount, currency, status, created_at) VALUES ($1, $2, $3, $4, $5, 'completed', NOW()) ON CONFLICT (capture_id) DO NOTHING,
[captureId, orderId, payerId, amount, currency]
);
console.log([CursoHub] Pago completado — Orden: ${orderId}, Captura: ${captureId}, Monto: ${amount} ${currency});
}
async function handleSubscriptionActivated(resource: Record<string, unknown>) { const subscriptionId = resource.id as string; const planId = (resource as any).plan_id as string; const payerId = (resource as any).subscriber?.payer_id as string;
await db.query(
UPDATE user_subscriptions SET status = 'active', activated_at = NOW(), plan_id = $2 WHERE paypal_subscription_id = $1,
[subscriptionId, planId]
);
console.log([CursoHub] Suscripción activada — ID: ${subscriptionId}, Plan: ${planId});
}
async function handleSubscriptionCancelled(resource: Record<string, unknown>) { const subscriptionId = resource.id as string;
await db.query(
UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = NOW() WHERE paypal_subscription_id = $1,
[subscriptionId]
);
// Suspender acceso al final del período facturado (grace period 0)
await db.query(
UPDATE users SET plan_tier = 'free' WHERE id = ( SELECT user_id FROM user_subscriptions WHERE paypal_subscription_id = $1 ),
[subscriptionId]
);
console.log([CursoHub] Suscripción cancelada — ID: ${subscriptionId});
}
async function handlePaymentCaptureRefunded(resource: Record<string, unknown>) { const refundId = resource.id as string; const captureId = (resource as any).links?.find((l: any) => l.rel === "up")?.href ?.split("/").pop() as string | undefined; const amount = (resource as any).amount?.value as string;
await db.query(
INSERT INTO refunds (refund_id, capture_id, amount, status, created_at) VALUES ($1, $2, $3, 'issued', NOW()) ON CONFLICT (refund_id) DO NOTHING,
[refundId, captureId ?? null, amount]
);
console.log([CursoHub] Reembolso registrado — Refund: ${refundId}, Captura: ${captureId}, Monto: ${amount});
}
// ─── Router Express ───────────────────────────────────────────────────────────
const router = express.Router();
// CRÍTICO: express.raw() para preservar el body crudo necesario para CRC32 router.post( "/webhooks/paypal", express.raw({ type: "application/json" }), async (req: Request, res: Response, _next: NextFunction): Promise => { // 1. Verificar firma const valid = await verifyPayPalWebhook( req.headers as Record<string, string>, req.body as Buffer, PAYPAL_WEBHOOK_ID ); if (!valid) { res.status(400).json({ error: "Firma inválida" }); return; }
// 2. Parsear evento
let event: PayPalEvent;
try {
event = JSON.parse((req.body as Buffer).toString("utf8"));
} catch {
res.status(400).json({ error: "JSON malformado" });
return;
}
// 3. Idempotencia
const duplicate = await isAlreadyProcessed(event.id);
if (duplicate) {
res.json({ received: true, duplicate: true });
return;
}
// 4. Despachar por tipo de evento
try {
switch (event.event_type) {
case "PAYMENT.CAPTURE.COMPLETED":
await handlePaymentCaptureCompleted(event.resource);
break;
case "PAYMENT.CAPTURE.REFUNDED":
await handlePaymentCaptureRefunded(event.resource);
break;
case "BILLING.SUBSCRIPTION.ACTIVATED":
await handleSubscriptionActivated(event.resource);
break;
case "BILLING.SUBSCRIPTION.CANCELLED":
await handleSubscriptionCancelled(event.resource);
break;
case "CHECKOUT.ORDER.APPROVED":
console.log("[CursoHub] Orden aprobada — esperando captura:", event.resource.id);
break;
default:
console.log("[CursoHub] Evento no manejado:", event.event_type);
}
} catch (err) {
console.error("[CursoHub] Error procesando evento:", event.event_type, err);
// Devolver 500 → PayPal reintentará (schedule de retries: 1h, 8h, 24h ...)
res.status(500).json({ error: "Error interno" });
return;
}
res.json({ received: true });
} );
export default router;
// qué_hace
Implementa handlers de webhooks de PayPal con verificación criptográfica RSA-SHA256 para eventos de pago, suscripción y checkout.
// cómo_lo_hace
Descarga el certificado público de PayPal por URL, construye el mensaje firmado con transmissionId y CRC32 del body, y verifica la firma offline sin llamadas OAuth adicionales.
// ejemplo_de_uso
Úsala cuando integras pagos con PayPal y necesitas verificar que los eventos de webhook son auténticos antes de activar la lógica de negocio. Ej.: el handler descarga el certificado público de PayPal y verifica la firma RSA-SHA256 antes de marcar el pedido como pagado.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.