Webhooks
Signed, retried, replayable deliveries that carry identifiers and state — never content.
Endpoints
Create an endpoint with POST /api/v1/webhook-endpoints (webhook:write — owner, security_admin and developer; scoped path /api/v1/organizations/{organization_id}/webhooks). url must be https with a public host — loopback, private and link-local addresses are refused with url_not_allowed. event_types lists catalog names or <noun>.* wildcards (unknown_event_type otherwise); an empty list subscribes to every event. The signing secret is returned exactly once, here and from rotate-secret; afterwards the endpoint shows only secret_last4.
# POST /api/v1/webhook-endpoints (webhook:write)
{ "url": "https://hooks.acme.dev/yelno", "description": "prod consumer",
"event_types": ["api_key.created", "credential.*"] }
# 201 — the secret appears here and in rotate-secret, never again
{ "signing_secret": "whsec_<64 hex>",
"endpoint": { "id": "whe_01J…", "state": "active", "enabled": true, "secret_last4": "c41e",
"event_types": ["api_key.created", "credential.*"], "consecutive_failures": 0, … },
"warning": "This value is shown only once and cannot be retrieved again." }PATCH changes the URL, description, event types or enabled — false pauses the endpoint and queued deliveries wait for it. POST …/{endpoint_id}/test queues a webhook.test delivery even on a paused endpoint. POST …/{endpoint_id}/rotate-secret issues a new secret and keeps the previous one signing for 24 hours. GET …/deliveries lists every delivery with its attempts_log; POST …/deliveries/{delivery_id}/retry re-queues a succeeded, failed or dead one.
Verify signatures
Each delivery carries Yelno-Signature: t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 over t + "." + rawBody with the endpoint's signing secret and compare in constant time; reject when |now − t| exceeds 300 seconds. For 24 hours after a rotation the header carries a second v1= computed with the previous secret — accept the delivery if any v1 verifies and you can roll secrets without a gap.
import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str) -> bool:
t, sigs = None, []
for part in header.split(","):
key, _, value = part.partition("=")
if key == "t": t = value
elif key == "v1": sigs.append(value) # two v1= during a rotation overlap
if t is None or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), (t + ".").encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in sigs)Payload shape
The body is the event envelope, serialised with compact separators and sorted keys — the bytes that are signed are the bytes that are sent. id is the event id, shared by every delivery fanned out from the same event; data carries identifiers and state only.
{
"id": "evt_01J9F3M2…",
"type": "api_key.created",
"created_at": "2026-09-13T11:08:13Z", // when the event was recorded
"api_version": "2026-09-01",
"organization_id": "org_01J8…",
"data": {
"api_key_id": "key_01J9…", "name": "checkout-agent", "prefix": "yk_live_3dq5",
"project_id": "proj_01J8…", "scopes": ["inference:invoke"] // never the key itself
}
}Event catalog
GET /api/v1/webhook-events returns the catalog; each entry is a valid event_types value and any noun may be subscribed as <noun>.*.
Delivery and retries
Any 2xx within 10 seconds acknowledges; redirects are not followed, so a 3xx is a failure. A failed attempt is retried after 1 minute, then 5 minutes, 30 minutes, 2 hours and 12 hours; the sixth failure marks the delivery dead — six attempts in about 14.6 hours, and no automatic retry after that. Every attempt carries the same event id and Idempotency-Key, so treat a duplicate as a no-op. After 10 consecutive failures the endpoint shows failing; after 25 the platform disables it, tells your other endpoints with webhook.endpoint_disabled, and PATCH with enabled: true re-arms it. A manual retry gets the full schedule again.