Conventions
Requests and responses are application/json unless noted (/v1/metrics returns Prometheus text). All money amounts are exact decimals encoded as JSON strings (e.g. "100.00"), never floats.
Authentication
Every /v1 endpoint except tenant creation requires a bearer credential — either a tenant API key or a JWT. The tenant is derived from the credential; there is no tenant header, and one tenant's data is never reachable with another's.
Authorization: Bearer lo-3f8c… # API key, or a JWT
Idempotency
Mutations that move money or start work require an Idempotency-Key header (a UUID): committing/reversing a transaction, starting a run, and delivering a signal. Replaying the same key with the same payload returns the original result; a different payload under the same key is a 409 conflict.
Idempotency-Key (or a key derived from the step) downstream and relies on the provider to deduplicate. Always enable idempotency with your banking and payment providers.Correlation
Optional X-Correlation-Id and X-Request-Id headers (UUIDs) are recorded on a run for the audit trail and are searchable via the run filter.
Pagination & filtering
List endpoints return a keyset-cursor envelope. Follow the opaque next cursor rather than using numeric offsets; next is null on the last page. Query params: cursor, page_size, and filter.
{
"cursor": {
"next": "eyJjcmVhdGVkX2F0Ijoi…",
"previous": null,
"data": [ /* rows */ ]
}
}
The filter param is a JSON object of one operator. Leaves compare a field to a value; and/or nest.
// match a field { "match": { "asset": "USD" } } // compare (gt / lt), or test presence (exists) { "and": [ { "match": { "state": "waiting" } }, { "exists": "input[customer_id]" } ] }
Ledger fields: asset, address, metadata[key]. Run fields: state, definition, correlation_id, request_id, idempotency_key, actor, input[key], context[key].
Errors
Errors are a JSON object with a single error string. Internal errors never leak details.
{ "error": "transaction does not balance: debits 30 != credits 20" }
| Status | Meaning |
|---|---|
| 400 | Validation — malformed input (bad field, unknown account, invalid filter) |
| 401 | Missing or unrecognized credential |
| 404 | Resource not found within the tenant |
| 409 | Conflict — idempotency key reused with a different payload; illegal state transition |
| 422 | Unprocessable — an accounting invariant would break (unbalanced, overdraft, asset mismatch) |
| 413 | Request body over the size limit |
| 500 | Internal error ({"error":"internal server error"}) |
Tenants
Bootstrap a fresh tenant and its first principal. Returns the tenant id and the plaintext API key once — only its hash is stored, so capture it now. A caller may claim an unused tenant_id, but never one that already has a principal.
POST /v1/tenants
{}{
"tenant_id": "a1b2c3d4-0000-0000-0000-000000000001",
"api_key": "lo-9f2c8a71-4e0b-4c1a-9d3e-2b7f5a6c8d90"
}Assets
An asset is a unit of value with a fixed decimal precision. Accounts and entries are denominated in one asset.
{ "code": "USD", "precision": 2 }{ "code": "USD", "precision": 2 }List the tenant's assets. GET /v1/assets/{code} fetches one.
Accounts
An account holds a balance in one asset. Optional constraints: no_overdraft (never negative), credit_limit (may go negative to -limit), and max_balance (upper cap). Balances are derived from entries, not stored mutably.
{
"asset_code": "USD",
"no_overdraft": true,
"metadata": { "customer_id": "cus_42" }
}{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"asset_code": "USD",
"metadata": { "customer_id": "cus_42" },
"no_overdraft": true,
"frozen": false,
"max_balance": null,
"credit_limit": null
}The account's derived balance. Add ?pit=<RFC3339> to read the balance as of a past instant.
{
"account_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"asset_code": "USD",
"balance": "70.00"
}List accounts (cursor envelope, supports filter). Related: GET /v1/accounts/{id}, /{id}/volumes, /{id}/entries, /count, PATCH /{id}/metadata, DELETE /{id}/metadata/{key}.
Transactions
A transaction is an immutable set of balanced double-entry lines — sum(debits) == sum(credits), all in one asset. Supply raw entries, postings shorthand (a source → destination pair), or both. The ledger is append-only; corrections are made with reverse.
Add ?dry_run=true to validate and compute the result without persisting.
# Idempotency-Key: 1f0b… { "entries": [ { "account_id": "…dest", "direction": "credit", "amount": "100.00" }, { "account_id": "…source", "direction": "debit", "amount": "100.00" } ], "metadata": { "ref": "invoice-42" } }
{
"id": "b2c3…",
"idempotency_key": "1f0b…",
"asset_code": "USD",
"reverses_transaction_id": null,
"metadata": { "ref": "invoice-42" },
"entries": [
{ "id": "…", "transaction_id": "b2c3…", "account_id": "…dest",
"direction": "credit", "amount": "100.00", "created_at": "2026-07-05T12:00:00Z" },
{ "id": "…", "transaction_id": "b2c3…", "account_id": "…source",
"direction": "debit", "amount": "100.00", "created_at": "2026-07-05T12:00:00Z" }
]
}A replayed key adds "replayed": true; a dry run adds "dry_run": true.
Post a compensating transaction that swaps the direction of every entry. Idempotent — a second reverse returns the existing reversal. Also: GET /v1/transactions (list), /{id}, /count, metadata PATCH/DELETE.
Balances
Net balances by asset, aggregated across the tenant's accounts (optional filter, pit).
[ { "asset_code": "USD", "input": "100.00", "output": "30.00", "balance": "70.00" } ]Definitions
A workflow definition is a versioned graph of nodes: primitive (a money op), action (an external connector), wait, signal, wait_for_settlement, human_task, branch, and terminal. Args interpolate from {{input.x}} and {{steps.key.result.y}}. Set "compensate_on_failure": true to unwind via the compensation saga on failure.
{
"name": "transfer", "version": 1,
"graph": {
"start": "move",
"steps": {
"move": { "type": "primitive", "op": "transfer",
"args": { "source": "{{input.source}}",
"destination": "{{input.destination}}",
"amount": "{{input.amount}}" },
"next": "done" },
"done": { "type": "terminal", "result": { "ok": true } }
}
}
}{ "name": "transfer", "version": 1,
"definition_hash": "c3a1…", "status": "active", "graph": { /* … */ } }Also: GET /v1/workflows/definitions, GET /v1/workflows/definitions/{name}/{version}.
Runs
Start a run of a definition version. input is your caller-defined data. Returns the run as a graph, with its steps.
# Idempotency-Key: 9c1e… X-Correlation-Id: 44aa… (optional) { "name": "transfer", "version": 1, "input": { "source": "…", "destination": "…", "amount": "30.00" } }
{
"id": "d4e5…",
"definition_name": "transfer", "definition_version": 1, "definition_hash": "c3a1…",
"state": "created", "cursor": null, "attempt_epoch": 0,
"input": { /* … */ }, "context": {}, "result": null, "error": null,
"correlation_id": "44aa…", "request_id": null, "actor": "…pid",
"steps": []
}States: created → running → waiting → completed, or on failure running → failed / compensating → compensated.
Fetch a run as a graph (same shape as above, with populated steps). GET /v1/workflows/runs lists runs (cursor, state, filter); GET /v1/workflows/runs/{id}/history returns the ordered event log.
Cancel a run, unwinding it through compensation. Body: { "reason": "…" } (optional). Also POST /{id}/retry (re-run a failed run) and POST /{id}/replay with { "from": "step_key" }.
Signals & human tasks
Wake a run parked on a signal or wait_for_settlement node. Returns the updated run.
{ "name": "settlement", "payload": { "acquirer_ref": "abc123" } }Decide a pending human_task (the {key} is the step key). The actor is the authenticated principal.
{ "decision": "approve", "reason": "cleared AML" }Callbacks & tick
Resume a run parked on an async action connector, supplying its result: { "result": { … } }. The {token} is the step's callback token.
Drive the durable runner for the caller's tenant once (fire due timers, advance runnable runs). Optional ?max_ticks=N. In production the background worker does this automatically; tick is for tests and manual stepping.
{ "advanced": 3 }Audit log
An append-only, hash-chained record of every state change. Each entry chains the previous one's hash, so any tampering is detectable.
List the tenant's log newest-first (cursor envelope). GET /v1/logs/export returns the full ordered chain including hashes; POST /v1/logs/import verifies an exported chain against the tenant's stored log.
{ "id": 7, "type": "NEW_TRANSACTION",
"data": { "transaction_id": "b2c3…", "asset_code": "USD" },
"previous_hash": "a1…", "hash": "f9…", "created_at": "2026-07-05T12:00:00Z" }Observability
| Endpoint | Returns |
|---|---|
| GET /v1/observability/money-waiting | Waiting runs and open holds — where money is currently paused |
| GET /v1/observability/failures | Failed / compensating runs with their error and which steps failed |
| GET /v1/observability/runs/{id} | Run detail: duration, retries, current step, pending timers, compensation chain |
| GET /v1/observability/runs/{id}/state | Completed steps reconstructed as of ?pit= |
| GET /v1/observability/accounts/{id}/snapshot | An account's state as of ?pit= |
Metrics
Prometheus-format text (not JSON): run/step counts by state, timers, reservations, scoped to the tenant.
# HELP workflow_runs Runs by state workflow_runs{state="completed"} 128 workflow_runs{state="waiting"} 4
End to end with curl
# 1) provision a tenant (grab the api_key from the response) curl -sX POST $URL/v1/tenants -H 'content-type: application/json' -d '{}' # 2) create an asset curl -sX POST $URL/v1/assets \ -H "authorization: Bearer $KEY" -H 'content-type: application/json' \ -d '{"code":"USD","precision":2}' # 3) commit a balanced transaction (idempotency key required) curl -sX POST $URL/v1/transactions \ -H "authorization: Bearer $KEY" -H "idempotency-key: $(uuidgen)" \ -H 'content-type: application/json' \ -d '{"postings":[{"source":"'$SRC'","destination":"'$DST'","amount":"100.00"}]}'