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

# Provider Callbacks

> How external providers report an outcome back to KeyStone: the signature scheme, what a callback may touch, and exactly which status values the API accepts.

External providers (custody, payment, or any off-chain service a settlement template parks on) report the outcome of an action by POSTing to `/v1/callbacks/{provider}`. The callback advances the settlement's state machine, so everything on this page - the signature, the acceptance window, what your callback may touch, and how your `status` value is read - is part of the contract, not an implementation detail.

<Note>
  This is the inbound direction: a provider calls KeyStone. For the outbound direction - KeyStone notifying your platform as settlements progress - see [Webhooks](/guides/webhooks). Both directions share one signature scheme, so if you have implemented webhook verification you already know how to sign a callback.
</Note>

## Request

```http theme={null}
POST /v1/callbacks/{provider}
Content-Type: application/json
X-Callback-Signature: t=1753190000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

```json theme={null}
{
  "settlement_id": "3f8c1e0a-9d4b-4f2e-9a11-2c5b7d8e0f31",
  "status": "success",
  "external_reference": "provider-side-id-123",
  "payload": { "anything": "the provider wants to send" }
}
```

| Field                | Type   | Required | Description                                                                                 |
| -------------------- | ------ | -------- | ------------------------------------------------------------------------------------------- |
| `settlement_id`      | uuid   | yes      | The settlement the callback concerns.                                                       |
| `status`             | string | yes      | The provider's outcome, at most 256 characters. Interpreted as described below.             |
| `external_reference` | string | no       | The provider's own identifier, at most 256 characters, recorded on the settlement timeline. |
| `payload`            | object | no       | Free-form provider data. Accepted for forward compatibility and not recorded.               |

`{provider}` is the provider name KeyStone registered for you when your signing secret was issued: lowercase letters, digits, underscore and hyphen. It selects the secret your signature is verified against.

## Signing a callback

Each provider holds its own signing secret, issued by KeyStone out of band. Sign every request:

* `t` is the current Unix timestamp in seconds.
* `v1` is `HMAC_SHA256(secret, "{t}." + body)` in lowercase hex, where `body` is the exact raw request bytes.
* Send `X-Callback-Signature: t=<t>,v1=<v1>`.

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, time

  def sign_callback(body: bytes, secret: str) -> str:
      t = int(time.time())
      v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
      return f"t={t},v1={v1}"
  ```

  ```typescript Node.js theme={null}
  import { createHmac } from "node:crypto";

  function signCallback(body: Buffer, secret: string): string {
    const t = Math.floor(Date.now() / 1000);
    const v1 = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
    return `t=${t},v1=${v1}`;
  }
  ```
</CodeGroup>

Three rules keep signatures verifiable:

* **Sign the exact bytes you send.** Serialize the body once, sign those bytes, send those bytes. Re-serializing between signing and sending changes whitespace or key order and the signature stops matching.
* **Stay inside the acceptance window.** The timestamp must be within 300 seconds of KeyStone's clock, in either direction - stale and future-dated signatures are both refused. If your clock drifts more than a few minutes, fix the clock; the window is not configurable per provider.
* **Sign at send time, retries included.** A retry is a new request: sign it freshly rather than replaying an old header, or the retry dies with the window.

A missing header returns `401 MISSING_CALLBACK_SIGNATURE`. Everything else that fails authentication - an unregistered provider name, a timestamp outside the window, a signature that does not verify - returns one uniform `401 INVALID_CALLBACK_SIGNATURE`, deliberately without saying which.

### Secret rotation

KeyStone rotates your secret by activating the new one alongside the old: during the rotation window a signature under either secret verifies, so you switch your signer whenever suits you inside the window that was agreed when the rotation was scheduled. After the window the old secret is removed and signatures under it return `401`.

## What a callback may touch

A settlement accepts your callback only while it is **parked on you**: its current state must be one whose settlement template binds an action with `resolution: "webhook"` naming your provider. That binding is pinned when the settlement is created, so it never changes mid-flight.

Concretely:

* While a settlement sits in a state parked on your provider, your callback resolves it - success advances along the machine, failure routes to that state's failure target.
* A callback against a settlement in any other state - a state parked on a different provider, a state the engine or an operator resolves, or a state with no binding at all - is refused with `409 NO_VALID_TRANSITION`. Retrying will not change it; the settlement is simply not waiting on you.
* Compliance gates are closed to callbacks in both directions, whatever the status: a state with a `compliance_check` action bound is resolved by the engine's own screening or through the compliance-decision endpoint. See [Compliance](/concepts/compliance).

This scoping is why callbacks are per-provider signed: your secret acts on exactly the settlements whose product flow parks on you, and only while parked there.

## Accepted status values

`status` is matched after trimming surrounding whitespace and lowercasing, so `FAILED`, `Failed` and `failed` are the same value.

