Webhooks
Ledger can send a signed HTTPS message to an address you choose whenever an exception is opened, resolved or suppressed. This page describes what is sent, how to check that a message really came from Ledger, and what happens when your receiver is down.
Sending is available only once it has been enabled for your account. While it is off, nothing is sent at all: no delivery is attempted and no retry runs.
You manage webhooks on the Webhooks screen in the app (linked from the home page). The same
actions are available over the API under /api/webhooks.
Generic webhooks
Add a webhook
- Open the Webhooks screen and use the Add webhook form.
- Enter the receiver's URL. It must be an
https://address on the public internet (see Delivery rules); an address that fails the check is refused with a message saying why. - Tick at least one event:
exception.opened,exception.resolvedorexception.suppressed. - Optionally enter your own signing secret. Leave it blank and Ledger generates one.
- Choose Add webhook. The signing secret appears once in a banner above the list. It is not shown again, so store it in your receiver before you dismiss the banner. There is no way to read it back later.
Adding a webhook needs sending to be switched on for your account. When it is off, the screen shows a banner saying nothing will be sent, and Add webhook and Test fire are disabled. Listing, enabling, disabling, the delivery log and deleting keep working, because none of them sends anything to a receiver.
Each webhook appears in the list with its URL, its event types, an Enabled toggle and its most recent delivery, if any. A webhook that Ledger disabled after repeated failures also shows when that happened.
Test fire
Test fire sends one real request to the webhook's URL, signed with its secret, so you can find out whether the address and secret work before you wait on a real exception. The result (accepted or failed, with the HTTP status or an error) appears under the row.
- It is a real outbound request from Ledger's servers, so it needs sending to be switched on.
- It works on a disabled webhook and ignores which events the webhook is subscribed to.
- It is sent once and never retried, and it does not count toward automatic disabling (see Automatic disabling), so testing a broken address cannot switch the webhook off.
- It is limited to 5 test fires per minute for your whole account; over that, the request is refused and you can try again shortly after.
- It appears in the delivery log with the type
test.
The test message has the same envelope as a real event, with type set to test and a short
message in place of an exception. It is signed exactly as described under Signing:
{
"data": {
"message": "This is a test delivery from Ledger."
},
"timestamp": "2026-09-29T09:20:00.000000+00:00",
"type": "test"
}
Delivery log
Delivery log on a row lists that webhook's most recent delivery attempts, newest first (up to 20 in the app): when it was attempted, the event type, the attempt number, the outcome with the HTTP status the receiver answered with, any short error text, and when the next retry is due. It never shows the request body, headers, secret or signature. It is how you diagnose a failure without asking anyone for help.
Enable, disable and delete
The Enabled toggle turns a webhook off and on without deleting it. Delete asks for a second click to confirm. Deleting a webhook also deletes its delivery history; that cannot be undone.
What is stored
For each webhook Ledger keeps the destination address, the event types it wants, whether it is enabled, how many events in a row have failed completely, and when it was disabled. The signing secret is stored only as encrypted data, never in the clear.
For each delivery attempt it keeps which webhook, which exception, which event, the attempt number (1 to 8), whether it succeeded, the HTTP status the receiver answered with, a short error text, and when the next retry is due. It never stores the request body, the headers, the secret or a signature.
A webhook can name any of the event types below.
Events
Three events exist. Each is sent right after the change it describes has been saved, not on a timer.
| Event | Sent when |
|---|---|
exception.opened |
A reconciliation pass opens a new exception, or reopens one that had been resolved. |
exception.resolved |
A reconciliation pass finds that an open, acknowledged or suppressed exception no longer applies and resolves it. |
exception.suppressed |
A person suppresses an exception, or bulk-suppresses several; each suppressed exception is one event. |
Events fire only from those places: the reconciliation pass and the two suppress routes. Other changes, such as unsuppressing or acknowledging an exception, send nothing.
The test message sent by Test fire is not one of these three events and is not something you
subscribe to.
Every message is a JSON object with the same shape. message is the message field of the
exception's detail and is null when there is none; no other part of the detail is sent.
Names below are made up.
{
"data": {
"exception": {
"client_id": "5b0e3f0a-6a5e-4c55-9d1e-2f6f8f1f0a11",
"client_name": "Client A",
"device_display_name": "WS-EXAMPLE-01",
"device_id": "0f8d3c2e-1b7a-4d3c-8a4e-9c1d2e3f4a5b",
"first_seen_at": "2026-09-01T06:00:12.481233+00:00",
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"last_seen_at": "2026-09-29T06:00:09.118420+00:00",
"message": "Device has no endpoint protection agent installed",
"rule_code": "MISSING_EDR",
"severity": "critical",
"status": "open"
}
},
"timestamp": "2026-09-29T06:00:10.502113+00:00",
"type": "exception.opened"
}
The other two events have the same fields; only type changes, and status follows the exception.
An exception.resolved message, with the data.exception fields shortened here:
{
"data": {
"exception": {
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"rule_code": "MISSING_EDR",
"status": "resolved"
}
},
"timestamp": "2026-09-30T06:00:10.331907+00:00",
"type": "exception.resolved"
}
An exception.suppressed message, shortened the same way:
{
"data": {
"exception": {
"id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"rule_code": "MISSING_EDR",
"status": "suppressed"
}
},
"timestamp": "2026-09-29T09:14:52.640118+00:00",
"type": "exception.suppressed"
}
client_id, client_name, device_id and device_display_name are
null when the exception has no client or device. The body is compact JSON with keys sorted.
The body is built from the exception as it is at the moment of each attempt, so a retry can show a
newer status or last_seen_at than the first attempt did. type and webhook-id do not change.
timestamp is the time of that attempt.
Signing
Every request carries three headers, following the Standard Webhooks specification. Header names are case-insensitive; Ledger sends them in lower case.
| Header | Value |
|---|---|
webhook-id |
msg_ followed by the event's id. The same value on every retry of one event. |
webhook-timestamp |
Unix time in seconds when this attempt was signed. |
webhook-signature |
v1, followed by the base64 of an HMAC-SHA256. |
The signature is computed over the text webhook-id, a dot, webhook-timestamp, a dot, and the
exact request body bytes. The key is the base64-decoded bytes of the secret after its whsec_
prefix, not the secret's text.
The secret is shown once, in the response to POST /api/webhooks that creates the webhook. Ledger
cannot show it again, so store it when you receive it. If you supply your own secret it must have the
form whsec_ plus base64 and decode to 24 to 64 bytes, or the request is refused with a 422. Leave the secret out and Ledger
generates one.
Verify a delivery
Verify against the raw body bytes exactly as received, before any JSON parsing.
import base64
import hashlib
import hmac
import time
def verify(secret: str, headers: dict[str, str], raw_body: bytes) -> bool:
"""True if the delivery is authentic and fresh. `headers` keys are lower case."""
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300: # reject anything older than 5 minutes
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = "v1," + base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return hmac.compare_digest(expected, headers["webhook-signature"])
Delivery rules
- HTTPS only. An
http://address is refused. - The address is checked twice. When you save the webhook, and again at every send. Addresses that are not public (loopback, private networks, link-local and the cloud metadata address) are refused, including when a name resolves to one of them.
- Redirects are not followed. A
3xxanswer counts as a failed attempt. - The response is read only up to 64 KiB. Anything beyond that is ignored.
- Each attempt has a total deadline (30 seconds). A receiver that does not finish answering in time counts as a failed attempt.
- Any
2xxanswer is success. Anything else, a timeout or a connection error is a failed attempt. - Delivery is throttled so that a burst of events does not flood your receiver.
- At-most-once, then at-least-once. The first attempt is queued after the change is saved. Once the first attempt has been made, retries repeat until one succeeds or all 8 are
used, so a receiver can see the same event more than once. Deduplicate on
webhook-id.
If a delivery keeps failing
Retry schedule
A failed attempt is retried on the schedule below. That is 8 attempts in all, spread over a little under 28 hours:
| Attempt | Starts |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds after attempt 1 |
| 3 | 5 minutes after attempt 2 |
| 4 | 30 minutes after attempt 3 |
| 5 | 2 hours after attempt 4 |
| 6 | 5 hours after attempt 5 |
| 7 | 10 hours after attempt 6 |
| 8 | 10 hours after attempt 7 |
A retry can start a few minutes after its due time. Every attempt appears in the delivery log.
Automatic disabling
Each event that has used up all 8 attempts without a success adds one to the webhook's count of consecutive failed events. A success sets the count back to zero. When the count reaches 3, the webhook is disabled and stops receiving events; pending retries for it are dropped.
Ledger counts failed events in a row rather than measuring elapsed time.
Re-enable a disabled webhook
Fix the receiver, then switch the webhook's Enabled toggle back on in the Webhooks screen (or
send PATCH /api/webhooks/{id} with is_enabled set to true). Turning it back on:
- sets the count of consecutive failed events back to zero, so the next failure does not switch it off again straight away, and
- clears the "automatically disabled" time shown on the row.
It does not replay anything: events that arrived while the webhook was disabled, and retries that were dropped when it was disabled, are not sent. Use Test fire afterwards to confirm the receiver answers.
Sending to an automation platform
A webhook is an ordinary HTTPS webhook, so you can point it at the incoming-webhook address of an automation platform of your choice. Paste the address the platform gives you into the URL field. Check how your platform handles the signature headers described under Signing before relying on them.
Changing a webhook's secret
The secret cannot be changed or read back. To change it, delete the webhook and add it again; the new webhook gets a new secret.