Webhooks integration guide
For a receiver of InBrief's outbound webhooks
Outbound webhooks capability
Creating a webhook
In the dashboard, go to Alerts → Channels and choose Add channel → Webhook. You give it an https URL, an optional description to tell several apart, and tick the events it should receive.
The signing secret is shown once, on the screen straight after you add it, and never again anywhere in the dashboard. Copy it then. If you lose it, remove the webhook and add a new one.
Before you rely on an endpoint, open the webhook and choose Send test. It delivers a
sample webhook.test payload immediately and records the result alongside everything else, so
you can confirm the endpoint is reachable and check your signature code against a real request.
Events
A webhook receives only the events you subscribe it to. Click the webhook's URL on the Webhooks page to change the selection at any time.
| Event | Fires when | Requires |
|---|---|---|
incident.created | An incident is posted | Incident management |
incident.updated | An existing incident is edited | Incident management |
incident.resolved | An incident is marked resolved | Incident management |
postmortem.published | An incident postmortem is published | Incident postmortems |
maintenance.created | A maintenance window is scheduled | Incident management |
maintenance.updated | A scheduled window is edited | Incident management |
maintenance.resolved | A maintenance window is marked complete | Incident management |
monitor.down | A monitor fails enough consecutive checks to count as down | Monitoring |
monitor.recovered | A monitor that was down starts passing again | Monitoring |
ssl.expiring | A monitored certificate crosses an expiry threshold | Monitoring |
domain.expiring | A watched domain registration crosses an expiry threshold | Monitoring; domain-registration warnings are coming soon |
Monitor, certificate, and domain-registration events require monitoring. Incident and maintenance events require incident management. The Webhooks screen only offers event families the account can produce.
Domain-registration warnings are coming soon. You can select domain.expiring now, but no
warnings are delivered yet.
New event types are always opt-in. Adding one to this list never changes what an existing webhook receives, so a receiver written today keeps getting exactly what it gets today.
Internal alert events
Two more events are not in the list above, because a webhook does not subscribe to them. An Internal alert rule names the webhook it reports to, and the rule decides when they are sent:
| Event | Fires when | Requires |
|---|---|---|
internal.alert.triggered | The rule's condition starts to match what your servers report | Internal analytics, or Database Health for a database rule |
internal.alert.resolved | The condition stops matching, or the rule is edited or deleted while it matches | The same as the rule |
They are signed and delivered like every other event. A paused webhook, a disabled rule and a muted rule
send nothing. A failed delivery is retried with the same id, so deduplicate on it as usual.
Payload
Every delivery is a POST with content-type: application/json. Four fields are on
every payload, whatever the event:
| Field | What it is |
|---|---|
id | Unique per delivery. Use it as your idempotency key. |
timestamp | ISO 8601 UTC time of the event: when the change was saved or the check that found it ran. Every redelivery of the event carries the same value. |
event | One of the event names above. |
tenant | { slug, name } of the status page it came from. |
What comes after those depends on the event:
| Event | Also carries |
|---|---|
incident.*, maintenance.* |
incident: { id, type, title, summary, startedAt, resolvedAt }. Lifecycle-managed maintenance also carries publishedAt, scheduledEndAt, maintenanceState, and cancelledAt. |
postmortem.published |
incident: { id, title }, plus postmortem: { excerpt, publishedAt, url } |
monitor.down, monitor.recovered |
monitor: { id, name, url } |
ssl.expiring |
monitor, plus certificate: { expiresAt, daysRemaining } |
domain.expiring |
domain: { registrableDomain, expirySource, expiresAt, daysRemaining, thresholdDays, monitorCount } |
internal.alert.* |
alert: { id, ruleId, state, summary, snapshotRevision, fingerprint, affectedReporters, affectedRequests, affectedErrors, affectedErrorRate, dominantStatus, onsetAt, resolvedAt } |
webhook.test | message: a fixed human-readable string |
In an internal.alert.* delivery, state is triggered or
resolved and summary is a sentence you can post as it is. Every other field in
alert can be null. affectedErrorRate is a fraction (errors divided
by requests), dominantStatus is the error status seen most often, and onsetAt and
resolvedAt are ISO 8601 instants.
A domain.expiring delivery carries no monitor object: one registration covers
every monitor on that domain, and monitorCount says how many. expiresAt is the
registry's date, and daysRemaining can be negative once it has passed.
An incident.created delivery in full:
{
"id": "delivery_example_01",
"timestamp": "2026-08-16T09:00:00.000Z",
"event": "incident.created",
"tenant": {
"slug": "acme",
"name": "Acme"
},
"incident": {
"id": "inc_example_02",
"type": "incident",
"title": "Elevated error rates",
"summary": "We are investigating reports of elevated error rates on the API.",
"startedAt": "2026-08-15T09:00:00.000Z",
"resolvedAt": null
}
}title and summary are always in the status page's default language. A
webhook receiver is a system, not a person choosing a language, so there is no per-recipient localisation
the way subscriber emails have. resolvedAt stays null until the incident is
actually marked resolved.
Verifying a delivery
Every request carries a signature header:
X-InBrief-Signature: sha256=<hex><hex> is the lowercase-hex HMAC-SHA256 of the raw request body (the
exact bytes received, before any JSON parsing), keyed with that webhook's own secret. Recompute it and
compare with a constant-time comparison, never == or ===, before trusting
anything in the payload.
In Node.js:
const crypto = require('node:crypto');
function isValidSignature(rawBody, header, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const given = (header || '').replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(given, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Any language with an HMAC-SHA256 primitive and a constant-time byte compare works the same way. The recipe is just "HMAC the raw body with your secret, hex-encode it, compare to the header".
Each request also carries a second header that signs the time of the attempt, so a captured delivery stops verifying once you decide it is too old:
X-InBrief-Signature-V1: t=<unix seconds>,v1=<hex><hex> is the HMAC-SHA256, keyed with the same secret, of the string
<t>.<raw body>. Recompute it, compare in constant time, and reject the request when
t is more than five minutes from your clock. A redelivery from the history screen carries the
same body and the same id but a new t. The body-only header above keeps working
for now; move to this one when you can.
Delivery semantics
| Behaviour | Detail |
|---|---|
| Timeout | 15 seconds per attempt. |
| Retry | A timeout, a connection failure or any non-2xx status is retried once, half a second
later. If that fails too, the event is redelivered later with growing gaps, from about 30 seconds up to
15 minutes, for at most 8 attempts and never more than 24 hours after the event. Only receivers that
have not yet answered with a 2xx are called again. ssl.expiring gets the quick retry
only. |
| Success | Any 2xx. Return one as soon as you have accepted the payload; do the slow work afterwards. |
| Repeated failure | After ten deliveries in a row without a 2xx, redeliveries included, the webhook is paused so a receiver that no longer exists stops being called. Resume it from the list once the receiver answers again. |
| Ordering | Not guaranteed. Two events fired close together can arrive in either order. |
| Duplicates | Possible: every redelivery of an event keeps the same id.
Deduplicate on it. |
| Response body | Read up to 2000 characters and stored for you to inspect. Anything longer is discarded unread. |
Deduplicate on id, not on the incident id, because the same incident legitimately produces many
incident.updated events, and each is a distinct delivery you are meant to process.
Delivery history
Every attempt is recorded. Open Delivery history on a webhook to see the most recent 50: the event, the response status, how long it took, and how many attempts it needed.
Opening one shows the exact request body that was signed and sent, and whatever the receiver sent back, or, when nothing came back at all, the reason: a timeout, a DNS failure, a refused connection.
Send this again on a delivery replays those exact bytes, keeping the same id,
so a receiver that already processed it can recognise the duplicate. The replay is recorded as its own
delivery rather than overwriting the original, so a success after a failure stays visible as both.
History is kept for 30 days. Removing a webhook removes its history too.