Klineo/Docs
Open app ↗

Developers

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