Developer Hub · Quickstart
Customer API quickstart
Go from a new API client to a live response in about five minutes: mint a token, then read settled balances. The marketplace flow is included when that capability is enabled.
Every step below is one HTTPS request carrying a bearer token. There is no SDK to install and no browser redirect to handle.
The examples use curl, jq, and uuidgen. Any language works the same way:
form-encode the token request, then send Authorization: Bearer <token> on everything
else.
What preview access covers
Balances are available to every operator. A foreign-exchange quote is a write that moves no funds:
it records provider-derived terms. Marketplace listings, offers, and
trades are available only when the operator enables the marketplace capability. Treasury vault endpoints (/vaults) likewise answer 404 unless the operator enables the treasury yield capability.
Payments, bank deposits (onramp), bank payouts (offramp), and vault deposits and withdrawals are open to a client that holds money.write, which an organisation owner grants under step-up authentication together with per-transaction limits in Settings → API clients. Payments also need a destination approved there by the owner. Onramp uses an operator-supplied source_id. Vault deposits and withdrawals need neither an approved destination nor a source_id. Offramp needs an owner-approved bank-account destination and takes a USDC amount for a sandbox USD/EUR payout within the existing sell windows. The Money movement section shows each money request.
Create an API client
As an organisation owner, open Settings → API clients and create a client with the read
and write scopes; confirm with your authenticator app when asked. Loam displays the client secret once, at creation — copy it into a
secret manager before leaving the page, because it cannot be shown again. Rotating the
client issues a new secret; the previous one keeps working for up to 24 hours, and stops
sooner if you rotate again, so integrations can switch over.
Only an organisation owner can create or rotate a client, after confirming with an authenticator app. Owners and admins can revoke one; only an owner can revoke a client that holds money.write.
If a secret leaked, revoke the client to invalidate both secrets immediately, then create a new client.
The API answers on the same host you sign in to, under /api/v1. Requests to any other
host return 401 with invalid_client, even when the credentials themselves are valid.
export LOAM_API_BASE="https://<your-loam-host>/api/v1"export LOAM_CLIENT_ID="loam_ck_01J8YQ7N2M4P6R8T0V2X4Z6A8C"export LOAM_CLIENT_SECRET_FILE="$HOME/.config/loam/client-secret"umask 077mkdir -p "$(dirname "$LOAM_CLIENT_SECRET_FILE")"printf 'Paste the client secret: ' >&2read -r -s LOAM_CLIENT_SECRETprintf '\n' >&2printf '%s' "$LOAM_CLIENT_SECRET" > "$LOAM_CLIENT_SECRET_FILE"unset LOAM_CLIENT_SECRETGet an access token
Exchange the client credentials for a short-lived bearer token. The request body is form-encoded rather than JSON, and HTTP Basic authentication is deliberately not accepted.
A valid exchange returns 200 with the token, its remaining life in seconds, and the
scopes currently on the client:
POST /api/v1/oauth/token HTTP/1.1Host: your-loam-hostContent-Type: application/x-www-form-urlencodedgrant_type=client_credentials&client_id=loam_ck_01J8YQ7N2M4P6R8T0V2X4Z6A8C&client_secret=%3Cclient-secret-from-secret-manager%3E# Response
{
"access_token": "access_token_value",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}An unknown, wrong, or revoked secret returns 401 with {"error": "invalid_client"},
and a client whose organisation has no API access returns 403 with
{"error": "unauthorized_client"}. Token failures use the OAuth 2 shape — error and
error_description — not the envelope the resource routes use.
Scopes are read from the client on every request, so narrowing them takes effect at once rather than when the token expires. Mint a new token when the old one runs out instead of storing it. In a shell, capture one now and reuse it for the rest of this guide:
LOAM_TOKEN="$( curl --fail-with-body --silent --show-error \ --request POST "$LOAM_API_BASE/oauth/token" \ --header "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_id=$LOAM_CLIENT_ID" \ --data-urlencode "client_secret@$LOAM_CLIENT_SECRET_FILE" \ | jq --raw-output ".access_token")"Read your settled balances
Send the token as a bearer credential. This endpoint returns 200 with the standard
ok and data envelope:
curl --fail-with-body --silent --show-error \ --header "Authorization: Bearer $LOAM_TOKEN" \ "$LOAM_API_BASE/balances" | jq# Response
{
"ok": true,
"data": [
{
"account_id": "6f9619ff-8b86-d011-b42d-00c04fc964ff",
"balance": 42500000000,
"currency": "USDC",
"last_settled_at": "2026-07-14T09:12:04.118Z"
}
]
}This projection is stablecoin-only — every row is a held stablecoin balance, never a fiat
one. Balances are integers in the currency's smallest unit: 42500000000 with USDC means
USDC 42,500.000000. Never parse them as decimals.
An empty data array is a success, not an error — it means the organisation has no
settled balances yet.
FX quote availability
No Convert pair is currently offered. A new quote request that reaches pair validation
returns 400 invalid_input for an unoffered pair. Authentication, authorization,
rate-limit, idempotency and transient-error responses still apply.
See GET /api/v1/openapi.json for the live offered set.
Retry writes with the same key
Send a retry with the same Idempotency-Key and the same body, and Loam replays the
original outcome instead of minting a second quote — so a timeout or a dropped
connection costs nothing. Reuse the key with a different body and the request is
rejected with 409. A new key always means a new operation.
Every money write — payments, onramp, offramp, and vault deposits and withdrawals — is
checked again before Loam replays its outcome, so a retry may be refused with
403 forbidden. Do not retry with a new key: read the original write back instead,
through the Location you received for it. If you have no Location, onramp, offramp
and vault requests are found by the ?idempotency_key= collection read; for a payment,
list /payments and match on your own amount, currency and destination. A retry
can also answer 503 transient_error, or 409 with Retry-After while the first attempt
is still held: wait, then retry with the same key.
Reads need no key. They change nothing and are safe to retry as they are.
Handle rate limits and errors
Token minting and resource requests are limited separately, per client. A 429 carries
Retry-After in seconds: wait that long, then retry the same request.
| Status | What to do |
|---|---|
400 |
Correct the request. Retrying the same body will fail the same way. |
401 |
Mint a new token. If a secret leaked, revoke the client immediately and create a new one. |
403 |
Ask an organisation owner to widen the grant. Do not retry. On a ramp or vault write, first read the collection with ?idempotency_key=: the request may already exist. If the grant already covers this request, contact Loam support with the request's idempotency key. |
409 |
Read the resource, resolve the conflict, then send a new operation. |
429 |
Wait for Retry-After, then retry. |
503 |
Retry reads with backoff; retry writes with the same idempotency key. |
Resource errors carry {"ok": false, "error": "<code>"} with a machine-readable code.
Handle broad cases on the status and specific recovery paths on the code. The
API reference lists every code, the scope each endpoint needs,
and the full response schema.
Money movement
Listing payments is a read and is open in preview.
A quote (POST /api/v1/fx/quotes) is a write that moves no funds: it locks provider-derived
terms under the write scope. You reuse those currencies and integer amounts on a money.write request — there is no execute-quote
route on this API.
GET /api/v1/payments returns the organisation's payments as a cursor page. An empty
data array is a success — it means nothing has been recorded yet. Poll
GET /api/v1/payments/<payment-id> for one payment.
curl --fail-with-body --silent --show-error \ --header "Authorization: Bearer $LOAM_TOKEN" \ "$LOAM_API_BASE/payments" | jq// Response
{
"ok": true,
"data": [
{
"id": "c4d8e2f0-1a3b-45cd-8e9f-0a1b2c3d4e5f",
"state": "settled",
"source_kind": "wallet_transfer",
"fiat_amount": 2500000,
"fiat_currency": "USD",
"crypto_amount": null,
"crypto_currency": null,
"trade_id": null,
"created_at": "2026-07-14T09:12:04.118Z",
"updated_at": "2026-07-14T09:14:11.004Z",
"settled_at": "2026-07-14T09:14:11.004Z",
"failed_at": null
}
],
"page": { "next_cursor": null }
}money.write is not on the client by default. A human organisation owner grants it
under fresh step-up authentication (AAL2 / MFA at grant time), sets a per-transaction
limit, and approves each destination in advance. Narrowing or revoking the grant takes
effect on the next request. Machine execution does not prompt for a second factor; the
SQL money-authorization fence is the authority.
The owner does this in Settings → API clients: grant money movement and set its limits,
then set the organisation limit, then approve each counterparty. The destination ID
shown once at approval is the destination_id payments use. Offramp uses the bank-account UUID approved by the owner as its destination_id; bank approval uses the RPC described below. Money limits and
fiat amounts must use a currency with two decimal places or a stablecoin; RWF and UGX
money moves are not supported yet.
Two failure modes are worth designing for now. A 403 with forbidden means the
destination is visible but authorization was refused — missing scope, withdrawn
enablement, a failed step-up grant, or an approved counterparty whose connection is no
longer active. A 400 with invalid_input means the
destination is unknown or invisible to the client. A revoked destination returns 403
with destination_revoked. None of these are transient, so none should be retried
blindly.
When the grant is in place, a payment to an approved destination is:
curl --fail-with-body --silent --show-error \ --request POST "$LOAM_API_BASE/payments" \ --header "Authorization: Bearer $LOAM_TOKEN" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \ --data '{"destination_id":"2f4cbb59-3ab1-4b6d-8d10-9cd2236cf94e","amount":2500000,"currency":"USD"}'// Response
HTTP/1.1 202 Accepted
Location: /api/v1/payments/<payment-id>
{
"ok": true,
"data": {
"id": "<payment-id>",
"state": "processing"
}
}amount is a positive integer in minor units, the same convention as the quote.
destination_id must be an approved destination for this client. Poll
GET /api/v1/payments/<payment-id> for later state.
Funds in use an operator-supplied funding account (source_id) rather than a payee. currency must equal the currency of the source_id account; otherwise the response is 403 forbidden.
curl --fail-with-body --silent --show-error \ --request POST "$LOAM_API_BASE/onramp" \ --header "Authorization: Bearer $LOAM_TOKEN" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \ --data '{"source_id":"7c501bfe-ffef-4670-9b8d-ed731e8ee3b2","amount":2500000,"currency":"USD"}'// Response
HTTP/1.1 202 Accepted
Location: /api/v1/onramp/<onramp-id>
{
"ok": true,
"data": {
"id": "<onramp-id>",
"state": "<accepted-state>"
}
}List your operator's vaults with GET /api/v1/vaults and set VAULT_ID to the id of the vault you choose; only an active vault accepts deposits. Vault deposits and withdrawals take an amount in minor units of that vault's currency.
curl --fail-with-body --silent --show-error \ --header "Authorization: Bearer $LOAM_TOKEN" \ "$LOAM_API_BASE/vaults" | jqcurl --fail-with-body --silent --show-error \ --request POST "$LOAM_API_BASE/vaults/$VAULT_ID/deposits" \ --header "Authorization: Bearer $LOAM_TOKEN" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \ --data '{"amount":2500000}'curl --fail-with-body --silent --show-error \ --request POST "$LOAM_API_BASE/vaults/$VAULT_ID/withdrawals" \ --header "Authorization: Bearer $LOAM_TOKEN" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \ --data '{"amount":2500000}'Both return 202 with { ok: true, data: { id, state } } and a Location under the
same vault path. Poll that location for later state.
POST /api/v1/offramp takes destination_id, amount in USDC minor units, and
currency: USDC. An organisation owner must approve the bank-account destination
through the existing api_client_destination_approve Supabase RPC, using their authenticated
owner session with fresh AAL2. Settings → API clients currently approves counterparties only.
The approval is a human control-plane action, not a request made with the API client's
bearer token. Set the project URL, publishable key, and the bank-account UUID first:
export LOAM_SUPABASE_URL="https://<your-project-ref>.supabase.co"export LOAM_SUPABASE_PUBLISHABLE_KEY="<your-publishable-key>"export LOAM_BANK_ACCOUNT_ID="2f4cbb59-3ab1-4b6d-8d10-9cd2236cf94e"Sign in as an organisation owner without a password, then verify their enrolled TOTP factor
immediately before approving. Sign-in requires a Turnstile CAPTCHA token. On the provisioned
sandbox using the Turnstile test key pair, enter XXXX.DUMMY.TOKEN.XXXX at the CAPTCHA prompt.
For a project using live Turnstile keys, obtain a fresh token from its sign-in page's Turnstile
widget. Request a sign-in code, then enter the code from the email at the prompt below.
The TOTP verification response replaces the AAL1 access token with the fresh AAL2 session used below:
printf 'Owner email: ' >&2read -r LOAM_OWNER_EMAILprintf 'CAPTCHA token: ' >&2read -r LOAM_OWNER_CAPTCHA_TOKENprintf '%s\n%s\n' "$LOAM_OWNER_EMAIL" "$LOAM_OWNER_CAPTCHA_TOKEN" | jq --raw-input --slurp 'split("\n") | {email: .[0], create_user: false, gotrue_meta_security: {captcha_token: .[1]}}' | curl --fail-with-body --silent --show-error \ --request POST "$LOAM_SUPABASE_URL/auth/v1/otp" \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header "Content-Type: application/json" \ --data @-unset LOAM_OWNER_CAPTCHA_TOKENprintf 'Email sign-in code: ' >&2read -r LOAM_OWNER_EMAIL_CODELOAM_OWNER_TOKEN="$( printf '%s\n%s\n' "$LOAM_OWNER_EMAIL" "$LOAM_OWNER_EMAIL_CODE" | jq --raw-input --slurp 'split("\n") | {email: .[0], token: .[1], type: "email"}' | curl --fail-with-body --silent --show-error \ --request POST "$LOAM_SUPABASE_URL/auth/v1/verify" \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header "Content-Type: application/json" \ --data @- | jq --raw-output '.access_token')"LOAM_OWNER_FACTOR_ID="$( printf 'Authorization: Bearer %s\n' "$LOAM_OWNER_TOKEN" | curl --fail-with-body --silent --show-error \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header @- \ "$LOAM_SUPABASE_URL/auth/v1/user" \ | jq --exit-status --raw-output '.factors[] | select(.factor_type == "totp" and .status == "verified") | .id' \ | head -n 1)"LOAM_OWNER_CHALLENGE_ID="$( printf 'Authorization: Bearer %s\n' "$LOAM_OWNER_TOKEN" | curl --fail-with-body --silent --show-error \ --request POST "$LOAM_SUPABASE_URL/auth/v1/factors/$LOAM_OWNER_FACTOR_ID/challenge" \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header @- \ | jq --raw-output '.id')"printf 'Authenticator code: ' >&2read -r LOAM_OWNER_TOTPLOAM_OWNER_TOKEN="$( { printf '%s\n%s\n' "$LOAM_OWNER_CHALLENGE_ID" "$LOAM_OWNER_TOTP" | jq --raw-input --slurp 'split("\n") | {challenge_id: .[0], code: .[1]}' | curl --fail-with-body --silent --show-error \ --request POST "$LOAM_SUPABASE_URL/auth/v1/factors/$LOAM_OWNER_FACTOR_ID/verify" \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header @/dev/fd/3 \ --header "Content-Type: application/json" \ --data @- | jq --raw-output '.access_token' } 3<<EOFAuthorization: Bearer $LOAM_OWNER_TOKENEOF)"unset LOAM_OWNER_EMAIL LOAM_OWNER_EMAIL_CODE LOAM_OWNER_TOTP LOAM_OWNER_CHALLENGE_IDLook up the API client's internal UUID through the redacted owner-readable view; do not use
the public client_id in the RPC. Then approve the bank account before the fresh AAL2 session
expires:
LOAM_API_CLIENT_UUID="$( printf 'Authorization: Bearer %s\n' "$LOAM_OWNER_TOKEN" | curl --fail-with-body --silent --show-error --get \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header @- \ --data-urlencode 'select=id' \ --data-urlencode "client_id=eq.$LOAM_CLIENT_ID" \ "$LOAM_SUPABASE_URL/rest/v1/api_clients_redacted" \ | jq --exit-status --raw-output 'if length == 1 then .[0].id else error("expected one API client") end')"printf 'Authorization: Bearer %s\n' "$LOAM_OWNER_TOKEN" | curl --fail-with-body --silent --show-error \ --request POST "$LOAM_SUPABASE_URL/rest/v1/rpc/api_client_destination_approve" \ --header "apikey: $LOAM_SUPABASE_PUBLISHABLE_KEY" \ --header @- \ --header "Content-Type: application/json" \ --data "$( jq --null-input \ --arg api_client_id "$LOAM_API_CLIENT_UUID" \ --arg destination_ref "$LOAM_BANK_ACCOUNT_ID" \ '{ p_api_client_id: $api_client_id, p_kind: "bank_account", p_destination_ref: $destination_ref }' )" | jqunset LOAM_OWNER_TOKEN LOAM_OWNER_FACTOR_IDUse that bank-account UUID as destination_id on the machine request. The bank account
also needs approved manual verification and payable bank details. Sandbox payouts support configured USD/EUR destinations only:
5–1,000,000 USDC for USD and 5–2,000,000 USDC for EUR. The payout currency is the
account's currency, and the rate is locked at operator approval. A new intent that cannot
be priced is refused before any reservation or debit. Existing intents remain replayable,
including when the manual rail is disabled. Accepted requests return 202 with Location;
follow it to retrieve the requested row. A same-key replay returns the same id with no
second treasury debit.
curl --fail-with-body --silent --show-error \ --request POST "$LOAM_API_BASE/offramp" \ --header "Authorization: Bearer $LOAM_TOKEN" \ --header "Content-Type: application/json" \ --header "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \ --data "{\"destination_id\":\"$LOAM_BANK_ACCOUNT_ID\",\"amount\":5000000,\"currency\":\"USDC\"}"Where to go next
- API reference — every endpoint, scope, schema, and error code.
- Settings → API clients — create, rotate, and revoke credentials.