Javier Valencia Javier Valencia
webhooks.javiervalencia.net: testing de webhooks en tiempo real

webhooks.javiervalencia.net: testing de webhooks sin registros, sin servidores, sin excusas

Javier Valencia · · 10 min de lectura · 3 visitas · Desarrollo · DevOps
webhooks go sse http debugging devops tutorial

Cualquiera que haya integrado Stripe, GitHub, GitLab, Mailgun, Twilio o cualquier sistema moderno que hable webhooks se ha encontrado con el mismo problema: necesitas una URL pública para recibir los eventos de prueba, pero estás desarrollando en local. Y el sistema de webhooks del proveedor no va a llegar a tu localhost:3000.

Durante años, la solución ha sido ngrok, localtunnel, cloudflared tunnel, o montar un VPS con un Docker expuesto. Todos funcionan. Todos exigen instalar algo, registrarse, abrir puertos, gestionar túneles. Para algo que debería ser: dame una URL, mándale peticiones, muéstrame qué llega.

webhooks.javiervalencia.net hace exactamente eso, sin registro y sin binarios.

Este post cuenta qué es, cómo funciona y cómo lo uso yo en el día a día.

Qué es

Dos herramientas en una:

Modo Inbound (Recibir): generas una URL temporal, gente o sistemas externos mandan peticiones HTTP a esa URL, y tú ves cada petición aparecer en tiempo real en una página web. Cabeceras, cuerpo, método, query string: todo.

Modo Outbound (Enviar): un formulario donde escribes un verbo HTTP, una URL, cabeceras y un cuerpo, le das a enviar y ves la respuesta completa. Útil para probar endpoints ajenos desde el navegador sin instalar nada.

No hay cuentas, no hay tokens, no hay cookies. Abres la página, trabajas, cierras.

El flujo típico

Modo Inbound: generar URL, recibir peticiones en vivo

Dibujemos el caso más frecuente: estoy integrando un webhook de Stripe en una aplicación en local, y quiero ver qué evento me manda Stripe cuando un cliente paga.

  1. Abro webhooks.javiervalencia.net/inbound y pincho "Generar URL".
  2. Me da una URL temporal del tipo https://webhooks.javiervalencia.net/inbound/abc123xyz.
  3. En el panel de Stripe configuro esa URL como endpoint de webhooks de prueba.
  4. Lanzo un pago de prueba desde el panel de Stripe.
  5. En mi pestaña abierta de webhooks.javiervalencia.net/inbound/abc123xyz, veo aparecer la petición de Stripe en directo: cabeceras, firma, cuerpo JSON completo. Puedo inspeccionarlo.
  6. Cuando tengo clara la estructura del payload, apunto el endpoint de Stripe a mi localhost (usando ngrok o similar) y paso al testing real contra mi código.

El paso 5 es lo que ahorra tiempo. Lo que antes significaba arrancar ngrok, cambiar la URL, recargar, mirar logs, aquí es una pestaña que ya tenías abierta.

Ejemplos prácticos

Recibir peticiones

Generas la URL y se la pasas a cualquier cosa que mande HTTP. Para probarlo a manopla, abres una terminal:

# La URL que te ha dado la web; copia la que te genere a ti
URL="https://webhooks.javiervalencia.net/inbound/abc123xyz"

curl -X POST "$URL" \
    -H "Content-Type: application/json" \
    -H "X-Custom-Header: prueba" \
    -d '{"evento": "pago_completado", "importe": 42.50}'

Y en la página web, sin recargar, ves aparecer la petición con:

  • Método: POST
  • URL completa
  • Cabeceras recibidas (las que mandaste más las que añade nginx: X-Forwarded-For, X-Real-IP, X-Request-ID, etc.)
  • Body en texto plano
  • Intento de parseo como JSON si el Content-Type lo permite
  • Timestamp de recepción con precisión de milisegundos

Lo que aparece en el navegador llega por un stream Server-Sent Events (SSE), no por polling. Es instantáneo.

Configurar la respuesta

