> ## Documentation Index
> Fetch the complete documentation index at: https://docs.keystoneos.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Event Catalog

> Complete reference for every webhook event type, payload structure, and recommended handling.

This page is a complete reference for the webhook events that KeyStone dispatches. For setup instructions, signature verification, and best practices, see the [Webhooks guide](/guides/webhooks).

## Payload structure

Every webhook delivery follows the same envelope format:

```json theme={null}
{
  "event": "settlement.state.awaiting_deposits",
  "data": {
    "settlement_id": "550e8400-e29b-41d4-a716-446655440000",
    "state": "AWAITING_DEPOSITS"
  }
}
```

| Field                | Type          | Description                                                  |
| -------------------- | ------------- | ------------------------------------------------------------ |
| `event`              | string        | Dot-delimited event name (e.g. `settlement.state.finalized`) |
| `data.settlement_id` | string (UUID) | The settlement this event relates to                         |
| `data.state`         | string        | The settlement state that triggered the event                |

The payload is intentionally minimal. When your handler needs more context (parties, legs, timestamps, transitions), fetch the settlement with `GET /v1/settlements/{settlement_id}` or its event history with `GET /v1/settlements/{settlement_id}/events`.

## When webhooks fire

Settlement webhooks are dispatched by KeyStone's settlement engine: each time an engine run advances a settlement and leaves it in a **new** state, one event fires, named for the state the run ended in (`settlement.state.` plus the lowercased state name).

Two consequences worth designing around:

