Black Friday dans 15 jours : vos clients pourront-ils payer ? -25 % la 1re année et mise en place offerte, jusqu’au 30 novembreVoir l’offre →
Intégration
Un récepteur complet en Node et en PHP, la signature vérifiée sur le corps brut, et ce qu’il faut prévoir puisque Pathly n’envoie chaque événement qu’une fois.
Obtenir ma clé d’APIContrat OpenAPI
Document complet, sans authentification, à donner à votre générateur de client.
Chaque événement part en un POST vers l’adresse de votre webhook, avec un corps JSON en UTF-8 et l’en-tête Content-Type: application/json. Le corps est envoyé compact, sur une seule ligne : l’exemple ci-dessous est indenté pour la lecture seulement. Un webhook créé sans liste d’événements reçoit run.failed et run.recovered.
Quatre en-têtes accompagnent le corps. X-Pathly-Id est l’identifiant unique de l’envoi, repris dans le champ id. X-Pathly-Event nomme l’événement. X-Pathly-Timestamp donne l’heure de l’envoi en secondes Unix. X-Pathly-Signature vaut sha256= suivi du HMAC-SHA256 en hexadécimal de l’horodatage, d’un point et du corps brut, calculé avec le secret du webhook.
Pour run.failed et run.recovered, le corps porte le scénario (monitorName, monitorId), l’exécution (runId), son état (status) et l’état précédent (previousStatus), le message, l’étape en cause (failedStep), la latence en millisecondes (latencyMs), la région de la sonde (region) et le nom de l’organisation (orgName). Un run.recovered porte toujours le status ok. failedStep peut valoir null, et un champ sans valeur est omis : ne supposez la présence d’aucun champ facultatif.
incident.opened et incident.resolved arrivent avec les mêmes en-têtes et la même signature, mais un autre corps : occurredAt, incidentId, status, title, openedAt, resolvedAt, durationSec, scenarioId, scenarioName, scenarioType, siteUrl, failedStep, message, runId, runUrl, scenarioUrl et screenshotUrl. Aiguillez sur X-Pathly-Event avant de lire le corps.
La signature de l’exemple est réelle. Elle est calculée sur le corps compact de l’exemple, clés dans le même ordre et sans espace, avec le secret fictif whsec_exemple et l’horodatage affiché. Servez-vous-en pour contrôler votre calcul avant de brancher un vrai webhook.
Corps 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"
}En-têtes de cet envoi
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=3e65396afd37e66ac4eeda02dd9fd343e454f19a725d3b25aebcd33bb1cb638bLa page Webhooks donne la fonction de vérification seule. Voici le serveur entier, sans dépendance. Il fait quatre choses dans cet ordre : lire le corps brut, refuser un horodatage trop ancien, vérifier la signature en temps constant, répondre 2xx avant de traiter.
Lisez les octets reçus avant tout JSON.parse. La signature couvre le corps exact envoyé par Pathly. Un corps décodé puis réécrit par votre framework n’a plus les mêmes octets, et la vérification échoue. Avec Express ou Fastify, demandez le corps brut pour cette route.
Comparez les signatures avec timingSafeEqual en Node ou hash_equals en PHP, jamais avec une égalité ordinaire. Une comparaison ordinaire s’arrête au premier caractère différent, et ce délai renseigne un attaquant sur la signature attendue.
Refusez un envoi dont l’horodatage s’écarte de plus de cinq minutes de votre horloge. Sans ce contrôle, une requête capturée reste rejouable sans limite avec sa signature valide. La durée est votre choix : gardez-la courte et gardez l’horloge du serveur à l’heure.
Répondez 401 sans détail quand la vérification échoue. Ne journalisez ni le secret ni la signature attendue. L’exemple Node garde en mémoire les identifiants déjà traités : en production, utilisez une clé unique en base.
Node — serveur http natif
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 et 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'] ?? ''));
}Pathly envoie chaque événement une seule fois à chaque webhook actif qui l’écoute. Il n’y a pas de nouvelle tentative automatique. Si votre serveur est injoignable ou répond par une erreur, cet envoi n’est pas refait.
Une redirection 3xx n’est jamais suivie et compte comme un échec : enregistrez l’adresse finale de votre récepteur. Seules les adresses publiques en https sont acceptées.
Pathly attend votre réponse avant de passer au webhook suivant. Un événement d’incident est abandonné après 10 secondes sans réponse. Répondez donc tout de suite, puis traitez dans une file ou une tâche de fond.
Dédoublonnez sur X-Pathly-Id. Dans la console, le bouton « Rejouer le dernier événement » renvoie un envoi avec le même identifiant, un nouvel horodatage et une nouvelle signature. Enregistrez l’identifiant avec une contrainte d’unicité et ignorez celui que vous avez déjà traité.
Ne faites pas du webhook votre seule source. Après une panne de votre côté, relisez GET /v1/incidents (portée incidents:read) ou GET /v1/runs (portée runs:read) et comparez avec ce que vous avez reçu. Une tâche planifiée qui fait cette relecture rattrape un événement manqué.
Un abonnement créé par POST /v1/hooks est supprimé quand sa destination répond 410 à un événement d’incident. Un webhook créé par POST /v1/webhooks n’est jamais désactivé par une réponse en erreur.
Rattraper un événement manqué en relisant l’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"Inutile d’attendre une vraie panne. Le script ci-dessous signe un corps d’essai comme le fait Pathly et l’envoie à votre récepteur. Remplacez l’adresse par la vôtre et le secret par celui de votre webhook de test.
printf écrit l’horodatage, un point, puis le corps, sans saut de ligne final. openssl calcule le HMAC-SHA256 avec le secret. curl envoie exactement le même corps grâce à --data-binary. Le script affiche le code HTTP reçu : un 2xx est attendu.
Vérifiez ensuite les trois refus. Modifiez un caractère du corps après la signature : le récepteur doit répondre 401. Fixez un horodatage vieux d’une heure : 401 aussi. Lancez le script deux fois : le second envoi porte le même X-Pathly-Id et ne doit déclencher aucun traitement.
Pour un essai de bout en bout, le bouton « Rejouer le dernier événement » des Réglages renvoie à votre webhook le dernier événement livré. Il n’a rien à renvoyer tant qu’aucun événement n’est parti vers ce webhook.
Signer et envoyer un corps d’essai
#!/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"Le secret est rendu une seule fois : dans le champ secret de la réponse à POST /v1/webhooks, ou à l’écran quand vous créez le webhook dans la console. Il commence par whsec_. Aucune lecture ne le redonne ensuite : GET /v1/webhooks indique seulement hasSecret.
Rangez-le tout de suite dans un gestionnaire de secrets et donnez-le au récepteur par une variable d’environnement. Ne l’écrivez jamais dans un journal, un ticket, un dépôt ou la sortie d’une CI. Ne journalisez pas non plus la réponse de création entière : elle le contient.
Chaque webhook a son propre secret, et aucune route ne le régénère. Secret perdu ou exposé : supprimez le webhook, recréez-le, puis donnez le nouveau secret au récepteur. Rien n’est livré entre la suppression et la création : rattrapez cet intervalle en relisant l’API.
Créer : le secret n’apparaît que dans cette réponse
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_…"
# }Secret perdu : supprimer, puis recréer
# 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"