Skip to content

Signed webhook

A webhook destination POSTs signed JSON to a URL you control, one request per notice. Use it to wire InfraInbox into something with no adapter of its own — Home Assistant, n8n, a status page, your own service.

  1. Go to Add destination → Webhook.
  2. Name it and enter your receiver’s URL (https, or http only to a private address).
  3. InfraInbox generates a signing secret in your browser and shows it once, shaped whsec_…. Copy it — your receiver needs it to verify what it gets, and InfraInbox can’t show it to you again.
  4. Save, then click Send test. Your receiver should get one request with "type": "test".
POST /your/path HTTP/1.1
Content-Type: application/json
webhook-id: dlv_01m28xg2pdexgbwj94sksn3kc2
webhook-timestamp: 1758268800
webhook-signature: v1,K5g0N2vqYFvXe3wq7z6z9J3m8s2q1p9r0c4h1x8v0m0=
{"type":"incident.opened","timestamp":"2026-09-19T08:00:00Z","data":{"v":1,"title":"Backup failed","severity":"CRITICAL","state":"open","sourceName":"Proxmox Home","resource":"job backup-daily","workspaceName":"Home lab","incidentId":"inc_2f9","firstSeenAt":"2026-09-12T21:31:04Z","url":"https://infrainbox.example.com/incidents/inc_2f9","reason":"opened"}}

webhook-id is the delivery’s own ID and stays the same across retries of the same attempt, so your receiver can drop duplicates by it. type is incident.<reason> for a lifecycle notice (incident.opened, incident.reoccurred, incident.escalated, incident.reopened, incident.resolved, incident.renotify, incident.fallback, incident.flapping, incident.silence_expired, incident.snooze_expired, incident.informational), incident.acknowledged, incident.snoozed or incident.updated for a state change made elsewhere, and test for a manual test send.

webhook-signature follows the Standard Webhooks scheme: v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the bytes your secret decodes to.

  1. Strip the whsec_ prefix from your secret and base64-decode the rest — that’s the raw HMAC key.
  2. Build the signed content: the webhook-id header value, a literal ., the webhook-timestamp header value, another literal ., then the exact raw request body bytes (don’t re-serialize the JSON — use the bytes as received).
  3. Compute HMAC-SHA256 of that content with the key from step 1, base64-encode the result, and prefix it with v1,.
  4. Compare that to the webhook-signature header using a constant-time comparison.
import hashlib
import hmac
import base64
def verify(secret: str, webhook_id: str, timestamp: str, body: bytes, signature_header: str) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{webhook_id}.{timestamp}.".encode() + body
expected = "v1," + base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(expected, signature_header)
const crypto = require("crypto");
function verify(secret, webhookId, timestamp, body, signatureHeader) {
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signedContent = Buffer.concat([Buffer.from(`${webhookId}.${timestamp}.`), body]);
const expected = "v1," + crypto.createHmac("sha256", key).update(signedContent).digest("base64");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
Your receiver answers InfraInbox does
2xx Marks it sent
401, 403, 404, 410 Stops sending here until you edit or test the destination again — your receiver is telling it to stop
408, 429, 5xx Retries with backoff
Any other 4xx Gives up on this one event, but keeps sending future ones. Five in a row with nothing sent in between, and the destination stops being tried, exactly as for a 401 — a receiver that refuses every notice is a configuration to fix, not a queue to burn. One notice that goes through clears the count
A redirect Never followed; treated as a failure

A webhook destination is one of the channels that can represent a state change: an acknowledgement, a snooze or a manual resolve made elsewhere arrives as its own incident.acknowledged, incident.snoozed or incident.updated request, rather than being skipped — there’s no message to edit, so InfraInbox just tells you what changed.

  • Send recovery and state updates — off, and this receiver is only told when something breaks.
  • Enabled — off holds the destination without deleting it.
  • Quiet-hours threshold — the severity floor this destination still hears during quiet hours.

Click Generate a new secret on the destination’s edit form, save, and update your receiver with the new value at the same time — the old secret stops verifying immediately once you save.