* **Intermediate states are skipped.** A single engine run can pass through several states; only the final one emits an event. On the happy path, compliance passes and on-chain registration completes in one run, so the first event you receive is `awaiting_deposits` (not `instructed` or `compliance_cleared`).
* **States written outside the engine do not emit their own event.** On-chain outcomes (`SETTLED`, `ROLLED_BACK`, `TIMED_OUT`) and operator actions are applied directly and are visible immediately via the API; a webhook follows only where the engine runs afterwards (execution is followed by an engine run that finalizes the settlement and emits `finalized`). See [states without their own webhook](#states-without-their-own-webhook) below.

***

## Events you will receive

### `settlement.state.awaiting_deposits`

Fired when compliance has passed and the settlement is registered on-chain, ready for escrow deposits. On the happy path this is the **first** event for a new settlement.

```json theme={null}
{
  "event": "settlement.state.awaiting_deposits",
  "data": {
    "settlement_id": "550e8400-e29b-41d4-a716-446655440000",
    "state": "AWAITING_DEPOSITS"
  }
}
```

<Tip>
  **What to do:** This is the key action event. Trigger your deposit workflow - instruct your custody provider (e.g. Fireblocks) to send assets to the escrow address, or notify the trader that a deposit is required.
</Tip>

***

### `settlement.state.compliance_checking`

Fired when screening **flags a party** and the settlement parks in `COMPLIANCE_CHECKING` awaiting a manual compliance decision. (When screening passes outright, the settlement continues to `AWAITING_DEPOSITS` in the same engine run and no `compliance_checking` event fires.)

```json theme={null}
{
  "event": "settlement.state.compliance_checking",
  "data": {
    "settlement_id": "550e8400-e29b-41d4-a716-446655440000",
    "state": "COMPLIANCE_CHECKING"
  }
}
```

<Tip>
  **What to do:** Route the settlement to your compliance team. Submit the outcome with `POST /v1/settlements/{settlement_id}/compliance-decision` - approving lets the settlement continue (you will receive `awaiting_deposits` when it registers on-chain); rejecting ends it in `REJECTED`.
</Tip>

***

### `settlement.state.rejected`

Fired when compliance screening fails outright and the settlement is auto-rejected before any deposits. Nothing is locked on-chain at this point. (A settlement rejected by a manual compliance decision reaches the same `REJECTED` state - poll or check the settlement after submitting a decision.)

```json theme={null}
{
  "event": "settlement.state.rejected",
  "data": {
    "settlement_id": "550e8400-e29b-41d4-a716-446655440000",
    "state": "REJECTED"
  }
}
```

<Tip>
  **What to do:** Notify the involved parties that the settlement cannot proceed. Do not expose the specific compliance failure reason to end users - log it internally for your compliance team. This is a terminal state.
</Tip>

***

### `settlement.state.compliance_cleared`

Fired only when compliance passed but the engine run halted at `COMPLIANCE_CLEARED` (for example, on-chain setup needed a retry). Normally you will not see this event - the settlement continues to `AWAITING_DEPOSITS` in the same run.

<Tip>
  **What to do:** Informational. The engine retries setup and you will receive `awaiting_deposits` when registration completes.
</Tip>

***

### `settlement.state.finalized`

Fired when the settlement has executed on-chain and the record is finalized. This is the success event.

```json theme={null}
{
  "event": "settlement.state.finalized",
  "data": {
    "settlement_id": "550e8400-e29b-41d4-a716-446655440000",
    "state": "FINALIZED"
  }
}
```

<Tip>
  **What to do:** Update your OMS to mark the trade as settled. Notify the trader that their assets have been delivered. This is a terminal state.
</Tip>

***

### `test.ping`

Fired when you test a webhook endpoint via the Dashboard or the API. Used to verify your endpoint is reachable and correctly verifying signatures.

```json theme={null}
{
  "event": "test.ping",
  "data": {
    "message": "Webhook test from KeyStone"
  }
}
```

<Tip>
  **What to do:** Return a `200` response. Note the `data` object carries no settlement fields - handle `test.ping` before any settlement-specific parsing.
</Tip>

***

## States without their own webhook

The `settlement.state.<state>` namespace covers every state in the [settlement state machine](/concepts/state-machine), but five states are currently never delivered as webhooks:

| State           | How it is reached                            | How to track it                                                           |
| --------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
| `INSTRUCTED`    | Settlement created from matched instructions | The instruction submission response carries the `settlement_id`           |
| `SETTLED`       | On-chain `SettlementExecuted` event          | You receive `finalized` moments later; or poll `GET /v1/settlements/{id}` |
| `ROLLED_BACK`   | On-chain abort                               | Poll `GET /v1/settlements/{id}` or the events endpoint                    |
| `TIMED_OUT`     | On-chain timeout claim                       | Poll `GET /v1/settlements/{id}` or the events endpoint                    |
| `MANUAL_REVIEW` | Operator escalation of a stuck settlement    | Poll, or watch the activity feed (`GET /v1/activity`)                     |

<Warning>
  Failure outcomes (`ROLLED_BACK`, `TIMED_OUT`) currently arrive **without a webhook**. If your integration must react to failed settlements promptly, poll `GET /v1/settlements/{settlement_id}` for any settlement that has passed its `timeout_at` without a `finalized` event.
</Warning>

***

## Event filtering

When registering a webhook endpoint, you specify which events to receive using glob-style patterns.

| Pattern                      | What it matches                        |
| ---------------------------- | -------------------------------------- |
| `*`                          | All events (settlement state and test) |
| `settlement.*`               | All settlement events                  |
| `settlement.state.*`         | All state transition events            |
| `settlement.state.finalized` | Only the finalized event               |
| `test.*`                     | Only test events                       |

You can subscribe to multiple patterns per endpoint:

```json theme={null}
{
  "url": "https://your-platform.com/webhooks/keystone",
  "event_types": [
    "settlement.state.awaiting_deposits",
    "settlement.state.finalized",
    "settlement.state.rejected",
    "settlement.state.compliance_checking"
  ]
}
```

<Info>
  Subscribing to `settlement.state.*` is the safe default: you receive every event that fires today and automatically pick up any states that gain webhooks later.
</Info>

***

## Signature verification

Every delivery includes an `X-Keystone-Signature` header containing an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret.

```typescript theme={null}
import crypto from "crypto";
import type { Request, Response } from "express";

function verifyWebhookSignature(
  body: string,
  signature: string,
  secret: string,
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  );
}

app.post("/webhooks/keystone", (req: Request, res: Response) => {
  const signature = req.headers["x-keystone-signature"] as string;
  const raw = JSON.stringify(req.body);

  if (!verifyWebhookSignature(raw, signature, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }

  // Process the event
  handleEvent(req.body);
  res.status(200).send("ok");
});
```

<Warning>
  Always use constant-time comparison (`crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python) to prevent timing attacks. Never compare signatures with `===` or `==`.
</Warning>

***

## Secret rotation

When you rotate a webhook secret, KeyStone provides a 24-hour grace period where both the old and new secrets are valid. During this window:

* `X-Keystone-Signature` is signed with the **new** secret
* `X-Keystone-Signature-Previous` is signed with the **old** secret

Your verification logic should check both headers during the transition:

```typescript theme={null}
function verifyWithRotation(
  body: string,
  headers: Record<string, string>,
  currentSecret: string,
  previousSecret?: string,
): boolean {
  const primarySig = headers["x-keystone-signature"];
  if (verifyWebhookSignature(body, primarySig, currentSecret)) {
    return true;
  }

  // During rotation, fall back to the previous secret
  const previousSig = headers["x-keystone-signature-previous"];
  if (previousSecret && previousSig) {
    return verifyWebhookSignature(body, previousSig, previousSecret);
  }

  return false;
}
```

After 24 hours, the old secret is discarded and only `X-Keystone-Signature` is sent.

***

## Retry behavior

Each delivery is attempted with up to **3 retries in quick succession** (short, jittered backoff; a `Retry-After` header is honored on `429` responses). A non-2xx response or a timeout after the final attempt marks the delivery as `failed` in the delivery log - there is no delayed redelivery queue.

Because failed deliveries are not replayed later, treat the delivery log as your recovery tool: inspect failures in the [Dashboard](https://app.keystoneos.xyz) or via the delivery log API, and reconcile missed events by fetching the settlement's current state.

<Warning>
  Your endpoint must respond within **10 seconds**. If processing takes longer, return `200` immediately and handle the event asynchronously in a background job.
</Warning>

***

## Idempotency

The same event may be delivered more than once. Your webhook handler **must be idempotent**.

Recommended patterns:

1. **Check current state before acting.** If you receive `settlement.state.finalized` but the trade is already marked as settled in your system, skip processing.
2. **Use the settlement ID plus state as a deduplication key.** Track which settlement/state combinations you have already processed.
3. **Make downstream calls idempotent.** If your handler triggers a transfer or notification, ensure the downstream system also handles duplicates.

```typescript theme={null}
app.post("/webhooks/keystone", async (req: Request, res: Response) => {
  const { event, data } = req.body;

  // Deduplicate: check if we already processed this state for this settlement
  const key = `${data.settlement_id}:${data.state}`;
  const alreadyProcessed = await redis.get(`webhook:processed:${key}`);

  if (alreadyProcessed) {
    return res.status(200).send("ok"); // Already handled, return success
  }

  // Process the event
  await handleEvent(event, data);

  // Mark as processed (TTL of 7 days)
  await redis.set(`webhook:processed:${key}`, "1", "EX", 604800);

  res.status(200).send("ok");
});
```

***

## Full handler example

A complete webhook handler in TypeScript that covers signature verification, idempotency, and event routing:

```typescript theme={null}
import crypto from "crypto";
import type { Request, Response } from "express";

const WEBHOOK_SECRET = process.env.KEYSTONE_WEBHOOK_SECRET!;

function verifySignature(body: string, signature: string): boolean {
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(body)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  );
}

app.post("/webhooks/keystone", async (req: Request, res: Response) => {
  // 1. Verify signature
  const signature = req.headers["x-keystone-signature"] as string;
  if (!signature || !verifySignature(JSON.stringify(req.body), signature)) {
    return res.status(401).send("Invalid signature");
  }

  // 2. Return 200 immediately
  res.status(200).send("ok");

  // 3. Process asynchronously
  const { event, data } = req.body;

  switch (event) {
    case "settlement.state.awaiting_deposits":
      await triggerDepositWorkflow(data.settlement_id);
      break;

    case "settlement.state.compliance_checking":
      await routeToComplianceReview(data.settlement_id);
      break;

    case "settlement.state.finalized":
      await markTradeAsSettled(data.settlement_id);
      await notifyTrader(data.settlement_id, "Your settlement is complete.");
      break;

    case "settlement.state.rejected":
      await markTradeAsFailed(data.settlement_id);
      await escalateToCompliance(data.settlement_id);
      break;

    case "test.ping":
      console.log("Webhook test received");
      break;

    default:
      // Future states may gain webhooks - log and fetch the settlement.
      console.log(`Unhandled event: ${event}`);
  }
});
```

<Info>
  For SDK-based webhook handling with built-in signature verification, see the [TypeScript SDK webhooks guide](/sdks/typescript/webhooks) or [Python SDK webhooks guide](/sdks/python/webhooks).
</Info>
