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
501on 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.
Related¶
- Idempotency and retries
- Writing data
- Bulk reads — querying transactions
- Error catalog