# Outgoing webhooks

> Event catalog, request format, signature verification, and delivery policy for Probara outgoing webhooks.

Outgoing webhooks send an HTTP request to your endpoint when something happens in a project. This page is the reference for receivers: the events, the request, how to verify it, and how Probara retries. To set up a webhook in the app, read the [Webhooks guide](https://docs.probara.net/en/guides/webhooks/). To manage webhooks from code, read the [Webhooks API](https://docs.probara.net/en/developers/api/webhooks/).

## Quick path

1. Expose an `https://` endpoint that accepts the configured method (`POST` by default).
2. Verify `webhook-signature` against the raw request body with your secret. The steps are under Verify the signature below.
3. Answer with a `2xx` status within 10 seconds, then do any slow work.
4. Skip events you have already processed, using `webhook-id`. Probara can send the same event more than once.

## Event catalog

Each event is named `entity.action`. Probara does not rename or reuse an event name, so your receiver can match on them. Ignore the names it does not handle.

| Event                        | Sent when                                                                                                                     |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `test_case.created`          | A test case is created, or cloned one at a time.                                                                              |
| `test_case.updated`          | A test case is edited or moved.                                                                                               |
| `test_case.archived`         | A test case is archived.                                                                                                      |
| `test_case.restored`         | An archived test case is restored.                                                                                            |
| `test_case.deleted`          | A test case is deleted.                                                                                                       |
| `test_case.commented`        | A comment is added to a test case.                                                                                            |
| `suite.created`              | A suite is created or cloned.                                                                                                 |
| `suite.updated`              | A suite is edited or moved.                                                                                                   |
| `suite.deleted`              | A suite is deleted.                                                                                                           |
| `test_plan.created`          | A test plan is created.                                                                                                       |
| `test_plan.updated`          | A test plan is edited.                                                                                                        |
| `test_plan.status_changed`   | A test plan's status changes.                                                                                                 |
| `test_plan.deleted`          | A test plan is deleted.                                                                                                       |
| `milestone.created`          | A milestone is created.                                                                                                       |
| `milestone.updated`          | A milestone is edited.                                                                                                        |
| `milestone.status_changed`   | A milestone's status changes.                                                                                                 |
| `milestone.deleted`          | A milestone is deleted.                                                                                                       |
| `test_run.created`           | A test run is created or cloned.                                                                                              |
| `test_run.updated`           | A test run is edited.                                                                                                         |
| `test_run.closed`            | A test run is closed.                                                                                                         |
| `test_run.aborted`           | A test run is aborted.                                                                                                        |
| `test_run.deleted`           | A test run is deleted.                                                                                                        |
| `test_run.results_submitted` | Results are recorded in bulk for a run, by a bulk mark, a bulk result submission or a CI report. One event covers the action. |
| `result.created`             | A single case is marked in a run.                                                                                             |
| `defect.created`             | A defect is created.                                                                                                          |
| `defect.updated`             | A defect is edited.                                                                                                           |
| `defect.status_changed`      | A defect's status changes, including when it is reopened.                                                                     |
| `defect.commented`           | A comment is added to a defect.                                                                                               |
| `defect.deleted`             | A defect is deleted.                                                                                                          |
| `shared_step.created`        | A shared step is created.                                                                                                     |
| `shared_step.updated`        | A shared step is edited.                                                                                                      |
| `shared_step.deleted`        | A shared step is deleted.                                                                                                     |

Some changes send no event:

- Reopening a run.
- Test cases created by an import or a bulk clone.
- Test cases that a Git sync creates or updates. The sync still sends `test_case.archived` and `test_case.restored` for the cases it archives and restores.
- Shared steps that belong to the organization rather than to a project.

## Envelope

Every event has this shape. The standard payload is this JSON object, and custom templates can read any of its fields by path.

| Field          | Type             | Meaning                                                                                                                                                                                                                                                                 |
| -------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | ULID             | The event id. Sent as `webhook-id`.                                                                                                                                                                                                                                     |
| `type`         | string           | The event name, from the catalog.                                                                                                                                                                                                                                       |
| `version`      | `1`              | The envelope version. It changes only for a breaking change to the envelope itself.                                                                                                                                                                                     |
| `createdAt`    | string           | When the event was created, in ISO 8601 UTC.                                                                                                                                                                                                                            |
| `test`         | boolean          | `true` for a delivery sent by **Send test**.                                                                                                                                                                                                                            |
| `organization` | object           | The organization's `ulid` and `name`.                                                                                                                                                                                                                                   |
| `project`      | object           | The project's `ulid`, `key` (its project code) and `name`.                                                                                                                                                                                                              |
| `actor`        | object           | `type` (`user`, `api_token` or `system`), `ulid` and `name`. An action taken with a personal API token has its owner as a `user` actor; `api_token` is an organization app token. `ulid` and `name` are `null` for `system`, and can be `null` when a user has no name. |
| `entity`       | object           | `type` (the item group, such as `test_case`), `ulid`, `displayId`, `label` and `url`. `displayId` is `null` for items without one. `url` is currently always `null`.                                                                                                    |
| `data`         | object or `null` | The entity as the public API returns it, read when the event is processed (normally seconds after the change). `null` for a deletion, or when the entity no longer exists by then.                                                                                      |
| `changes`      | object or `null` | For an update, each changed field with its `from` and `to` values. `null` when the event records no changes.                                                                                                                                                            |

For `test_run.results_submitted`, `data` also has a `summary` object:

- `source` is `report` for a CI report, and `null` for the other bulk actions.
- `results` is the number of results a CI report recorded, and `null` for the other bulk actions.
- `cases` holds the bulk details the action recorded: `summary`, with the `affected` and `unchanged` counts, and `cases`, the listed cases. `omittedCount` appears when the list was cut short.

Example of an event for a test case update. The `data` object is shortened:

```json
{
  "id": "01JC8Z4K9P2Q7R5T1V3W6X8Y0A",
  "type": "test_case.updated",
  "version": 1,
  "createdAt": "2026-03-02T15:04:05.123Z",
  "test": false,
  "organization": { "ulid": "01JC8Z3M0N1P2Q3R4S5T6V7W8X", "name": "Acme" },
  "project": { "ulid": "01JC8Z3M1A2B3C4D5E6F7G8H9J", "key": "CHK", "name": "Checkout" },
  "actor": { "type": "user", "ulid": "01JC8Z3M2K3L4M5N6P7Q8R9S0T", "name": "Ada Lovelace" },
  "entity": {
    "type": "test_case",
    "ulid": "01JC8Z3M3U4V5W6X7Y8Z9A0B1C",
    "displayId": "TC-42",
    "label": "Pay with a saved card",
    "url": null
  },
  "data": {
    "ulid": "01JC8Z3M3U4V5W6X7Y8Z9A0B1C",
    "displayId": "TC-42",
    "title": "Pay with a saved card"
  },
  "changes": { "title": { "from": "Pay with a card", "to": "Pay with a saved card" } }
}
```

## Request

Each request carries these headers. Custom headers you configured on the webhook are added as well.

| Header              | Value                                                                                      |
| ------------------- | ------------------------------------------------------------------------------------------ |
| `Content-Type`      | `application/json` for the standard payload. Otherwise, the content type of your template. |
| `User-Agent`        | `Probara-Webhooks/1.0`                                                                     |
| `webhook-id`        | The event `id`. It is the same on every attempt, and on every redelivery, of that event.   |
| `webhook-timestamp` | Unix time in seconds, when this attempt was signed.                                        |
| `webhook-signature` | `v1,` followed by the base64-encoded HMAC-SHA256 signature.                                |

## Verify the signature

The signature is an HMAC-SHA256 of the text `<webhook-id>.<webhook-timestamp>.<body>`, where `<body>` is the raw request body. The key is the secret's key bytes: the part after `whsec_`, base64-decoded. The result is base64-encoded and sent after `v1,`.

1. Read the raw body before you parse it. Re-serializing parsed JSON produces different bytes.
2. Reject the request if any of the three `webhook-*` headers is missing.
3. Reject timestamps outside your tolerance. The examples below allow five minutes.
4. Compute the expected signature and compare it in constant time with each `v1` entry in `webhook-signature`. The header is a space-separated list, so accept the request when any `v1` entry matches.

Node.js (using `node:crypto`; Node provides header names in lowercase):

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

/**
 * rawBody: the request body as received, before JSON parsing.
 * headers: the request headers, with lowercase names.
 * secret: the webhook's signing secret, starting with whsec_.
 */
export function verifyProbaraWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) return false;

  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - sentAt) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  return signatures.split(' ').some((entry) => {
    const [version, value = ''] = entry.split(',');
    if (version !== 'v1') return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

Python (standard library only; pass the header names in lowercase):

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60

def verify_probara_webhook(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
    """headers: the request headers with lowercase names. raw_body: the body as received."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - sent_at) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1":
            continue
        try:
            given = base64.b64decode(value, validate=True)
        except ValueError:
            continue
        if hmac.compare_digest(given, expected):
            return True
    return False
```

## Delivery policy

| Setting               | Value                                                                                                                                                                                                                |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Timeout per attempt   | 10 seconds, for the response headers and the body                                                                                                                                                                    |
| Success               | Any `2xx` status                                                                                                                                                                                                     |
| Attempts per delivery | 8 attempts: the first attempt and seven retries. A test sends one attempt.                                                                                                                                           |
| Redirects             | Not followed. A redirect is a failed attempt. Before each attempt the host is resolved, and an address outside the public internet fails it with `The endpoint resolves to a non-public address`.                    |
| Automatic disable     | After 5 deliveries in a row failed, with no successful delivery for 72 hours (counted from creation if it never succeeded). Probara records `webhook.auto_disabled` and emails the organization's owners and admins. |
| Request body          | The standard envelope is up to 256 KiB. A custom body over 256 KiB is not sent.                                                                                                                                      |
| Delivery history      | Finished deliveries are kept for 30 days. Deleting a webhook deletes its history.                                                                                                                                    |
| Response body kept    | Up to 4,096 bytes per delivery                                                                                                                                                                                       |
| User agent            | `Probara-Webhooks/1.0`                                                                                                                                                                                               |
| Webhooks per project  | Up to 20 webhooks                                                                                                                                                                                                    |

Retries follow this schedule:

| Attempt | Sent                                | Wait (seconds) |
| ------- | ----------------------------------- | -------------- |
| 1       | Asynchronously, after the event     | 0              |
| 2       | At least 30 seconds after attempt 1 | 30             |
| 3       | At least 2 minutes after attempt 2  | 120            |
| 4       | At least 10 minutes after attempt 3 | 600            |
| 5       | At least 30 minutes after attempt 4 | 1800           |
| 6       | At least 1 hour after attempt 5     | 3600           |
| 7       | At least 3 hours after attempt 6    | 10800          |
| 8       | At least 6 hours after attempt 7    | 21600          |

The last attempt comes at least 10 hours and 42 minutes after the first one.

## Responding

- A `2xx` status within 10 seconds counts as delivered. Only the status code decides success.
- Any other outcome is a failed attempt, and it is retried on the schedule above: a status outside `2xx`, a redirect, a timeout, or a connection error.
- Probara may send the same event more than once, for example when a retry follows a response that arrived after the timeout. Ignore an event whose `webhook-id` you have already processed.
- Do not rely on arrival order, because retries can deliver an earlier event after a later one. Use `createdAt` to order events.
