Webhooks and the feed through the API¶
This page is for whoever has their own program follow what changes in OS. It shows how to read the feed, register a webhook, check a delivery and what to do when something goes wrong. What events and webhooks are is in Events and webhooks. How to make a request to OS (address, sign-in, pages, errors) is in The access API.
Reading the feed¶
curl -s "$OS/api/v1/organisations/$ORG/events?limit=100" -H "Authorization: Bearer $TOKEN"
{"items": [
{"id": "evt_...", "type": "directory.invitation.created", "organisation_id": "org_...",
"cursor": "41", "created_at": "2026-10-17T09:14:03Z", "refs": {"invitation_id": "inv_..."}}
],
"next_cursor": "41"}
- Without
cursoryou start at the oldest event OS still keeps. Handle the page, keepnext_cursor, and ask?cursor=with that value next time. next_cursoris there on a short page too: it then points past everything OS has recorded so far, the kinds you left out withtypesincluded. When nothing is new, you get an empty page and the samenext_cursor; ask again later.typesnarrows the feed to some kinds:?types=directory.membership.removed.limitis at most 100. A page can hold fewer events thanlimitwhilenext_cursorcontinues: you see only what you may see.410means the events after your cursor are no longer kept. Read the current state again and start without a cursor.
You need see on event.
Registering a webhook¶
curl -s -X POST "$OS/api/v1/organisations/$ORG/webhooks" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://crm.example.com/os-events", "label": "CRM",
"kinds": ["directory.membership.created", "directory.membership.removed"],
"actor_membership_id": "mem_..."}'
{"id": "whk_...", "label": "CRM", "url": "https://crm.example.com/os-events",
"kinds": ["directory.membership.created", "directory.membership.removed"],
"actor_membership_id": "mem_...", "state": "active", "consecutive_failures": 0,
"allowed_actions": ["see", "write", "delete"], "secret": "whsec_..."}
- Store
secretstraight away. OS shows the secret only in this answer, and never again. - An empty
kindsmeans every kind, including kinds added later. actor_membership_idis the account the webhook receives as: an active member of your organisation that you may change and whose every grant you hold yourself.- An organisation has at most 20 webhooks.
| Answer | Meaning |
|---|---|
201 |
Registered; Location points at the webhook. |
403 |
You do not hold everything the account holds. |
409 |
Webhooks are not on in this environment, or your organisation already has 20. |
422 |
The address is not https, names an IP address instead of a host name, holds a user, password or #, or its host leads to a private, internal or blocked address (webhook_host_refused); a kind does not exist; the account is not an active member. |
Changing, replacing, removing¶
| Request | What it does |
|---|---|
GET .../webhooks |
the webhooks, without their secrets |
GET .../webhooks/{id} |
one webhook, with ETag |
PATCH .../webhooks/{id} |
change label, url, kinds or actor_membership_id, with If-Match: "<version>"; a new address and a new account are checked as at registering |
DELETE .../webhooks/{id} |
the webhook and its log gone; the events stay in the feed |
POST .../webhooks/{id}/rotate-secret |
{"overlap_hours": 24}: a new secret, only in this answer; the old one keeps signing that long (0 to 168 hours, 0 is at once) |
POST .../webhooks/{id}/enable |
a failing or disabled webhook active again; 409 while its account is not active |
POST .../webhooks/{id}/test |
sends one events.webhook.tested event to this webhook alone, in any state; 202 with delivery_id |
These requests need write on webhook (removing delete, reading see).
Receiving a delivery¶
OS sends each event as a POST with the event as JSON and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
artific-os |
Artific-Signature |
t=<unix seconds>,v1=<hex>, and during a replacement a second v1 |
Idempotency-Key |
the delivery's id, the same on every attempt |
Artific-Event-Id |
the event's id |
Answer with a 2xx as soon as you have stored the event safely; do the work afterwards. OS waits at
most 10 seconds for your answer and does not read its content. Another status, a redirect or no
answer counts as failed, and OS tries again.
You can receive the same delivery more than once, for example when your answer got lost on the way.
Drop a delivery whose Idempotency-Key you already handled.
Check the signature¶
The signature is the HMAC-SHA256 of the time, a dot and the exact content, under the secret, as
hexadecimal. Accept a delivery when one of the v1 values matches and the time is no more than
five minutes off:
import hashlib, hmac, time
def is_from_os(secret: str, header: str, body: bytes) -> bool:
parts = [part.split("=", 1) for part in header.split(",")]
timestamp = next(value for key, value in parts if key == "t")
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
signed = any(hmac.compare_digest(expected, value) for key, value in parts if key == "v1")
return signed and abs(time.time() - int(timestamp)) <= 300
Check the raw content as you received it, not JSON you built again. The same function also checks the webhooks of the AI features, which are signed the same way.
During a replacement with an overlap every delivery carries two v1 values, the new one first.
Put the new secret in your program whenever it suits you, while the overlap lasts.
The delivery log¶
curl -s "$OS/api/v1/organisations/$ORG/webhooks/$WEBHOOK/deliveries?state=failed" \
-H "Authorization: Bearer $TOKEN"
Each delivery has state (queued, sending, delivered, failed or skipped), attempts,
last_status, last_error (timeout, connect, tls, host_refused, redirect), and for a
skipped delivery skipped_reason:
skipped_reason |
Meaning |
|---|---|
not_visible |
the account may not see the event or the thing |
actor_inactive |
the account is no longer active; the webhook is disabled |
webhooks_off |
webhooks are not on in this environment |
POST .../deliveries/{delivery_id}/redeliver sends a finished delivery again, with the same
Idempotency-Key, while its event is kept; 409 while it still waits or is being sent. To replay
a whole stretch, read the feed from your last cursor.