Flujo de verificación
Request lifecycle: POST /webhooks/paddle
Paddle
envia POST
envia POST
Paddle-Signature
header adjunto
header adjunto
→
raw body
buffer
buffer
NO parsear JSON
antes de verificar
antes de verificar
→
HMAC-SHA256
verify
verify
ts + rawBody
timingSafeEqual
timingSafeEqual
→
400 Invalid
Signature
Signature
si falla
→ rechazar
→ rechazar
→
Check
idempotency
idempotency
event_id en DB
ya procesado?
ya procesado?
→
200 OK
inmediato
inmediato
< 5 segundos
siempre
siempre
→
dispatch
async handler
async handler
DB · email · CRM
en background
en background
Handler principal — src/webhooks/paddle.js
JavaScript · Express · NutriPaw Pro
// Generated with: paddle-webhooks skill | CULTIVA IA // Cliente: NutriPaw Pro — webhook handler v1.0.0 const crypto = require('crypto'); const { db } = require('../db'); const { sendEmail } = require('../email'); /** Verifica firma Paddle HMAC-SHA256 (soporta rotación de secretos) */ function verifyPaddleSignature(rawBody, header, secret) { const parts = header.split(';'); const ts = parts.find(p => p.startsWith('ts='))?.slice(3); const sigs = parts.filter(p => p.startsWith('h1=')).map(p => p.slice(3)); if (!ts || !sigs.length) return false; const expected = crypto .createHmac('sha256', secret) .update(`${ts}:${rawBody}`) .digest('hex'); return sigs.some(sig => { try { return crypto.timingSafeEqual( Buffer.from(sig), Buffer.from(expected) ); } catch { return false; } }); } /** Enrutador de eventos (async, después del 200 OK) */ async function dispatchEvent(event) { switch (event.event_type) { case 'subscription.created': { const { id, customer_id, items } = event.data; await db.query( 'INSERT INTO subscriptions (paddle_id,customer_id,plan,status) VALUES ($1,$2,$3,$4)', [id, customer_id, items[0].price.id, 'created'] ); await sendEmail({ to: event.data.customer.email, template: 'welcome' }); break; } case 'subscription.activated': { await db.query( 'UPDATE subscriptions SET status=$1 WHERE paddle_id=$2', ['active', event.data.id] ); break; } case 'subscription.canceled': { await db.query( 'UPDATE subscriptions SET status=$1,canceled_at=NOW() WHERE paddle_id=$2', ['canceled', event.data.id] ); await sendEmail({ to: event.data.customer.email, template: 'retention' }); break; } case 'transaction.completed': { await issueInvoice(event.data); break; } case 'transaction.payment_failed': { await sendEmail({ to: event.data.customer.email, template: 'payment_failed', data: { update_url: event.data.checkout.url } }); break; } case 'customer.created': { await syncToCRM(event.data); break; } default: console.log(`[paddle] unhandled: ${event.event_type}`); } } /** Endpoint principal */ router.post('/webhooks/paddle', express.raw({ type: 'application/json' }), async (req, res) => { const sig = req.headers['paddle-signature']; const body = req.body.toString(); const secret = process.env.PADDLE_WEBHOOK_SECRET; // 1. Verificar firma if (!verifyPaddleSignature(body, sig, secret)) return res.status(400).json({ error: 'invalid_signature' }); const event = JSON.parse(body); // 2. Idempotencia: evitar reprocesamiento const { rowCount } = await db.query( 'INSERT INTO processed_events(id) VALUES($1) ON CONFLICT DO NOTHING', [event.event_id] ); if (rowCount === 0) return res.json({ received: true, duplicate: true }); // 3. Responder 200 inmediato (antes de procesar) res.json({ received: true }); // 4. Dispatch async (no bloquea la respuesta) dispatchEvent(event).catch(err => console.error(`[paddle] dispatch error:`, err) ); } );
Eventos configurados — NutriPaw Pro
| Evento | Prioridad | Acciones |
|---|---|---|
subscription.created |
Alta | INSERT DBEmail bienvenida |
subscription.activated |
Crítica | UPDATE DBDesbloquear acceso |
subscription.canceled |
Crítica | Revocar accesoEmail retención |
subscription.paused |
Normal | UPDATE statusLimitar acceso |
transaction.completed |
Alta | Emitir facturaLog transacción |
transaction.payment_failed |
Crítica | Email + link pago |
customer.created |
Baja | Sync CRM |
customer.updated |
Baja | UPDATE cliente |
Checklist de seguridad
- Cuerpo raw almacenado ANTES de parsear JSON
- Comparación en tiempo constante (
timingSafeEqual) - Soporte para múltiples
h1=(rotación de secretos) - Idempotencia via
processed_eventsen DB - Respuesta 200 inmediata + dispatch async
- Replay attacks: validar antigüedad del
ts(<5min)
Variables de entorno
PADDLE_WEBHOOK_SECRET
pdl_ntfset_01hx…
PADDLE_API_KEY
live_…xxxxx
PADDLE_ENV
production
Verificación Python / FastAPI
Python 3.11+
import hmac, hashlib from fastapi import Request, HTTPException def verify_paddle(raw_body: str, sig_header: str, secret: str) -> bool: parts = sig_header.split(';') ts = next((p[3:] for p in parts if p.startswith('ts=')), None) sigs = [p[3:] for p in parts if p.startsWith('h1=')] if not ts or not sigs: return False expected = hmac.new( secret.encode(), f"{ts}:{raw_body}".encode(), hashlib.sha256 ).hexdigest() return any(hmac.compare_digest(s, expected) for s in sigs) async def webhook_handler(request: Request): raw = (await request.body()).decode() sig = request.headers.get("paddle-signature", "") if not verify_paddle(raw, sig, PADDLE_SECRET): raise HTTPException(400, "invalid signature") return {"received": True}
Desarrollo local
bash
# Hookdeck tunnel (sin cuenta) npx hookdeck-cli listen 3000 paddle \ --path /webhooks/paddle # Output: https://events.hookdeck.com/e/src_xxx # → Configura esta URL en Paddle Dashboard # → Notification Destinations → Add Destination
Funcionalidades implementadas
HMAC-SHA256
Secret rotation
Async dispatch
Idempotencia
Email triggers
PostgreSQL sync
Python port
Tests incluidos
Timing esperado — respuesta debe llegar < 5s a Paddle
1. Verify HMAC
2. Parse JSON
3. Idempotency check
4. res.json 200
5. Async handlers