Skip to main content

A webhook is not arriving

Six causes in the order worth checking: an event type nothing emits, a header name you stopped reading, the signature, the URL, the timeout and the retry ladder.

Written for whoever runs IT11 min readUpdated

You created a webhook endpoint, and nothing has arrived. Check the first two causes below before you touch your receiver — between them they account for most of these, and neither is a fault in your code.

Cause 1: you subscribed to a type nothing emits

Nineteen event types are declared. You can subscribe to any of them; six will never arrive. The split is held in the code as a map that a contract test reads, so it cannot drift the way a document would.

The thirteen that fire
alert.triggered         alert.resolved          sentinelq.decision
approval.requested      approval.granted
connector.synced        connector.error
organization.created    organization.member_added
organization.role_changed
api_key.created         api_key.revoked         user.deleted
The six that do not, and never have
user.created            user.updated
user.login              user.login_failed
organization.member_removed                     policy.violation

Outbound webhooks gives the reason for each of the six; they are obstacles rather than backlog entries, and two of them exist because emitting the event would mean guessing which tenant it belonged to. The practical substitutions: use organization.member_added where you wanted user.created, and user.deleted where you wanted organization.member_removed.

Cause 2: your receiver reads a header name we also send under another one

Every delivery carries each header twice: once as X-Qorionix-… and once as the deprecated X-ZentryQ-…, with identical values written by the same call, so they cannot disagree. If your receiver was written before the rename it still works. If somebody has half-migrated it — swapped one literal in one place, or added a strict if "X-Qorionix-Signature" in headers check on a path that also runs against older captured deliveries — the symptom is a receiver that rejects or ignores requests that arrived perfectly.

The fix is the same one we recommend for the migration itself: read the new name, fall back to the old. Never swap one literal for the other in a single deploy. Verifying a webhook signature has the one-line form for Python, Node and Go.

One asymmetry to know about while you are here. X-Qorionix-Retry: true is set only by the background retry sweep. The two in-process retries do not carry it, so its absence does not mean a delivery is the first attempt. Deduplicate on the delivery id.

The other ways to be told something happened

Webhooks are now one of three working paths, not the only unfinished one. Depending on what you are building, one of the others may be a better fit:

  • Notifications — email, Slack, PagerDuty, Microsoft Teams and a generic webhook target all deliver through the notification router. See Where notifications go and Notification preferences.
  • The realtime stream — a WebSocket at /api/v1/organizations/{orgID}/ws, with a server-sent-events fallback at .../events/stream for networks that block WebSocket upgrades. This carries alerts, incidents and Sentinel-Q activity live.
  • Polling the API — unglamorous, and it works. The alert and incident list endpoints are stable.

Cause 3: a signature mismatch, and you are almost certainly MACing the wrong bytes

This is the number-one cause of a delivery that arrives and gets rejected by the receiver, and it is subtle enough that good engineers hit it. The signature is computed over the raw request body, byte for byte, prefixed by the timestamp:

The signed payload
signed_payload = "<t>" + "." + <the raw request body>
signature      = HMAC-SHA256(secret, signed_payload), lowercase hex

X-Qorionix-Signature: t=1756713600,v1=3f9a...c1
X-Qorionix-Timestamp: 1756713600
X-Qorionix-Delivery:  <stable across retries>
X-Qorionix-Event:     alert.triggered

…and each of those four again as X-ZentryQ-*, same values.

Two more details trip people up. Read the timestamp from inside the signature header — the t= value — not from the convenience X-Qorionix-Timestamp header, which is not covered by the MAC. And use a constant-time comparison rather than string equality. Our verifier's own error strings, several of which carry the detail you need appended after a colon:

  • webhook: signature header is malformed — the header did not parse as t=...,v1=.... The wrapped forms name the problem: : empty, : no t= element, : no v1= element, : timestamp "abc" is not an integer, : v1 value is not hex.
  • webhook: signature does not match — the MAC differs. See the callout.
  • webhook: signature timestamp is outside the freshness window: 7m12s off — more than five minutes either side, with the actual skew reported. The check is symmetric: a far-future timestamp is as suspicious as a stale one, and freshness is checked before the MAC so a flood of stale replays costs a parse rather than a hash.
  • webhook: subscription has no signing secret — see the section below; this endpoint will never be delivered to.

: no t= element on every delivery is worth singling out. That is what a notification-connector delivery looks like to a subscription verifier, because its signature is sha256=<hex> with no timestamp at all. If you see it consistently rather than intermittently, you are pointed at the wrong scheme, not at a bug.

On rotation, the old secret stops working immediately — there is no dual-secret window. Update your receiver in the same change window. The header parser does collect multiple v1= values and ignores versions it does not recognise, so a future scheme change will not break a correct receiver.

An endpoint with no signing secret is never delivered to

Signing with an empty key produces a MAC that anybody can compute, which is strictly worse than no signature at all because the receiver believes it verified something. So an endpoint without a secret is skipped, and the delivery log says so in as many words:

not delivered: this endpoint has no signing secret. Rotate the signing secret to re-enable delivery.
the delivery record's response body

