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.

On this page
  1. Submitting a transfer
  2. Decimal amounts
  3. Read the response body, not just the HTTP status
  4. Health and version
  5. Where the servers differ
  6. The surface
  7. Batch ingest
  8. Auth and deployment posture

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 returns debit_account_id, credit_account_id and an account’s id as JSON integers, so a client that may face either should accept both forms.
  • Field names are accepted in both snake_case and camelCase.
  • 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 with 400 REQ_FIELD_INVALID_VALUE. Use amount_decimal for a decimal amount. Account ids stay strings; currency may also be an alpha-3 code such as "USD".
  • txn_id is the idempotency key — send it in the body. On the Python gateway only, an Idempotency-Key header may stand in for it: a body txn_id wins; otherwise a header of up to 36 characters that is a valid txn_id is used as-is, and any other header value becomes a stable derived txn_id, so a retry still deduplicates. The Java gateway ignores the header, and the C gateway reads only the body; on both, a write without txn_id is refused with 400 REQ_FIELD_REQUIRED.

Decimal amounts

Next gateway release. Decimal amounts are implemented and tested on all three gateways’ feat/decimal-amounts branches and ship with the next gateway release; integer amount works 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:

  • 503 with Retry-After means retry. The engine shed the request or hit a transient failure, and nothing was written. Wait, then send the same request again with the same txn_id. The engine’s duplicate check makes the retry safe whichever attempt landed.
  • A 409 duplicate_txn is a success. A binary-protocol client sees the same fact as OK_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 200 with ok: false, never a 5xx. The ledger considered the request and refused it, so a load balancer doesn’t count it as an outage. Read ok, not the HTTP status, and don’t retry it.
  • A 502 is an upstream fault worth an alert: an invariant, corruption or configuration fault, or a status newer than the gateway.
  • A 503 ledgerd_connect_error means 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 same txn_id; the duplicate check settles which.
  • A 400 invalid_request is your request, not the ledger. Every gateway answers 400 for a request refused on its shape, whichever check caught it. Fix the field named in field and send it again. The only 422 for a malformed request is a batch whose envelope is unusable. Any other 422 is an engine decline on a read, with error: "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.