Developer Hub · Inicio rápido

Guía rápida de la Customer API

Pasa de un cliente API nuevo a una respuesta en vivo en unos cinco minutos: consulta los saldos liquidados y solicita una cotización de cambio. El flujo del marketplace se incluye cuando esa capacidad esté habilitada.

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 the preview write: it records provider-derived terms and moves no funds. Marketplace listings, offers, and trades are available only when the operator enables the marketplace capability.

Money movement stays closed during preview. Payments, bank transfers, and vault deposits or withdrawals need the money.write scope, which an organisation owner grants by hand under step-up authentication, alongside per-transaction limits and an approved destination. Build the balance read and a quote now; the last section explains what money endpoints will ask for.

Create an API client

Open Settings → API clients and create a client with the read and write scopes. Loam displays the client secret once, at creation — copy it into a secret manager before leaving the page, because rotating the client is the only way to see a secret again.

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.

Environment variables
export LOAM_API_BASE="https://<your-loam-host>/api/v1"export LOAM_CLIENT_ID="client_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_SECRET

Get 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
POST /api/v1/oauth/token HTTP/1.1Host: your-loam-hostContent-Type: application/x-www-form-urlencodedgrant_type=client_credentials&client_id=client_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:

Capture the token in a shell
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:

GET /api/v1/balances
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": 4250000,
      "currency": "USD",
      "last_settled_at": "2026-07-14T09:12:04.118Z"
    }
  ]
}

Balances are integers in the currency's smallest unit: 4250000 with USD means USD 42,500.00. Never parse them as decimals.

An empty data array is a success, not an error — it means the organisation has no settled balances yet.

Request a foreign-exchange quote

A quote is a write, not a money movement. The body is strict: from_currency, to_currency, and amount are all required, the two currencies must differ, and amount is a positive integer in the source currency's smallest unit — 10000 with USD is USD 100.00. Every write needs an Idempotency-Key header.

The call returns 202 Accepted with a Location pointing at the quote. Creating a quote does not move funds; execution is a separate capability and is not exposed by this operation. When money.write opens, you reuse the quoted currencies and integer amounts on a payment, ramp, or vault request.

POST /api/v1/fx/quotes
curl --fail-with-body --silent --show-error \
  --request POST "$LOAM_API_BASE/fx/quotes" \
  --header "Authorization: Bearer $LOAM_TOKEN" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: $(uuidgen | tr '[:upper:]' '[:lower:]')" \
  --data '{"from_currency":"USD","to_currency":"EUR","amount":10000}'

// Response
HTTP/1.1 202 Accepted
Location: /api/v1/fx/quotes/a6bbdbeb-ed3b-42cb-85b1-af3112f2fe06

{
  "ok": true,
  "data": {
    "id": "a6bbdbeb-ed3b-42cb-85b1-af3112f2fe06",
    "from_amount": 10000,
    "from_currency": "USD",
    "to_amount": 9125,
    "to_currency": "EUR",
    "rate_decimal": "0.9125",
    "fee_amount": -25,
    "fee_currency": "USD",
    "expires_at": "2099-08-14T16:35:00.000Z",
    "state": "active"
  }
}

rate_decimal is a decimal string, not a float. fee_amount is a signed integer in fee_currency minor units. state is active, consumed, or expired.

A malformed body, a missing field, or the same currency on both sides returns 400 with {"ok": false, "error": "invalid_input"}. A withdrawn API enablement or KYB denial returns 403 with forbidden. Provider degradation returns 503 with transient_error.

Read the quote back at the Location. The path is the quote id:

GET /api/v1/fx/quotes/{id}
curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $LOAM_TOKEN" \
  "$LOAM_API_BASE/fx/quotes/a6bbdbeb-ed3b-42cb-85b1-af3112f2fe06" | jq

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.

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. Rotate the secret only if it leaked.
403 Ask an organisation owner to widen the grant. Do not retry.
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

Payment, funding, and vault endpoints are held to a stricter standard than the reads and quotes above, and they stay closed during preview. A quote (POST /api/v1/fx/quotes) is the preview stand-in: it locks provider-derived terms under the write scope and moves no funds. When money movement opens, you reuse those currencies and integer amounts on a money.write request — there is no execute-quote route on this API.

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.

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, or a failed step-up grant. 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:

POST /api/v1/payments
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; GET /api/v1/payments lists the organisation's payments.

Funds in from an approved bank account use source_id rather than a payee:

POST /api/v1/onramp
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>"
  }
}

Vault deposits and withdrawals take the vault id in the path and an amount in minor units. The vault id is supplied with the API client; these routes do not list vaults.

POST /api/v1/vaults/{vault}/deposits
curl --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}'
POST /api/v1/vaults/{vault}/withdrawals
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 is on the contract — destination_id, amount, and currency, same envelope — and stays closed until destination screening and travel-rule certification land. Do not call it during preview.

POST /api/v1/offramp
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":"2f4cbb59-3ab1-4b6d-8d10-9cd2236cf94e","amount":2500000,"currency":"USD"}'

Where to go next