Black Friday en 15 días: ¿podrán pagar sus clientes? -25 % el primer año y puesta en marcha gratuita, hasta el 30 de noviembreVer la oferta →

Integración

Recibir y verificar un webhook de Pathly

Un receptor completo en Node y PHP, la firma verificada sobre el cuerpo en bruto, y qué prever, ya que Pathly envía cada evento una sola vez.

Obtener mi clave de APIContrato OpenAPI

Documento completo, sin autenticación, listo para su generador de clientes.

Qué envía Pathly

Cada evento es un POST a la dirección de su webhook, con un cuerpo JSON en UTF-8 y la cabecera Content-Type: application/json. El cuerpo se envía compacto, en una sola línea: el ejemplo de abajo está indentado solo para leerlo. Un webhook creado sin lista de eventos recibe run.failed y run.recovered.

Cuatro cabeceras acompañan al cuerpo. X-Pathly-Id es el identificador único del envío, repetido en el campo id. X-Pathly-Event nombra el evento. X-Pathly-Timestamp da la hora del envío en segundos Unix. X-Pathly-Signature vale sha256= seguido del HMAC-SHA256 en hexadecimal de la marca de tiempo, un punto y el cuerpo en bruto, calculado con el secreto del webhook.

En run.failed y run.recovered, el cuerpo lleva el escenario (monitorName, monitorId), la ejecución (runId), su estado (status) y el estado anterior (previousStatus), el mensaje, el paso que falló (failedStep), la latencia en milisegundos (latencyMs), la región de la sonda (region) y el nombre de la organización (orgName). Un run.recovered lleva siempre el status ok. failedStep puede valer null, y un campo sin valor se omite: no dé por presente ningún campo opcional.

incident.opened e incident.resolved llegan con las mismas cabeceras y la misma firma, pero con otro cuerpo: occurredAt, incidentId, status, title, openedAt, resolvedAt, durationSec, scenarioId, scenarioName, scenarioType, siteUrl, failedStep, message, runId, runUrl, scenarioUrl y screenshotUrl. Decida según X-Pathly-Event antes de leer el cuerpo.

La firma del ejemplo es real. Se calcula sobre el cuerpo compacto del ejemplo, con las claves en el mismo orden y sin espacios, con el secreto ficticio whsec_exemple y la marca de tiempo mostrada. Úsela para comprobar su cálculo antes de conectar un webhook real.

Cuerpo de run.failed

{
  "id": "5b0c2f0e-6d0a-4f5e-9a51-2f1c7d3e8a10",
  "event": "run.failed",
  "monitorName": "Checkout",
  "monitorId": "0b7e1c52-3f0d-4c1b-8f8e-6a2d9c4e7f31",
  "runId": "c1a4e8d2-9b3f-4e6a-a7d5-3f2b1c0e9d84",
  "status": "fail",
  "previousStatus": "ok",
  "message": "Element #place-order not found",
  "failedStep": "click",
  "latencyMs": 8421,
  "region": "eu-west",
  "orgName": "Example Studio"
}

Cabeceras de este envío

POST /hooks/pathly HTTP/1.1
Content-Type: application/json
X-Pathly-Id: 5b0c2f0e-6d0a-4f5e-9a51-2f1c7d3e8a10
X-Pathly-Event: run.failed
X-Pathly-Timestamp: 1794301200
X-Pathly-Signature: sha256=3e65396afd37e66ac4eeda02dd9fd343e454f19a725d3b25aebcd33bb1cb638b

Un receptor mínimo completo

La página Webhooks da solo la función de verificación. Aquí está el servidor entero, sin dependencias. Hace cuatro cosas en este orden: leer el cuerpo en bruto, rechazar una marca de tiempo demasiado antigua, verificar la firma en tiempo constante y responder 2xx antes de procesar.

Lea los bytes recibidos antes de cualquier JSON.parse. La firma cubre el cuerpo exacto que envió Pathly. Un cuerpo decodificado y reescrito por su framework ya no tiene los mismos bytes, y la verificación falla. Con Express o Fastify, pida el cuerpo en bruto para esta ruta.

Compare las firmas con timingSafeEqual en Node o hash_equals en PHP, nunca con una igualdad ordinaria. Una comparación ordinaria se detiene en el primer carácter distinto, y ese retraso informa a un atacante sobre la firma esperada.

