# Webhooks

Receive signed notifications when organization records or their events change. Webhooks let your integration maintain a local view without repeatedly polling every resource. A webhook delivery confirms that your HTTP endpoint accepted a notification; it does not prove that a proposal was executed or that a transaction finalized.

## Create a subscription

Use an API key or OAuth bearer token with `webhooks:manage`, bound to the organization that owns the subscription. Scope checks also preserve workspace role checks: creating a subscription requires `ORGANIZATION_OWNER` or `KLINEO_OPERATOR` authority. Keep the API credential and webhook signing secret on your server.

```ts
import { randomUUID } from 'node:crypto';
import { LiquidityOsClient } from '@klineo/liquidity-os-sdk';

const client = new LiquidityOsClient({
  baseUrl: process.env.KLINEO_API_BASE_URL!,
  organizationId: process.env.KLINEO_ORGANIZATION_ID!,
  accessToken: process.env.KLINEO_ACCESS_TOKEN!,
});

// Persist this key before the request; reuse it if the response is lost.
const intentKey = `webhook:${randomUUID()}`;
const created = await client.createWebhooks({
  idempotencyKey: intentKey,
  body: {
    url: 'https://your-public-domain.example/webhooks/klineo',
    eventTypes: [
      'record.simulation_study.completed',
      'record.proposal_workspace.validated',
    ],
    reason: 'Notify our integration when study and proposal records change.',
  },
});

// Write this value directly to your secret manager; never log it.
const signingSecret = created.data.signingSecret;
const subscription = created.data.subscription;
```

`KLINEO_API_BASE_URL` is the full API prefix for your environment, including `/api/liquidity-studio/v2`. Replace the example destination with a real public HTTPS endpoint. Python exposes the same operation as `client.create_webhooks(body=..., idempotency_key=...)`.

The first successful response contains `signingSecret`, `secretReturnedOnce: true`, and `signatureInput: "timestamp.deliveryId.body"`. A later identical request returns HTTP `409` with `LIQUIDITY_OPERATING_SYSTEM_SECRET_ALREADY_RETURNED` and non-secret resource metadata. GET and list operations return subscription metadata, never the signing secret. If the first response was lost, have an authorized administrator revoke the subscription and create a replacement with a new subscription ID and intent key. There is no webhook secret retrieval or rotation endpoint.

## Select events

Record notifications use `record.<record_type>.<status>`, with both components in lowercase. Event notifications use `event.<record_type>.<event_kind>`, also in lowercase. For example, a completed simulation study produces `record.simulation_study.completed`; a validated proposal workspace produces `record.proposal_workspace.validated`.

Creation accepts 1–64 exact event type strings. Each string must match `[a-z][a-z0-9_.-]{2,119}`. Wildcard `*` is not accepted by the public creation endpoint. A syntactically valid name does not guarantee that the corresponding transition occurs. Choose names from the workflow you actually consume.

The JSON body has this envelope:

```json
{
  "schemaVersion": "klineo-token-liquidity-os-webhook-v1",
  "eventType": "record.simulation_study.completed",
  "occurredAt": "2026-09-30T09:00:00.000Z",
  "data": {}
}
```

`data` contains the immutable record or event captured when the delivery was queued. It is an observation at that point in time. Read the resource through its API when you need its current state. Treat `data` as an event payload rather than casting it to an SDK response type: database-backed delivery payloads can use database field names.

## Verify the signature

Each POST includes these headers. Header names are case-insensitive.

| Header | Meaning |
| --- | --- |
| `x-klineo-webhook-delivery` | Stable delivery ID, generated as `whd_` followed by 32 lowercase hexadecimal characters. |
| `x-klineo-webhook-event` | Event type; compare it to the verified body's `eventType`. |
| `x-klineo-webhook-timestamp` | Original signed outbox/event timestamp in ISO format. |
| `x-klineo-webhook-signature` | `v1=` followed by the lowercase hexadecimal HMAC-SHA256 digest. |

The signature input is the UTF-8 timestamp, a period, the UTF-8 delivery ID, a period, and the exact request body bytes:

```text
HMAC-SHA256(signingSecret, timestamp + "." + deliveryId + "." + rawBody)
```

Use the complete `whsec_...` signing-secret string as the HMAC key. It is not a Base64 value to decode. Preserve the raw body before any JSON middleware parses it, and verify with a constant-time comparison. Parsing and reserializing JSON changes the signature input. The event header is not independently signed; only trust its value after matching it against `eventType` in the authenticated body.

### TypeScript verification

Save this as `verify-webhook.ts` and run `node verify-webhook.ts` with Node.js 22.18 or later. It includes a deterministic check; replace the sample inputs with the raw body and headers from your HTTP handler. Reject duplicate header values at your HTTP boundary.

