Skip to content

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 cursor you start at the oldest event OS still keeps. Handle the page, keep next_cursor, and ask ?cursor= with that value next time.
  • next_cursor is there on a short page too: it then points past everything OS has recorded so far, the kinds you left out with types included. When nothing is new, you get an empty page and the same next_cursor; ask again later.
  • types narrows the feed to some kinds: ?types=directory.membership.removed.
  • limit is at most 100. A page can hold fewer events than limit while next_cursor continues: you see only what you may see.
  • 410 means 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 secret straight away. OS shows the secret only in this answer, and never again.
  • An empty kinds means every kind, including kinds added later.
  • actor_membership_id is 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.