Sign in

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.

EventWhen
run.needs_approval RecommendedA run waits for someone to approve it.
run.waiting RecommendedA run waits for a person to answer its question. With run.needs_approval, that’s everything that needs a person.
run.failed RecommendedA run failed. Get the run for what it got to.
run.pr_opened RecommendedA run opened its draft pull request.
run.pr_merged, run.pr_closedA person merged or closed that pull request.
run.created, run.started, run.finished, run.cancelledThe rest of a run’s life.
run.summarizedThe run’s summary is ready to fetch: after it finished, failed or was cancelled, for runs started through the API.
endpoint.testA 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:

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);
  });
}

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.