Docs

Transports

Webhooks

The platform calls your HTTPS endpoint when something happens, with a signed event that carries ids, never content.

Rolling out

Webhooks are rolling out from 2026-10-02. The changelog says when they reach every account.

Shape

You give the platform an HTTPS address and the event types it should receive. When one of them happens, it sends a POST to that address with the event as a JSON body:

POST /hooks/inorbit HTTP/1.1
Content-Type: application/json
webhook-id: evt_01J9…
webhook-timestamp: 1790949791
webhook-signature: v1,…

{"id":"evt_01J9…","type":"token.revoked","occurred_at":"2026-10-02T14:03:11Z","account_id":"acc_…","data":{"token_id":"tok_…","actor":"user_…"}}

The headers and the signature follow the Standard Webhooks specification, so any of its libraries verifies a delivery as well as the code below.

Events carry ids, never content

An event says what happened and to which object, and nothing about what was inside it:

FieldWhat it is
idthe event's id; the same on every retry of it
typeone of the types below
occurred_atwhen it happened, RFC 3339 in UTC
account_idthe account it happened in; yours for a Radar digest, which has none
datathe ids of the objects involved, and who acted; the fields vary by type

To learn more, ask the API with your own token, which checks that you may. A leaked or forwarded event carries no personal data and no content.

TypeWhen
token.created, token.revokedan API token is made or revoked
key.created, key.revokedan API key is made or revoked
member.invited, member.joined, member.removedthe team changes
usage.thresholdthe account's units cross 80% or 100% of its budget
radar.digest.publisheda new Radar digest is out
audit.eventan entry is written to the account's audit log
domain.verified, domain.unverifieda domain is verified for the account, or stops being verified (Domains)
domain.transferredanother account proved a domain this account held
webhook.testyou sent a test event from the console or the API

GET /v1/events/types lists the catalogue with a JSON Schema per type. The catalogue grows; a new type is a new schema, an existing schema never changes. Ignore types you do not know.

Verifying a delivery

