The machine-readable contract is openapi.yaml, and it is enforced: the
drift_tests integration test validates live responses against that spec, so the two cannot
silently diverge. This page is the human-readable tour.
All responses use a consistent envelope — including errors, where data is null and a
stable machine-readable code is added (see Error codes):
{ "statusCode": 200, "message": "OK", "data": { } }
{ "statusCode": 409, "code": "conflict", "message": "payment link is cancelled and cannot be reactivated", "data": null }Two credential types, with deliberately different power:
| Credential | Header | Can do |
|---|---|---|
| Dashboard JWT | Authorization: Bearer <token> |
Everything: create wallets, provision a gas tank, read the key backup |
| Wallet API key | Authorization: Bearer <key> |
Per-wallet operations only. Cannot provision a gas tank or read a backup |
Neither can move funds — see below. Tokens carry a unique jti and the id of their login
session (sid); logout and refresh both deny-list the presented token, and every
authenticated request checks the deny-list, the session, and the user's session epoch.
POST /v1/auth/signup— create an account, returns a JWT.POST /v1/auth/login— returns a JWT.POST /v1/auth/refresh— issue a new token and revoke the presented one.POST /v1/auth/logout— revoke the presented token and its session (repeating it is a harmless200).GET /v1/auth/me— the current user.GET /v1/auth/sessions— the caller's live sessions:id,created_at,last_seen_at(updated at most once a minute),expires_at,user_agent_hash(SHA-256 hex of the sign-in User-Agent) andcurrent.DELETE /v1/auth/sessions/:id— revoke one session; its token is rejected on its next request.404if the id is unknown, already revoked, or another user's.POST /v1/auth/sessions/revoke-others— revoke every other session, including tokens issued before session tracking. Returns{ token, revoked }: the presented token is invalidated too, so switch to the returned one.
Session endpoints require a dashboard JWT (API keys get 401).
Both flows require a dashboard JWT plus the current password plus an emailed one-time
code. Export and delete codes are separate purposes, so one can never be redeemed for the other.
All four calls share a cap of 5 per user per hour (429 beyond that).
POST /v1/auth/account/export/request-otp— body{ "password" }; emails a code.POST /v1/auth/account/export— body{ "password", "code" }; returns the caller's profile, wallets, addresses, transactions, withdrawals, sponsored transactions, API keys (prefix and scopes only), webhook endpoints (no secret), allowlist, payment links and payments, sessions and audit log. No secrets: password hash, sealed seeds, the encrypted backup blob, webhook secrets and API key hashes are never included. Each section holds at mostsection_limitrows; cut-off sections are named intruncated. Every export writes an audit row, and the request fails rather than export untraced.POST /v1/auth/account/delete/request-otp— body{ "password" }; emails a code, or409if any wallet still holds funds.POST /v1/auth/account/delete— body{ "password", "code" }; deletes the account.
Deletion is refused (409) while any wallet's Stellar account or gas tank holds a non-zero
balance — withdraw or merge them first. If Horizon can't confirm the balances the request
fails closed with 503. On success the user row becomes a tombstone (email, username and
password hash scrubbed), every session and API key is revoked, wallets are archived with their
label, description and backup removed, webhooks and payment links are retired, payer details
are erased, and the audit trail is kept but anonymised (see audit-log.md).
Transactions and withdrawals are retained as financial records.
octo is non-custodial. The wallet's private key is generated and held client-side; the server stores only the public account and an opaque, client-encrypted backup blob it cannot decrypt. Consequently:
- There is no endpoint that signs a payment for you. You build and sign locally, then relay.
POST /v1/wallets/:id/withdrawandPOST /v1/wallets/:id/trustlinesare410 Gonetombstones. They exist only to give integrators a clear error pointing atsubmit-signed.
POST /v1/wallets— register a wallet from a client-generated keypair. Body:{ "public_key": "G...", "encrypted_backup"?: string, "label"?: string, "description"?: string }.public_keyis required; a body without it is400. Returns201with{ id, network, address, custody, funded }. Never returns a mnemonic — the client generated it and the server never saw it.GET /v1/wallets— list your wallets (paginated).GET /v1/wallets/{id}— wallet details.PATCH /v1/wallets/{id}— rename or annotate a wallet. Body:{ "label"?: string|null, "description"?: string|null }— omit a field to keep it,nullor""to clear it.label≤ 64 characters,description≤ 500; control and invisible formatting characters are400. Owner login or awrite-scoped API key; archived wallets are403. Audit-logged.GET /v1/wallets/{id}/balances— live on-chain balances.GET /v1/wallets/{id}/transactions— deposits + outbound transfers (paginated). Optional filters compose with each other and with?before=keyset pagination:direction(deposit|withdrawal),status(pending|confirmed|failed),asset(native/XLM, a code such asUSDCfor any issuer, orCODE:ISSUER),from(inclusive) andto(exclusive) as RFC 3339 timestamps orYYYY-MM-DD(midnight UTC). Any invalid value, orfromnot earlier thanto, is400. Abeforecursor from another wallet yields an empty page.GET /v1/wallets/{id}/transactions.csv— the same history as an accounting CSV download (text/csv,Content-Disposition: attachment,Cache-Control: no-store). Same auth and the same filters as the JSON endpoint, but no pagination: rows stream newest first in batches of 500, so memory is bounded however long the history is. Columns:id, created_at, direction, status, asset_code, asset_issuer, amount_stroops, source_account, destination_account, stellar_tx_hash, operation_index, ledger, memo_id, address_id, reference. Text cells starting with=,+,-,@, tab or CR are prefixed with'so spreadsheets never evaluate them. If the database fails mid-export the connection is aborted rather than ending cleanly, so a partial file is never mistaken for a complete one.PATCH /v1/wallets/{id}/ingest/pause/PATCH /v1/wallets/{id}/ingest/resume— stop or restart deposit polling for one wallet. Dashboard JWT only, owner-only, audit-logged. The ingest cursor is kept while paused, so resume continues from where polling stopped with no gap; a pause takes effect from the supervisor's next tick. Wallet views exposeingest_pausedandingest_paused_at.GET /v1/wallets/{id}/backup— the opaque client-encrypted backup blob, for new-device recovery. Dashboard JWT only. Useless without the user's password. Rate-limited to 5 requests per hour per user (429beyond that); every successful read writes an audit row, and the request fails with500rather than return the blob if that row can't be written.
GET /v1/wallets/{id}/signing-info— returns the accountsequence, network passphrase, selected per-operationbase_fee_stroops, and absolute Unixmax_time. The fee is selected from the configured Horizon/fee_statspercentile and never exceeds the configured ceiling; when fee stats are unavailable it falls back to the Stellar base fee.- Build and sign locally, including a time bound whose
max_timeis the returned value. The envelope will then expire after the configured short window rather than remaining submit-able indefinitely. POST /v1/wallets/{id}/submit-signed— body{ "transaction_xdr": "<base64>" }. The server validates (v1 envelope, at least one signature, source account == this wallet, operation-type allowlist) and relays it unmodified. On failure it returns Horizon's result codes (tx_bad_seq,op_no_trust, …) so you can correct and re-sign.
GET /v1/wallets/{id}/withdraw/estimate — preview a withdrawal's fee and minimum-balance impact
before building it. Query: asset_code + asset_issuer (omit both for XLM), optional
amount_stroops, optional operations (1–100, default 1). Returns:
| Field | Meaning |
|---|---|
base_fee_stroops |
Network minimum per operation (Horizon last_ledger_base_fee) |
recommended_fee_stroops |
Per-op bid: max(base_fee, fee_charged.p90) |
estimated_fee_stroops |
recommended_fee_stroops × operations — the tx fee to build with |
base_reserve_stroops, min_reserve_stroops |
base_reserve × (2 + subentries + sponsoring − sponsored) |
native_balance_stroops, asset_balance_stroops |
Current balances (asset_balance only for credit assets) |
fee_covered |
Native balance above the reserve covers the fee |
max_withdrawable_stroops |
Most of the asset that can leave after fee and reserve |
sufficient |
Only when amount_stroops is given: fee covered and amount ≤ max |
The reserve figures come from the same AccountInfo read and BASE_RESERVE_STROOPS constant the
withdrawal pre-flight uses. Fee stats are cached in-process for 10 seconds (FEE_STATS_TTL) and
shared by all requests; a failed fetch is never cached. The estimate ignores selling liabilities
from open offers (the relay's op allowlist doesn't create them). Unfunded wallet or missing
trustline → 400.
POST /v1/wallets/{id}/trustlines and public payment-link signing info return the same fee and
max_time guidance. The server defaults to a five-minute lifetime, fee percentile 50, and a
10,000-stroop per-operation ceiling. Configure these with STELLAR_TX_MAX_AGE_SECS
(1–86400), STELLAR_FEE_PERCENTILE (10, 20, 30, 40, 50, 60, 70, 80, 90, 95, or 99), and
STELLAR_FEE_CEILING_STROOPS (at least 100).
POST /v1/wallets/{id}/addresses— generate a dedicated customer address. Body:{ "customer_ref"?: string, "metadata"?: object }. Returnsmuxed_address(M...) and the{ base_address, memo_id }fallback.customer_ref(≤ 128 characters, trimmed, no control characters) is unique per wallet: reusing one on the same wallet is409.POST /v1/wallets/{id}/addresses/batch— allocate many addresses at once. Body:{ "addresses": [{ "customer_ref"?: string, "metadata"?: object }, ...] }, 1–100 entries. All-or-nothing: if any entry fails (a bad or duplicatecustomer_ref→400/409) no address is created. Each batch gets a contiguous, collision-free block of muxed ids even under concurrent batches. Returns201with{ data: [address, ...] }in request order.GET /v1/wallets/{id}/addresses— list addresses (paginated).?customer_ref=returns just the address with that reference (an empty list if none).
Lets you pay your users' Stellar fees. The gas tank is a separate, server-held account that carries fee float only — the one server-held key in the system, bounded by your gas budget.
POST /v1/wallets/{id}/gas-tank— provision the gas tank. Dashboard JWT only (an API key gets401). Idempotent: a second call returns the existing tank.GET /v1/wallets/{id}/sponsorship/PUT— read/updateenabled, the per-transaction fee cap, the daily budget, andallowed_operations.allowed_operationsnarrows the global sponsorship allowlist (Payment,PathPaymentStrictSend,PathPaymentStrictReceive) for this wallet: send a non-empty subset to restrict,nullto inherit the global list, or omit it to keep the current setting. Anything outside the global list is400. The response also reportseffective_allowed_operations.POST /v1/wallets/{id}/sponsorrejects (400 op_not_allowed) any operation outside the wallet's effective allowlist before anything is signed.POST /v1/wallets/{id}/sponsor— fee-bump a user's already-signed inner transaction. The gas tank signs only the outer fee-bump envelope; the inner transaction is passed through untouched. Over budget →429; duplicate inner tx →409.POST /v1/wallets/{id}/sponsor/validate— dry run of/sponsorwith the same body. It runs the same validation code (enabled flag, per-tx cap, op allowlist, self-sponsorship, custody, on-chain sequence) plus read-only dedup and daily-budget projections, then returns200with{ verdict: "accepted" | "rejected", reason, rejection_status, rejection_code, projected_fee_stroops, inner_tx_hash, budget_remaining_stroops }.rejection_status,rejection_codeandreasonare the status, errorcodeand message/sponsorwould answer with. Nothing is signed, reserved, submitted, audited or sent as a webhook. Auth failures and upstream errors (5xx) still answer as errors. A dry run is advisory: concurrent sponsorships can still exhaust the budget before the real call.GET /v1/wallets/{id}/sponsored-transactions— sponsorship history (paginated, filterable by status).
POST /v1/wallets/{id}/webhooks— register an endpoint (URL + generated secret).GET /v1/wallets/{id}/webhooks— list active endpoints.DELETE /v1/wallets/{id}/webhooks/{endpoint_id}— deactivate (soft delete, so the delivery history survives as an audit trail).GET /v1/wallets/{id}/webhooks/{endpoint_id}/deliveries— delivery history (?limit=, default 50, max 200;?status=pending|delivered|failed|deadto filter, e.g. the dead-letter queue).POST /v1/wallets/{id}/webhooks/{endpoint_id}/test— send a signedpingevent and return the result (one attempt, recorded in history, 5/min per endpoint).POST /v1/wallets/{id}/webhooks/{endpoint_id}/deliveries/{delivery_id}/redeliver— re-send a past event with its originaldataplusredelivery_of/redelivered_at(active endpoints only, 10/min per endpoint, audit-logged).
Deliveries are signed HMAC-SHA256 over the raw body. Endpoint URLs are SSRF-screened:
loopback, private and link-local targets are rejected, IPv4 and bracketed IPv6 alike.
Every delivery carries an X-Octo-Signature header. Its value is the lowercase hex
HMAC-SHA256 of the raw request body bytes, keyed with the endpoint's secret (the one
returned when the endpoint was registered). Compute the digest over the bytes exactly as
received — do not parse and re-serialize the JSON first, since any whitespace or key-order
change breaks the match.
Compare the computed digest with the header using a constant-time comparison (e.g.
crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hmac.Equal in Go,
subtle::ConstantTimeEq in Rust). A plain == leaks timing information and must not be used.
Reject the delivery with a non-2xx status when the signature is missing or does not match.
For complete, runnable samples in Node, Python, Go and Rust — plus guidance on retries, out-of-order delivery and idempotent handling — see webhook-consumer-guide.md.
A delivery whose retries are exhausted ends in the terminal dead status. Set
OCTO_WEBHOOK_AUTO_DISABLE_AFTER=N to deactivate an endpoint after N consecutive dead
deliveries (its auto_disabled_at is then set); unset means never.
POST /v1/wallets/{id}/payment-links— create a link. Optionalexpires_at(RFC 3339, in the future, at most 365 days away) time-boxes it.POST /v1/wallets/{id}/payment-linksaccepts an optionalIdempotency-Keyheader (1-255 visible ASCII characters, scoped to the wallet):- A retry with the same key and the byte-identical body returns the original
201response withIdempotent-Replayed: trueand creates nothing new. - The same key with a different body is
409. So is a retry while the first request is still running. - A request that fails (e.g.
400) frees its key, so a corrected retry can reuse it. - Keys expire 24 hours after first use (
IDEMPOTENCY_KEY_TTL_SECS), after which the key starts fresh.
- A retry with the same key and the byte-identical body returns the original
PATCH /v1/wallets/{id}/payment-links/{link_id}— editdescription,redirect_urlandamount_usdc_stroops(omit to keep,nullto clear). Allowed only while no payment is pending or has landed; otherwise409. Requires thewritescope. Edits are audit-logged.POST /v1/wallets/{id}/payment-links/{link_id}/cancel— permanently close a link (idempotent,writescope, audit-logged). Its pending payments becomecancelledand each fires apayment_link.cancelledwebhook. A cancelled link can't be re-activated (PUT→409).
Every link view carries status: active, inactive (paused via PUT), expired (at or past
expires_at) or cancelled. Expired and cancelled are terminal.
On the public pay routes a paused link is 404. An expired or cancelled link is still readable
at GET /v1/pay/{slug} with its status and deposit_address: null; creating an intent,
fetching signing info or relaying a payment answers 410. submit-signed for a payment_id
that is no longer pending answers 409. Polling a pending payment on a closed link reports
the link's expired/cancelled status straight away.
A deposit is matched to a payment only if, when ingest records it, the payment is still
pending and the link is neither cancelled nor at/past expires_at. Anything later is a
late deposit:
- The funds are already in the merchant's wallet (Octo is non-custodial and cannot bounce them).
The deposit is recorded and
deposit.createdfires as for any deposit; its addressmetadatacarriespayment_link_slugfor reconciliation. - The payment is not confirmed, no
payment_link.paid/payment_link.mismatchedfires, and it does not count towardscollected_usdc_stroops. - Refunding or honouring a late deposit is the merchant's decision.
Pending payments on an expired link are flipped to expired by the ingest sweep (firing
payment_link.expired), alongside the existing one-hour intent deadline.
All three require a dashboard JWT and wallet ownership — an API key can never manage keys, so it cannot escalate or revoke itself.
POST /v1/wallets/{id}/api-key— generate/regenerate (the plaintext key is shown once; only a SHA-256 hash is stored).GET /v1/wallets/{id}/api-key— metadata (prefix, created_at) — never the key itself.DELETE /v1/wallets/{id}/api-key— revoke.
GET /v1/audit-logs— your account's activity, filterable bycategoryand a free-textsearch.
- Pagination: list endpoints take
?limit=(default 50, max 200) and?before=<uuid>for keyset pagination. They return{ "data": [...], "next_cursor": <uuid|null> }— note this sits inside the response envelope, so the full shape is{ statusCode, message, data: { data: [...], next_cursor } }. - Amounts are integer stroops (1 XLM = 10,000,000) end-to-end — never floats.
- Errors map to
400(validation),401,403,404,409(conflict),410(removed custodial endpoints),413(body over 64 KiB),429(budget exceeded). There is no422. Branch oncode, never onmessage.
Every error response — from a handler, an extractor rejection (bad JSON, bad path/query
parameter), an unknown route, a wrong method, or the body-size limit — carries code. Codes are
a stable contract: they are never renamed or reused, and a new one is only ever added. message
is human-readable and may change at any time. The catalogue lives in ERROR_CODES
(crates/api/src/error.rs), mirrors ErrorResponse.code in openapi.yaml, and is pinned by a
snapshot test (crates/api/tests/snapshots/error_codes.snap).
| Code | Status | Meaning |
|---|---|---|
bad_request |
400 | The request was malformed or failed validation. |
stale_sequence |
400 | The transaction's sequence number is stale; refresh signing info and rebuild. |
unauthorized |
401 | Missing, invalid or revoked credentials. |
forbidden |
403 | Authenticated, but the server refuses this action for this resource. |
not_found |
404 | The resource or route does not exist, or is not visible to this caller. |
method_not_allowed |
405 | The route exists but not for this HTTP method. |
already_exists |
409 | A resource with this identity already exists (duplicate). |
conflict |
409 | The resource's current state does not allow this change. |
gone |
410 | The endpoint or resource has been permanently removed or closed. |
payload_too_large |
413 | The request body exceeds the 64 KiB limit. |
unsupported_media_type |
415 | The request body has an unsupported Content-Type. |
too_many_requests |
429 | A rate limit or budget would be exceeded. |
internal_error |
500 | An internal error occurred; details are logged, never returned. |
upstream_timeout |
504 | An upstream dependency (e.g. Horizon) did not answer in time. |
See also errors.md for the per-code remedies.
The API enforces fixed-window rate limiting on unauthenticated and authentication endpoints to protect against brute-force and resource-exhaustion attacks. When a rate limit is exceeded, the server responds with HTTP 429 Too Many Requests.
Counters are shared across replicas through Postgres by default (RATE_LIMIT_BACKEND=postgres), so limits hold regardless of how many instances run and survive restarts; see docs/ha-topology.md. Per-IP keys use the first X-Forwarded-For hop only when it parses as an IP address.
| Endpoint | Method | Key Scope | Limit | Window | Code Constant |
|---|---|---|---|---|---|
/v1/auth/signup |
POST | Per-IP | 10 req | 60s (1m) | AUTH_RATE_LIMIT / AUTH_RATE_WINDOW |
/v1/auth/verify-email |
POST | Per-IP | 10 req | 60s (1m) | AUTH_RATE_LIMIT / AUTH_RATE_WINDOW |
/v1/auth/login |
POST | Per-IP | 10 req | 60s (1m) | AUTH_RATE_LIMIT / AUTH_RATE_WINDOW |
/v1/auth/refresh |
POST | Per-IP | 10 req | 60s (1m) | AUTH_RATE_LIMIT / AUTH_RATE_WINDOW |
/v1/auth/resend-otp |
POST | Per-IP | 10 req | 60s (1m) | AUTH_RATE_LIMIT / AUTH_RATE_WINDOW |
/v1/auth/resend-otp |
POST | Per-User (otp:{user_id}) |
3 req | 3600s (1h) | OTP_RESEND_USER_LIMIT / OTP_RESEND_USER_WINDOW |
/v1/auth/resend-otp |
POST | Per-IP | 10 req | 3600s (1h) | OTP_RESEND_IP_LIMIT / OTP_RESEND_IP_WINDOW |
/v1/pay/:slug |
GET | Per-IP | 60 req | 60s (1m) | PAY_READ_LIMIT / PAY_READ_WINDOW |
/v1/pay/:slug/intent |
POST | Per-IP | 5 req | 60s (1m) | PAY_INTENT_LIMIT / PAY_INTENT_WINDOW |
/v1/pay/:slug/payments/:payment_id |
GET | Per-IP | 60 req | 60s (1m) | PAY_STATUS_LIMIT / PAY_STATUS_WINDOW |
/v1/pay/:slug/signing-info |
GET | Per-IP | 60 req | 60s (1m) | PAY_SIGNING_INFO_LIMIT / PAY_SIGNING_INFO_WINDOW |
/v1/pay/:slug/submit-signed |
POST | Per-IP | 20 req | 60s (1m) | PAY_SUBMIT_LIMIT / PAY_SUBMIT_WINDOW |
/v1/wallets/:id/transactions.csv |
GET | Per-Wallet (csv_export:{wallet_id}) |
10 req | 60s (1m) | CSV_EXPORT_LIMIT / CSV_EXPORT_WINDOW |
Note
Process Note: Any new rate limit added to the API must update this table and reference named constants in crates/api/src/rate_limit.rs within the same pull request.