Webhooks

Be told instead of asking. An org registers https endpoints and receives every event it asks for, signed, with retries.

Register

An org holds at most 20 active endpoints. The signing secret starts with whsec_ and is in the response of the registration and nowhere else, so store it then.

POST/v1/webhooks

Register an https endpoint that receives the org's events, signed; the signing secret is returned once and never again. At most 20 active endpoints per org

webhooks.createservice keywebhook:managesuccess 200

Body

urlstring
events?"all" | "signal.clustered" | "decision.created" | "decision.resolved" | "job.created" | "job.completed" | "job.failed" | "theme.shipped" | "theme.merged" | "decision.escalated" | "job.expired"[]

Response

idstring
urlstring
events"all" | "signal.clustered" | "decision.created" | "decision.resolved" | "job.created" | "job.completed" | "job.failed" | "theme.shipped" | "theme.merged" | "decision.escalated" | "job.expired"[]The event types this endpoint receives, or all
status"active" | "disabled"
disabledReason"key_revoked" | "key_lapsed" | "deleted" | nullWhy a disabled endpoint stopped: its registering key was revoked, its key stopped resolving (expired, or its person left the org or lost the capability), or it was deleted
createdBystringThe id of the key that registered it
createdAtinteger
secretstringThe signing secret, shown only in this response

Errors: 401 unauthorized400 validation_failed409 conflict429 rate_limited

Proved by webhooks.register

What is sent

Each attempt is a POST of the event envelope as JSON, abandoned when the connection is idle for 10 seconds, with redirects not followed (a 3xx is a failure). Any 2xx is success and its body is ignored. Dedupe by the envelope id: a delivery can arrive more than once.

HeaderCarries
Belora-EventThe event type
Belora-DeliveryThe delivery id, the same on every retry of one delivery
Belora-Signaturet=<unix seconds>,v1=<hex>

Proved by webhooks.signature

Verify the signature

v1 is the lowercase hex HMAC-SHA256, keyed with the endpoint secret, of the timestamp, a dot, and the exact bytes received, before any JSON parsing. Reject a timestamp more than 300 seconds from your clock. A retry is signed afresh, so its timestamp is its own. Ignore fields other than t and v1, so a later version does not break a receiver.

Node

const crypto = require("node:crypto");

function verify(secret, header, rawBody) {
  const fields = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
  const timestamp = Number(fields.t);
  if (!Number.isInteger(timestamp) || !fields.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const given = Buffer.from(fields.v1);
  const wanted = Buffer.from(expected);
  return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}

Python

import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    fields = dict(part.split("=", 1) for part in header.split(","))
    if "t" not in fields or "v1" not in fields or not fields["t"].isdigit():
        return False
    timestamp = int(fields["t"])
    if abs(time.time() - timestamp) > 300:
        return False
    message = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, fields["v1"])

Proved by webhooks.signature

Retries

A delivery makes at most 6 attempts over about 14.6 hours. Each attempt records its number before it sends, so a delivery is never tried again without its counter moving.

AttemptRuns
1immediately
21 minute after the previous attempt fails
35 minutes after the previous attempt fails
430 minutes after the previous attempt fails
52 hours after the previous attempt fails
612 hours after the previous attempt fails

A delivery is one of pending, delivered, failed. It ends failed after the last attempt, when its endpoint was disabled, when its event passed the 90 days retention, or when an attempt began and never reported back for 5 minutes. A failed delivery keeps lastStatusCode and a lastError of at most 200 characters, never the body your server answered.

Stated, not run by the suite: webhooks.retry

Why an endpoint is disabled

An endpoint lives only as long as the key that created it. A disabled endpoint lists a disabledReason:

disabledReasonMeaning
key_revokedThe key that created the endpoint was revoked. Revoking disables its endpoints in the same transaction.
key_lapsedThe key stopped resolving without a revoke: it passed its expiry, its person went inactive or left the org, or it lost webhook:manage. Found at the next event or attempt; nothing more is sent.
deletedDELETE /v1/webhooks/{id} disables the endpoint rather than removing it. Its history stays readable.