Check every delivery before you act on it:

  1. Read webhook-id, webhook-timestamp and webhook-signature. Refuse a delivery without them.
  2. Refuse a webhook-timestamp (Unix seconds) more than 5 minutes from your clock. This stops a captured delivery from being replayed later.
  3. Take the signing secret, drop its whsec_ prefix and base64-decode the rest: that is the HMAC key.
  4. Compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, with the body exactly as received, before any JSON parsing, and base64-encode it.
  5. webhook-signature is a space-separated list of v1,<signature> entries. Accept the delivery when any v1 entry equals yours, compared in constant time.
  6. Drop a webhook-id you have already processed: a delivery can arrive more than once.
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// body is the raw request body, exactly as received (a Buffer or a string).
export function verifyWebhook(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const sent = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isInteger(sent) || Math.abs(now - sent) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(body).digest();

  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

A framework that parses JSON before your handler runs has already changed the body. Ask it for the raw bytes (express.raw({ type: "application/json" }) in Express, request.get_data() in Flask, io.ReadAll(r.Body) in Go, Bytes in axum).

Try it without a server

A test inbox is an address on the API that keeps what is posted to it, so you can see a real, signed delivery before you write a receiver.

  1. In the console, open Webhooks and press Use a test inbox, or call POST /v1/webhooks/inboxes. You get an address like https://api.inorbit.hr/v1/webhooks/inboxes/whi_…/receive.
  2. Add that address as an endpoint. The console fills it in for you.
  3. Press Send test event on the endpoint, or call its test route.
  4. The request shows up on the inbox page within a few seconds, or in GET /v1/webhooks/inboxes/{inbox_id}/requests: the method, the content type, the webhook-id, webhook-timestamp, webhook-signature, content-type and user-agent headers, and the body as it arrived.

When an endpoint of your account points at the inbox, the inbox checks each request's signature with that endpoint's secret, as your receiver would (both secrets during a rotation, and a timestamp within 5 minutes), and says valid or invalid. A request without webhook-signature is unsigned; a signed one that no endpoint of yours points at is no_endpoint. Copy the headers and the body to test your own verification code against a known-good delivery.

The receive route takes no token, since a sender has none: anyone who knows the address can post to it, so trust only the valid ones. Only POST is served; the inbox answers 200 with {}, so the delivery counts as a success. It is for trying webhooks, not for production:

  • one inbox per account, for 24 hours from when it was made; creating again answers the one you have;
  • the last 50 requests are kept, the oldest dropped; only the headers above and the body are stored, and they go when the inbox is deleted or expires;
  • a body over 64 KiB is refused with 413, more than 60 requests a minute with 429, and an unknown, deleted or expired inbox answers 404;
  • everyone in the account can read what it received.

Answering and retries

Answer any 2xx as soon as the delivery is verified and do the work afterwards. Any other status, a timeout, a refused connection or a redirect is a failed attempt, and the delivery is tried again after a growing gap, with some randomness added to each:

AttemptAfter the previous one
1at once
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 hours

After the eighth attempt fails, about 28 hours after the first, the delivery is abandoned and stays on record. The webhook-id is the same on every attempt.

  • 410 Gone disables the endpoint at once; nothing more is sent to it.
  • An endpoint whose deliveries have all failed for 5 days is disabled, and the console says so. Turn it back on in the console or with PATCH once it answers again.
  • Deliveries are not ordered. Use occurred_at, and the API, when order matters.

The signing secret

Each endpoint has its own secret, whsec_ followed by base64. It is shown once, when the endpoint is made or the secret rotated; the platform keeps it sealed and never shows it again. Keep it in your secret store, not in code.

To rotate, call rotate (or use the console). For the next 24 hours every delivery is signed with both the old and the new secret, so webhook-signature carries two v1 entries and either verifies. Move your receiver to the new secret within that day; the old one then stops signing.

Endpoints

  • An endpoint is a public https:// address. The platform resolves it before every delivery and refuses private, loopback, link-local and cloud metadata addresses, then connects to the address it checked. Redirects are not followed.
  • Each endpoint names the event types it receives.
  • Every attempt is on record for 30 days: the event, the attempt, the status code, how long it took and when the next retry is due. The body your server answered is not stored; it can carry your data.
  • Deleting an endpoint, or the account, deletes its delivery history.

The bounds (endpoints per account, deliveries in flight, payload size) are on Limits.

Managing endpoints

In the console, the Webhooks section lists your endpoints and each one's deliveries, and can send a test event, resend a delivery, rotate the secret or disable an endpoint. Over the API, with a token that holds webhooks:read or webhooks:write (Authentication):

RouteScopeDoes
GET /v1/webhooks/endpointswebhooks:readlists the account's endpoints
POST /v1/webhooks/endpointswebhooks:writemakes an endpoint; the answer carries its secret, once
GET /v1/webhooks/endpoints/{endpoint_id}webhooks:readone endpoint
PATCH /v1/webhooks/endpoints/{endpoint_id}webhooks:writechanges its address, description, event types or state
DELETE /v1/webhooks/endpoints/{endpoint_id}webhooks:writedeletes it and its delivery history
POST /v1/webhooks/endpoints/{endpoint_id}/rotatewebhooks:writea new secret, shown once; the old one signs for 24 hours
POST /v1/webhooks/endpoints/{endpoint_id}/testwebhooks:writesends a webhook.test event
POST /v1/webhooks/endpoints/{endpoint_id}/recoverwebhooks:writesends again what it missed since a time, failed or never sent
GET /v1/webhooks/endpoints/{endpoint_id}/deliverieswebhooks:readthe endpoint's deliveries and their attempts
POST /v1/webhooks/deliveries/{delivery_id}/retrywebhooks:writesends a delivery again, with the same webhook-id
GET /v1/webhooks/statswebhooks:readdelivered and failed per hour or day, answer times, why
GET /v1/webhooks/inboxeswebhooks:readthe account's test inbox, when it has one
POST /v1/webhooks/inboxeswebhooks:writemakes the test inbox, or answers the one there is
GET /v1/webhooks/inboxes/{inbox_id}/requestswebhooks:readwhat the inbox received, newest first
DELETE /v1/webhooks/inboxes/{inbox_id}webhooks:writedeletes the inbox and what it received
POST /v1/webhooks/inboxes/{inbox_id}/receivenone, no tokenwhere senders post; the inbox's address
GET /v1/events/typesevents:readthe event catalogue, a schema per type

An endpoint wants at least one event type; webhook.test is not one you subscribe to, it comes only when you ask for it. Leaving event_types out of a PATCH keeps the types it has. To manage a team's endpoints, pass the team's account_id (a query parameter on a GET, a field on a create); without it you manage your own account's.

curl https://api.inorbit.hr/v1/webhooks/endpoints \
  -H "Authorization: Bearer $INORBIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/inorbit", "description": "revocations to our audit log", "event_types": ["token.revoked", "key.revoked"]}'

Every change to an endpoint and every delivery is logged as who, what and the outcome. The platform's logs never carry the event or your address.