REST API
Every route of the Solva orchestrator, with authentication, request, response, status codes, errors and idempotency.
The orchestrator serves a JSON API over HTTP. Use it to run a cycle, read the proofs it has stored, fetch a holder's inclusion path, and provision tenants. The routes are defined in services/orchestrator/internal/controller/http/router.go.
A local stack listens on http://localhost:8080 (ORCH_HTTP_ADDR, default :8080).
Conventions
Tenants. A tenant is one institution. Every tenant id is a UUID, for example 11111111-1111-1111-1111-111111111111. A value that is not a UUID fails inside the database layer, so it surfaces as a 500 or a 501, not a 400.
Money. Every amount is a decimal string of integer minor units, such as "9000000". Amounts are never JSON numbers.
Hashes. Hashes are hex strings. root_hash on a proof has no 0x prefix. Hashes in an inclusion response have one.
Errors. Every error has the same body:
{ "error": "tenant_id is required" }Authentication. There are three levels.
| Level | Routes | Header |
|---|---|---|
| API token | POST /v1/cycles | Authorization: Bearer <ORCH_API_TOKEN> |
| Admin token | POST /v1/tenants, PUT /v1/tenants/{id}/contract, PUT /v1/tenants/{id}/wallet | Authorization: Bearer <ORCH_ADMIN_TOKEN> |
| None | every GET route | none |
A token is set by its environment variable on the orchestrator. When the variable is empty, that check is off. Local runs from just e2e and just demo leave both empty. Never run a shared orchestrator without them.
Run a cycle
POST /v1/cyclesRuns one full cycle for a tenant: read and verify reserves, load liabilities, prove, publish to the tenant's registry, store. The call is synchronous and returns when the cycle has published or failed. A cycle takes as long as proving takes, so give your client a long timeout; the e2e script allows 300 seconds.
Auth: API token.
Headers
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer <token> |
Content-Type | yes | application/json |
Idempotency-Key | no | Any string unique to this attempt. |
Body
| Field | Type | Required | Value |
|---|---|---|---|
tenant_id | string | yes | The tenant's UUID. |
curl -X POST http://localhost:8080/v1/cycles \
-H "Authorization: Bearer $ORCH_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: kestrel-2026-11-04" \
-d '{"tenant_id":"11111111-1111-1111-1111-111111111111"}'Response
{ "status": "accepted" }The response does not carry the proof. Read it with GET /v1/proofs/latest.
Status codes
| Status | Body | Meaning |
|---|---|---|
| 202 | {"status":"accepted"} | The cycle ran and the proof is published and stored. |
| 200 | {"status":"already processed"} | This Idempotency-Key was used before. Nothing ran. |
| 400 | invalid request body | The body is not JSON. |
| 400 | tenant_id is required | The body has no tenant_id. |
| 401 | unauthorized | The API token is missing or wrong. |
| 409 | cycle already in progress | Another cycle for this tenant holds the lock. Nothing ran. |
| 422 | reserves are below liabilities; no proof generated | Reserves do not cover liabilities. Nothing is proven or published. |
| 500 | the proof cycle could not complete | Any other failure, such as a bad reserve signature, a growth-bound failure, an unprovisioned tenant, a contract error or a network error. The orchestrator log has the cause. |
Idempotency
A cycle is guarded twice. First, a per-tenant lock in Redis, held for up to 15 minutes, stops two cycles running at once and returns 409. Second, the Idempotency-Key is claimed in Postgres before any work starts. A key that was claimed before returns 200 already processed, even if Redis has restarted.
The claim happens before the cycle runs, so a key is used up even when its cycle fails. To retry a failed cycle, send a new key. Without the header, the orchestrator generates a random key, so every call runs.
Get the latest proof
GET /v1/proofs/latest?tenant_id={tenant_id}Returns the tenant's most recent stored proof.
Auth: none.
Query
| Parameter | Required | Value |
|---|---|---|
tenant_id | yes | The tenant's UUID. |
curl "http://localhost:8080/v1/proofs/latest?tenant_id=11111111-1111-1111-1111-111111111111"Response
{
"id": "ab31f400-d433-4af0-bbda-80e26a2a1a05",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"chain_proof_id": 1,
"root_hash": "0c5c65b8b72f420da5a035470aa35ce6bce46a8309bd605cb10b9392d82c922e",
"reserves_total": "16000000",
"liabilities_total": "9000000"
}| Field | Type | Meaning |
|---|---|---|
id | string | The orchestrator's proof UUID. Use it in an inclusion ref. |
tenant_id | string | The tenant's UUID. |
chain_proof_id | number | The proof id in the tenant's registry. |
root_hash | string | The Merkle sum tree root, hex without 0x. |
reserves_total | string | R, minor units. |
liabilities_total | string | L, minor units. |
Status codes
| Status | Body | Meaning |
|---|---|---|
| 200 | the proof | Found. |
| 400 | tenant_id query param is required | No tenant_id. |
| 501 | latest proof lookup not implemented | Any lookup failure: the tenant has no proof yet, the tenant is unknown, or the id is not a UUID. |
Get a proof
GET /v1/proofs/{id}Returns one stored proof by its orchestrator UUID, in the same shape as the latest proof.
Auth: none.
| Status | Body | Meaning |
|---|---|---|
| 200 | the proof | Found. |
| 501 | proof lookup not implemented | Any lookup failure, including an unknown id or one that is not a UUID. |
Get an inclusion path
GET /v1/proofs/inclusion/{ref}Returns a holder's leaf and inclusion path for one proof. Pass the result to the contract's verify_inclusion to check that the holder was counted. The orchestrator does not hash anything here. It reads the path from the tree the prover returned.
Auth: none.
Path
ref is <proof-uuid>:<id-hash>. proof-uuid is the id of a stored proof. id-hash is the holder's customer_id_hash, hex, with or without 0x, in any case. Percent-encode the colon as %3A, or send it as is.
curl "http://localhost:8080/v1/proofs/inclusion/ab31f400-d433-4af0-bbda-80e26a2a1a05%3A0000000000000000000000000000000000000000000000000000000000000001"Response
{
"proof_id": "1",
"customer_id_hash": "0x0000000000000000000000000000000000000000000000000000000000000001",
"balance": "4000000",
"root_hash": "0x0c5c65b8b72f420da5a035470aa35ce6bce46a8309bd605cb10b9392d82c922e",
"path": [
{ "hash": "0x<sibling hash>", "sum": "3000000", "sibling_is_left": false },
{ "hash": "0x<sibling hash>", "sum": "2000000", "sibling_is_left": false },
{ "hash": "0x<sibling hash>", "sum": "0", "sibling_is_left": false }
]
}| Field | Type | Meaning |
|---|---|---|
proof_id | string | The on-chain proof id, the id argument of verify_inclusion. |
customer_id_hash | string | The leaf's id hash. |
balance | string | The balance committed for this holder, minor units. |
root_hash | string | The root of the tree. |
path | array | Siblings from the leaf up to the root. sum is the sibling subtree's total. sibling_is_left matches the contract's PathNode. |
The example is the demo's first customer, owed 4,000,000, in a tree of three customers padded to eight leaves. The sibling sums are the next customer (3,000,000), the pair holding the third customer (2,000,000) and the four empty leaves (0).
Sibling sums are totals of other customers' balances. Near the leaves a sibling sum can be one other customer's exact balance. Give a holder only their own path.
Status codes
| Status | Body | Meaning |
|---|---|---|
| 200 | the path | Found. |
| 400 | invalid inclusion ref encoding | The ref is not valid percent-encoding. |
| 404 | no inclusion path for that proof and customer | The ref has no colon, the proof does not exist, or the id hash is not in that proof's tree. |
| 500 | internal error | The proof part is not a UUID, or the stored tree could not be read. |
Get a tenant's contract
GET /v1/tenants/{id}/contractReturns the registry a tenant publishes to and its network. The SDK uses this route to find a tenant's registry when you do not pass a contract id.
Auth: none.
Response
{ "contract_id": "CAFUK25F56VW442VAOHDKGMPYF2BZ3ERVLVAM4H6JTN2WGZPDUQLRTPN", "network": "testnet" }| Status | Body | Meaning |
|---|---|---|
| 200 | the contract | Found. |
| 404 | no contract for that tenant | The tenant is unknown, or has no contract yet. |
| 500 | internal error | The id is not a UUID, or the lookup failed. |
Create a tenant
POST /v1/tenantsRegisters an institution. The new tenant has no contract. Deploy its registry, then record it with PUT /v1/tenants/{id}/contract.
Auth: admin token.
Body
| Field | Type | Required | Value |
|---|---|---|---|
name | string | yes | The institution's name. |
plan | string | no | free (the default) runs a scheduled cycle daily at 00:00 UTC. Any other value, such as pro, runs hourly. |
curl -X POST http://localhost:8080/v1/tenants \
-H "Authorization: Bearer $ORCH_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Kestrel Anchor","plan":"free"}'Response, status 201:
{ "id": "3f0c9a52-6c1e-4f1e-9a43-2b7d1c0e8a11", "name": "Kestrel Anchor", "plan": "free" }| Status | Body | Meaning |
|---|---|---|
| 201 | the tenant | Created. The id is a new UUID. |
| 400 | invalid request body | The body is not JSON. |
| 400 | name is required | No name. Any database failure also returns 400 with its message. |
| 401 | missing or invalid admin token | The admin token is missing or wrong. |
Record a tenant's contract
PUT /v1/tenants/{id}/contractRecords the registry a tenant publishes to. Cycles for a tenant fail until this is set.
Auth: admin token.
Body
| Field | Type | Required | Value |
|---|---|---|---|
contract_id | string | yes | The registry's C... address. |
network | string | yes | testnet, mainnet or local. |
curl -X PUT http://localhost:8080/v1/tenants/3f0c9a52-6c1e-4f1e-9a43-2b7d1c0e8a11/contract \
-H "Authorization: Bearer $ORCH_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"contract_id":"CAFUK25F56VW442VAOHDKGMPYF2BZ3ERVLVAM4H6JTN2WGZPDUQLRTPN","network":"testnet"}'Response, status 200: the recorded contract_id and network.
| Status | Body | Meaning |
|---|---|---|
| 200 | the contract | Recorded. The route does not check that the tenant exists. |
| 400 | invalid request body | The body is not JSON. |
| 400 | usecase: network must be testnet, mainnet, or local | Unknown network. |
| 401 | missing or invalid admin token | The admin token is missing or wrong. |
| 500 | internal error | contract_id is empty, the id is not a UUID, or the write failed. |
just provision-tenant <tenant-id> <owner> deploys a registry and writes the same column directly to Postgres.
Record a tenant's wallet
PUT /v1/tenants/{id}/walletRecords the passkey smart wallet that owns the tenant's registry. See Passkey wallet.
Auth: admin token.
Body
| Field | Type | Required | Value |
|---|---|---|---|
wallet_address | string | yes | A contract address: starts with C, 56 characters. |
Response, status 200:
{ "wallet_address": "C..." }| Status | Body | Meaning |
|---|---|---|
| 200 | the wallet | Recorded. |
| 400 | invalid request body | The body is not JSON. |
| 400 | usecase: wallet must be a contract address (C...) | Not a C address of 56 characters. |
| 401 | missing or invalid admin token | The admin token is missing or wrong. |
| 500 | internal error | The id is not a UUID, or the write failed. |
Health and metrics
GET /healthReturns {"status":"ok"} with status 200 while the process is up.
GET /metricsReturns Prometheus metrics in the text exposition format.
Next: SDK.