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. To manage webhooks from code, read the Webhooks API.
Quick path
Section titled “Quick path”- Expose an
https://endpoint that accepts the configured method (POSTby default). - Verify
webhook-signatureagainst the raw request body with your secret. The steps are under Verify the signature below. - Answer with a
2xxstatus within 10 seconds, then do any slow work. - Skip events you have already processed, using
webhook-id. Probara can send the same event more than once.
Event catalog
Section titled “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.archivedandtest_case.restoredfor the cases it archives and restores. - Shared steps that belong to the organization rather than to a project.
Envelope
Section titled “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:
sourceisreportfor a CI report, andnullfor the other bulk actions.resultsis the number of results a CI report recorded, andnullfor the other bulk actions.casesholds the bulk details the action recorded:summary, with theaffectedandunchangedcounts, andcases, the listed cases.omittedCountappears when the list was cut short.
Example of an event for a test case update. The data object is shortened:
{ "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
Section titled “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
Section titled “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,.
- Read the raw body before you parse it. Re-serializing parsed JSON produces different bytes.
- Reject the request if any of the three
webhook-*headers is missing. - Reject timestamps outside your tolerance. The examples below allow five minutes.
- Compute the expected signature and compare it in constant time with each
v1entry inwebhook-signature. The header is a space-separated list, so accept the request when anyv1entry matches.
Node.js (using node:crypto; Node provides header names in lowercase):
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):
import base64import hashlibimport hmacimport 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 FalseDelivery policy
Section titled “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
Section titled “Responding”- A
2xxstatus 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-idyou have already processed. - Do not rely on arrival order, because retries can deliver an earlier event after a later one. Use
createdAtto order events.