Integrate / Webhooks

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)
HeaderValue
Yelno-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")> — a second ,v1= is appended during a rotation overlap
Yelno-Event-Idevt_… — stable across attempts and retries; shared by every delivery of the same event
Yelno-Event-Typethe catalog name, e.g. api_key.created
Yelno-Delivery-Iddlv_… — one delivery per endpoint per event
Yelno-Delivery-Attempt1, 2, … 6
Idempotency-Keythe delivery id, identical on every attempt of that delivery
User-AgentYelno-Webhooks/1.0

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>.*.

EventMeaning
api_key.created · api_key.revokedAn API key was minted / revoked (prefix and scopes; never the key)
credential.*Any BYOK provider-credential lifecycle event (created, rotated, revoked, disabled, enabled, compromised, auth_failed)
budget.threshold_crossed · budget.exceededA budget crossed a warn threshold / its hard limit
alert.firedAn alert rule fired
payment.succeeded · payment.failedA top-up was confirmed by a verified Stripe webhook / could not be collected
wallet.credit_grantedCredit was granted to the wallet
export.completed · export.failedAn async export finished / failed
request.reconciliation_requiredA request's usage could not be finalised and needs finance review
incident.opened · incident.updated · incident.resolvedA platform incident affecting the tenant changed state
approval.requested · approval.decidedA gated mutation is awaiting / received a decision
policy.updatedThe organization's data/routing policy changed (new version)
fine_tuning.job.succeeded · fine_tuning.job.failed · fine_tuning.job.cancelledA fine-tuning job finished, with the trained tokens and the final charge (fine_tuning.* covers every fine-tuning event)
fine_tuning.endpoint.running · fine_tuning.endpoint.stoppedA fine-tuned model's endpoint started serving / stopped, with the reason (user, idle, max_hours, budget, wallet_empty, …)
webhook.test · integration.testSent by "Send test" on an endpoint / an integration; never via the outbox
webhook.endpoint_created · endpoint_updated · endpoint_deleted · secret_rotatedEndpoint lifecycle (ids, url, event_types, secret_last4 — never a secret)
webhook.endpoint_disabledThe platform disabled an endpoint after 25 consecutive failures (delivered to the org's other endpoints)
integration.created · integration.deletedIntegration lifecycle

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.

Content is never sent Payloads carry identifiers, usage and state. Prompts and outputs never leave the gateway through webhooks, regardless of your retention setting.