flowchart LR
P[Proveedor de pagos] -->|POST + firma HMAC| W[/webhooks/payments]
W --> V{¿Firma válida?}
V -->|no| R1[401]
V -->|sí| D{¿event_id ya procesado?}
D -->|sí| R2[200 — ya lo vi]
D -->|no| Q[Encolar procesamiento] --> R3[200 — rápido]
31 22. Necesitamos recibir lo que otros nos envían
Webhooks entrantes y uploads: el endpoint público peligroso
Hasta ahora tus endpoints respondían a tu frontend logueado. Ahora el mundo te llama: Stripe avisa que un pago llegó, el usuario sube su avatar. Dos superficies nuevas — webhooks (requests firmados de otro servidor) y uploads (datos que no son JSON) — y las dos son el endpoint más atacado de tu API si las tratas como las demás.
31.1 El problema
El proveedor de pagos avisa payment.completed con un POST a tu /webhooks/payments. Problemas: ¿cómo sabes que el POST vino de ellos y no de alguien probando /webhooks/payments con {"amount": 999999}? ¿Qué pasa cuando el mismo webhook llega dos veces (ellos reintentan si tardas)? Y el avatar: avatar.png que en realidad es un .exe de 50MB disfrazado. Todo lo que entra necesita verificación antes de procesar.
31.2 Cómo lo resuelve un equipo
Un equipo serio trata estos endpoints con tres reglas:
- Verifica antes de creer — el webhook viene firmado (HMAC con secreto compartido): se verifica la firma sobre el body crudo, antes de parsear. El upload se valida por contenido (magic bytes), no por nombre ni
Content-Typedeclarado. - Responde rápido, procesa después — el proveedor reintenta si no respondes en segundos; el procesamiento va a una cola (cap. 21).
- Idempotencia — el mismo evento puede llegar 5 veces; procesarlo debe tener el efecto de procesarlo 1 vez (dedupe por
event_id).
Una firma (HMAC) prueba que un mensaje es auténtico y no fue tocado: se calcula con un secreto sobre el contenido. Los webhooks la usan para que verifiques “esto realmente vino de Stripe”.
Un secreto es un dato que no puede publicarse: contraseñas de BD, llaves de API, el secreto que firma los JWT. Viven en .env (fuera de git) o en un secret manager — nunca en el código ni en el repo.
31.3 Conceptos nuevos
- U Webhooks entrantes: verificar firma, responder rápido, procesar después
- U Idempotencia: el mismo webhook llega dos veces —
event_idy deduplicación en BD - U Uploads: multipart, tamaños máximos, validación de tipo real (no el nombre)
- Ops Dónde van los archivos: disco vs object storage — la decisión, no la implementación
31.4 La explicación visual
Verificar → deduplicar → responder → entonces procesar. En ese orden.
31.5 Implementación
El webhook verificado — el patrón completo en compacto:
app.post("/webhooks/payments",
express.raw({ type: "application/json" }), // RAW body — signature needs bytes
async (req, res) => {
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body).digest("hex");
const sig = req.headers["x-signature"] as string;
if (!sig || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
return res.sendStatus(401); // verify BEFORE parsing
const event = JSON.parse(req.body.toString());
const inserted = await dedupeInsert(event.id); // UNIQUE(event_id)
if (!inserted) return res.sendStatus(200); // already processed: ok
enqueue("process_payment", event); // fast response, work after
res.sendStatus(200);
});@app.post("/webhooks/payments")
async def payments_webhook(request: Request):
body = await request.body() # raw bytes for the signature
expected = hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
sig = request.headers.get("x-signature", "")
if not hmac.compare_digest(sig, expected):
raise HTTPException(401) # verify BEFORE parsing
event = json.loads(body)
if not dedupe_insert(event["id"]): # UNIQUE(event_id)
return {"ok": True} # already processed
enqueue("process_payment", event) # fast response, work after
return {"ok": True}func paymentsWebhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // cap size
mac := hmac.New(sha256.New, webhookSecret)
mac.Write(body)
if !hmac.Equal([]byte(r.Header.Get("X-Signature")),
[]byte(hex.EncodeToString(mac.Sum(nil)))) {
w.WriteHeader(401); return // verify BEFORE parsing
}
var event PaymentEvent
json.Unmarshal(body, &event)
if !dedupeInsert(event.ID) { // UNIQUE(event_id)
w.WriteHeader(200); return // already processed
}
enqueue("process_payment", event) // fast response, work after
w.WriteHeader(200)
}Y el upload, las reglas sin depender del stack:
POST /me/avatar (multipart)
→ MaxBytes/límite: 5MB — el servidor rechaza antes de llenar el disco
→ Leer los primeros bytes: 89 50 4E 47 = PNG real (magic bytes),
no confiar en filename ni Content-Type declarado
→ Guardar con nombre generado (uuid.png), nunca el nombre del usuario
→ Destino: object storage (S3/Blob/GCS) — el disco del servidor no escala
Implementa POST /webhooks/payments en [Express/FastAPI/net-http] que
reciba eventos de un proveedor. Requisitos: verificar la firma HMAC
SHA-256 del header X-Signature sobre el body CRUDO antes de parsear,
deduplicar por event_id con constraint UNIQUE en Postgres, responder
200 rápido y encolar el procesamiento real, límite de tamaño del body,
y tests: firma inválida → 401, mismo evento dos veces → procesado una
sola, y proveedor-caído → reintento seguro.
31.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: el patrón es idéntico en los tres — body crudo → firma → dedupe → 200 rápido → procesar después. Lo que cambia es cómo obtienes el body crudo (Express lo re-serializa sin el middleware raw, por eso va explícito).
Un middleware es un filtro en la cadena del request: pasa por él antes de llegar a tu handler. Auth es middleware — “verifica el token” vive una vez y protege todas las rutas, no se copia en cada una.
| Destino | Qué es | Qué te cuesta | Cuándo |
|---|---|---|---|
| Disco local | Carpeta del servidor | Muere con el contenedor; no escala a 2 instancias | Dev, prototipo |
| Object storage (S3/Blob/GCS) | Servicio de archivos de la nube | Configurar + URLs firmadas | Prod casi siempre |
| CDN delante | Cacheo global del archivo | Una capa más | Archivos públicos muy leídos |
La regla: en prod el archivo vive fuera del proceso — dos instancias de tu API deben ver el mismo avatar.
31.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
| Verificar la firma sobre el JSON parseado | “Ya lo tengo como objeto” | La firma cubre los bytes — parsear puede reordenar/reformatear |
| Procesar antes de responder | El proveedor reintenta a los 5s | 200 rápido + cola — el patrón del cap. 21 |
| Sin deduplicación | “¿Por qué cobraría dos veces?” | event_id UNIQUE — el mismo evento, una sola ejecución |
Confiar en filename/Content-Type |
Lo declara el cliente | Magic bytes + extensión generada por ti |
| Sin límite de tamaño | Upload de 50GB = disco lleno | MaxBytes/corte en el middleware |
| Guardar en disco del contenedor | “Funciona en mi máquina” | Object storage — el disco de prod es efímero |
31.8 Buenas prácticas
timingSafeEqual/compare_digest/hmac.Equalpara comparar firmas — el==normal puede filtrar por tiempo de respuesta.- Registra
event_id+ tipo en logs, nunca el payload entero — los webhooks traen datos personales ajenos. - El handler del webhook es tonto: verifica, deduplica, encola. La lógica vive en el worker — testeable y reintentable.
- Replay endpoint interno: cuando un evento se procesó mal, querrás re-ejecutarlo desde la BD de eventos sin pedirle al proveedor nada.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
31.9 Ejercicio
- ¿Por qué la deduplicación va en la BD (
UNIQUE(event_id)) y no en memoria (unSetde ids procesados)? - El proveedor manda el mismo evento 5 veces seguidas. Traza qué devuelve tu endpoint cada vez y qué efecto total tiene.
- ¿Qué valida realmente que
avatar.pnges una imagen: su nombre, suContent-Type, o sus primeros bytes? ¿Y por qué los otros dos no sirven?
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Una imagen es la plantilla inmutable de la que nacen contenedores: la foto del disco + cómo arrancar. Se construye en capas (Dockerfile), se versiona con tags, se publica en un registry.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
31.10 Mini reto
Hacer que procesar el mismo webhook 5 veces tenga el efecto de procesarlo 1 vez.
Ejercicio.
- Memoria muere con el proceso y no se comparte entre instancias — el quinto retry puede llegar a otra réplica de tu API. La constraint UNIQUE en Postgres es deduplicación real: sobrevive reinicios y escala horizontal.
- Primera: firma ok → inserta
event_id→ 200 + encola. Segunda a quinta: firma ok →dedupe_insertdevuelve “ya existe” → 200 sin encolar. Efecto total: procesado una vez; el proveedor recibió 200 las cinco veces (deja de reintentar). - Los primeros bytes (magic bytes:
89 50 4E 47= PNG,FF D8= JPEG). El nombre y elContent-Typelos declara el cliente — un.exerenombrado declaraimage/pngsin ruborizarse.
Mini reto. La tabla webhook_events(event_id UNIQUE, payload, processed_at): insertar el event_id es la deduplicación — el segundo INSERT choca con la constraint y sabes que ya lo viste. Idempotencia = mismo efecto sin importar cuántas veces llegue.
31.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Un hash es una función de un solo sentido: contraseña →$2b\(10\)…`. No se puede revertir — por eso las contraseñas se hashean, no se cifran. bcrypt es lento a propósito: fuerza bruta cara para el atacante.
Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.
Una CDN (Content Delivery Network) es una red de copias: tu estático se cachea en servidores cercanos al usuario. El de Buenos Aires no viaja a Virginia por una imagen — la recibe del nodo local.