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

Cualquiera que haya integrado [**Stripe**](https://docs.stripe.com/webhooks), [**GitHub**](https://docs.github.com/en/webhooks), [**GitLab**](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html), [**Mailgun**](https://documentation.mailgun.com/docs/mailgun/user-manual/tracking-messages/#webhooks), [**Twilio**](https://www.twilio.com/docs/usage/webhooks) 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`](https://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](fig-01.webp)

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`](https://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:

```bash
# 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)](https://developer.mozilla.org/es/docs/Web/API/Server-sent_events), 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](fig-02.webp)

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](https://www.rfc-editor.org/rfc/rfc9110#name-methods)).
- 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`](https://curl.se/)? 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:

```bash
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:

```go
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](fig-03.webp)

Es un servicio pequeño escrito en [Go](https://go.dev) con un objetivo explícito: no depender de nada más allá de la stdlib.

- **[`net/http` de Go](https://pkg.go.dev/net/http)**: 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)](https://developer.mozilla.org/es/docs/Web/API/Server-sent_events)**: el mecanismo que empuja al navegador cada nueva petición recibida. Elegí SSE sobre [WebSockets](https://developer.mozilla.org/es/docs/Web/API/WebSockets_API) 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`](https://developer.mozilla.org/es/docs/Web/API/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](https://docs.stripe.com/webhooks), [GitHub](https://docs.github.com/en/webhooks/webhook-events-and-payloads) y [Mailgun](https://documentation.mailgun.com/docs/mailgun/user-manual/tracking-messages/#webhooks) 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:

```javascript
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:

```go
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**](https://developer.paypal.com/api/rest/webhooks/), **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](https://api.slack.com/interactivity/slash-commands) mandan un `POST` con `application/x-www-form-urlencoded`. Verlos antes de escribir el handler ahorra horas.

**Validar webhooks de email**. [Mailgun](https://www.mailgun.com/), [SendGrid](https://sendgrid.com/) y [Postmark](https://postmarkapp.com/) 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](https://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](https://requestbin.com)**: similar pero requiere cuenta Pipedream para la mayoría de features. Más orientado a automatización.

- **[`ngrok`](https://ngrok.com)**: 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`](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)**: como ngrok pero gratis y de Cloudflare.

- **[Hookdeck](https://hookdeck.com/)**: 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`](https://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ú.
