Skip to content

Transactions

Four endpoints record money movement and move it through its lifecycle: one creates a transaction, and three approve, reject or cancel it. All four require an Idempotency-Key.

Early access

The transaction endpoints are not enabled on production. See the changelog.

Who may move money

A server credential needs transactions:write. A delegated trader token needs trader:funds and must not be read-only. Either scope reaches all four endpoints.

As with trading, a server credential holding trader:funds is not limited to one account — the account limit belongs to the delegated token, not to the scope name.

Cancelling a transaction that has already settled additionally requires transactions:amend. On its own that scope grants nothing at all: it only widens what transactions:write or trader:funds may cancel.

Create a transaction

POST /api/v1/transactions
curl -sS -X POST "$BASE/api/v1/transactions" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Idempotency-Key: 2a6c1de9-84b7-4f39-9d02-1cf5b7e3a884' \
  -H 'Content-Type: application/json' \
  -d '{
        "client_reference_id": "ACME-DEP-55120",
        "account_id": "4210001",
        "trading_account_id": "4210044",
        "currency": "USD",
        "amount": "1500.00",
        "method": "Deposit",
        "transaction_type": "Deposit"
      }'
Field Required Notes
client_reference_id Yes Your own id for this transaction, 1–255 characters. Also the idempotency subject, so two deliberately identical deposits never collide.
account_id Yes Decimal string.
trading_account_id Yes Must belong to account_id, or the answer is 404.
currency Yes Currency code.
amount Yes Decimal string.
method No Deposit, Withdrawal or Bonus. Omitted, it is taken from the sign of amount.
transaction_type Yes How your operators see it: Deposit, Withdrawal, Credit or Debit.
{
  "id": "88231",
  "account_id": "4210001",
  "trading_account_id": "4210044",
  "currency": "USD",
  "amount": "1500.00",
  "method": "Deposit",
  "transaction_type": "Deposit",
  "status": "pending",
  "is_ftd": false,
  "created_at": 1753634000123
}
Status Means
201 Created.
200 This client_reference_id already names a transaction. The body is that transaction.
403 Missing a funds scope, or the delegated token is read-only.
404 The account or trading account does not exist or is out of reach.
422 currency is empty, client_reference_id is blank or over 255 characters, or amount is not a decimal.

A transaction created here is always pending

There is no field that creates an already-approved transaction, by design: approving is a separate call under a separately-checked scope, so no single request can both record money and settle it unreviewed. If that is inconvenient, it is inconvenient on purpose.

The lifecycle

status is one value rather than a set of flags, so there is no combination to interpret:

status Means
pending Recorded, awaiting a decision.
approved Approved. The money is on its way to the balance.
settled The movement has completed.
rejected Refused. Terminal.
cancelled Withdrawn. Terminal.

A transaction that was cancelled reads cancelled even if it had settled first — terminal states win.

is_ftd marks a first-time deposit. It is set once, when the first deposit for the account is approved, and never moves afterwards.

Approve, reject, cancel

POST /api/v1/transactions/{id}/approve?account_id={account_id}
POST /api/v1/transactions/{id}/reject?account_id={account_id}
POST /api/v1/transactions/{id}/cancel?account_id={account_id}

All three take account_id as a required query parameter. You name the owning account rather than letting the API find it, which keeps every lookup a direct read; you already have the id from creating or listing the transaction.

reject and cancel also take a body with a non-empty reason:

curl -sS -X POST "$BASE/api/v1/transactions/88231/reject?account_id=4210001" \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Idempotency-Key: 9b12f704-6c3a-4a88-bd51-0f9e2a6c4471' \
  -H 'Content-Type: application/json' \
  -d '{"reason": "Source of funds not evidenced"}'

The reason is kept with the transaction for your operators. It is not returned on this API — you sent it, and reading your own text back adds nothing.

approve takes no body.

All three answer 200 with the updated transaction.

Call Behavior
approve on an already-approved transaction 200, unchanged. Approving twice does not credit twice.
approve on a rejected or cancelled one 409 invalid_state.
reject on an already-rejected one 200. The first reason and the operator who gave it are kept.
cancel on an already-cancelled one 200, unchanged.
cancel on a transaction that has settled Allowed only with transactions:amend, and it reverses the money with a compensating entry. Without the scope: 403.
Any of them with an empty reason where one is required 422.

Approving a payment-provider withdrawal is refused

501 not_implemented

POST /api/v1/transactions/{id}/approve refuses, with 501, any withdrawal that pays out through a payment provider.

This is deliberate and it is a gap, not a bug. Approving such a withdrawal has two halves: setting the customer's money aside, and instructing the provider to pay it. The second half is not built on this API yet. Approving here would therefore take the money out of the customer's available balance, tell you it succeeded, and never pay anyone — indefinitely. Refusing before anything is set aside is the better failure.

Do not retry. The answer will be identical until the missing half ships; this is the one refusal on the whole API where retrying is always wrong.

What to do instead:

  • Approve payment-provider withdrawals in the Backoffice. That path is complete and unaffected.
  • Deposits and withdrawals that do not go through a payment provider are unaffected and approve normally through this API.
  • If you cannot tell the two apart from your side, treat a 501 on approve as "this one is for an operator" and route it to your operations queue.

It will be a changelog entry when it ships.

Refusals

Status code Means
403 forbidden No funds scope, or cancelling a settled transaction without transactions:amend.
403 read_only_credential The delegated token was minted read-only.
404 not_found The transaction or account does not exist or is out of reach — including a transaction that does not belong to the account_id you named.
409 invalid_state The transaction's current state does not allow this transition.
409 idempotency_in_flight An attempt with this key is still running.
422 validation_failed A missing reason, a bad amount, or an id that is not a decimal string.
501 not_implemented Approving a payment-provider withdrawal. Never retry.

The key on a transition is scoped to the account

On approve, reject and cancel, the idempotency key is scoped to the account rather than to the transaction. Reusing one key across two transactions on the same account returns the first result for both. Use a fresh key per transition. See the body is not compared.

What this API does not show you

The transaction shape published here is a fixed list of fields. It never carries which payment provider was used, any provider-side reference, or an operator's free-text reasoning — those belong to your operators, not to an integration.