Re-enabling such an endpoint is refused with 409 this webhook has no signing secret and cannot be enabled; rotate the signing secret first. The fix is one call to POST .../webhooks/{webhookID}/rotate-secret, which returns the new secret once: 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. A related deployment-level refusal, 503 credential encryption is not configured on this deployment; refusing to store a webhook signing secret, is ours.

A private or non-public URL is refused

Destinations are checked at three points: when you save them, again at delivery time, and once more at the socket on the literal address about to be dialled. That third check is the real control — it runs after the name is resolved, so a hostname that resolves to a public address at save time and a private one later is still blocked. Redirects re-enter the same check and the chain is capped at three.

Destination refusals. The message names the offending address or scheme, which is what makes the failure diagnosable without asking us.
RefusalWhyFix
destination must use httpsA plain http URL. The refusal text says why: webhook payloads carry alert contents and must not cross the network in the clear.Use HTTPS. There is no setting to relax this in a hosted deployment.
destination address is not permitted, with the address and a reasonA blocked range. Reasons include private address, loopback address, unspecified address, multicast address and — the important one — link-local address (this range contains the cloud instance metadata service). An explicit table also blocks carrier-grade NAT (100.64.0.0/10, where cloud metadata proxies live), 0.0.0.0/8, benchmarking and reserved ranges, and the IPv6 equivalents: NAT64, Teredo, 6to4 and the documentation range.Publish an endpoint on the public internet, or use a tunnelling service. A URL inside your own network is not reachable from us and never will be.
url has no host, url must not embed credentials, url must not contain a fragmentMalformed destinations. Credentials are refused because they end up in delivery and proxy logs; a fragment is never sent to a server anyway.Fix the URL. Move any credential into a header or a path token.
host example.invalid does not resolve / host x resolves to no addressesDNS failure at save time.Check the name. Every answer must be acceptable — a name with one public and one link-local answer is a rebinding attempt, not a misconfiguration.

When a URL is blocked at delivery time rather than at save time, the delivery record is marked failed with not delivered: followed by the reason. That is where to look if a previously working endpoint stops.

The timeout, and which responses count as failure

You have ten seconds to respond. That is the whole budget, including TLS negotiation. Do not do work in the handler — acknowledge, queue, and process afterwards. A receiver that takes eleven seconds to write to its own database will be recorded as a failure on every single delivery.

How your response status is interpreted. Both delivery paths now agree on this, which they did not always.
You returnWe do
2xxMark the delivery successful.
5xxRetry, up to three attempts in total.
429Retry. If you include a Retry-After header we use your value instead of our own backoff — both delta-seconds and an HTTP-date are accepted, and a value that is unparseable or already in the past is ignored rather than treated as zero. Your value is capped at 60 seconds on the in-process pass and 15 minutes on the background sweep.
4xx other than 429Treat as permanent. No retry. The assumption is that a 400 or a 404 will still be a 400 or a 404 in five minutes. 408 and 425 are deliberately included in this row rather than treated as transient.
No response within ten secondsRecord a failure and retry as for a 5xx.
A connection errorRetry as for a 5xx.

Response bodies are stored on the delivery record, truncated to 1,024 bytes, so a short diagnostic message in your error response is genuinely useful to whoever debugs this later. Delivery records are listed at GET .../webhooks/{webhookID}/deliveries, default fifty and up to five hundred at a time, with statuses pending, success, failed and retrying. There is no redeliver or replay endpoint.

The retry defect that used to be here, and the one row it can still affect

This section used to describe a genuinely bad defect, and it is worth restating because a receiver hardened against it will have code you can now delete. The background retry worker had no copy of the original event, and rather than failing it posted the receiver's own previous response body in its place — correctly signed, labelled with a real event type. A tenant whose endpoint blipped did not get its alert late; it got a signed, well-formed, entirely fictional event, and on a 2xx the delivery log recorded success.

The request payload is now recorded on the delivery row before the first attempt, and a retry re-sends those exact bytes. Deliveries created before that change have no recorded payload and are refused rather than substituted, because an empty body and a {} body are both events the platform never generated and a receiver cannot tell either from a real one. Such a row is terminal and says so:

not retried: the original payload was not recorded for this delivery (it predates migration 430). Re-trigger the source event to deliver it.
the delivery record's response body

You will only see that on a delivery older than the fix. If you see it on a fresh one, tell us — it would mean the payload is not being recorded, which is ours.

The two retry paths still use different backoff, and that has not changed. The in-process dispatcher waits five seconds, then ten, capped at sixty. The background sweeper runs every thirty seconds and uses a fixed ladder of one minute, five minutes, fifteen minutes. Both stop after three attempts in total. Do not assume a particular interval.

If you cannot create the webhook at all

The management routes live under /api/v1/integrations/organizations/{orgID}/webhooks, gated on the integrations webhook permission — read to list and view deliveries, write to create, update and rotate, delete to remove. A 403 insufficient permissions here is a role problem; see Insufficient permissions. Delivery lists are checked for tenant ownership first, so a 404 webhook not found on an identifier you believe is yours means it belongs to another organisation.

Was this article wrong?

If a procedure here does not match what you see, or a limit we described has changed, tell us and we will fix the page. Email us about this article, or see how to get help if you need an answer rather than a correction.

Everything in troubleshooting