← Volver al catálogo
WebReferenciaIntermedioGratis

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.

Descargar skill (.zip)

Descarga abierta · sin registro · para Node.js, Python, Express

// resultado_de_ejemplo

// 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

Node.jsPythonExpressFastAPINext.js
Categoría
Web
Tipo
Referencia
Nivel
Intermedio
Licencia
MIT
Seguridad
seguro · riesgo bajo
Versión
1.0.0

// 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 €.