Cuando un sistema externo te manda un webhook, espera una respuesta. Stripe, por ejemplo, reintenta si no recibe un 2xx en 20 segundos. Para simular distintos escenarios, la página deja configurar:

  • Status code de la respuesta (200, 201, 400, 500, 503...).
  • Cabeceras de respuesta.
  • Body de respuesta.

Así puedes simular "mi servidor ha fallado" y ver cómo se comporta el cliente que manda el webhook: ¿reintenta? ¿cuántas veces? ¿con qué backoff? Tremendamente útil para probar la lógica de reintentos de una integración.

Enviar peticiones

Modo Outbound: formulario de envío de peticiones HTTP

El modo outbound es lo inverso. Una pantalla con:

  • Un input de URL de destino.
  • Un selector de método: GET, POST, PUT, PATCH, DELETE (los métodos HTTP estándar).
  • Una sección de cabeceras con un desplegable de las más comunes (Content-Type, Accept, Authorization, Cache-Control, User-Agent, X-API-Key...) y un campo libre para las custom.
  • Un body de texto (por defecto trae {"status":"ok"}).
  • Un botón "Send Request".

Al enviar, se muestra la respuesta completa en dos vistas: Formatted (JSON bonito, HTML resaltado) y Raw (lo que llega por la red, tal cual).

¿Por qué usar esto en vez de curl? Dos motivos:

  1. Es una URL compartible. "Oye, prueba este endpoint desde aquí" con un copy-paste del link y los campos precargados.
  2. No todo el mundo tiene curl a mano o domina sus flags. Un formulario vale.

Yo suelo acabar volviendo a curl para iteraciones rápidas, pero cuando le mando a un colega un endpoint para que pruebe, le mando este.

Probando un endpoint que requiere auth

URL:        https://api.ejemplo.com/v1/usuarios/42
Método:     GET
Cabeceras:
    Authorization: Bearer eyJhbGc...
    Accept: application/json
Body:       (vacío)

Le das al botón, ves el JSON de respuesta, compruebas que tu token funciona y que la estructura es la esperada.

Probando un webhook de salida contra ti mismo

URL:        https://webhooks.javiervalencia.net/inbound/abc123xyz
Método:     POST
Cabeceras:
    Content-Type: application/json
Body:
    {"from": "outbound", "to": "inbound", "ts": "2026-04-14T09:00:00Z"}

Es decir: puedes usar el modo outbound para mandarte a ti mismo una petición al endpoint inbound. Suena absurdo, pero es el test más rápido para confirmar que el servicio está vivo.

Llamarlo por curl directamente

El endpoint outbound también es una API. Desde la CLI:

curl -X POST https://webhooks.javiervalencia.net/outbound \
    -H "Content-Type: application/json" \
    -d '{
        "url": "https://httpbin.org/post",
        "method": "POST",
        "headers": {"Content-Type": "application/json"},
        "body": "{\"hola\":\"mundo\"}"
    }'

Y te devuelve la respuesta que dio httpbin.org. Útil para saltarse restricciones de CORS o probar endpoints desde otra IP de salida (no desde la tuya).

Flujo completo: depurar un webhook de GitHub paso a paso

Escenario real que hice la semana pasada: un webhook de GitHub a una Lambda interna no estaba llegando, o llegaba mal. No tenía los logs de la Lambda delante y no quería perder una hora buscándolos. Pasos:

  1. Genero una URL inbound en webhooks.javiervalencia.net/inbound y copio la cadena abc123xyz.
  2. En el repo de GitHub, Settings → Webhooks → Edit del webhook existente. Cambio la URL de https://lambda-proxy.interno/github a https://webhooks.javiervalencia.net/inbound/abc123xyz. Content type sigue en application/json, secret lo mantengo.
  3. En la página de GitHub del webhook, pincho Recent Deliveries → Redeliver en la última entrega fallida.
  4. En mi pestaña de webhooks.javiervalencia.net aparece la petición en vivo.
  5. Leo la cabecera X-GitHub-Event: pull_request, X-GitHub-Delivery: ..., X-Hub-Signature-256: sha256=.... Y el cuerpo completo.
  6. Comparo lo que GitHub manda con lo que la Lambda espera. En mi caso, la Lambda esperaba X-GitHub-Signature (viejo) y GitHub manda X-Hub-Signature-256 (nuevo). Bug encontrado en cinco minutos.
  7. Restauro la URL original del webhook en GitHub.

