Docs/SDKs & toolsv0.0.1-preview
REST gateway
The HTTP/JSON gateway — one OpenAPI 3.1 contract, three server implementations, ledger semantics preserved end to end.
Not every service wants a binary-protocol client. The SDK server is an HTTP/JSON gateway in front of the engine: your application speaks REST; the gateway speaks the wire protocol over mTLS. One OpenAPI 3.1 contract defines the surface, and three implementations serve it (Python/FastAPI — the most complete, and the one the Docker stacks run — plus Java/Spring Boot, which the EKS platform chart runs, and C). The local evaluation stack publishes it on 127.0.0.1:8080. All three give the same answer to every engine status, with the same HTTP status and the same body field names. They differ in a few edges — batch support, the Idempotency-Key header, auth with no key configured, rate-limit defaults and the JSON type of account ids in responses; where the servers differ lists them.
Submitting a transfer
curl -s -X POST http://localhost:8080/v1/transfer \
-H 'Content-Type: application/json' \
-H 'X-API-Key: <your key>' \
-d '{
"source_id": 1,
"ledger_id": 1,
"currency": 840,
"debit_account_id": "1001",
"credit_account_id": "1002",
"amount": 100,
"txn_id": "order-42"
}'
Three conventions to know:
- Send account ids as decimal strings (
"1001"), preserving the full 128-bit account space across JavaScript, Python, and Java clients. In responses, the Java and C gateways return them as decimal strings too; the Python gateway returnsdebit_account_id,credit_account_idand an account’sidas JSON integers, so a client that may face either should accept both forms. - Field names are accepted in both
snake_caseandcamelCase. - Send integers as JSON numbers (next gateway release, breaking):
"amount": 800, not"800"— a numeric string, a fraction or a boolean in an integer field is refused with400 REQ_FIELD_INVALID_VALUE. Useamount_decimalfor a decimal amount. Account ids stay strings;currencymay also be an alpha-3 code such as"USD". txn_idis the idempotency key — send it in the body. On the Python gateway only, anIdempotency-Keyheader may stand in for it: a bodytxn_idwins; otherwise a header of up to 36 characters that is a validtxn_idis used as-is, and any other header value becomes a stable derivedtxn_id, so a retry still deduplicates. The Java gateway ignores the header, and the C gateway reads only the body; on both, a write withouttxn_idis refused with400 REQ_FIELD_REQUIRED.
Decimal amounts
Next gateway release. Decimal amounts are implemented and tested on all three gateways’
feat/decimal-amountsbranches and ship with the next gateway release; integeramountworks today. The C gateway has no batch route; on Python and Java, batch items are converted and refused per item.
Every request that takes amount also accepts amount_decimal — a JSON string in the currency’s registered scale. Send exactly one of the two:
curl -s -X POST http://localhost:8080/v1/transfer \
-H 'Content-Type: application/json' \
-d '{"source_id": 1, "ledger_id": 1, "currency": 124,
"debit_account_id": "7001", "credit_account_id": "7101",
"amount_decimal": "800.00", "txn_id": "E2E-20261029-000042"}'
The gateway converts "800.00" to 80,000 minor units with CAD’s registered scale before anything reaches the engine; the wire and the stored record carry the integer. The same applies to max_txn_amount_decimal on ledger create and /max-amount.
Responses keep every integer field and add a string twin with a _decimal suffix for each money field whose currency is registered — amount_decimal, posted_balance_decimal, available_balance_decimal, the dr_/cr_ balances on a transfer result, account limits, max_txn_amount_decimal. If the currency isn’t registered, the twins are omitted; the request never fails because of it.
Refusals are all 400 with error: "invalid_request":
code |
When | Extra fields |
|---|---|---|
REQ_FIELD_CONFLICT |
both amount and amount_decimal sent |
fields |
REQ_FIELD_REQUIRED |
neither sent | field |
invalid_amount |
the string breaks the rules — reason: invalid (sign, exponent, spaces, separators, a JSON number instead of a string), too_many_decimals (more digits than the scale; never rounded), overflow (past the 64-bit range) |
reason, field, scale |
currency_not_registered |
no scale is registered for the currency | field, currency |
New currencies are picked up automatically. Each gateway asks the engine’s registry for a currency’s scale on first use, keeps a found scale for good (it cannot change), and remembers a miss for only two seconds — so a currency registered elsewhere is usable within two seconds, and one registered through the same gateway immediately.
Read the response body, not just the HTTP status
ok is true only when the ledger accepted this submission. All three gateways map an engine status to HTTP from one table: the “SDK servers (HTTP)” section of the SDK’s contracts/status-codes.env. The same file names the body fields and their fixed values. Each gateway keeps its own copy of that file, and its tests send every status from 0 to 48, plus codes the file doesn’t list, through the endpoints and compare each answer, its HTTP status and its body fields, with the file. For a single submit (/v1/transfer, /reserve, /commit, /rollback, /reject and the tiered forms) the order is:
| Engine status | HTTP | Body |
|---|---|---|
0 OK |
200 |
ok: true, with the engine’s result in ledgerd_response |
1 OK_DUPLICATE |
409 |
ok: false, error: "duplicate_txn", duplicate: true, status_code: 1, status: "OK_DUPLICATE", with the original transfer’s transfer_id, phase, batch_seq and batch_index |
41 on an explicit reject |
200 |
ok: true: the reject was recorded, as asked. ledgerd_response.status is 41 |
44 ERR_ACCOUNT_CONFLICT |
409 |
error: "account_conflict", retryable: false |
43 ERR_HISTORY_PURGED |
410 |
error: "history_purged" |
Retryable: 4, 10, 12, 13, 14, 18, 21, 38, 48 (ERR_CAPACITY, ERR_QUORUM_TIMEOUT, ERR_DEADLINE_EXCEEDED, ERR_RECONFIG_NO_QUORUM, …) |
503, header Retry-After: 1 |
error: "ledgerd_error" and the engine error object with retryable: true |
Engine fault: 16, 17, 19, or a code the gateway doesn’t know |
502 |
error: "ledgerd_error" and the engine error object with retryable: false |
Any other status: a decline (ERR_LIMIT 2, ERR_AMOUNT_EXCEEDS_MAX 42, a 41 on a plain transfer, …) |
200 |
ok: false and no error field. The status and status_name sit in ledgerd_response, plus the engine error object with retryable: false |
| No engine answer: unreachable, or the connection dropped or timed out | 503, no Retry-After |
error: "ledgerd_connect_error", and no engine error object |
| Request refused on shape: a missing or mistyped field, an unknown currency, a body that isn’t JSON | 400 |
error: "invalid_request" and a REQ_* code. Nothing reached the engine |
The engine error object is ledgerd_error. It carries status_code (the number), status and status_name (both the name) and retryable. What this means for a client:
503withRetry-Aftermeans retry. The engine shed the request or hit a transient failure, and nothing was written. Wait, then send the same request again with the sametxn_id. The engine’s duplicate check makes the retry safe whichever attempt landed.- A
409 duplicate_txnis a success. A binary-protocol client sees the same fact asOK_DUPLICATE. It is framed from this submission’s point of view: your submission didn’t apply because the original already had. The body carries the original’s identity. Proceed. - A decline is
200withok: false, never a 5xx. The ledger considered the request and refused it, so a load balancer doesn’t count it as an outage. Readok, not the HTTP status, and don’t retry it. - A
502is an upstream fault worth an alert: an invariant, corruption or configuration fault, or a status newer than the gateway. - A
503 ledgerd_connect_errormeans the outcome is unknown. The gateway got no answer from the engine, so a write may or may not have landed. Send it again with the sametxn_id; the duplicate check settles which. - A
400 invalid_requestis your request, not the ledger. Every gateway answers400for a request refused on its shape, whichever check caught it. Fix the field named infieldand send it again. The only422for a malformed request is a batch whose envelope is unusable. Any other422is an engine decline on a read, witherror: "ledgerd_error".
Reads and admin calls use the same order with one change for declines: 404 for ERR_NOT_FOUND (9) and 422 for any other decline. They also answer 410 history_purged for a transfer whose history record has been deleted by retention, and 409 account_conflict for status 44. Batch ingest answers 200 with a status per item.
Health and version
GET /healthz reports whether the gateway process is alive, not whether the ledger behind it is ready. It answers 200 with ok: true whenever the gateway can serve the request, including when no engine is running. Engine reachability sits beside it: ledgerd_ok is true only when every route’s engine accepted a connection, and each route has its own row in ledgerd:
{
"ok": true,
"service": "tuladb-sdk-server-c",
"routes_source": "env:TULADB_SDK_SERVER_ROUTES_JSON",
"ledgerd_ok": false,
"ledgerd": [
{"currency": 840, "ledger_id": 1, "mode": "default",
"target": {"host": "127.0.0.1", "port": 7700},
"ok": false, "error": "connect_failed"}
]
}
Point a liveness probe at the HTTP status, so a ledger outage doesn’t restart every gateway. To gate traffic on the engine, read ledgerd_ok. There is one ledgerd row per configured route, even when several routes share an engine. The endpoint needs no API key, so its rows carry only the route and its host and port, never the cluster topology or a raw error. A route whose engine can’t be reached says error: "connect_failed", and error is null when it’s fine.
GET /version follows the same rule: ok: true, the gateway’s service and sdk_server (name, language, version), and one daemons row per engine. Routes that share an engine’s host and port share its row, which carries only target, ok, version and error. An engine that can’t be reached shows ok: false, version: null and error: "version_probe_failed" in its own row.
Both bodies have exactly these fields on every gateway, so a probe written against one works against the others.
service names the implementation that answered: tuladb-sdk-server-python, tuladb-sdk-server-java or tuladb-sdk-server-c. /v1/healthz and /v1/version are aliases with the same body.
Where the servers differ
All three gateways answer every engine status, every refused request, and the health and version probes the same way, with the same body field names; the next gateway release makes every error code identical too (see Where the gateways differ). All three pass the SDK’s shared contract suite, with and without an engine behind them. These differences remain:
| Python | Java | C | |
|---|---|---|---|
Batch submit endpoints (/v1/batch/transfers, /v1/batch/tiered-transfers) |
yes | yes | none: they answer 404 |
Idempotency-Key header |
honoured when the body has no txn_id |
ignored | ignored; txn_id required in the body |
| Auth on, no API key configured | 503 auth_not_configured |
503 auth_not_configured (next release; 401 before) |
503 auth_not_configured |
Rate limit default (TULADB_SDK_SERVER_RATE_LIMIT_RPM) |
100,000 per minute, per worker process | 10,000 per minute | 10,000 per minute |
| Client IP for rate limiting | socket address, or the first X-Forwarded-For hop with TULADB_SDK_SERVER_TRUST_PROXY_HEADERS=true |
same as Python | socket address only |
| Account ids in responses | JSON integers | decimal strings | decimal strings |
GET /openapi.yaml |
served | served | not served (404) |
The contract marks the batch submit endpoints optional. Against the C gateway, submit transfers one at a time with POST /v1/transfer.
The surface
| Area | Endpoints |
|---|---|
| Health & meta | GET /healthz, GET /version (and /v1/ aliases), GET /openapi.yaml (Python and Java), GET /v1/routes |
| Gateway admin | POST /v1/routes/reload (re-read the route table), POST /v1/cluster/refresh (re-discover cluster topology) |
| Writes | POST /v1/transfer, /v1/reserve, /v1/commit, /v1/rollback, /v1/reject, with /v1/default/… aliases; POST /v1/txn (or POST /v1/transactions) takes any of the five, named by an operation field |
| Tiered writes | POST /v1/tiered/{transfer,reserve,commit,rollback,reject} |
| Batch ingest | POST /v1/batch/transfers, /v1/batch/tiered-transfers (Python and Java), /v1/batch/flush (all three) |
| Registries | GET/POST /v1/ledgers, GET /v1/ledgers/{ledger_id}, POST /v1/ledgers/{ledger_id}/max-amount; GET/POST /v1/sources, /v1/currencies, GET /v1/sources/{numeric}, /v1/currencies/{numeric} (+ PATCH …/{numeric}/active) |
| Account reads & limits | GET /v1/accounts, /v1/accounts/search; under /v1/ledgers/{ledger_id}/currencies/{currency}/accounts/{account_id}: GET (the account), GET /balance, /history, /metadata-history, POST or PATCH /limit-low, /limit-high, PATCH /active; GET /v1/ledgers/{ledger_id}/accounts/{account_id}/balances (every currency the id holds) |
| Transaction reads | GET /v1/transactions (paged, filtered by state: posted, pending, rejected, expired or all), GET /v1/transactions/{transfer_id}, /v1/transactions/by-ref, /v1/transactions/search, /v1/transfers, /v1/pending-reservations, /v1/rejected-transfers |
The full request and response schemas are in the OpenAPI contract, served at GET /openapi.yaml by the Python and Java gateways. Account paths name the full key (ledger_id, currency, account_id), because an account is identified by all three. Account creation is intentionally not on the gateway — provisioning flows through the SDKs, tulactl, or the dashboard, keeping the HTTP surface transactional.
POST /v1/reject records an explicit upstream reject: an optional reason_code (unsigned 32-bit, your own taxonomy) is recorded verbatim on the durable record and echoed in the response, while the record’s result_code is engine-fixed (ERR_UPSTREAM_REJECTED). It requires only txn_id, source_id, currency and ledger_id; amount, debit_account_id and credit_account_id are optional context recorded as given. Rejected records are listed at GET /v1/rejected-transfers.
Batch ingest
POST /v1/batch/transfers accepts an array of transfers and returns per-item outcomes. Because every item carries its own idempotency tuple, redelivering an entire batch after a timeout is safe — landed items answer as duplicates. This is the highest-throughput HTTP path, and the one the load-test harness drives; note that batch-request latency is the latency of the whole batch, not of one transfer.
The request answers 200 whatever the items did. The exception is a request whose envelope is unusable: transfers missing, empty, not a list, over the maximum, or a body that isn’t JSON. There is no item to report on, so that request answers 422 with error: "invalid_request" and a REQ_* code. A malformed transfer inside a usable envelope is reported at its own index, and the rest of the batch still goes through. Each item carries its own status and status_name, and every item that was neither accepted nor a duplicate also carries retryable. If the engine refuses the whole batch frame, the request still answers 200, and every item carries the frame’s status, status_name, retryable and ledgerd_error. If the connection to the engine fails, every item carries error: "ledgerd_connect_error" and retryable: true. The gateway never retries a batch for you: resubmit the items with retryable: true under their same txn_ids, and leave declined items alone. The Python and Java gateways serve this endpoint. The C gateway has no batch submit endpoint.
Auth and deployment posture
Programmatic access authenticates with an API key header. Three environment variables, the same on every gateway, control it: TULADB_SDK_SERVER_AUTH_ENABLED (default true), TULADB_SDK_SERVER_API_KEY (no default) and TULADB_SDK_SERVER_API_KEY_HEADER (default X-API-Key). With auth on and no key configured, every protected request is refused rather than served open — 503 auth_not_configured on the Python and C gateways, 401 unauthorized on Java. A missing or wrong key answers 401. /healthz is always open, and /version and /openapi.yaml are open by default (TULADB_SDK_SERVER_REQUIRE_AUTH_FOR_METADATA=true closes them on Python and Java).
Per-client rate limiting is on by default (TULADB_SDK_SERVER_RATE_LIMIT_ENABLED, default true): a fixed 60-second window per client IP — not per API key — capped at TULADB_SDK_SERVER_RATE_LIMIT_RPM requests (100,000 on Python, per worker process; 10,000 on Java and C). Over the cap, a request answers 429 with error: "rate_limited" and no Retry-After header; back off before retrying. Behind a shared proxy every client can share one IP, so either raise the limit or let the Python or Java gateway read the client address from X-Forwarded-For (TULADB_SDK_SERVER_TRUST_PROXY_HEADERS=true). The gateway holds the TLS client credentials for its engine connection, so application services never touch ledger certificates. The local evaluation stack turns auth off and binds the gateway to loopback only, for convenience. Treat the gateway as part of your trusted tier — it holds a write-capable ledger credential.