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)
```

| Header | Value |
| --- | --- |
| Yelno-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")> — a second ,v1= is appended during a rotation overlap |
| Yelno-Event-Id | evt_… — stable across attempts and retries; shared by every delivery of the same event |
| Yelno-Event-Type | the catalog name, e.g. api_key.created |
| Yelno-Delivery-Id | dlv_… — one delivery per endpoint per event |
| Yelno-Delivery-Attempt | 1, 2, … 6 |
| Idempotency-Key | the delivery id, identical on every attempt of that delivery |
| User-Agent | Yelno-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>.*`.

| Event | Meaning |
| --- | --- |
| api_key.created · api_key.revoked | An 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.exceeded | A budget crossed a warn threshold / its hard limit |
| alert.fired | An alert rule fired |
| payment.succeeded · payment.failed | A top-up was confirmed by a verified Stripe webhook / could not be collected |
| wallet.credit_granted | Credit was granted to the wallet |
| export.completed · export.failed | An async export finished / failed |
| request.reconciliation_required | A request's usage could not be finalised and needs finance review |
| incident.opened · incident.updated · incident.resolved | A platform incident affecting the tenant changed state |
| approval.requested · approval.decided | A gated mutation is awaiting / received a decision |
| policy.updated | The organization's data/routing policy changed (new version) |
| fine_tuning.job.succeeded · fine_tuning.job.failed · fine_tuning.job.cancelled | A 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.stopped | A fine-tuned model's endpoint started serving / stopped, with the reason (user, idle, max_hours, budget, wallet_empty, …) |
| webhook.test · integration.test | Sent by "Send test" on an endpoint / an integration; never via the outbox |
| webhook.endpoint_created · endpoint_updated · endpoint_deleted · secret_rotated | Endpoint lifecycle (ids, url, event_types, secret_last4 — never a secret) |
| webhook.endpoint_disabled | The platform disabled an endpoint after 25 consecutive failures (delivered to the org's other endpoints) |
| integration.created · integration.deleted | Integration 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.

Source: https://yelno.ai/docs/webhooks
