Webhooks
Receive a signed event for every change, verify it, handle retries and duplicates, and recover anything you missed.
ProBB AI sends a signed HTTP POST to your server for every change, whether it came from the API, the app or an AI assistant. Each event carries the changed record as GET returns it, so most receivers never need a second call.
The event catalog lists every event and what it carries. Events are produced only while API access is on for the workspace (it is unless an Admin turns it off). Webhooks are on too, unless an Admin turns them off with the switch on the Webhooks card in Settings, Developer; while they are off, creating an endpoint returns 403 webhooks_not_enabled and nothing is sent. Workspace Admins manage endpoints, and an API key needs webhooks:write, which only an Admin's key can hold. The events feed works either way. Nothing is sent for changes that happened before you added an endpoint. Read those from the events feed.
{
"id": "evt_0123456789abcdef0123456789abcdef",
"object": "event",
"type": "report.completed",
"createdAt": "2026-11-02T10:00:00.000Z",
"actor": { "type": "api_key", "id": "key_example" },
"data": { "object": { "id": "rep_example", "object": "report", "status": "completed" } }
}actor.type is api_key, user, assistant or system. If you sync in both directions, ignore events from your own key. *.updated events also carry previous, the old values of the fields that changed.
Set up an endpoint
You need a public HTTPS address on your server. An endpoint must use https:// on port 443, on the public internet, with no username or password in the address. Addresses inside private networks are refused. A workspace can have 5 endpoints.
In the app
- Open Settings, Developer, Webhooks and choose Add webhook endpoint.
- Enter your address. Choose All events, or pick events grouped by resource.
- Copy the signing secret. It starts with
whsec_and is shown once. - Choose Send test. Your server should receive a
pingevent. - Open Delivery history to see each send and your server's answer.
With the API
You need a key with webhooks:write. The Sync only preset has only webhooks:read.
curl -X POST "$PROBB_URL/api/v1/webhook-endpoints" \
-H "Authorization: Bearer $PROBB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://example.com/webhooks/probb",
"description": "Production receiver",
"events": ["report.completed", "interview.completed", "decision.updated"]
}'The answer includes secret. It is the only time you see it, apart from rotating it. Store it now. Send "events": ["*"] for every event, including ones added later. * cannot be mixed with named events. Then send a test:
curl -X POST "$PROBB_URL/api/v1/webhook-endpoints/$ENDPOINT_ID/test" \
-H "Authorization: Bearer $PROBB_API_KEY"You can change an endpoint with PATCH /api/v1/webhook-endpoints/{id}: its url, description, events and status. Changing the address cancels deliveries that were waiting for the old one.
Handle a delivery
Your server should:
- Read the raw request body before any JSON parsing.
- Verify the signature (see below).
- Save the event
idin a table with a unique constraint, and queue the work, in one database transaction. - Answer with any
2xxstatus.
Answer quickly. ProBB AI waits 10 seconds, and then counts the delivery as failed. Do slow work after you have answered. If saving fails, answer with a 5xx so the event is sent again. The response body is ignored.
Each request has these headers: webhook-id, webhook-timestamp, webhook-signature, content-type: application/json, user-agent: ProBB-Webhooks/1.0 and x-request-id. Redirects are not followed.
Verify the signature
ProBB AI signs events in the Standard Webhooks format.
- The signed text is
webhook-id, a dot,webhook-timestamp, a dot, and the raw body. - The key is the signing secret without the
whsec_prefix, decoded from base64. - The signature is HMAC-SHA256, in base64, in
webhook-signatureasv1,<signature>. - During a secret rotation the header holds two signatures separated by a space. Accept the event if any
v1signature matches. Compare them in constant time. - Reject a
webhook-timestampmore than five minutes from your clock. Every retry gets a new timestamp and signature.
Verify before you parse. Changing the whitespace of the body changes the signature.
Download the tested verifiers: TypeScript and Python. Both check raw bytes, the timestamp and rotation signatures, and return the event ID.
import { verifyWebhook } from "./verify-webhook";
export async function POST(request: Request) {
const rawBody = Buffer.from(await request.arrayBuffer());
let eventId: string;
try {
eventId = verifyWebhook(rawBody, request.headers, process.env.PROBB_WEBHOOK_SECRET!);
} catch {
return new Response(null, { status: 400 });
}
const event = JSON.parse(rawBody.toString("utf8"));
if (event.id !== eventId) return new Response(null, { status: 400 });
// In one database transaction: insert eventId with a UNIQUE constraint
// and queue the work. If the insert finds the ID, answer 204 and stop.
return new Response(null, { status: 204 });
}import json
import os
from verify_webhook import verify_webhook
def handle(raw_body: bytes, headers: dict[str, str]) -> int:
try:
event_id = verify_webhook(raw_body, headers, os.environ["PROBB_WEBHOOK_SECRET"])
except ValueError:
return 400
event = json.loads(raw_body)
if event["id"] != event_id:
return 400
# In one database transaction: insert event_id with a UNIQUE constraint
# and queue the work. If the insert finds the ID, answer 204 and stop.
return 204Your server verifies with the signing secret. ProBB AI never sends your API key.
Retries
Any 2xx answer is a success. These are tried again: no answer, a timeout, a network failure, 408, 429 and any 5xx. Any other answer, including a redirect or a 4xx such as 401 or 404, ends the delivery as failed.
A delivery is sent up to 7 times. The waits after each failure are about 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours, each moved a little at random. The last send is about a day and a half after the first. If your server sends Retry-After with a 429 or 5xx, ProBB AI waits at least that long, up to 24 hours.
An endpoint is sent one event at a time. A delivery that fails does not hold up the ones behind it.
Duplicates and order
Delivery is at least once. The same event can arrive twice, and events can arrive out of order. So:
- Save every event
idand ignore one you have seen. - Compare
data.object.updatedAtwith your copy before you apply an event. If yours is newer, skip it. - Do not rely on the order of different events. Use each event's
createdAt, or read the record.
Pause, rotate and delete
Pause an endpoint with PATCH and "status": "paused", or Pause in the app. A paused endpoint gets no new events. Events that happen while it is paused are not sent later. Read them from the feed after you resume. Deliveries that were already waiting are held and sent when you resume, until their event is 30 days old.
Rotate the secret with the app or POST /api/v1/webhook-endpoints/{id}/rotate-secret. The new secret is shown once. The old one keeps working for 24 hours, and during that time every event carries both signatures. Deploy the new secret to your server, then you are done. A second rotation during the 24 hours is 409 secret_rotation_in_progress.
Delete an endpoint to remove it, its secrets and its waiting deliveries.
Delivery history and retry
Each endpoint keeps its deliveries for 30 days, with every send: the time, the HTTP status your server gave and how long it took. See them in Delivery history, or with GET /api/v1/webhook-endpoints/{id}/deliveries. Filter by status or eventType.
To send a finished delivery again, choose Retry in the app or call POST /api/v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry. It works for succeeded, failed and cancelled deliveries. The event and its id stay the same, and it gets a fresh set of retries. A delivery that is still pending or running cannot be retried: 409 delivery_not_retryable.
Recover missed events
If your server was down for longer than the retries last, or you paused an endpoint, or you have no endpoint at all, read the events feed. It holds the last 30 days of events in the same format as webhooks, oldest first.
curl "$PROBB_URL/api/v1/events?after=$CURSOR&limit=100" \
-H "Authorization: Bearer $PROBB_API_KEY"Run the same handler you use for webhooks on each event. Save nextCursor after every page. nextCursor is always set. When nothing is new it is a fresh cursor to use next time. Keep going while hasMore is true. Leave out after to start from the oldest event. Filter with types=report.completed,decision.updated.
A cursor older than 30 days returns 410 event_cursor_expired. Start again without after, and rebuild from the lists with updatedSince. See keep an ATS in sync. The feed needs webhooks:read.
If events do not arrive
- Open the endpoint in Delivery history. Look at the HTTP status and result of the latest sends.
- Check the endpoint is Active and subscribed to the event.
- Check your server answers within 10 seconds over valid HTTPS on port 443, and that your firewall allows it.
- Fix the cause, then use Retry on the failed deliveries.
- Read the feed for anything older than the retries.