Outbound webhooks
How to register an endpoint, which of the nineteen event types actually fire, at-least-once delivery, the retry policy, and the six types nothing emits yet.
A webhook is a URL you own that we send an HTTP request to when something happens, so that your own systems can react without polling us. This article covers registering one, the events, what we guarantee about delivery, the retry behaviour, and the rules your endpoint has to satisfy — including one restriction we will not lift for anybody, and why.
Which of the nineteen you actually see depends on what your organisation does. A tenant with no SIEM rules firing and no live-response tickets will see identity events and nothing else.
Registering an endpoint
Webhooks are managed per organisation, under /api/v1/integrations/organizations/{orgID}/webhooks. Listing and reading need integration:webhooks read, creating and updating need write, and deleting needs delete. There is a screen in the console for the same operations.
| Operation | Path | Permission |
|---|---|---|
| List | GET .../webhooks/ | read |
| Create | POST .../webhooks/ | write |
| Read one | GET .../webhooks/{webhookID} | read |
| Update | PUT .../webhooks/{webhookID} | write |
| Delete | DELETE .../webhooks/{webhookID} | delete |
| Delivery history | GET .../webhooks/{webhookID}/deliveries | read |
| Rotate the signing secret | POST .../webhooks/{webhookID}/rotate-secret | write |
Creating one returns 201 and is the only time you will ever see the signing secret. The response carries it alongside this notice, verbatim:
Store this signing secret now. It is encrypted at rest and will never be shown again; if you lose it, rotate the secret and update your receiver.
Read Verifying a webhook signature before you write the receiver. It is a copy-and-paste article with working Python and Node.
Two failures worth recognising at creation time. If your deployment has no credential encryption configured, you get 503 and credential encryption is not configured on this deployment; refusing to store a webhook signing secret — a webhook whose secret sat in plaintext would be worse than no webhook. And an endpoint cannot be switched on without a secret: enabling one that has none returns 409 and this webhook has no signing secret and cannot be enabled; rotate the signing secret first.
The event types
Nineteen event types are declared. An endpoint subscribes to a list of them, and an empty list means every event. Which of them are wired is not a matter of judgement or of reading a document: it is a map in the code that a contract test asserts against, so a new emitter cannot land without the list being updated in the same change.
| Type | Fires when | `source` |
|---|---|---|
alert.triggered | a detection rule fires and an alert row is written | siem |
alert.resolved | an alert reaches resolved, closed or false_positive | siem, xdr |
sentinelq.decision | Sentinel-Q reaches a triage disposition | xdr |
approval.requested | a live-response ticket enters pending_approval — the platform has stopped and is waiting on a person. For most integrations this is the highest-value event on the list, because not being told means the action never happens at all. | xdr |
approval.granted | that ticket is approved | xdr |
connector.synced | a directory or integration sync completes | integration |
connector.error | a sync fails, or a background health probe transitions an integration to unhealthy. The health-probe form fires on transition only — once when an integration breaks, not once per probe for as long as it stays broken. | integration |
organization.created | a new organisation is provisioned | iam |
organization.member_added | a user joins an organisation | iam |
organization.role_changed | a member's role is granted or revoked | iam |
api_key.created | an API key is issued. The key itself is never in the payload. | iam |
api_key.revoked | an API key is revoked | iam |
user.deleted | a user's access is removed — lock, disable, suspend, or a SCIM deprovision | iam |
The identity events reach the dispatcher through the privileged-audit fan-out rather than from each handler individually, which is why they are consistent: an identity change that is worth an audit row is worth a webhook, and both are written from the same place.
The six that nothing emits, and why
These are accepted in a subscription and will never arrive. They stay declared because tenants already have them stored, and quietly dropping a type from the API is worse than admitting it is not wired. Each reason is a real obstacle rather than a backlog entry:
- `user.created`
- All three creation paths — ordinary registration, accepting an invitation, and SCIM — commit without an organisation in scope or without a seam to hang an emitter on. Registration genuinely has no organisation yet, because membership comes afterwards. The closest honest signal today is
organization.member_added, which is emitted. - `user.updated`
- The update path is a one-line passthrough to the repository with no organisation, no audit call and no hook. The SCIM path does have the organisation and the store has nowhere to attach an emitter. Note: our own webhook reference document lists this type as emitted. The code disagrees, and the code is right.
- `organization.member_removed`
- No production code removes an organisation membership. SCIM deprovisioning deliberately keeps the row so the audit history stays attached to a name. There is no event to emit because the action does not exist.
- `user.login`
- The audit call every login path uses is user-scoped and takes no organisation, and webhook endpoints are organisation-scoped, so there is nowhere to deliver it to without threading a tenant through about fifteen call sites.
- `user.login_failed`
- The same, and worse on the branch that matters: a failed login against an unknown email address has no user and no tenant, so attributing it to an organisation would mean guessing — and guessing across tenants would disclose whether an account exists.
- `policy.violation`
- Nothing in production commits a record described as a policy violation. The nearest candidate — a sign-in policy evaluation persisting a denial — also covers the case where the policy engine itself was unavailable. Emitting that as a violation would report our outages to you as attacks on you.
What we guarantee about delivery
Delivery is at-least-once. Your endpoint may see the same event more than once, and your receiver has to cope with that.
- Use the delivery id as your idempotency key
- Every request carries
X-Qorionix-Delivery— and, for now, the identical deprecatedX-ZentryQ-Delivery— a unique identifier for that delivery. It is stable across retries, so recording the ids you have already processed and ignoring repeats is the correct and sufficient way to deduplicate. Read the new name and fall back to the old one; see Verifying a webhook signature. - Respond quickly, work later
- Return a
2xxas soon as you have accepted the event, and do the actual work asynchronously. We give up after 10 seconds. A receiver that does its processing before responding will start timing out under load, and timeouts are retried, so slow processing turns into duplicate processing. - Every attempt is signed at the moment it is sent
- A retry after a delay carries a fresh timestamp and a fresh signature, so it still arrives inside the five-minute freshness window your verifier should be enforcing.
Anything outside the 2xx range counts as a failure, including a redirect chain that never terminates in one.
Retries and what counts as permanent
A delivery gets three attempts in total. The first pass happens in process, with an exponential backoff of roughly 5 seconds then 10, capped at 60. A delivery still unsuccessful after that is picked up by a background sweep every 30 seconds and re-attempted on a longer ladder — about a minute, then five minutes, then fifteen — until the same three-attempt ceiling is reached.
Both paths now agree on what is worth retrying, and the rule is the one published in our signature contract: 5xx and 429 are retried; everything else in the 4xx range is permanent, on the reasoning that a receiver which says bad request will say it again. Connection errors and timeouts are retried too. 408 and 425 are deliberately not retried.
If you answer 429 with a Retry-After header we use your value instead of our own backoff. Both forms are accepted — delta-seconds and an HTTP-date — and a value that is unparseable or already in the past is ignored rather than treated as zero. The cap on your value differs between the two paths, which is worth knowing if you are asking for a long pause: the in-process pass caps it at 60 seconds and the background sweep at 15 minutes. Past that we would be holding your alerts, so we come back and try anyway.
After the third attempt the delivery is recorded as failed and left alone. There is no manual replay or redelivery endpoint. If you need an event you missed, it has to come from your own records or from the console.
A retry is otherwise byte-identical to the attempt it repeats — same delivery id, same event type header, same body — re-signed at the moment it is sent so it still arrives inside your freshness window. The one difference is an added X-Qorionix-Retry: true (and its deprecated twin). Note that this header is set only by the background sweep: the two in-process retries do not carry it, so its absence does not prove a delivery is the first attempt. Deduplicate on the delivery id, not on the retry header.
Seeing what happened to a delivery
Each attempt is recorded, and the record is readable over the API: the delivery id, the event id, a status of pending, success, failed or retrying, the HTTP status your endpoint returned, the first kilobyte of your response body, the attempt count, when the next retry is due, and when it completed.
Two limitations to know about. The delivery list returns 50 records by default and up to 500 with a limit parameter, but there is no pagination past that first page. And there is no delivery-history screen in the console yet — the data is there, the API returns it, nothing renders it. If you are debugging a webhook you will be reading JSON.
The request payload we sent is not stored, so the delivery record tells you what your endpoint said back, not what we said to it. Two entries you may see in the response-body field are ours rather than yours: not delivered: this endpoint has no signing secret. Rotate the signing secret to re-enable delivery. and not delivered: followed by an address-validation failure, which means the destination was re-checked immediately before the request and refused.
What your endpoint has to be
Two rules, both enforced when you save the URL and again immediately before every single delivery and every retry.
- It must be
https://. Plain HTTP is refused withdestination must use https: "<your url>" uses http; webhook payloads carry alert contents and must not cross the network in the clear. A redirect that downgrades to cleartext is refused too. - It must resolve to a public address. Loopback, link-local, private RFC1918 ranges, carrier-grade NAT and a list of other reserved ranges are all refused, in both IPv4 and IPv6.
The refusal messages name the address and the reason, so you can see which rule you hit:
url is required
url does not parse: <detail>
url has no host
url must not embed credentials
url must not contain a fragment
destination must use https: scheme "ftp" is not supported
destination address is not permitted: 127.0.0.1 is a loopback address
destination address is not permitted: 169.254.169.254 is a link-local address
(this range contains the cloud instance metadata service)
destination address is not permitted: 10.0.0.5 is a private address
destination address is not permitted: 100.64.0.1 is a carrier-grade NAT
(cloud metadata proxies live here) address
destination address is not permitted: host example.internal resolves to 192.168.1.10,
a private address
host example.invalid does not resolve: <detail>
host example.com resolves to no addresses
stopped after 3 redirectsEvery name is resolved and every address it resolves to has to be acceptable, and the check is repeated on the actual IP the connection is about to be made to. That closes the trick where a hostname passes validation and then returns a private address a second later. Redirects go through the same check on every hop, and the chain stops after three.
If you need events inside a private network, terminate them on a public endpoint you control and forward inward from there. That endpoint is also the right place to verify the signature, so nothing unauthenticated crosses your perimeter.
The other webhook, and how to tell which one you are receiving
There is a second signed-webhook target inside the notification delivery path — the generic webhook connector, alongside email, Slack and PagerDuty. It shares the word and almost nothing else, and a receiver written from Verifying a webhook signature will reject every one of its requests.
| Webhook subscription (this article) | Generic webhook notification connector | |
|---|---|---|
| Signature value | t=1756652400,v1=<hex> | sha256=<hex> — no t, and therefore nothing our verifier will parse |
| What is MACed | The timestamp, a full stop, then the raw body | The body alone. No timestamp binding, so no replay resistance. |
| Timestamp header | Unix seconds | RFC 3339, e.g. 2026-09-03T10:20:00Z |
| Delivery id | Present and stable across retries | Absent |
| Severity header | None — severity is inside data | X-Qorionix-Severity, one of critical, high, medium, low, info |
| Private-address blocking | Checked at save, at delivery, and at the socket | DNS-level only — a weaker control, by its own admission |
What to do next
- Verifying a webhook signature — the headers, the rules and working code you can paste.
- Where notifications go — the path that reaches a human today.
- Configuring a connector — if a purpose-built connector fits better than a webhook.