```ts
import assert from 'node:assert/strict';
import { createHmac, timingSafeEqual } from 'node:crypto';

type WebhookHeaders = {
  deliveryId: string;
  timestamp: string;
  signature: string;
  eventType: string;
};

export function verifyWebhook(
  rawBody: Buffer,
  headers: WebhookHeaders,
  secret: string,
  maxAgeSeconds: number,
  nowMs = Date.now(),
): Record<string, unknown> {
  if (!/^whd_[0-9a-f]{32}$/u.test(headers.deliveryId)
    || !/^v1=[0-9a-f]{64}$/u.test(headers.signature)
    || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/u.test(headers.timestamp)) {
    throw new Error('Invalid webhook headers');
  }
  if (!secret.startsWith('whsec_') || !Number.isFinite(maxAgeSeconds)
    || maxAgeSeconds <= 0 || !Number.isFinite(nowMs)) {
    throw new Error('Invalid receiver configuration');
  }
  const expected = createHmac('sha256', secret)
    .update(`${headers.timestamp}.${headers.deliveryId}.`, 'utf8')
    .update(rawBody)
    .digest();
  const received = Buffer.from(headers.signature.slice(3), 'hex');
  if (!timingSafeEqual(expected, received)) throw new Error('Invalid signature');
  const signedMs = Date.parse(headers.timestamp);
  if (!Number.isFinite(signedMs) || signedMs > nowMs + 60_000
    || nowMs - signedMs > maxAgeSeconds * 1000) {
    throw new Error('Timestamp outside receiver acceptance window');
  }
  const payload: unknown = JSON.parse(rawBody.toString('utf8'));
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
    throw new Error('Invalid webhook body');
  }
  const event = payload as Record<string, unknown>;
  if (event.schemaVersion !== 'klineo-token-liquidity-os-webhook-v1'
    || event.eventType !== headers.eventType
    || typeof event.occurredAt !== 'string'
    || Date.parse(event.occurredAt) !== signedMs
    || !event.data || typeof event.data !== 'object' || Array.isArray(event.data)) {
    throw new Error('Invalid webhook envelope');
  }
  return event;
}

const secret = 'whsec_local-example-only';
const headers: WebhookHeaders = {
  deliveryId: `whd_${'a'.repeat(32)}`,
  timestamp: '2026-09-30T09:00:00.000Z',
  signature: '',
  eventType: 'record.simulation_study.completed',
};
const body = Buffer.from(JSON.stringify({
  schemaVersion: 'klineo-token-liquidity-os-webhook-v1',
  eventType: headers.eventType,
  occurredAt: headers.timestamp,
  data: {},
}));
headers.signature = 'v1=' + createHmac('sha256', secret)
  .update(`${headers.timestamp}.${headers.deliveryId}.`)
  .update(body).digest('hex');
const now = Date.parse(headers.timestamp);
assert.equal(verifyWebhook(body, headers, secret, 86400, now).eventType, headers.eventType);
assert.throws(() => verifyWebhook(Buffer.concat([body, Buffer.from(' ')]), headers, secret, 86400, now));
assert.throws(() => verifyWebhook(body, headers, secret, 86400, now + 86401_000));
assert.throws(() => verifyWebhook(body, { ...headers, eventType: 'tampered.event' }, secret, 86400, now));
console.log('Signature, body, event and age checks passed');
```

### Python verification

Save this as `verify_webhook.py` and run `python3 verify_webhook.py` with Python 3.11 or later. It uses only the standard library.

```python
import hashlib
import hmac
import json
import math
import re
from datetime import datetime, timezone
from typing import Mapping


def verify_webhook(raw_body: bytes, headers: Mapping[str, str], secret: str,
                   max_age_seconds: float, now: datetime | None = None) -> dict:
    delivery_id = headers["deliveryId"]
    timestamp = headers["timestamp"]
    signature = headers["signature"]
    if (not re.fullmatch(r"whd_[0-9a-f]{32}", delivery_id)
            or not re.fullmatch(r"v1=[0-9a-f]{64}", signature)
            or not re.fullmatch(
                r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})",
                timestamp)):
        raise ValueError("Invalid webhook headers")
    if (not secret.startswith("whsec_") or not math.isfinite(max_age_seconds)
            or max_age_seconds <= 0):
        raise ValueError("Invalid receiver configuration")
    message = f"{timestamp}.{delivery_id}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature[3:]):
        raise ValueError("Invalid signature")
    signed_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
    current = now if now is not None else datetime.now(timezone.utc)
    age = (current - signed_at).total_seconds()
    if age < -60 or age > max_age_seconds:
        raise ValueError("Timestamp outside receiver acceptance window")
    event = json.loads(raw_body)
    if (not isinstance(event, dict)
            or event.get("schemaVersion") != "klineo-token-liquidity-os-webhook-v1"
            or event.get("eventType") != headers["eventType"]
            or not isinstance(event.get("occurredAt"), str)
            or datetime.fromisoformat(event["occurredAt"].replace("Z", "+00:00")) != signed_at
            or not isinstance(event.get("data"), dict)):
        raise ValueError("Invalid webhook envelope")
    return event


if __name__ == "__main__":
    secret = "whsec_local-example-only"
    headers = {
        "deliveryId": "whd_" + "a" * 32,
        "timestamp": "2026-09-30T09:00:00.000Z",
        "eventType": "record.simulation_study.completed",
    }
    body = json.dumps({
        "schemaVersion": "klineo-token-liquidity-os-webhook-v1",
        "eventType": headers["eventType"],
        "occurredAt": headers["timestamp"],
        "data": {},
    }, separators=(",", ":")).encode("utf-8")
    message = f'{headers["timestamp"]}.{headers["deliveryId"]}.'.encode() + body
    headers["signature"] = "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    now = datetime.fromisoformat(headers["timestamp"].replace("Z", "+00:00"))
    assert verify_webhook(body, headers, secret, 86400, now)["eventType"] == headers["eventType"]
    for candidate, changed_headers, changed_now in [
        (body + b" ", headers, now),
        (body, headers, datetime(2026, 10, 1, 9, 0, 1, tzinfo=timezone.utc)),
        (body, {**headers, "eventType": "tampered.event"}, now),
    ]:
        try:
            verify_webhook(candidate, changed_headers, secret, 86400, changed_now)
        except ValueError:
            pass
        else:
            raise AssertionError("Invalid delivery was accepted")
    print("Signature, body, event and age checks passed")
```

