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:
| Field | What it is |
|---|---|
id | the event's id; the same on every retry of it |
type | one of the types below |
occurred_at | when it happened, RFC 3339 in UTC |
account_id | the account it happened in; yours for a Radar digest, which has none |
data | the 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.
| Type | When |
|---|---|
token.created, token.revoked | an API token is made or revoked |
key.created, key.revoked | an API key is made or revoked |
member.invited, member.joined, member.removed | the team changes |
usage.threshold | the account's units cross 80% or 100% of its budget |
radar.digest.published | a new Radar digest is out |
audit.event | an entry is written to the account's audit log |
domain.verified, domain.unverified | a domain is verified for the account, or stops being verified (Domains) |
domain.transferred | another account proved a domain this account held |
webhook.test | you 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:
- Read
webhook-id,webhook-timestampandwebhook-signature. Refuse a delivery without them. - Refuse a
webhook-timestamp(Unix seconds) more than 5 minutes from your clock. This stops a captured delivery from being replayed later. - Take the signing secret, drop its
whsec_prefix and base64-decode the rest: that is the HMAC key. - Compute HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{body}, with the body exactly as received, before any JSON parsing, and base64-encode it. webhook-signatureis a space-separated list ofv1,<signature>entries. Accept the delivery when anyv1entry equals yours, compared in constant time.- Drop a
webhook-idyou 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.
- In the console, open Webhooks and press Use a test inbox, or call
POST /v1/webhooks/inboxes. You get an address likehttps://api.inorbit.hr/v1/webhooks/inboxes/whi_…/receive. - Add that address as an endpoint. The console fills it in for you.
- Press Send test event on the endpoint, or call its
testroute. - 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, thewebhook-id,webhook-timestamp,webhook-signature,content-typeanduser-agentheaders, 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 with429, and an unknown, deleted or expired inbox answers404; - 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:
| Attempt | After the previous one |
|---|---|
| 1 | at once |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 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 Gonedisables 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
PATCHonce 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):
| Route | Scope | Does |
|---|---|---|
GET /v1/webhooks/endpoints | webhooks:read | lists the account's endpoints |
POST /v1/webhooks/endpoints | webhooks:write | makes an endpoint; the answer carries its secret, once |
GET /v1/webhooks/endpoints/{endpoint_id} | webhooks:read | one endpoint |
PATCH /v1/webhooks/endpoints/{endpoint_id} | webhooks:write | changes its address, description, event types or state |
DELETE /v1/webhooks/endpoints/{endpoint_id} | webhooks:write | deletes it and its delivery history |
POST /v1/webhooks/endpoints/{endpoint_id}/rotate | webhooks:write | a new secret, shown once; the old one signs for 24 hours |
POST /v1/webhooks/endpoints/{endpoint_id}/test | webhooks:write | sends a webhook.test event |
POST /v1/webhooks/endpoints/{endpoint_id}/recover | webhooks:write | sends again what it missed since a time, failed or never sent |
GET /v1/webhooks/endpoints/{endpoint_id}/deliveries | webhooks:read | the endpoint's deliveries and their attempts |
POST /v1/webhooks/deliveries/{delivery_id}/retry | webhooks:write | sends a delivery again, with the same webhook-id |
GET /v1/webhooks/stats | webhooks:read | delivered and failed per hour or day, answer times, why |
GET /v1/webhooks/inboxes | webhooks:read | the account's test inbox, when it has one |
POST /v1/webhooks/inboxes | webhooks:write | makes the test inbox, or answers the one there is |
GET /v1/webhooks/inboxes/{inbox_id}/requests | webhooks:read | what the inbox received, newest first |
DELETE /v1/webhooks/inboxes/{inbox_id} | webhooks:write | deletes the inbox and what it received |
POST /v1/webhooks/inboxes/{inbox_id}/receive | none, no token | where senders post; the inbox's address |
GET /v1/events/types | events:read | the 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.