Rechace un envío cuya marca de tiempo se aleje más de cinco minutos de su reloj. Sin este control, una petición capturada se puede reenviar sin límite con su firma válida. La duración la elige usted: manténgala corta y mantenga en hora el reloj del servidor.

Responda 401 sin detalle cuando la verificación falle. No registre ni el secreto ni la firma esperada. El ejemplo de Node guarda en memoria los identificadores ya tratados: en producción, use una clave única en su base de datos.

Node — servidor http nativo

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.PATHLY_WEBHOOK_SECRET;
const MAX_AGE_SEC = 5 * 60;
const MAX_BODY_BYTES = 1_000_000;
const seen = new Set(); // X-Pathly-Id already handled (a unique key in your database in production)

function verify(timestamp, rawBody, signature) {
  const expected =
    "sha256=" +
    createHmac("sha256", SECRET)
      .update(timestamp + ".")
      .update(rawBody)
      .digest("hex");
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

function handle(event) {
  if (event.event === "run.failed") console.log("down:", event.monitorName);
  if (event.event === "run.recovered") console.log("recovered:", event.monitorName);
}

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/hooks/pathly") {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  let size = 0;
  req.on("data", (chunk) => {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) req.destroy();
    else chunks.push(chunk);
  });
  req.on("end", () => {
    // 1. Raw bytes, exactly as received: no JSON.parse yet.
    const rawBody = Buffer.concat(chunks);
    const id = String(req.headers["x-pathly-id"] ?? "");
    const timestamp = String(req.headers["x-pathly-timestamp"] ?? "");
    const signature = String(req.headers["x-pathly-signature"] ?? "");

    // 2. Stale timestamp or wrong signature: same answer, no detail.
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!(age <= MAX_AGE_SEC) || !verify(timestamp, rawBody, signature)) {
      res.writeHead(401).end();
      return;
    }

    // 3. Answer 2xx right away.
    res.writeHead(204).end();

    // 4. Then deduplicate and do the work.
    if (seen.has(id)) return;
    seen.add(id);
    const event = JSON.parse(rawBody.toString("utf8"));
    setImmediate(() => handle(event));
  });
}).listen(8080);

PHP — php://input y hash_equals

<?php
// hooks/pathly.php
$secret = getenv('PATHLY_WEBHOOK_SECRET');

// 1. Raw bytes, exactly as received: no json_decode yet.
$rawBody = file_get_contents('php://input');
$id = $_SERVER['HTTP_X_PATHLY_ID'] ?? '';
$timestamp = $_SERVER['HTTP_X_PATHLY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_PATHLY_SIGNATURE'] ?? '';

