Webhooks
Events and signatures
nightlab posts JSON to your endpoint when a run needs someone, fails or opens a pull request. Check its signature before you trust it.
Events
Each event but the test is about one run in the endpoint’s projects. An endpoint receives the events it was set up for.
| Event | When |
|---|---|
run.needs_approval Recommended | A run waits for someone to approve it. |
run.waiting Recommended | A run waits for a person to answer its question. With run.needs_approval, that’s everything that needs a person. |
run.failed Recommended | A run failed. Get the run for what it got to. |
run.pr_opened Recommended | A run opened its draft pull request. |
run.pr_merged, run.pr_closed | A person merged or closed that pull request. |
run.created, run.started, run.finished, run.cancelled | The rest of a run’s life. |
run.summarized | The run’s summary is ready to fetch: after it finished, failed or was cancelled, for runs started through the API. |
endpoint.test | A test, sent when an admin asks for one. Its data is {"endpoint": <its id>}, with no run, and it’s never in GET /v1/events. |
A workspace admin adds an endpoint in Settings › Integrations › Webhooks, or through the workspace’s admin API, POST /workspace/webhooks, with its HTTPS URL, the events it wants and, if not all, its projects. The URL must be public, on port 443, and nightlab follows no redirect. The answer holds the endpoint’s signing secret, whsec_…, this once.
Rather poll? List events returns the same events, with a key that has events:read.
The payload
A POST with a JSON body: the event’s id and type, when it happened, and the run as it stood, by ids and states only. No issue text, code or amounts: get the run for more. timestamp is ISO 8601 in UTC, with microseconds: parse it rather than comparing it as text.
{
"id": "evt_6f1c2b9a04d84e7f9a3e5b7c1d2f8e90",
"type": "run.pr_opened",
"timestamp": "2026-11-01T10:24:00.743059+00:00",
"data": {
"run": {
"id": "run-42",
"project": "shop",
"issue": "SHOP-1431",
"action": "build",
"state": "running",
"outcome": null,
"no_pr_reason": null,
"pr_url": "https://github.com/acme/shop/pull/322",
"pr_state": "draft",
"external_ref": "ci-8812"
}
}
}
Check the signature
Each delivery follows Standard Webhooks, with three headers:
webhook-id: the event’s id, the same on every try and redelivery. Keep the ones you’ve handled and skip a repeat.webhook-timestamp: when this try was sent, in seconds. Refuse one more than 5 minutes off your clock.webhook-signature:v1,and the base64 HMAC-SHA256 ofid.timestamp.body, under the secret’s bytes (base64-decode what followswhsec_). It can hold two, space-separated, while a secret is being rotated: accept either.
Check the signature over the body’s raw bytes, as they arrived, before any JSON parsing. Standard Webhooks also has libraries that do this for most languages.
import { createHmac, timingSafeEqual } from "node:crypto";
// body: the raw bytes of the request; headers: its headers, by lowercase name
export function verify(body, headers, secret) {
const id = headers["webhook-id"], stamp = headers["webhook-timestamp"];
if (!id || Math.abs(Date.now() / 1000 - Number(stamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const want = Buffer.from(
createHmac("sha256", key).update(`${id}.${stamp}.`).update(body).digest("base64"),
);
return (headers["webhook-signature"] ?? "").split(" ").some((part) => {
const got = Buffer.from(part.startsWith("v1,") ? part.slice(3) : "");
return got.length === want.length && timingSafeEqual(got, want);
});
}
import base64, hashlib, hmac, time
# body: the raw bytes of the request; headers: its headers, by lowercase name
def verify(body: bytes, headers, secret: str) -> bool:
msg_id, stamp = headers.get("webhook-id", ""), headers.get("webhook-timestamp", "")
if not msg_id or not (stamp.isascii() and stamp.isdigit()) or abs(time.time() - int(stamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
mac = hmac.new(key, f"{msg_id}.{stamp}.".encode() + body, hashlib.sha256).digest()
want = base64.b64encode(mac)
return any(part.startswith("v1,") and hmac.compare_digest(part[3:].encode(), want)
for part in headers.get("webhook-signature", "").split())
<?php
// $body: the raw bytes of the request (file_get_contents('php://input')); $headers: by lowercase name
function verify(string $body, array $headers, string $secret): bool {
$id = $headers['webhook-id'] ?? '';
$stamp = $headers['webhook-timestamp'] ?? '';
if ($id === '' || !ctype_digit($stamp) || abs(time() - (int) $stamp) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$want = base64_encode(hash_hmac('sha256', "$id.$stamp.$body", $key, true));
foreach (explode(' ', $headers['webhook-signature'] ?? '') as $part) {
if (str_starts_with($part, 'v1,') && hash_equals($want, substr($part, 3))) return true;
}
return false;
}
Rotating a secret (POST /workspace/webhooks/{id}/rotate) shows the new one once, and both sign each delivery for the overlap the admin picks, 24 hours unless they choose 1 to 168. Deploy the new secret within it, and nothing is refused.
Answering and retries
Answer with any 2xx once you’ve taken the event; do the work after. Anything else, or no answer within 10 seconds, is tried again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: eight tries in all, about 28 hours. Your answer’s body is never read.
Answering 410 Gone turns the endpoint off at once, and so do 3 days of nothing but failures. Either way, the workspace’s admins are emailed.
The delivery log
nightlab keeps each delivery for 30 days: its state, how many of its eight tries it has had, and its last try’s status, how long it took and why it failed, if it did. A delivery’s detail shows the body each of its tries sends; your endpoint’s answers are never read, so there are none to show. Workspace admins read it on the endpoint’s page in Settings › Integrations › Webhooks, where a delivery can be sent again, or through GET /workspace/webhooks/deliveries, which filters by endpoint, event type, state, status (like 5xx), event id, run id, issue key and time.
From the log, an admin can redeliver an event, with the same webhook-id, so your receiver knows it if it took it once, or send a test to an endpoint: an endpoint.test event, signed like any other.