Articles · Operations & Administration
Webhooks that survive
Try out the running example
token.refresh_reuse_detected fires at 3 AM. Your SOC tooling needs to hear about it —
and “we POST some JSON at your URL” is the easy 10%. The hard 90% is what happens when
your receiver is deploying at that moment, how the receiver knows the POST is really from
Sentinel, and what stops one dead endpoint from silently eating a week of security
events. Sentinel’s answer is a durable outbox with signed deliveries, an explicit retry
ladder, dead-lettering, and a circuit breaker — and the companion sample plays both sides
of the contract in one app.
The outbox, not fire-and-forget
// file: HooksComposition.cs
services.AddSentinelWebhooks(webhooks ?? delegate { }); // the durable outbox
services.AddSentinelWebhookDispatcher(); // the hosted pump
AddSentinelWebhooks rewires ISentinelEventSink into a composite: your sink still gets
every event, and events matching a subscription are enqueued as durable delivery rows
before any HTTP happens. The dispatcher — a hosted service — claims due rows in batches
and attempts them. Emit and deliver are decoupled on purpose: a login never waits on your
SOC’s HTTP endpoint, and a crashed process never loses an accepted event.
The timings are all options with production-shaped defaults:
WebhookDispatcherOptions |
Default |
|---|---|
PollInterval |
5s |
RequestTimeout |
10s |
RetryBackoff |
1m, 5m, 30m, 2h, 12h |
DisableAfterConsecutiveFailures |
10 |
Subscribing
Subscriptions are webhook endpoints: a URL plus event-kind patterns (login.*,
token.refresh_reuse_detected, * — single-star wildcards), realm-level or fenced to
one org. Manage them through WebhookAdminService or its HTTP surface,
MapSentinelWebhookAdmin() at /sentinel-admin/webhooks — authorization rides the same
delegated-admin scopes as article 005
(sentinel:global:manage for realm-level endpoints).
POST /sentinel-admin/webhooks/
Authorization: Bearer <admin access token>
{ "url": "http://localhost:5000/billing/events", "eventKinds": ["login.*"] }
The 201 carries the endpoint view and the whsec_ secret — a 256-bit value shown
exactly once, at creation or rotation. Every later read returns the endpoint without it.
A_failed_login_arrives_as_a_signed_verified_delivery asserts the view never leaks
whsec_ again.
The delivery contract
Every delivery is a POST with three headers and a stable envelope:
| Header | Carries |
|---|---|
X-Sentinel-Signature |
t=<unix-seconds>,v1=<hmac-sha256 hex> |
X-Sentinel-Event |
the event kind, e.g. login.failed |
X-Sentinel-Delivery |
the delivery id — identical across retries, your dedupe key |
{ "id": "…", "kind": "login.failed", "occurredAt": "…",
"realmId": "…", "organizationId": "…", "subjectId": "…", "data": { } }
The body is serialized once at enqueue and re-sent byte-identical on every retry — which is what makes signature verification over exact bytes possible at all.
The receiver recipe
This is the part you write, and the sample’s BillingReceiver is the recipe verbatim:
// file: BillingReceiver.cs
// 1-5 of the recipe: exact bytes, parse t/v1, tolerance window, HMAC-SHA256 over
// "{t}.{body}", constant-time compare — all inside Verify.
if (Secret is null
|| !WebhookSignature.Verify(Secret, signatureHeader, body, DateTimeOffset.UtcNow))
{
return StatusCodes.Status400BadRequest;
}
// Deduplicate on X-Sentinel-Delivery: retries re-send the same delivery id.
if (_seen.Add(deliveryId))
{
_accepted.Add(new AcceptedDelivery(deliveryId, eventKind, body));
}
return StatusCodes.Status204NoContent;
Read the raw request body before any JSON parsing — re-serialization would change the
bytes and kill the MAC. WebhookSignature.Verify handles the rest: header parsing, a
5-minute default timestamp tolerance (replay bound), HMACSHA256(secret, "{t}.{body}"),
constant-time comparison. The receiver endpoint itself is anonymous by design —
authentication IS the signature.
Verify_accepts_the_exact_bytes_and_rejects_tampering_and_stale_timestamps pins all
three properties with nothing but the public API.
Surviving the outage
Success is any 2xx. Anything else — a 500, a timeout, a connection refused — schedules a
retry at now + RetryBackoff[attempt-1]. The sample’s outage test tells the whole story
in four assertions: the receiver 500s once, heals, and
// An_outage_is_retried_with_the_same_delivery_id_until_the_receiver_heals
host.Receiver.TotalRequests.Should().Be(2); // two HTTP attempts…
host.Receiver.Accepted.Should().HaveCount(1); // …one accepted delivery (dedupe absorbed the retry)
item.GetProperty("attemptCount").GetInt32().Should().Be(2);
item.GetProperty("deliveredAt").ValueKind.Should().NotBe(JsonValueKind.Null);
When the outage outlives the ladder, the delivery is abandoned: marked dead with its
lastError, and a webhook.abandoned event goes to the (working) sinks. Nothing retries
forever, and nothing disappears — the row stays in the delivery log,
GET /sentinel-admin/webhooks/{id}/deliveries, with attemptCount, nextAttemptAt,
lastError, abandoned.
Two more safety valves ride along. An endpoint that fails
DisableAfterConsecutiveFailures times in a row — across deliveries — is disabled
outright (webhook.endpoint_disabled), so a decommissioned URL can’t grind the
dispatcher; re-enabling it through the admin surface resets the counter. And a
webhook.* event about an endpoint is never enqueued to that endpoint — no
failure-notification recursion.
Secret rotation closes the loop:
After_rotation_only_the_new_secret_verifies_and_the_retry_heals_the_gap rotates, lets
the next delivery fail verification against the receiver’s stale secret, updates the
receiver, and watches the automatic retry land under the new one — the retry ladder
doubling as the rotation grace window.
What to subscribe to
The kinds worth a pager: token.refresh_reuse_detected
(article 003), abuse.account_locked
(article 013), risk.blocked and risk.stepup
(article 014), breakglass.login, plus the saml.*
and federation.* streams. Admin mutations are not events — they land on the
tamper-evident ledger instead, which is article 012’s
subject. The operations page keeps the full catalog.