// 2. Stale timestamp or wrong signature: same answer, no detail.
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 5 * 60;
if (!$fresh || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

// 3. Answer 2xx right away: with PHP-FPM the response leaves here.
http_response_code(204);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

// 4. Then deduplicate on $id (a unique key in your database) and do the work.
$event = json_decode($rawBody, true);
if (($event['event'] ?? '') === 'run.failed') {
    error_log('down: ' . ($event['monitorName'] ?? ''));
}

Cómo entrega Pathly

Pathly envía cada evento una sola vez a cada webhook activo que lo escucha. No hay reintento automático. Si su servidor no responde o contesta con un error, ese envío no se repite.

Una redirección 3xx nunca se sigue y cuenta como un fallo: registre la dirección final de su receptor. Solo se aceptan direcciones públicas en https.

Pathly espera su respuesta antes de pasar al webhook siguiente. Un evento de incidente se abandona tras 10 segundos sin respuesta. Responda, por tanto, de inmediato y procese después en una cola o una tarea en segundo plano.

Elimine duplicados con X-Pathly-Id. En la consola, el botón «Reenviar el último evento» vuelve a enviar una entrega con el mismo identificador, una nueva marca de tiempo y una nueva firma. Guarde el identificador con una restricción de unicidad e ignore el que ya haya tratado.

No haga del webhook su única fuente. Tras una caída por su parte, vuelva a leer GET /v1/incidents (alcance incidents:read) o GET /v1/runs (alcance runs:read) y compare con lo que recibió. Una tarea programada que haga esta lectura recupera un evento perdido.

Una suscripción creada con POST /v1/hooks se elimina cuando su destino responde 410 a un evento de incidente. Un webhook creado con POST /v1/webhooks nunca se desactiva por una respuesta de error.

Recuperar un evento perdido leyendo la API

# Incidents (scope incidents:read)
curl -sS "https://api.pathlyhq.com/v1/incidents?limit=50" \
  -H "Authorization: Bearer $PATHLY_API_TOKEN"

# Runs of one scenario (scope runs:read)
curl -sS "https://api.pathlyhq.com/v1/runs?scenarioId=$SCENARIO_ID&limit=20" \
  -H "Authorization: Bearer $PATHLY_API_TOKEN"

Probar en local

No hace falta esperar a una caída real. El script de abajo firma un cuerpo de prueba como lo hace Pathly y lo envía a su receptor. Sustituya la dirección por la suya y el secreto por el de su webhook de prueba.

printf escribe la marca de tiempo, un punto y luego el cuerpo, sin salto de línea final. openssl calcula el HMAC-SHA256 con el secreto. curl envía exactamente el mismo cuerpo gracias a --data-binary. El script muestra el código HTTP recibido: se espera un 2xx.

Compruebe después los tres rechazos. Cambie un carácter del cuerpo tras firmar: el receptor debe responder 401. Fije una marca de tiempo de hace una hora: 401 también. Ejecute el script dos veces: el segundo envío lleva el mismo X-Pathly-Id y no debe desencadenar ningún tratamiento.

Para una prueba de extremo a extremo, el botón «Reenviar el último evento» de Ajustes vuelve a enviar a su webhook el último evento entregado. No tiene nada que reenviar mientras ningún evento haya salido hacia ese webhook.

Firmar y enviar un cuerpo de prueba

#!/usr/bin/env bash
set -euo pipefail

SECRET="whsec_exemple" # the secret of your test webhook
URL="https://www.example.com/hooks/pathly"
ID="00000000-0000-4000-8000-000000000001"
TS=$(date +%s)
BODY='{"id":"'$ID'","event":"run.failed","monitorName":"Checkout","status":"fail","previousStatus":"ok","message":"test","latencyMs":0,"region":"eu-west","orgName":"Test"}'

# HMAC-SHA256 of "<timestamp>.<raw body>", in hexadecimal
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')

curl -sS -o /dev/null -w '%{http_code}\n' -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "X-Pathly-Id: $ID" \
  -H "X-Pathly-Event: run.failed" \
  -H "X-Pathly-Timestamp: $TS" \
  -H "X-Pathly-Signature: sha256=$SIG" \
  --data-binary "$BODY"

El secreto de firma

El secreto se devuelve una sola vez: en el campo secret de la respuesta a POST /v1/webhooks, o en pantalla cuando crea el webhook en la consola. Empieza por whsec_. Ninguna lectura lo devuelve después: GET /v1/webhooks solo indica hasSecret.

Guárdelo de inmediato en un gestor de secretos y páselo al receptor mediante una variable de entorno. No lo escriba nunca en un registro, un ticket, un repositorio ni la salida de una CI. Tampoco registre la respuesta de creación entera: lo contiene.

Cada webhook tiene su propio secreto, y ninguna ruta lo regenera. Secreto perdido o expuesto: elimine el webhook, créelo de nuevo y pase el nuevo secreto al receptor. No se entrega nada entre la eliminación y la creación: recupere ese intervalo leyendo la API.

Crear: el secreto solo aparece en esta respuesta

curl -sS -X POST https://api.pathlyhq.com/v1/webhooks \
  -H "Authorization: Bearer $PATHLY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://www.example.com/hooks/pathly",
    "events": ["run.failed", "run.recovered"]
  }'

# 201
# {
#   "id": "…",
#   "events": ["run.failed", "run.recovered"],
#   "enabled": true,
#   "hasSecret": true,
#   "urlFingerprint": "…",
#   "createdAt": "…",
#   "secret": "whsec_…"
# }

Secreto perdido: eliminar y volver a crear

# Scope alerting:write. Then run the creation call above again.
curl -sS -X DELETE https://api.pathlyhq.com/v1/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $PATHLY_API_TOKEN"