A live key holding webhook:manage brings an endpoint back with POST /v1/webhooks/{id}/enable, which also rebinds it to that key. A retry already waiting when an endpoint is disabled fails with The endpoint was disabled when its attempt runs, unless the endpoint was enabled again first.

Stated, not run by the suite: webhooks.key_lapsed

Replay

Replay sets deliveries back to pending with a fresh series of 6 attempts. With no eventId it replays every failed delivery, at most 100. With an eventIdit replays that event's delivery whether it failed or was delivered. A delivery already pending is left alone, and a disabled endpoint answers 409.

POST/v1/webhooks/{id}/replay

Send deliveries again: the one for eventId, or when it is absent every failed delivery (up to 100). A delivery already pending is left as it is, so a repeat re-queues nothing new

webhooks.replayservice keywebhook:managesuccess 200

Path parameters

idstring

Body

eventId?string

Response

requeuedobject[]
requeued[].idstring
requeued[].endpointIdstring
requeued[].eventIdstringThe id of the event envelope the receiver was sent
requeued[].eventTypestring
requeued[].attemptintegerAttempts made so far; at most 6
requeued[].status"pending" | "delivered" | "failed"pending: more attempts to come; delivered: a 2xx answered; failed: every attempt failed and none is left
requeued[].nextAttemptAtinteger | nullWhen the next attempt runs; null once delivered or failed
requeued[].lastStatusCodeinteger | nullThe HTTP status of the last attempt, null when it got no response
requeued[].lastErrorstring | null
requeued[].deliveredAtinteger | null
requeued[].createdAtinteger

Errors: 401 unauthorized404 not_found409 conflict400 validation_failed429 rate_limited

Proved by webhooks.replay

Where a URL may point

Only https on the default port, with no credentials, whitespace or backslashes. Internal host names, wildcard DNS names, and private, loopback, link-local and cloud metadata addresses in any written form are refused at registration with 400 validation_failed on body.url. The check that counts runs again before every send: the host is resolved, every answer is judged, and the connection is pinned to the address judged, so a DNS rebind after the check cannot redirect it.

Proved by webhooks.ssrf

Proved by

Every claim on this page is one of these checks; those marked stated are documented behaviour the suite cannot drive. Run them against your org:

npx @belora/conformance --key $BELORA_CONFORMANCE_KEY --api $API --mcp https://mcp.belora.dev/mcp
  • webhooks.register An endpoint registers and its secret is shown once

    POST /v1/webhooks with an https url answers the endpoint and a whsec_ secret; GET /v1/webhooks lists the endpoint without any secret.

  • webhooks.signature A captured delivery's signature verifies

    A Belora-Signature header captured from a delivery matches hmac_sha256(secret, t + "." + raw body) for the secret and raw body supplied with it. A wrong secret or an altered body fails. Whether the delivery arrived inside the tolerance is the receiver's own check.

  • webhooks.retry A failed delivery retries on the published schedulestated, not run by the suite

    An endpoint that answers non-2xx sees the same Belora-Delivery id again after each published backoff, a fresh signature each time, and the delivery ends failed with its attempt count after the last try.

  • webhooks.replay A failed delivery replays on demand

    POST /v1/webhooks/{id}/replay sets failed deliveries back to pending with a new series of attempts; with an eventId it requeues that event's delivery whether it failed or was delivered, leaves a pending one alone, and answers 409 conflict on a disabled endpoint.

  • webhooks.key_lapsed An endpoint stops with its keystated, not run by the suite

    Revoking the creating key disables its endpoints with disabledReason key_revoked; a key that expires or loses webhook:manage disables them with key_lapsed at the next event; a live key brings them back with POST /v1/webhooks/{id}/enable.

  • webhooks.ssrf A private target is refused

    POST /v1/webhooks with a non-https, credentialed, internal-name or private-address url answers 400 validation_failed with a path of body.url, and a hostname that resolves to a private address is never connected to.