> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reasonos.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a signed HTTPS delivery when something happens in your organization's projects — a change request merges, a run finishes, an issue changes status.

A webhook is an HTTPS endpoint you register once. From then on ReasonOS sends it
a signed request each time one of the events you chose happens in your
organization's projects. Agencies use it to bill on ReasonOS work; anyone can use
it to follow merges, runs and issue status from another system.

Webhooks are available with the core ReasonOS and QA products.

## Add a webhook

Open **Settings → Organization settings → Webhooks** and choose **Add webhook**.
Only organization administrators can manage webhooks; an agent working on a
branch server cannot.

* **Name** it after the system that receives it, so you know which one to pause later.
* **Endpoint** must be `https://` with no username, password or fragment. Addresses
  on private networks, the local machine and other special-purpose ranges are
  refused, both when you save and again at every delivery: the name is resolved
  afresh each time and the connection is made only to the addresses that passed.
* **Project** limits deliveries to one project; leave it empty to receive events
  from every project in the organization. Deleting a project removes the webhooks
  limited to it; organization-wide webhooks keep their history.
* **Events**: choose at least one from the list below.

When you save, ReasonOS shows the webhook's **signing secret once**. Copy it into
the receiving system now; it is never shown again. If you lose it, choose
**Rotate secret** on the webhook's row: a new secret is issued and every delivery
from then on, including retries, is signed with it. A delivery that was already in
flight at that moment may still arrive signed with the previous secret, so keep
accepting the old one for a minute or two after rotating.

An organization can hold up to 20 webhooks.

## Events

| Event | Sent when | `data` fields |
| - | - | - |
| `change_request.opened` | a change request is opened | `id`, `number`, `title`, `state` |
| `change_request.reviewed` | a review is submitted on a change request | `id`, `verdict`, `commit_sha` |
| `change_request.merged` | a change request merges | `id`, `number`, `title`, `state`, `merge_sha` |
| `change_request.closed` | a change request is closed without merging | `id`, `number`, `title`, `state` |
| `ci.run.completed` | a CI run finishes, whatever its result | `id`, `number`, `workflow`, `status`, `commit_sha` |
| `qa.run.completed` | a QA run finishes, whatever its result | `id`, `group_run_id`, `status` |
| `work.task.created` | an issue is created, including by an accepted proposal or an import | `id`, `number`, `title`, `status_id`, `assignee_id`, `priority` |
| `work.task.assigned` | an issue's assignee changes | same as `work.task.created` |
| `work.task.status_changed` | an issue moves to another status | same as `work.task.created` |
| `cycle.started` | a cycle starts | `id`, `name`, `state` |
| `cycle.closed` | a cycle closes | `id`, `name`, `state` |
| `branch.created` | a branch with a commit is first seen in the project, including a deleted branch that is pushed again | `name`, `head_sha` |
| `branch.deleted` | a branch that had been seen is removed | `name`, `head_sha` (the last head) |
| `observe.alert.fired` | an Observe monitor moves a group into warn, alert or no data | `id`, `monitor_id`, `monitor_name`, `group`, `from_state`, `to_state`, `value`, `note`, `window_from`, `window_to`, `url` |
| `observe.alert.recovered` | an Observe monitor moves a group back to ok | same as `observe.alert.fired` |

The two `observe.alert` events come from monitors in Observe. A monitor can name
the webhooks it notifies; it then delivers only to those, and otherwise to every
webhook that subscribed to the event and covers the monitor's project. `value` is `null` when the
condition had no row to judge, and `group` is empty for a monitor without
`group_by`.

Events describe transitions, not every change: a push that only moves a branch
forward, an edit to an issue's title, or a CI run that is still running send
nothing. The list in the editor comes from your workspace's control plane, so a
newly supported event appears there without a docs change.

## What a delivery looks like

Each delivery is an HTTPS `POST` with a JSON body, `Content-Type: application/json`,
the user agent `ReasonOS-Webhooks/1.0` and these headers:

| Header | Meaning |
| - | - |
| `X-ReasonOS-Event` | the event name, for example `change_request.merged` |
| `X-ReasonOS-Delivery` | the delivery's id. It stays the same across retries and redeliveries of that delivery, so your receiver can ignore duplicates |
| `X-ReasonOS-Timestamp` | when this attempt was signed, as Unix seconds |
| `X-ReasonOS-Signature-256` | `sha256=` followed by the hex HMAC-SHA256 described below |

The body carries the event, when it happened, the organization and project, who
acted, the resource it concerns, and the fields listed above as they were at the
moment of the event:

```json theme={null}
{
  "id": "7337991234567890",
  "event": "change_request.merged",
  "occurred_at": "2026-10-08T21:04:52Z",
  "org": "acme",
  "project": "casework",
  "actor": { "id": "7332372020811040" },
  "resource": { "kind": "change_request", "ref": "12" },
  "data": { "id": "7337990000000001", "number": 12, "title": "Add an Overdue cases filter", "state": "merged", "merge_sha": "9f2c…" }
}
```

