Black Friday tra 15 giorni: i Suoi clienti potranno pagare? -25 % il 1° anno e configurazione offerta, fino al 30 novembreVedi l’offerta →
Integrazione
Un ricevitore completo in Node e PHP, la firma verificata sul corpo grezzo, e che cosa prevedere, dato che Pathly invia ogni evento una sola volta.
Ottenere la mia chiave APIContratto OpenAPI
Documento completo, senza autenticazione, da passare al Suo generatore di client.
Ogni evento è un POST verso l’indirizzo del Suo webhook, con un corpo JSON in UTF-8 e l’intestazione Content-Type: application/json. Il corpo è inviato compatto, su una sola riga: l’esempio qui sotto è indentato solo per la lettura. Un webhook creato senza elenco di eventi riceve run.failed e run.recovered.
Quattro intestazioni accompagnano il corpo. X-Pathly-Id è l’identificativo unico dell’invio, ripreso nel campo id. X-Pathly-Event indica l’evento. X-Pathly-Timestamp riporta l’ora dell’invio in secondi Unix. X-Pathly-Signature vale sha256= seguito dall’HMAC-SHA256 esadecimale del timestamp, di un punto e del corpo grezzo, calcolato con il segreto del webhook.
Per run.failed e run.recovered, il corpo riporta lo scenario (monitorName, monitorId), l’esecuzione (runId), il suo stato (status) e lo stato precedente (previousStatus), il messaggio, il passaggio in errore (failedStep), la latenza in millisecondi (latencyMs), la regione della sonda (region) e il nome dell’organizzazione (orgName). Un run.recovered ha sempre lo status ok. failedStep può valere null, e un campo senza valore è omesso: non dia per presente alcun campo facoltativo.
incident.opened e incident.resolved arrivano con le stesse intestazioni e la stessa firma, ma con un altro corpo: occurredAt, incidentId, status, title, openedAt, resolvedAt, durationSec, scenarioId, scenarioName, scenarioType, siteUrl, failedStep, message, runId, runUrl, scenarioUrl e screenshotUrl. Smisti in base a X-Pathly-Event prima di leggere il corpo.
La firma dell’esempio è reale. È calcolata sul corpo compatto dell’esempio, con le chiavi nello stesso ordine e senza spazi, con il segreto fittizio whsec_exemple e il timestamp mostrato. La usi per controllare il Suo calcolo prima di collegare un webhook reale.
Corpo di 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"
}Intestazioni di questo invio
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 pagina Webhook fornisce solo la funzione di verifica. Ecco il server intero, senza dipendenze. Fa quattro cose in quest’ordine: leggere il corpo grezzo, rifiutare un timestamp troppo vecchio, verificare la firma in tempo costante, rispondere 2xx prima di elaborare.
Legga i byte ricevuti prima di qualsiasi JSON.parse. La firma copre il corpo esatto inviato da Pathly. Un corpo decodificato e poi riscritto dal Suo framework non ha più gli stessi byte, e la verifica fallisce. Con Express o Fastify, richieda il corpo grezzo per questa route.
Confronti le firme con timingSafeEqual in Node o hash_equals in PHP, mai con un’uguaglianza ordinaria. Un confronto ordinario si ferma al primo carattere diverso, e quel ritardo rivela a un attaccante informazioni sulla firma attesa.
Rifiuti un invio il cui timestamp si discosta di più di cinque minuti dal Suo orologio. Senza questo controllo, una richiesta intercettata resta riproducibile senza limiti con la sua firma valida. La durata è una Sua scelta: la mantenga breve e tenga sincronizzato l’orologio del server.
Risponda 401 senza dettagli quando la verifica fallisce. Non registri nei log né il segreto né la firma attesa. L’esempio Node tiene in memoria gli identificativi già elaborati: in produzione, usi una chiave univoca nel database.
Node — server 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 e 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 invia ogni evento una sola volta a ogni webhook attivo che lo ascolta. Non c’è alcun nuovo tentativo automatico. Se il Suo server non è raggiungibile o risponde con un errore, quell’invio non viene ripetuto.
Un reindirizzamento 3xx non viene mai seguito e conta come un errore: registri l’indirizzo finale del Suo ricevitore. Sono accettati solo indirizzi pubblici in https.
Pathly attende la Sua risposta prima di passare al webhook successivo. Un evento di incidente viene abbandonato dopo 10 secondi senza risposta. Risponda quindi subito, poi elabori in una coda o in un’attività in background.
Elimini i duplicati con X-Pathly-Id. Nella console, il pulsante «Rinvia l’ultimo evento» rimanda un invio con lo stesso identificativo, un nuovo timestamp e una nuova firma. Salvi l’identificativo con un vincolo di unicità e ignori quello che ha già elaborato.
Non faccia del webhook la Sua unica fonte. Dopo un guasto dal Suo lato, rilegga GET /v1/incidents (ambito incidents:read) o GET /v1/runs (ambito runs:read) e confronti con ciò che ha ricevuto. Un’attività pianificata che esegue questa rilettura recupera un evento perso.
Un abbonamento creato con POST /v1/hooks viene rimosso quando la sua destinazione risponde 410 a un evento di incidente. Un webhook creato con POST /v1/webhooks non viene mai disattivato da una risposta di errore.
Recuperare un evento perso rileggendo 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"Non serve aspettare un guasto reale. Lo script qui sotto firma un corpo di prova come fa Pathly e lo invia al Suo ricevitore. Sostituisca l’indirizzo con il Suo e il segreto con quello del Suo webhook di test.
printf scrive il timestamp, un punto e poi il corpo, senza ritorno a capo finale. openssl calcola l’HMAC-SHA256 con il segreto. curl invia esattamente lo stesso corpo grazie a --data-binary. Lo script mostra il codice HTTP ricevuto: è atteso un 2xx.
Verifichi poi i tre rifiuti. Modifichi un carattere del corpo dopo la firma: il ricevitore deve rispondere 401. Imposti un timestamp vecchio di un’ora: ancora 401. Esegua lo script due volte: il secondo invio porta lo stesso X-Pathly-Id e non deve avviare alcuna elaborazione.
Per una prova end-to-end, il pulsante «Rinvia l’ultimo evento» nelle Impostazioni rimanda al Suo webhook l’ultimo evento consegnato. Non ha nulla da rinviare finché nessun evento è partito verso quel webhook.
Firmare e inviare un corpo di prova
#!/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"Il segreto viene restituito una sola volta: nel campo secret della risposta a POST /v1/webhooks, o a schermo quando crea il webhook nella console. Inizia con whsec_. Nessuna lettura lo restituisce in seguito: GET /v1/webhooks indica soltanto hasSecret.
Lo conservi subito in un gestore di segreti e lo passi al ricevitore tramite una variabile d’ambiente. Non lo scriva mai in un log, in un ticket, in un repository o nell’output di una CI. Non registri nemmeno l’intera risposta di creazione: lo contiene.
Ogni webhook ha il proprio segreto, e nessun endpoint lo rigenera. Segreto perso o esposto: elimini il webhook, lo ricrei, poi passi il nuovo segreto al ricevitore. Nulla viene consegnato tra l’eliminazione e la creazione: recuperi quell’intervallo rileggendo l’API.
Creare: il segreto compare solo in questa risposta
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_…"
# }Segreto perso: eliminare, poi ricreare
# 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"