Skip to content

Webhook deliveries

Every webhook POST we make is a delivery: one row, one payload, one signature, and a record of what your endpoint said. This page covers the shape you receive, how to verify it, what happens when a delivery does not get through, and how to read the log.

HeaderValue
Content-Typeapplication/json
User-Agentdisclosebot/1.0
webhook-idThe delivery’s id. Stable across retries, so use it as your idempotency key.
webhook-timestampUnix seconds, taken when the request was sent.
webhook-signaturev1,<base64 HMAC-SHA256>.

The three webhook-* headers follow Standard Webhooks, so an off-the-shelf library verifies them.

The signed string is <webhook-id>.<webhook-timestamp>.<raw body>, and the key is your signing secret with its whsec_ prefix removed. Sign the raw bytes of the request body, before any JSON parsing or re-serialization.

Verify a delivery in Ruby
require "openssl"
require "base64"
def verify(request_body, headers, secret)
key = secret.delete_prefix("whsec_")
signed = "#{headers['webhook-id']}.#{headers['webhook-timestamp']}.#{request_body}"
expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, signed))
headers["webhook-signature"].to_s.split(" ").any? do |candidate|
version, value = candidate.split(",")
version == "v1" && value && OpenSSL.secure_compare(value, expected)
end
end

Reject a delivery whose webhook-timestamp is outside a tolerance you choose, five minutes being the usual one, so an old request cannot be replayed at you.

One shape, every time. Keys are always present; a part that does not apply is null.

A delivery payload
{
"source": "IDOR to Jira",
"event": { "type": "Events::Report::Created", "id": "…", "at": "2026-01-01T00:00:00Z" },
"report": {
"id": "…",
"shortcode": "AB12CD",
"title": "IDOR in the invoice endpoint",
"url": "https://app.disclosebot.io/reports/…",
"brief": { "id": "…", "name": "Public brief" },
"received_at": "2026-01-01T00:00:00Z"
},
"body": {
"description": "GET /invoices/2 returns another tenant's invoice",
"file_names": ["poc.png"]
},
"results": {
"severity": { "value": "critical", "reason": "Unauthenticated", "source": "triage", "written_at": "2026-01-01T00:00:00Z" }
},
"encrypted": null
}
KeyWhat it is
sourceThe name of the channel that sent it, or null for an event subscription. Store it, do not interpret it.
eventWhat happened and when.
reportThe report’s identifiers and a link to its page.
bodyThe readable detail. null when the delivery is encrypted or the body was dropped.
resultsTriage results, keyed by name.
encryptedThe reporter’s ciphertext. null on a plaintext delivery.

Exactly one of body and encrypted carries the report’s detail, which is what tells you whether you need the private key. An event that names no report carries only source and event.

A payload is built once, when the delivery is created, and never rebuilt. A delivery sent later sends what was decided then, not what the report looks like now.

Report::Destroyed is the one event whose report cannot be read, because the record has gone. It sends the id and the fact and nothing else, which is what you need to delete your own copy:

A Report::Destroyed payload
{
"source": null,
"event": { "type": "Events::Report::Destroyed", "id": "…", "at": "2026-01-01T00:00:00Z" },
"report": { "id": "…", "destroyed": true }
}

Answer 2xx and the delivery is done. Anything else is retried once, about thirty seconds later, and a second failure records the delivery failed and marks the endpoint broken.

There is no long retry window. A channel’s run waits on its delivery, so hours of retries would leave runs open for hours.

What your endpoint doesWhat happens
2xxDelivered. If the endpoint was broken, it is no longer.
408 or 429Rescheduled for your Retry-After, or sixty seconds. It does not spend the retry: you are alive and have said when to come back. We honour up to five minutes, and accept being asked three times; past either, it counts as a fault.
Any other statusOne retry, then the delivery fails and the endpoint is marked broken.
Timeout, refused connection, TLS or DNS failureThe same one retry, then failed and broken.

While an endpoint is broken, a new delivery is recorded rather than sent, so a backlog builds up as data instead of as calls to a dead host. The endpoint is never disabled: disabling it silently was the old behaviour, and one DNS failure could stop an integration with nothing to say so.

Only a delivery that succeeds clears the broken flag. Receiving events is not evidence that anyone fixed the endpoint.

A delivery a channel asked for is part of that channel’s run, so a failed one pauses the run rather than losing it. Fix the endpoint, then open the channel in the app and choose Resume: every paused delivery is re-attempted with the payload it already holds, and each run carries on from where it stopped. A resume is one attempt, not another retry cycle.

Deliveries for a plain event subscription have no channel behind them, so there is nothing to resume; the endpoint recovers with the next event.

Each delivery is readable through the API, so you can see what we sent and what you answered:

List a webhook's deliveries
curl "https://api.disclosebot.io/v1/webhooks/<webhook-id>/deliveries?q[status_eq]=failed" \
-H "Authorization: Bearer disclosebot_svc_xxxxxxxx" \
-H "Accept: application/vnd.api+json"
AttributeWhat it holds
statuspending, delivered, or failed.
trigger_event_typeThe event that caused the delivery.
request_idSet when a channel asked for the delivery, null for an event subscription.
attemptsHow many times we called your endpoint.
attempt_logOne entry per attempt: when, the status code, and an excerpt of your response body.
payloadExactly what was sent.
delivered_atWhen it got through.

The delivery’s id is the webhook-id header of every attempt, so a row in your own logs matches a row here.

See the Platform API reference for the full schema.