Skip to content

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.

  1. Go to Manage → Integrations and click the Webhook card.

  2. Give it a name (for example Internal event bus) and the URL that should receive POSTs. Click Add integration.

  3. 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:

Terminal window
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:

Terminal window
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.url moves deliveries to the new URL from the next alert. The signing secret stays.
  • auth is {"type": "bearer", "token"}, {"type": "basic", "username", "password"} or {"type": "none"}. It goes in the Authorization header.
  • headers replaces the whole set, at most 10; [] removes them. Names are refused if they are Host, Content-Length, Content-Type, Authorization (use auth), Cookie, Proxy-Authorization, Forwarded, Transfer-Encoding, Connection, Upgrade, TE, Expect, User-Agent, or start with X-Forwarded-, webhook- or X-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.

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:

  1. Go to Respond → Notifications. Click a group’s name to open it, or click Add group to make a new one.

  2. Under Notifications, click Add notification method. Set Notification type to Webhook and choose this integration under Integration.

  3. Click Done editing, then Save changes (Create group for a new group).

Through the API, create the channel with POST /notification:

Terminal window
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>"
}
}'

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": { }
}
  • id is the delivery ID. It is the same on every retry of one delivery, so use it to drop duplicates.
  • metadata appears only if you set some on the endpoint (see Payload settings).
  • data depends on the event. For an uptime monitor going down or recovering it is the check result, with check_type added 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.

EventSeverity
monitor.downcritical
monitor.recoverinfo
incident.createdcritical
incident.resolvedinfo
incident.acknowledgedinfo
incident.updatedinfo
incident.reopenedcritical
incident.mergedinfo
incident.service_changedinfo
tls.expiringwarning
tls.invalidcritical
tls.recoveredinfo
dns.expiringwarning
dns.record_changewarning
page_speed.degradedwarning
alert.firingcritical
alert.resolvedinfo
rum.issue.createdwarning
rum.issue.regressedcritical
rum.issue.escalatingcritical
rum.alert.firingcritical
rum.alert.resolvedinfo

Deliveries follow Standard Webhooks, so its libraries verify them.

HeaderValue
Content-Typeapplication/json
User-AgentSiteQwality-Webhooks/1
webhook-idThe message ID, equal to id in the body. It stays the same on every retry and replay, so use it to drop duplicates.
webhook-timestampUnix seconds when this attempt was signed.
webhook-signaturev1, followed by the base64 HMAC. Two space-separated entries after a rotation.
X-SiteQwality-DeliveryThe message ID again.
X-SiteQwality-EventThe event name. Not signed.
X-SiteQwality-Attempt1 to 8. Not signed.
X-SiteQwality-Replay-OfOn a replay only: the ID of the delivery it replays. Not signed.
X-SiteQwality-Timestamp, X-SiteQwality-SignatureThe older signature, still sent for now (see below).

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 standardwebhooks
const { 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);
});

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:

LibraryRaw key
Nodenew Webhook(secret, { format: 'raw' })
PythonWebhook(secret.encode())
Gostandardwebhooks.NewWebhookRaw([]byte(secret))
RubyStandardWebhooks::Webhook.new(Base64.strict_encode64(secret))

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.

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:

Terminal window
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.

Each attempt times out after 15 seconds. Your endpoint should answer with a 2xx quickly and do slow work afterwards.

ResponseWhat happens
2xxDelivered.
3xxFailed, not retried. Redirects are not followed.
408 or 429Retried. A Retry-After header in seconds sets the earliest time for the next attempt, up to 10 hours later.
Other 4xxFailed, not retried.
5xx, timeout or connection errorRetried.

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.

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.

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=failed lists deliveries (limit up to 100; state is pending, delivered, failed or cancelled). A pending delivery carries next_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}/replay sends 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 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).

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.

Terminal window
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 below info, warning or critical. Unset means info (everything).
  • fields: keep only these keys in data. 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.

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.

EventSent when
incident.createdThe incident opened.
incident.acknowledgedSomeone acknowledged it.
incident.updatedAn update was posted, or its severity or title changed.
incident.resolvedIt was resolved.
incident.reopenedA resolved incident was reopened.
incident.mergedIt was merged into another incident.
incident.service_changedIt 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:

FieldTypeNotes
changestringacknowledged, 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.
summarystringOne plain line, such as Acknowledged by Ana Diaz or Severity changed from minor to major.
actor_namestringWho acknowledged or posted the update, when known. Omitted otherwise.
update_visibilitystringOn update_posted only: public or internal.
update_textstringThe text of a public update, on update_posted and reopened. Never sent for an internal update.
previous_severitystring or nullOn severity_changed: the severity before. severity holds the new one.
merged_intoobjectOn merged: { "incident_id", "incident_title", "incident_url" } of the incident it was merged into.
previous_service_name, new_service_namestring or nullOn 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"
}