Black Friday in 15 days: will your customers be able to pay? 25% off the first year and free setup, until 30 NovemberSee the offer →
Integration
A complete receiver in Node and PHP, the signature checked on the raw body, and what to plan for since Pathly sends each event only once.
Get my API keyOpenAPI contract
Full document, no authentication, ready for your client generator.
Each event is one POST to your webhook address, with a UTF-8 JSON body and the header Content-Type: application/json. The body is sent compact, on a single line: the example below is indented for reading only. A webhook created without an events list receives run.failed and run.recovered.
Four headers come with the body. X-Pathly-Id is the unique identifier of the delivery, repeated in the id field. X-Pathly-Event names the event. X-Pathly-Timestamp is the sending time in Unix seconds. X-Pathly-Signature is sha256= followed by the hexadecimal HMAC-SHA256 of the timestamp, a dot and the raw body, computed with the webhook secret.
For run.failed and run.recovered, the body carries the scenario (monitorName, monitorId), the run (runId), its state (status) and the previous state (previousStatus), the message, the step at fault (failedStep), the latency in milliseconds (latencyMs), the probe region (region) and the organisation name (orgName). A run.recovered always carries status ok. failedStep can be null, and a field with no value is left out: do not assume any optional field is present.
incident.opened and incident.resolved arrive with the same headers and the same signature, but a different body: occurredAt, incidentId, status, title, openedAt, resolvedAt, durationSec, scenarioId, scenarioName, scenarioType, siteUrl, failedStep, message, runId, runUrl, scenarioUrl and screenshotUrl. Branch on X-Pathly-Event before reading the body.
The signature in the example is real. It is computed over the compact body of the example, keys in the same order and no whitespace, with the dummy secret whsec_exemple and the timestamp shown. Use it to check your own computation before you plug in a real webhook.
run.failed body
{
"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"
}Headers of this delivery
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=3e65396afd37e66ac4eeda02dd9fd343e454f19a725d3b25aebcd33bb1cb638bThe Webhooks page gives the verification function alone. Here is the whole server, with no dependency. It does four things in this order: read the raw body, reject a stale timestamp, verify the signature in constant time, answer 2xx before doing the work.
Read the received bytes before any JSON.parse. The signature covers the exact body Pathly sent. A body decoded and then rewritten by your framework no longer has the same bytes, and verification fails. With Express or Fastify, ask for the raw body on this route.
Compare signatures with timingSafeEqual in Node or hash_equals in PHP, never with a plain equality. A plain comparison stops at the first differing character, and that delay tells an attacker about the expected signature.
Reject a delivery whose timestamp is more than five minutes away from your clock. Without this check, a captured request can be replayed forever with its valid signature. The window is your choice: keep it short and keep the server clock in sync.
Answer 401 with no detail when verification fails. Log neither the secret nor the expected signature. The Node example keeps the identifiers already handled in memory: in production, use a unique key in your database.
Node — built-in http server
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 and 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 sends each event once to every enabled webhook that listens to it. There is no automatic retry. If your server is unreachable or answers with an error, that delivery is not attempted again.
A 3xx redirect is never followed and counts as a failure: register the final address of your receiver. Only public https addresses are accepted.
Pathly waits for your answer before moving on to the next webhook. An incident event is dropped after 10 seconds without an answer. So answer right away, then process in a queue or a background task.
Deduplicate on X-Pathly-Id. In the console, the “Replay last event” button resends a delivery with the same identifier, a new timestamp and a new signature. Store the identifier under a unique constraint and ignore one you have already handled.
Do not make the webhook your only source. After an outage on your side, read GET /v1/incidents (scope incidents:read) or GET /v1/runs (scope runs:read) again and compare with what you received. A scheduled job that does this reading catches up on a missed event.
A subscription created with POST /v1/hooks is removed when its destination answers 410 to an incident event. A webhook created with POST /v1/webhooks is never disabled by an error response.
Catch up on a missed event by reading the 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"No need to wait for a real outage. The script below signs a test body the way Pathly does and sends it to your receiver. Replace the address with yours and the secret with the one of your test webhook.
printf writes the timestamp, a dot, then the body, with no trailing newline. openssl computes the HMAC-SHA256 with the secret. curl sends exactly the same body thanks to --data-binary. The script prints the HTTP code it got: a 2xx is expected.
Then check the three refusals. Change one character of the body after signing: the receiver must answer 401. Set a timestamp one hour old: 401 as well. Run the script twice: the second delivery carries the same X-Pathly-Id and must trigger no processing.
For an end-to-end test, the “Replay last event” button in Settings resends the last delivered event to your webhook. It has nothing to resend until an event has gone out to that webhook.
Sign and send a test body
#!/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"The secret is returned once only: in the secret field of the POST /v1/webhooks response, or on screen when you create the webhook in the console. It starts with whsec_. No read gives it back afterwards: GET /v1/webhooks only reports hasSecret.
Put it in a secrets manager right away and hand it to the receiver through an environment variable. Never write it to a log, a ticket, a repository or CI output. Do not log the whole creation response either: it contains the secret.
Each webhook has its own secret, and no endpoint regenerates it. Secret lost or exposed: delete the webhook, create it again, then give the new secret to the receiver. Nothing is delivered between deletion and creation: catch up on that gap by reading the API.
Create: the secret only appears in this response
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_…"
# }Lost secret: delete, then create again
# 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"