Skip to content

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.

  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.

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.

EventSent when
test_case.createdA test case is created, or cloned one at a time.
test_case.updatedA test case is edited or moved.
test_case.archivedA test case is archived.
test_case.restoredAn archived test case is restored.
test_case.deletedA test case is deleted.
test_case.commentedA comment is added to a test case.
suite.createdA suite is created or cloned.
suite.updatedA suite is edited or moved.
suite.deletedA suite is deleted.
test_plan.createdA test plan is created.
test_plan.updatedA test plan is edited.
test_plan.status_changedA test plan’s status changes.
test_plan.deletedA test plan is deleted.
milestone.createdA milestone is created.
milestone.updatedA milestone is edited.
milestone.status_changedA milestone’s status changes.
milestone.deletedA milestone is deleted.
test_run.createdA test run is created or cloned.
test_run.updatedA test run is edited.
test_run.closedA test run is closed.
test_run.abortedA test run is aborted.
test_run.deletedA test run is deleted.
test_run.results_submittedResults 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.createdA single case is marked in a run.
defect.createdA defect is created.
defect.updatedA defect is edited.
defect.status_changedA defect’s status changes, including when it is reopened.
defect.commentedA comment is added to a defect.
defect.deletedA defect is deleted.
shared_step.createdA shared step is created.
shared_step.updatedA shared step is edited.
shared_step.deletedA 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.

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

FieldTypeMeaning
idULIDThe event id. Sent as webhook-id.
typestringThe event name, from the catalog.
version1The envelope version. It changes only for a breaking change to the envelope itself.
createdAtstringWhen the event was created, in ISO 8601 UTC.
testbooleantrue for a delivery sent by Send test.
organizationobjectThe organization’s ulid and name.
projectobjectThe project’s ulid, key (its project code) and name.
actorobjecttype (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.
entityobjecttype (the item group, such as test_case), ulid, displayId, label and url. displayId is null for items without one. url is currently always null.
dataobject or nullThe 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.
changesobject or nullFor 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:

{
"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" } }
}

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

HeaderValue
Content-Typeapplication/json for the standard payload. Otherwise, the content type of your template.
User-AgentProbara-Webhooks/1.0
webhook-idThe event id. It is the same on every attempt, and on every redelivery, of that event.
webhook-timestampUnix time in seconds, when this attempt was signed.
webhook-signaturev1, followed by the base64-encoded HMAC-SHA256 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):

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 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
SettingValue
Timeout per attempt10 seconds, for the response headers and the body
SuccessAny 2xx status
Attempts per delivery8 attempts: the first attempt and seven retries. A test sends one attempt.
RedirectsNot 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 disableAfter 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 bodyThe standard envelope is up to 256 KiB. A custom body over 256 KiB is not sent.
Delivery historyFinished deliveries are kept for 30 days. Deleting a webhook deletes its history.
Response body keptUp to 4,096 bytes per delivery
User agentProbara-Webhooks/1.0
Webhooks per projectUp to 20 webhooks

Retries follow this schedule:

AttemptSentWait (seconds)
1Asynchronously, after the event0
2At least 30 seconds after attempt 130
3At least 2 minutes after attempt 2120
4At least 10 minutes after attempt 3600
5At least 30 minutes after attempt 41800
6At least 1 hour after attempt 53600
7At least 3 hours after attempt 610800
8At least 6 hours after attempt 721600

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

  • 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.