El mismo flujo vale para cualquier webhook que puedas reenviar manualmente: Stripe tiene "Resend", GitLab tiene "Test", Mailgun tiene un botón equivalente. Cuando se puede disparar a voluntad, inspeccionar el payload es trivial con este servicio.

Verificar la firma de un webhook

Cuando tienes la firma cruda delante, escribir el verificador es trivial. Ejemplo Go para la firma X-Hub-Signature-256 de GitHub, que uso como chuleta:

func verifyGitHubSignature(body []byte, sigHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sigHeader))
}

Con el payload y la firma que ves en la página del servicio, pruebas este snippet en un main suelto hasta que Equal devuelve true. Es la iteración más rápida que existe para depurar una verificación de HMAC.

Tecnología por dentro

Stack tecnológico: Go stdlib + SSE + vanilla JS

Es un servicio pequeño escrito en Go con un objetivo explícito: no depender de nada más allá de la stdlib.

  • net/http de Go: el router y el servidor. Los path params del mux 1.22+ sirven para rutas como /inbound/{id}. No hay chi, echo, gin ni fiber.

  • Server-Sent Events (SSE): el mecanismo que empuja al navegador cada nueva petición recibida. Elegí SSE sobre WebSockets por tres razones: es HTTP plano (pasa por cualquier proxy sin problemas), es unidireccional (servidor → cliente, que es todo lo que necesito aquí), y la API del navegador (EventSource) es trivial.

  • Vanilla JS: la interfaz web no usa React, Vue, Svelte, ni ningún framework. Es HTML+CSS+JS a pelo, menos de 200 líneas totales. El stream se consume con new EventSource(url) y ya está.

  • Almacenamiento en memoria con expiración: cada URL inbound tiene un buffer circular de las últimas peticiones recibidas, con TTL. No hay base de datos. Si el proceso cae, se pierde. Lo cual encaja con el modelo mental: esto es un scratch pad, no un sistema de registro.

  • TTL de sesión: una hora desde la última actividad. Si dejas una URL creada y nadie la toca en una hora, el buffer se libera. Si la usas cada pocos minutos, la URL vive indefinidamente.

  • Límite de tamaño: 10 KB por petición (body). Suficiente para cualquier webhook de verdad (los payloads de Stripe, GitHub y Mailgun están muy por debajo), excesivo solo para cargas de archivos, que no son el caso de uso.

El código total ronda las 600 líneas de Go y 200 de JavaScript. Intencionadamente pequeño.

Ejemplo del cliente SSE en el navegador

El JavaScript que consume el stream es prácticamente este:

const id = location.pathname.split('/').pop();
const source = new EventSource(`/inbound/${id}/stream`);

source.addEventListener('request', (event) => {
    const req = JSON.parse(event.data);
    addRequestToUI(req);
});

source.addEventListener('error', () => {
    console.warn('SSE reconectando...');
});

Y del lado servidor, un http.Handler estándar con los headers de SSE:

func (h *Hub) Stream(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    id := r.PathValue("id")
    ch := h.subscribe(id)
    defer h.unsubscribe(id, ch)

    for {
        select {
        case req := <-ch:
            fmt.Fprintf(w, "event: request\ndata: %s\n\n", req.JSON())
            w.(http.Flusher).Flush()
        case <-r.Context().Done():
            return
        }
    }
}

Es literalmente todo lo que hace falta. No hay librerías SSE; ni se necesitan.

Casos de uso reales

Los que usa la gente normal:

Integrar el webhook de Stripe. Lo he descrito arriba. También vale para PayPal, Redsys, Paddle, Lemon Squeezy: cualquier pasarela de pagos que notifique con webhooks.