| Vocabulary    | Values                                                                                  | Effect                                                                                                                               |
| ------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Success       | `complete`, `completed`, `ok`, `succeeded`, `success`                                   | The settlement advances along its machine.                                                                                           |
| Failure       | `canceled`, `cancelled`, `declined`, `denied`, `error`, `failed`, `failure`, `rejected` | The settlement moves to the machine's failure target for its current state.                                                          |
| Anything else | any other value, including an empty string                                              | The settlement does **not** advance. It parks in `MANUAL_REVIEW` for an operator, with the status you sent recorded on the timeline. |

Every failure word is read as a decided outcome, `error` included. If your provider retries an errored action, report the outcome once it is decided rather than reporting the transient error.

<Warning>
  Anything KeyStone cannot read is never treated as a success. A status outside both vocabularies may well be a failure reported in a spelling this API does not carry, and advancing a settlement on that reading is the one outcome that cannot be undone. If your provider's vocabulary is not listed above, tell us before you go live rather than discovering it as a parked settlement.
</Warning>

A parked settlement returns `200` with `"state": "MANUAL_REVIEW"`: the callback was accepted and recorded, and retrying it returns the same `200` without recording anything further. Any URL embedded in a recorded value is reduced to its origin - scheme, host, port - before it is written, so error text pasted into `status` cannot publish a credentialed endpoint onto the settlement timeline. Resolution is an operator action, not a provider one.

## Response

```json theme={null}
{
  "settlement_id": "3f8c1e0a-9d4b-4f2e-9a11-2c5b7d8e0f31",
  "state": "AWAITING_DEPOSITS"
}
```

`state` is the settlement's state after the callback, which is the target for a success, the failure target for a failure, and `MANUAL_REVIEW` for a status in neither vocabulary.

One success is different: a success that moves a settlement out of `COMPLIANCE_CLEARED` on an on-chain settlement is handed to the settlement engine, which performs the on-chain setup and then advances. That response carries `"state": "COMPLIANCE_CLEARED"` - the current state, not the target - and the transition lands moments later. Do not assert that a success response's `state` equals the target.

## Retries and idempotency

Retrying a callback never applies a transition twice.

* **Re-sending the exact signed bytes** of a delivery that was accepted, inside the acceptance window, returns `200` with the settlement's current state and writes nothing. This is what happens when you lose our `200` and retry the identical request - the answer you get is the one you missed.
* A settlement already in the state your callback produces returns `200` without re-processing. This covers the park: retrying the callback that parked a settlement returns `200` with `"state": "MANUAL_REVIEW"` again, and records nothing further.
* A settlement in a terminal state (`FINALIZED`, `REJECTED`, `ROLLED_BACK`, `TIMED_OUT`) returns `200` for any callback. Terminal settlements are never rejected, because that would put a retrying provider into a loop.
* A settlement in `MANUAL_REVIEW` accepts only a status that parks - one in neither vocabulary. A failure status against it is refused with `409`: the park is operator-owned, and a failure word would route the settlement somewhere new rather than land where it already is.
* A `4xx` means stop retrying, with one exception: `409 SETTLEMENT_BUSY` means the settlement is momentarily held or its on-chain setup is being recorded; retry it after a short delay, signed freshly. Every other refusal is deterministic and no number of re-signed retries will change it. A `401` on a request that verified locally usually means your clock drifted past the window or your secret was rotated out - re-sign and check with KeyStone, do not blind-retry.

## When a callback is refused

| Status | Code                           | Meaning                                                                                                                                                                                                                                                                                                                            |
| ------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 401    | `MISSING_CALLBACK_SIGNATURE`   | No `X-Callback-Signature` header.                                                                                                                                                                                                                                                                                                  |
| 401    | `INVALID_CALLBACK_SIGNATURE`   | Unregistered provider, timestamp outside the window, or a signature that verifies under none of the provider's active secrets. Deliberately one answer for all three.                                                                                                                                                              |
| 404    | `SETTLEMENT_NOT_FOUND`         | No settlement with that id.                                                                                                                                                                                                                                                                                                        |
| 409    | `NO_VALID_TRANSITION`          | The settlement's state does not accept this callback: it is not parked on your provider, it is owned by on-chain events or by an operator, the machine forbids the transition, or the transition would cross a compliance gate.                                                                                                    |
| 409    | `SETTLEMENT_STATE_CONFLICT`    | The callback arrived for a state this provider's flow does not expect.                                                                                                                                                                                                                                                             |
| 409    | `SETTLEMENT_BUSY`              | Another operation holds the settlement, or its on-chain setup is being recorded. Retry with backoff: the holder is bounded by its own chain-call timeouts, and once it commits the settlement is free, or in `AWAITING_DEPOSITS` where the retry answers `200` as a no-op, or back where the callback applies if the setup halted. |
| 422    | `CANNOT_RESOLVE_TARGET_STATE`  | No next state can be resolved from the settlement's current state.                                                                                                                                                                                                                                                                 |
| 422    | `TEMPLATE_NOT_FOUND`           | The settlement's pinned template config cannot be read, so what the callback may touch cannot be determined.                                                                                                                                                                                                                       |
| 503    | `CALLBACK_AUTH_NOT_CONFIGURED` | No callback provider is registered on this deployment at all.                                                                                                                                                                                                                                                                      |