These examples check authenticity and a configurable age window. They do not implement durable deduplication or business processing. The 24-hour window used in the self-check is an example receiver policy, not a Klineo delivery guarantee.

## Handle retries and replay safely

The timestamp is the original event/outbox time. All retries of a delivery retain the same timestamp, ID, raw body, and signature. A five-minute freshness cutoff can therefore reject legitimate queued or retried deliveries. Set an acceptance window appropriate for your outage and queue-latency budget; provide API reconciliation when older events fall outside it.

After verifying the signature, atomically insert the delivery into a durable inbox with a unique key on `(subscription_id, delivery_id)`. Store the body hash as well so a reused ID with different content is rejected. Commit the inbox row and the work-to-process in the same transaction, then return `2xx`. An already accepted identical delivery should also receive `2xx`. Process the inbox asynchronously and make downstream effects idempotent. Retain accepted IDs for at least the entire period in which their signed timestamps can pass your acceptance window. An in-memory set disappears on restart and cannot coordinate multiple receivers.

Do not return success before durable acceptance: a lost notification cannot be recovered by a retry once Klineo receives `2xx`. If your inbox is unavailable, return a retryable status such as `503`. Keep response handling fast; the delivery worker has a 10-second request timeout.

| Response or failure | Delivery behavior |
| --- | --- |
| Any `2xx` | Delivery is acknowledged. |
| `400`, `401`, `403`, `404`, `410`, `422` | Delivery stops as a non-retryable failure. |
| Other non-`2xx`, including `429` and `5xx` | Delivery is retried within the attempt limit. |
| Network/TLS error or timeout | Delivery can be retried. |
| Invalid destination or subscription no longer current | Delivery fails closed. |

New subscriptions have `maximumAttempts: 8`. Retry delays use exponential backoff starting at 5 seconds, capped at 3,600 seconds; worker scheduling and outages can delay actual attempts. The worker does not use your `Retry-After` header to choose this delay. Exhausted or non-retryable jobs move to a dead-letter state. Delivery has at-least-once attempt semantics within these limits, not guaranteed delivery. Do not assume ordering across records or jobs. Use the API's current resource version to reconcile stale or out-of-order notifications.

## Destination safety and administration

Your destination must use HTTPS without embedded credentials or a URL fragment. Local hosts, private, loopback, link-local, and reserved addresses are rejected. Before delivery, DNS must resolve exclusively to public addresses. The worker pins those validated addresses for the socket while retaining the original HTTPS hostname and certificate validation, and checks the current subscription before each address attempt. It does not follow redirects. A public tunnel with valid HTTPS can support local development; `localhost` cannot be used directly.

Use `listWebhooks` / `list_webhooks` and `getWebhooks` / `get_webhooks` to inspect subscriptions. An authorized interactive session can call `supersedeWebhooks` / `supersede_webhooks` with a fresh [idempotency key and `If-Match`](/developers/idempotency) to pause or revoke one. The current API-key/OAuth scope mapping does not grant PATCH access. Allowed transitions are `ACTIVE → PAUSED`, `ACTIVE → REVOKED`, and `PAUSED → REVOKED`; create a new subscription to resume service or change its URL and event selection. Revocation prevents new authorized socket attempts; it cannot undo a notification your endpoint already accepted.

Never treat an event name, valid signature, or acknowledged delivery as execution authority. For execution outcomes, verify the relevant receipt, finality, reconciliation evidence, and current API state.