Depurar un webhook de GitHub. Cuando algún push, pull_request o release dispara un evento que no llega bien a tu CI, apuntar el webhook a una URL del servicio te deja ver el payload sin tocar el CI. ¿El JSON de pull_request.synchronize tiene pull_request.number o number? Fácil: lo disparas y lo miras.

Probar integraciones con Slack. Los "slash commands" y los "Interactive Components" de Slack mandan un POST con application/x-www-form-urlencoded. Verlos antes de escribir el handler ahorra horas.

Validar webhooks de email. Mailgun, SendGrid y Postmark mandan eventos (delivered, bounced, opened, clicked). Conocer la forma exacta del payload desde una URL temporal evita leer tres docs a la vez.

Ver qué manda un cliente al que estás dando soporte. Esta es una de mis favoritas: un cliente se queja de que tu API le está rechazando sus peticiones. Le pides que apunte su código a una URL del servicio unos minutos. Miras lo que manda. Le dices qué está mal. Problema resuelto en diez minutos en lugar de dos días de ping-pong con logs.

Reproducir un bug reportado por un cliente. Le pides el JSON exacto que le falló. Lo pegas en el outbound, lo mandas a tu endpoint de staging, reproduces el bug localmente. Mucho más rápido que un hilo de email.

Comparativa rápida con alternativas

  • webhook.site: la alternativa clásica. Funciona bien, tiene más features (variables, scripting, replays...), pero mete un montón de JavaScript de tracking y los datos se guardan en sus servidores. Si eso te da igual, es más potente.

  • RequestBin: similar pero requiere cuenta Pipedream para la mayoría de features. Más orientado a automatización.

  • ngrok: otra categoría. Expone tu localhost al exterior con un túnel. Ideal cuando ya tienes el handler del webhook y quieres recibirlo en local. No sirve para la fase de "¿qué forma tiene este payload?".

  • cloudflared tunnel: como ngrok pero gratis y de Cloudflare.

  • Hookdeck: herramienta comercial más seria, con replays, filtros, transformaciones. Para equipos que procesan millones de webhooks al día.

La regla que uso yo: webhooks.javiervalencia.net para la fase de exploración ("¿cómo es el payload? ¿funciona este endpoint?"), ngrok o cloudflared para la fase de desarrollo real ("quiero recibir el webhook en mi código en ejecución"), y una herramienta seria como Hookdeck para cuando eso se convierte en infraestructura de producción.

Limitaciones

Seamos honestos:

  • 10 KB de body. Si necesitas subir ficheros grandes, no es el sitio.
  • Sin persistencia. Cierras el navegador y listo; el buffer sigue una hora, pero si reinicio el proceso se pierde.
  • Sin replay. A diferencia de webhook.site, aquí no puedes re-ejecutar una petición vieja contra otro endpoint.
  • Sin scripting. No hay transformaciones, reglas, filtros. Se mira y se compara.
  • No tiene SLA. Es mío, corre en mi servidor; si se cae, se cae. Úsalo para lo que es: pruebas puntuales.
  • Visibilidad pública por URL. Cualquiera con la URL aleatoria puede ver lo que se ha enviado mientras la sesión esté viva. No pongas datos reales de producción (tarjetas, tokens válidos, datos personales) en las pruebas; manda siempre contra sandboxes o con datos falsos. La aleatoriedad de la URL da seguridad por oscuridad, que es suficiente para pruebas pero nada más.

Si necesitas algo con SLA, persistencia y replays, paga una cuenta de webhook.site o Hookdeck. Si lo que necesitas es "dame una URL, muéstrame el payload", aquí lo tienes.

Conclusión

Las herramientas que me resultan útiles en el día a día tienen dos virtudes: están disponibles sin fricción (sin registro, sin instalación, sin configuración) y resuelven un problema concreto sin intentar resolver otros tres de paso. webhooks.javiervalencia.net intenta ser eso para el problema de explorar webhooks: una URL, un stream en tiempo real, un formulario para enviar peticiones, cero cuentas.

Si te ahorra aunque sean quince minutos la próxima vez que integres una pasarela de pagos, habrá merecido la pena. Si rompes alguno de los límites, ya sabes dónde ver el código y montarlo tú.