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.
Headers
Section titled “Headers”| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | disclosebot/1.0 |
webhook-id | The delivery’s id. Stable across retries, so use it as your idempotency key. |
webhook-timestamp | Unix seconds, taken when the request was sent. |
webhook-signature | v1,<base64 HMAC-SHA256>. |
The three webhook-* headers follow
Standard Webhooks, so an off-the-shelf
library verifies them.
Verifying the signature
Section titled “Verifying the signature”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.
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) endendReject 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.
The payload
Section titled “The payload”One shape, every time. Keys are always present; a part that does not apply is null.
{ "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}| Key | What it is |
|---|---|
source | The name of the channel that sent it, or null for an event subscription. Store it, do not interpret it. |
event | What happened and when. |
report | The report’s identifiers and a link to its page. |
body | The readable detail. null when the delivery is encrypted or the body was dropped. |
results | Triage results, keyed by name. |
encrypted | The 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:
{ "source": null, "event": { "type": "Events::Report::Destroyed", "id": "…", "at": "2026-01-01T00:00:00Z" }, "report": { "id": "…", "destroyed": true }}When a delivery fails
Section titled “When a delivery fails”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 does | What happens |
|---|---|
2xx | Delivered. If the endpoint was broken, it is no longer. |
408 or 429 | Rescheduled 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 status | One retry, then the delivery fails and the endpoint is marked broken. |
| Timeout, refused connection, TLS or DNS failure | The 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.
Resuming a channel
Section titled “Resuming a channel”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.
The delivery log
Section titled “The delivery log”Each delivery is readable through the API, so you can see what we sent and what you answered:
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"| Attribute | What it holds |
|---|---|
status | pending, delivered, or failed. |
trigger_event_type | The event that caused the delivery. |
request_id | Set when a channel asked for the delivery, null for an event subscription. |
attempts | How many times we called your endpoint. |
attempt_log | One entry per attempt: when, the status code, and an excerpt of your response body. |
payload | Exactly what was sent. |
delivered_at | When 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.