`actor.id` is the acting member's id, or empty when the platform itself acted (a
CI run finishing, a branch observed during a sync). The body is written once, when
the event happens, and never rebuilt: a retry a day later still describes the state
at that moment. Payloads are at most 256 KB and never contain secrets, issue
bodies, comments, document contents or branch-server addresses.

### Verify the signature

The signed text is the value of `X-ReasonOS-Timestamp`, a single `.`, and the raw
request body, in that order. Compute HMAC-SHA256 over it with the webhook's
secret exactly as shown to you (the `whsec_` prefix is part of the key), encode the
result as lowercase hex, and compare it in constant time with the value after
`sha256=`. Reject anything that does not match, and anything whose timestamp is
older than you are willing to accept — five minutes is a common choice.

```js Node.js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

// Header shapes ReasonOS sends: a Unix timestamp in seconds, and
// "sha256=" followed by 64 lowercase hex digits. Checking the shape first
// means a malformed or hostile header is a clean refusal, never an exception:
// timingSafeEqual throws when the two buffers differ in byte length, which a
// non-ASCII signature of the right character count would cause.
const TIMESTAMP = /^[0-9]{1,15}$/;
const SIGNATURE = /^sha256=[0-9a-f]{64}$/;

// rawBody is the request body as received (a Buffer), before any JSON parsing.
export function verifyReasonOSDelivery(headers, rawBody, secret) {
  const timestamp = headers["x-reasonos-timestamp"];
  const signature = headers["x-reasonos-signature-256"];
  if (typeof timestamp !== "string" || !TIMESTAMP.test(timestamp)) return false;
  if (typeof signature !== "string" || !SIGNATURE.test(signature)) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected =
    "sha256=" +
    createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  // Both strings are now ASCII of the same length, so the byte lengths match.
  return timingSafeEqual(Buffer.from(expected, "ascii"), Buffer.from(signature, "ascii"));
}
```

```python Python theme={null}
import hashlib, hmac, re, time

# Header shapes ReasonOS sends: a Unix timestamp in seconds, and
# "sha256=" followed by 64 lowercase hex digits. Checking the shape first
# means a malformed or hostile header is a clean refusal, never an exception:
# str.isdigit() accepts characters such as "²" that int() cannot parse, and
# hmac.compare_digest() raises on a str that is not ASCII.
TIMESTAMP = re.compile(r"[0-9]{1,15}")
SIGNATURE = re.compile(r"sha256=[0-9a-f]{64}")

def verify_reasonos_delivery(headers, raw_body: bytes, secret: str) -> bool:
    timestamp = headers.get("X-ReasonOS-Timestamp")
    signature = headers.get("X-ReasonOS-Signature-256")
    if not isinstance(timestamp, str) or not TIMESTAMP.fullmatch(timestamp):
        return False
    if not isinstance(signature, str) or not SIGNATURE.fullmatch(signature):
        return False
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "sha256=" + hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected.encode("ascii"), signature.encode("ascii"))
```

Both functions answer `false` (`False`), without raising, for a missing header, a
timestamp such as `"1760000000²"` or `"-5"`, a signature in uppercase hex, one that
is too short or long, or one with non-ASCII characters. Answer such a delivery with
a non-`2xx` status such as `401`. ReasonOS counts it as a failed attempt and retries it
on the schedule below; every delivery ReasonOS signs has these shapes.

Answer with any `2xx` status within ten seconds; do your own processing after you
have answered. Anything else, no answer, a redirect or a certificate ReasonOS
cannot verify counts as a failed attempt. ReasonOS records only the status code you
answered, never the response body.

## Retries and redelivery

Each delivery gets up to five attempts. After a failed attempt ReasonOS waits
10 seconds, then 1 minute, 5 minutes and 30 minutes before the next one. After the
fifth failure the delivery is marked failed. Deliveries stay visible for
thirty days; an event older than that is not delivered.

On the webhook's row, the chevron opens **Recent deliveries**: each event, the
status your receiver answered, the number of attempts and the time of the next
retry. **Redeliver** queues another attempt of a finished delivery, delivered or
failed, with five fresh attempts: it keeps the same delivery id and the same
payload, so your receiver's de-duplication on `X-ReasonOS-Delivery` still applies.
A delivery that is still pending cannot be redelivered; it will be retried on its
own.

Pausing a webhook stops new deliveries: events that happen while it is paused are
not delivered later, and deliveries that were already queued resume when you
resume it. Deleting a webhook discards its unsent deliveries. A request that is
already on its way cannot be recalled.

## From the MCP

With your own access token, the ReasonOS MCP offers the same operations as the
settings page: list, create, update (including pause and resume), rotate, delete,
list the event catalogue, list a webhook's deliveries and redeliver. The secret is
returned by create and rotate only, once. An agent working on a branch server uses
a project-scoped connection: it cannot create, change, rotate or delete webhooks,
and it can list its own project's delivery summaries — event, status and the
answered status code, never endpoints or payloads — only when the person whose
credential it carries is an organization administrator.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.