Set up an endpoint
- In the dashboard open Settings > Webhooks, or call
POST /webhook-endpointswith a key that has thewebhooksscope. - Give an https address on a public host, and pick the events from the list below. A private address is refused. The address is checked again at every delivery, and redirects are not followed.
- Copy the signing secret. It starts with
whsec_and is shown once, in the answer that makes the endpoint. PerfBee keeps it encrypted. - Send a test event from the dashboard or with
POST /webhook-endpoints/{endpoint_id}/test. It is signed like any other.
A workspace can have 5 endpoints. Each can take any events.
What is sent
Each delivery is one POST with a JSON body and these headers.
| Header | Meaning |
|---|---|
| Content-Type | Always application/json. |
| PerfBee-Event | The event name. |
| PerfBee-Event-Id | The id of the event. It is the same on every attempt and on every redelivery: use it to ignore a repeat. |
| PerfBee-Attempt | The number of this attempt, from 1. |
| PerfBee-Signature | The signature of this attempt: see below. |
The body has these members.
| Member | Meaning |
|---|---|
| id | The event id, the same as PerfBee-Event-Id. |
| api_version | The version of the payload format. |
| event | The event name. |
| event_description | The event in words. |
| timestamp | When the event was made, ISO 8601 in UTC. |
| data | What happened. Members depend on the event, and new members can appear. |
{
"id": "evt_5f0c2b7e9a1d4c3e8b6a7d9f01234567",
"api_version": "v1",
"event": "scan_data_ready",
"event_description": "Crawl data ready",
"timestamp": "2026-11-01T09:30:12.512Z",
"data": {
"crawl_id": "0f7c9a52-3d4b-4a39-8c0e-5e2a9d1b6f10",
"mode": "full",
"site_id": 7
}
}A client should ignore members it does not know: within v1 new members are added, never removed. The data of an event that names a crawl includes crawl_id, site_id and report_url, and an api_url to read the crawl.
Check the signature
Every delivery carries PerfBee-Signature: t=<unix seconds>,v1=<hex>. Check it before you trust the body.
- Read the raw bytes of the request body. Do not parse and write the JSON again: the signature covers the exact bytes.
- Take
tand everyv1from the header. - Refuse the delivery if
tis more than 300 seconds away from your clock. Every attempt is signed again with its own time. - Compute HMAC-SHA256 over
<t>.<raw body>, that is the time, a dot and the raw body, with the signing secret as the key. Use the secret exactly as shown,whsec_included. Write the result as hex. - Compare it to each
v1with a constant-time comparison. One match is enough. There are two while a secret is rotated.
Working code is in the recipe Verify a webhook signature.
Events
| Event | Group | When it is sent |
|---|---|---|
| scan_started | Site Crawler | A crawl began, started by hand or by a schedule. |
| scheduled_scan_triggered | Site Crawler | A schedule put its scan in the queue. |
| scan_data_ready | Site Crawler | A crawl's pages, checks and fixes are in place, once per site after each crawl. Read them through the API. |
| broken_links_found | Site Crawler | A crawl finished with at least one broken link. |
| scan_completed | Site Crawler | Every finished crawl, with or without broken links. |
| scan_failed | Site Crawler | A crawl aborted before finishing. |
| crawler_signature_expiring | Site Crawler | The crawler signature a Shopify store gave us runs out in two weeks; after that the crawl is refused. |
| site_verification_lost | Site Crawler | The weekly check could not find the DNS record, file or meta tag that proves a site is yours. |
| fast_lane_off | Site Crawler | A scan of a site in the fast lane was refused, so its scans go at the standard pace again. |
| ai_access_lost | Site Crawler | An AI search crawler (ChatGPT, Claude, Perplexity, Google) could reach the site last scan and cannot now. |
| content_drift | Content Monitor | A monitored element disappeared from pages or broke a rule. |
| stock_changed | Monitors | Products of a site in Stock watch ran out of stock or came back. |
| performance_alert | Monitors | A scheduled performance check crossed one of its thresholds. |
| css_changed | Monitors | A scheduled Critical CSS check saw the stylesheet change beyond its threshold. |
| credit_low | Account | Less than 20% of the monthly credits remain. |
| credit_exhausted | Account | No credits left; scheduled scans are skipped until the cycle resets. |
| fix_check_done | Site Crawler | A Check if fixed you started in Fixes has its result. |
| fixes_resolved | Site Crawler | A scan found that fixes on the Fixes list are done. |
| scheduled_scan_skipped | Account | A scheduled scan did not start because the credits left do not cover it. Once per schedule a month. |
Start from scan_data_ready when you want to read a crawl. scan_completed can arrive while the crawl's pages and fixes are still being processed. scan_data_ready is sent once per crawl and site, after that is done. The list of events is also served by GET /webhook-events.
Your answer and retries
- Answer with any 2xx within 10 seconds. Do the work after you have answered.
- A timeout, a failed connection, a status from 500 up and the statuses 408, 425 and 429 are tried again. Any other answer ends the delivery as failed.
- A delivery gets 5 attempts in all. The waits between them are 1, 5, 30 and 120 minutes.
- After 5 failed attempts in a row the workspace gets an in-app notice that the endpoint is failing.
A delivery can arrive twice, and deliveries can arrive out of order. Use id to ignore a repeat. When the order matters, read the current state through the API instead of trusting the order of arrival.
Deliveries and redelivery
GET /webhook-endpoints/{endpoint_id}/deliveries lists what was sent, with the status of the last attempt. Delivery records are kept 14 days. A failed delivery also keeps its body for 3 days, and during that time POST /webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver sends it again with the same id. A delivered event keeps no body.
Rotate the secret
POST /webhook-endpoints/{endpoint_id}/rotate-secret makes a new secret and shows it once. The old one keeps signing for 24 hours, so every delivery in that time has two v1 values. Switch your receiver to the new secret in that time.
Endpoints from before signing
The single endpoint that Settings > Webhooks had before is moved into the list the first time it is needed. It keeps its secret, and the signature is made with it. For 90 days after the move it also sends that secret in the plain header X-PerfBee-Secret, with X-PerfBee-Event, X-PerfBee-Delivery and X-PerfBee-Attempt. Check the signature instead: the plain headers stop after that time. An endpoint that had no secret is unsigned until you rotate one.