Reference · v1

JSON API

Every endpoint speaks JSON over HTTPS. Drive the ledger and the workflow runtime directly, or use the typed Rust SDK that wraps this surface. The base URL is your Pillara deployment (e.g. https://api.pillara.dev).

Basics

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.

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

Idempotent, precisely Pillara posts to its own ledger idempotently — a retried or duplicated call never posts twice — and dispatches to external systems at most once per idempotency key. End-to-end exactly-once with a third party is not physically achievable — a provider can accept a call and drop the connection before acknowledging it — so Pillara carries your 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.

Basics

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.

envelope200
{
  "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.

filter dsl
// 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].

Basics

Errors

Errors are a JSON object with a single error string. Internal errors never leak details.

error body4xx / 5xx
{ "error": "transaction does not balance: debits 30 != credits 20" }
StatusMeaning
400Validation — malformed input (bad field, unknown account, invalid filter)
401Missing or unrecognized credential
404Resource not found within the tenant
409Conflict — idempotency key reused with a different payload; illegal state transition
422Unprocessable — an accounting invariant would break (unbalanced, overdraft, asset mismatch)
413Request body over the size limit
500Internal error ({"error":"internal server error"})
Onboarding

Tenants

POST/v1/tenantsunauthenticated

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.

request
POST /v1/tenants
{}
response200
{
  "tenant_id": "a1b2c3d4-0000-0000-0000-000000000001",
  "api_key":   "lo-9f2c8a71-4e0b-4c1a-9d3e-2b7f5a6c8d90"
}
Ledger

Assets

An asset is a unit of value with a fixed decimal precision. Accounts and entries are denominated in one asset.

POST/v1/assetsbearer
request
{ "code": "USD", "precision": 2 }
response200
{ "code": "USD", "precision": 2 }
GET/v1/assetsbearer

List the tenant's assets. GET /v1/assets/{code} fetches one.

Ledger

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.

POST/v1/accountsbearer
request
{
  "asset_code":   "USD",
  "no_overdraft": true,
  "metadata":     { "customer_id": "cus_42" }
}
response200
{
  "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
}
GET/v1/accounts/{id}/balancebearer

The account's derived balance. Add ?pit=<RFC3339> to read the balance as of a past instant.

response200
{
  "account_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "asset_code": "USD",
  "balance":    "70.00"
}
GET/v1/accountsbearer

List accounts (cursor envelope, supports filter). Related: GET /v1/accounts/{id}, /{id}/volumes, /{id}/entries, /count, PATCH /{id}/metadata, DELETE /{id}/metadata/{key}.

Ledger

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.

POST/v1/transactionsbearerIdempotency-Key

Add ?dry_run=true to validate and compute the result without persisting.

request
# Idempotency-Key: 1f0b…
{
  "entries": [
    { "account_id": "…dest",   "direction": "credit", "amount": "100.00" },
    { "account_id": "…source", "direction": "debit",  "amount": "100.00" }
  ],
  "metadata": { "ref": "invoice-42" }
}
response200
{
  "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/v1/transactions/{id}/reversebearer

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.

Ledger

Balances

GET/v1/balancesbearer

Net balances by asset, aggregated across the tenant's accounts (optional filter, pit).

response200
[ { "asset_code": "USD", "input": "100.00", "output": "30.00", "balance": "70.00" } ]
Workflows

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.

POST/v1/workflows/definitionsbearer
request
{
  "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 } }
    }
  }
}
response200
{ "name": "transfer", "version": 1,
  "definition_hash": "c3a1…", "status": "active", "graph": { /* … */ } }

Also: GET /v1/workflows/definitions, GET /v1/workflows/definitions/{name}/{version}.

Workflows

Runs

POST/v1/workflows/runsbearerIdempotency-Key

Start a run of a definition version. input is your caller-defined data. Returns the run as a graph, with its steps.

request
# Idempotency-Key: 9c1e…   X-Correlation-Id: 44aa… (optional)
{ "name": "transfer", "version": 1,
  "input": { "source": "…", "destination": "…", "amount": "30.00" } }
response200
{
  "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.

GET/v1/workflows/runs/{id}bearer

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.

POST/v1/workflows/runs/{id}/cancelbearer

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

Workflows

Signals & human tasks

POST/v1/workflows/runs/{id}/signalsbearerIdempotency-Key

Wake a run parked on a signal or wait_for_settlement node. Returns the updated run.

request
{ "name": "settlement", "payload": { "acquirer_ref": "abc123" } }
POST/v1/workflows/runs/{id}/tasks/{key}/decisionbearer

Decide a pending human_task (the {key} is the step key). The actor is the authenticated principal.

request
{ "decision": "approve", "reason": "cleared AML" }
Workflows

Callbacks & tick

POST/v1/workflows/callbacks/{token}bearer

Resume a run parked on an async action connector, supplying its result: { "result": { … } }. The {token} is the step's callback token.

POST/v1/workflows/tickbearer

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.

response200
{ "advanced": 3 }
Audit & ops

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.

GET/v1/logsbearer

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.

export entry
{ "id": 7, "type": "NEW_TRANSACTION",
  "data": { "transaction_id": "b2c3…", "asset_code": "USD" },
  "previous_hash": "a1…", "hash": "f9…", "created_at": "2026-07-05T12:00:00Z" }
Audit & ops

Observability

EndpointReturns
GET /v1/observability/money-waitingWaiting runs and open holds — where money is currently paused
GET /v1/observability/failuresFailed / 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}/stateCompleted steps reconstructed as of ?pit=
GET /v1/observability/accounts/{id}/snapshotAn account's state as of ?pit=
Audit & ops

Metrics

GET/v1/metricsbearer

Prometheus-format text (not JSON): run/step counts by state, timers, reservations, scoped to the tenant.

text/plain200
# HELP workflow_runs Runs by state
workflow_runs{state="completed"} 128
workflow_runs{state="waiting"} 4
Try it

End to end with curl

shell
# 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"}]}'