Webhook integrations
A webhook integration POSTs a JSON event to a URL you control. Use it when there is no native integration for your tool: an internal event bus, a ticketing system, a data warehouse ingest URL. For PagerDuty or Jira Service Management (Opsgenie), use the native integration instead: it closes the alert when the monitor recovers.
Set up
Section titled “Set up”-
Go to Manage → Integrations and click the Webhook card.
-
Give it a name (for example
Internal event bus) and the URL that should receive POSTs. Click Add integration. -
Copy the signing secret. It is shown once, when the integration is created, and no endpoint returns it again.
Or through the API, with POST /integration/webhook:
curl -X POST https://api.siteqwality.com/integration/webhook \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "friendly_name": "Internal event bus", "url": "https://events.example.com/sq-webhook" }'The response carries the secret in details.signing_secret. It starts with whsec_. Endpoints added before 22 October 2026 have a secret that starts with sqwh_ until you rotate it (see older sqwh_ secrets). If you never saw an endpoint’s secret, rotate it to get one you can see.
Adding, changing or removing a webhook integration takes an account admin, or an API key or API client with integrations:manage (see who can change integrations).
The URL reads back as its scheme and host only, even in the create response.
Change the URL, authentication and headers
Section titled “Change the URL, authentication and headers”Open the webhook integration from Manage → Integrations and go to its Settings tab. The Endpoint section shows the name, the URL, the authentication and the custom header names, and Change replaces each one. Through the API, PATCH /v1/integrations/{id} with integrations:manage:
curl -X PATCH https://api.siteqwality.com/v1/integrations/<webhook-integration-id> \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "credentials": { "url": "https://events.example.com/sq-webhook-v2" }, "auth": { "type": "bearer", "token": "<token-your-server-expects>" }, "headers": [{ "name": "X-Api-Key", "value": "<key-your-server-expects>" }] }'credentials.urlmoves deliveries to the new URL from the next alert. The signing secret stays.authis{"type": "bearer", "token"},{"type": "basic", "username", "password"}or{"type": "none"}. It goes in theAuthorizationheader.headersreplaces the whole set, at most 10;[]removes them. Names are refused if they areHost,Content-Length,Content-Type,Authorization(useauth),Cookie,Proxy-Authorization,Forwarded,Transfer-Encoding,Connection,Upgrade,TE,Expect,User-Agent, or start withX-Forwarded-,webhook-orX-SiteQwality-. Values are one line of up to 1,024 printable characters.
Send any of the three alone. Token, password and header values are stored encrypted and never shown again: reads return webhook_request with the header names, the auth type and a basic auth username. The delivery log keeps these headers’ names with their values masked. Any of these changes starts the integration’s health over.
The URL must use a public host. A private, loopback or link-local address (10.x, 192.168.x, 127.0.0.1, localhost, 169.254.169.254, and their IPv6 equivalents) is refused when you save it, and a hostname that resolves only to such addresses at delivery time fails with blocked_destination and is not retried.
Add it to a notification group
Section titled “Add it to a notification group”Right after you add the integration, the dashboard offers to do this for you: choose a group or create one, choose which monitors alert that group (the ones that already do, every monitor, or ones you pick), then send a test. If you skipped it, Route alerts on the integration’s card opens the same step. To do it by hand:
-
Go to Respond → Notifications. Click a group’s name to open it, or click Add group to make a new one.
-
Under Notifications, click Add notification method. Set Notification type to Webhook and choose this integration under Integration.
-
Click Done editing, then Save changes (Create group for a new group).
Through the API, create the channel with POST /notification:
curl -X POST https://api.siteqwality.com/notification \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "notification_group_id": "<your-group-id>", "type": "webhook", "details": { "integration_id": "<webhook-integration-id>" } }'Payload
Section titled “Payload”Every delivery has the same envelope:
{ "id": "0f1c3a92-5c1e-4c3b-9f43-3d2a6c1b7e10", "event": "monitor.down", "severity": "critical", "created_at": "2026-03-14T09:26:53Z", "account_id": "<account-uuid>", "metadata": { "team": "payments" }, "data": { }}idis the delivery ID. It is the same on every retry of one delivery, so use it to drop duplicates.metadataappears only if you set some on the endpoint (see Payload settings).datadepends on the event. For an uptime monitor going down or recovering it is the check result, withcheck_typeadded beside the result fields:
{ "id": "<result-uuid>", "account_id": "<account-uuid>", "http_job_id": "<monitor-uuid>", "created_at": "2026-03-14T09:26:51Z", "check_region": "aws-us-east-1", "response_http_code": 503, "request_uri": "https://api.example.com/health", "final_uri": "https://api.example.com/health", "request_duration_ms": 412, "failure_reason": "Expected status 200, got 503", "status": "failed", "check_type": "http"}The full result also carries the response headers and body, the timing breakdown and the request headers (with values masked except for a short list of harmless headers). Use fields in the payload settings to send only the keys you need.
data.check_type says which kind of uptime monitor it is: http, tcp or ping. The result also carries the ping statistics packets_sent, packets_received, rtt_min_us, rtt_avg_us, rtt_max_us and rtt_mdev_us (round-trip times in microseconds), which are null on HTTP and TCP port results.
Events
Section titled “Events”| Event | Severity |
|---|---|
monitor.down | critical |
monitor.recover | info |
incident.created | critical |
incident.resolved | info |
incident.acknowledged | info |
incident.updated | info |
incident.reopened | critical |
incident.merged | info |
incident.service_changed | info |
tls.expiring | warning |
tls.invalid | critical |
tls.recovered | info |
dns.expiring | warning |
dns.record_change | warning |
page_speed.degraded | warning |
alert.firing | critical |
alert.resolved | info |
rum.issue.created | warning |
rum.issue.regressed | critical |
rum.issue.escalating | critical |
rum.alert.firing | critical |
rum.alert.resolved | info |
Request headers
Section titled “Request headers”Deliveries follow Standard Webhooks, so its libraries verify them.
| Header | Value |
|---|---|
Content-Type | application/ |
User-Agent | SiteQwality-Webhooks/ |
webhook-id | The message ID, equal to id in the body. It stays the same on every retry and replay, so use it to drop duplicates. |
webhook-timestamp | Unix seconds when this attempt was signed. |
webhook-signature | v1, followed by the base64 HMAC. Two space-separated entries after a rotation. |
X-SiteQwality-Delivery | The message ID again. |
X-SiteQwality-Event | The event name. Not signed. |
X-SiteQwality-Attempt | 1 to 8. Not signed. |
X-SiteQwality-Replay-Of | On a replay only: the ID of the delivery it replays. Not signed. |
X-SiteQwality-Timestamp, X-SiteQwality-Signature | The older signature, still sent for now (see below). |
Verify the signature
Section titled “Verify the signature”The signature is an HMAC-SHA256 over the message ID, the timestamp and the raw request body, joined with dots:
signed_payload = "{webhook-id}.{webhook-timestamp}.{raw body}"webhook-signature = "v1," + base64(HMAC_SHA256(key, signed_payload))The key is the base64-decoded part of the secret after whsec_. Each attempt is signed again with a fresh timestamp, so a retry hours later still passes the libraries’ 5-minute tolerance check. Pass the raw request body, not a re-serialized object.
// npm install standardwebhooksconst { Webhook } = require('standardwebhooks');
const wh = new Webhook(secret); // the secret exactly as shown
app.post('/sq-webhook', express.raw({ type: 'application/json' }), (req, res) => { const event = wh.verify(req.body, req.headers); // throws if invalid or too old res.sendStatus(204);});# pip install standardwebhooksfrom standardwebhooks import Webhook
wh = Webhook(secret) # the secret exactly as shown
# Raises WebhookVerificationError if invalid or too old.event = wh.verify(request.get_data(), dict(request.headers))// go get github.com/standard-webhooks/standard-webhooks/librariesimport standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
wh, err := standardwebhooks.NewWebhook(secret) // the secret exactly as shown
body, err := io.ReadAll(r.Body)err = wh.Verify(body, r.Header) // nil when valid and recent# gem install standardwebhooksrequire 'standardwebhooks'
wh = StandardWebhooks::Webhook.new(secret) # the secret exactly as shown
# Raises if invalid or too old.event = wh.verify(request.raw_post, request.headers)Older sqwh_ secrets
Section titled “Older sqwh_ secrets”A secret that starts with sqwh_ signs with the secret’s own characters as the key, not a base64-decoded one. Give the libraries the raw key, or rotate to get a whsec_ secret:
| Library | Raw key |
|---|---|
| Node | new Webhook(secret, { format: 'raw' }) |
| Python | Webhook(secret.encode()) |
| Go | standardwebhooks.NewWebhookRaw([]byte(secret)) |
| Ruby | StandardWebhooks::Webhook.new(Base64.strict_encode64(secret)) |
The older X-SiteQwality-Signature header
Section titled “The older X-SiteQwality-Signature header”Every delivery still carries the signature scheme from before Standard Webhooks. It will be removed, with notice in the changelog, so verify webhook-signature instead. It is v1= followed by the hex HMAC-SHA256 of "{X-SiteQwality-Delivery}.{X-SiteQwality-Timestamp}.{raw body}", keyed with the secret’s own characters (whsec_ or sqwh_ included), with two space-separated entries after a rotation.
Rotate the secret
Section titled “Rotate the secret”Open the webhook integration from Manage → Integrations, go to its Settings tab, choose Rotate secret and pick how long the old secret keeps working. Or call the API as an account admin, or with an API key or API client that has integrations:manage:
curl -X POST https://api.siteqwality.com/v1/integrations/<webhook-integration-id>/signing-secret/rotate \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "overlap_hours": 24 }'The response carries the new secret once, in signing_secret, with previous_secret_expires_at. Until then every delivery carries two entries in webhook-signature, one under each secret, and the libraries accept either, so your receiver keeps working while you deploy the new secret. overlap_hours is 0 to 168 and defaults to 24; 0 stops the old secret at once, for a secret that leaked.
Rotating again while the old secret still works answers 409, because it would stop that secret early. Send "expire_previous_now": true to do it anyway. The older POST /integration/webhook/{id}/rotate_secret still works from an admin session, with a 24-hour overlap.
Delivery and retries
Section titled “Delivery and retries”Each attempt times out after 15 seconds. Your endpoint should answer with a 2xx quickly and do slow work afterwards.
| Response | What happens |
|---|---|
| 2xx | Delivered. |
| 3xx | Failed, not retried. Redirects are not followed. |
| 408 or 429 | Retried. A Retry-After header in seconds sets the earliest time for the next attempt, up to 10 hours later. |
| Other 4xx | Failed, not retried. |
| 5xx, timeout or connection error | Retried. |
After the first attempt, retries wait 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, each counted from the end of the attempt before it. Each wait varies by up to 10% so that retries from one busy minute do not arrive together. That is eight attempts over about 27.6 hours, so an endpoint that is down overnight still gets the alert. A delivery that has not succeeded after the eighth attempt is marked failed. Deleting the integration cancels any remaining attempts.
A retry can arrive hours after the event. Compare the payload’s created_at (when the event happened) with webhook-timestamp (when this attempt was signed), or read X-SiteQwality-Attempt, to decide whether a late alert still matters to you.
What the alert records
Section titled “What the alert records”In the monitor’s alert history, the alert’s receipt reads delayed while retries are pending, then delivered, failed or rejected when the attempts end.
Delivery log and replay
Section titled “Delivery log and replay”Click the webhook integration’s card in Manage → Integrations, or its Deliveries button, to open its page. The Deliveries tab shows the share delivered over the last 24 hours and 5 days, then every delivery to the endpoint, which you can narrow to Failed, Delivered or Retrying. A delivery still retrying shows when it tries next. Deliveries are kept for 30 days. An account admin can open a delivery to see each attempt and to replay it.
Each attempt records your server’s status code, how long it took, and the first 8 KiB of the response body (only its size when it is not UTF-8 text). A failed attempt also records where it stopped in error_stage: dns (the host name did not resolve), connect (no connection), tls (the handshake failed), timeout or http (your server answered with something other than a 2xx, or the connection broke mid-request). Response headers keep their names; only Content-Type, Content-Length, Date, Retry-After and Server keep their values, and Location keeps only its host.
Through the API:
GET /integration/webhook/{id}/deliveries?limit=25&offset=0&state=failedlists deliveries (limitup to 100;stateispending,delivered,failedorcancelled). A pending delivery carriesnext_attempt_at.GET /integration/webhook/{id}/delivery/{delivery_id}returns one delivery with the request body and every attempt, with their headers and response bodies. It is for account admins only.POST /integration/webhook/{id}/delivery/{delivery_id}/replaysends it again and returns the new delivery’s ID.
With an API key, listing deliveries needs read:notifications, opening one also needs integrations:manage, and replaying needs write:notifications.
A replay is a new delivery with its own timestamp and a signature under the current secret, and no alert receipt of its own. It keeps the original’s body and webhook-id, so a receiver that drops duplicates by ID drops it too when the original already arrived. X-SiteQwality-Replay-Of names the delivery it replays, and each delivery in the log carries its message_id. A request body over 64 KiB is sent in full, but the log keeps only its first 64 KiB, so that delivery cannot be replayed.
Turn an endpoint off
Section titled “Turn an endpoint off”Turn off on the integration’s page, or in the ⋯ menu on its card, stops deliveries without deleting anything: alerts to it are skipped until you turn it back on, and retries still pending end with “Not delivered: the webhook endpoint is turned off”. Through the API, call POST /v1/integrations/{id}/disable and POST /v1/integrations/{id}/enable with integrations:manage.
While it is off, its page says why and when, and Turn back on sits right there (the card has Turn on). Send test first shows what your server answers now; a test is sent even while the endpoint is off. When you turn it back on, Site Qwality offers to replay what failed since it last worked: up to 1,000 deliveries, oldest first, ten a second, each one once. POST /integration/webhook/{id}/replay_failed does the same with integrations:manage.
An endpoint that keeps failing is turned off for you. The first failed delivery after one that worked emails the account’s owners and admins. When deliveries have failed for 4 days with none delivered (at least 3 of them), a second email says the endpoint goes off the next day, and after 5 days it is turned off with a third email. An endpoint that answers 410 Gone is turned off at once. A delivery that gets through starts the count over. Turn it back on from its page or card when it works again.
Replaying one delivery is limited to 10 a minute and 100 a day per endpoint, counting what a bulk replay sent, and answers 429 above that. No replay is sent while the endpoint is off (409).
Payload settings
Section titled “Payload settings”Each endpoint has four optional settings, under Payload on the integration’s Settings tab or through GET and PUT /integration/webhook/{id}/settings. Changing them takes the same access as creating the integration.
curl -X PUT https://api.siteqwality.com/integration/webhook/<webhook-integration-id>/settings \ -H "Authorization: Bearer $SITEQWALITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "events": ["monitor.down", "monitor.recover"], "min_severity": "warning", "fields": ["http_job_id", "status", "response_http_code", "failure_reason"], "metadata": { "team": "payments", "env": "prod" } }'events: send only these events. Unset means all events.min_severity: drop events belowinfo,warningorcritical. Unset meansinfo(everything).fields: keep only these keys indata. Unset means the whole object.metadata: a flat object added to every envelope. At most 10 keys, keys up to 64 characters, values up to 256.
An unknown event or field name, or an empty list, is refused with an error that names it. GET lists the selectable fields and events.
Incident webhooks
Section titled “Incident webhooks”A service can also send its incidents’ lifecycle events to a webhook URL as a broadcast target. Those deliveries use the same envelope and retries and are signed. A target that sends with a webhook integration uses the integration’s signing secret and appears in its delivery log; a target with a pasted URL is signed with a secret of its own, returned once when you add it, and verified with the same check.
data for an incident event:
{ "incident_id": "<incident-uuid>", "incident_title": "checkout-api is down", "severity": "critical", "service_name": "Checkout", "affected_monitors": ["checkout-api"], "responder_status": "triggered", "incident_url": "https://app.siteqwality.com/incidents/<incident-uuid>"}incident_url opens the incident in Site Qwality, which needs a sign-in. The payload carries no acknowledge or resolve links; those go only to the paged person by email and SMS.
| Event | Sent when |
|---|---|
incident.created | The incident opened. |
incident.acknowledged | Someone acknowledged it. |
incident.updated | An update was posted, or its severity or title changed. |
incident.resolved | It was resolved. |
incident.reopened | A resolved incident was reopened. |
incident.merged | It was merged into another incident. |
incident.service_changed | It moved to another service. Both services’ targets get it. |
incident.created and incident.resolved carry exactly the fields above. Every other incident event adds these:
| Field | Type | Notes |
|---|---|---|
change | string | acknowledged, update_posted, severity_changed, title_changed, reopened, merged or service_changed. A value you do not know yet may appear later; treat it as a generic update. |
summary | string | One plain line, such as Acknowledged by Ana Diaz or Severity changed from minor to major. |
actor_name | string | Who acknowledged or posted the update, when known. Omitted otherwise. |
update_visibility | string | On update_posted only: public or internal. |
update_text | string | The text of a public update, on update_posted and reopened. Never sent for an internal update. |
previous_severity | string or null | On severity_changed: the severity before. severity holds the new one. |
merged_into | object | On merged: { "incident_id", "incident_title", "incident_url" } of the incident it was merged into. |
previous_service_name, new_service_name | string or null | On service_changed: the old and new service, null for none. |
data for an acknowledgement:
{ "incident_id": "<incident-uuid>", "incident_title": "checkout-api is down", "severity": "critical", "service_name": "Checkout", "affected_monitors": [], "responder_status": "acknowledged", "incident_url": "https://app.siteqwality.com/incidents/<incident-uuid>", "change": "acknowledged", "summary": "Acknowledged by Ana Diaz", "actor_name": "Ana Diaz"}