Writing data¶
The write endpoints create and change accounts, place and close trades, and record money movements. They share one set of rules, and this page covers those rules plus the account endpoints themselves.
Early access
The write endpoints are not enabled on production. Credentials are issued to design-partner firms only. See the changelog before you plan work around them.
What you can write¶
| Operation | Endpoint | Scope |
|---|---|---|
| Create an account | POST /api/v1/accounts |
accounts:write |
| Update an account | PATCH /api/v1/accounts/{id} |
accounts:write |
| Create up to 1000 accounts | POST /api/v1/accounts/bulk |
accounts:write |
| Open a position | POST /api/v1/positions/open |
trades:write or trader:trade |
| Close a position | POST /api/v1/positions/close |
trades:write or trader:trade |
| Record a transaction | POST /api/v1/transactions |
transactions:write or trader:funds |
| Approve a transaction | POST /api/v1/transactions/{id}/approve |
transactions:write or trader:funds |
| Reject a transaction | POST /api/v1/transactions/{id}/reject |
transactions:write or trader:funds |
| Cancel a transaction | POST /api/v1/transactions/{id}/cancel |
transactions:write or trader:funds |
Trading is covered in Trading; money movement in Transactions.
There is nothing else. In particular there is no endpoint to open a trading account, to change an existing order, to place a pending order, or to edit an email or phone number — those are Backoffice operations.
Rules that apply to every write¶
Send an Idempotency-Key¶
It is required on all nine endpoints, and repeating one has precise consequences. Idempotency and retries is the page to read first.
Ids are strings¶
Every id on these endpoints — in the request and in the response — is a decimal string:
{ "account_id": "4210001", "trading_account_id": "4210044", "brand_id": "13001" }
Sending "brand_id": 13001 is a 422, not a helpful coercion. Ids are strings
because they can exceed what a JSON number holds exactly in most clients, and a
silently rounded id is worse than a rejected request.
The read endpoints do the opposite
Bulk reads return record_id and account_id as JSON
numbers. If you feed a read straight into a write, convert them. This
is an inconsistency between the two halves of the API, not a rule with a
reason behind it, and it is on the list to reconcile before general
availability.
Money and sizes are strings¶
Amounts, volumes and prices are decimal strings, in both directions:
{ "amount": "1500.00", "size": "0.25", "desired_price": "1.09442" }
Parse them with a decimal type. Some fields will accept a JSON number on the way in and some will refuse it, so always send the string form — it is correct everywhere.
Refusals you will see everywhere¶
| Status | code |
Means |
|---|---|---|
400 |
idempotency_key_required |
The header is missing, blank, or over 255 characters |
403 |
forbidden |
The credential does not hold the scope, or your firm's own permissions no longer allow it |
404 |
not_found |
The record does not exist, or your credential cannot reach it |
409 |
idempotency_in_flight |
An attempt with this key is still running |
409 |
invalid_state |
The request is valid; the record's current state refuses it |
422 |
validation_failed |
The body is wrong |
503 |
write_contention |
Concurrent activity on the same record; retry |
A 404 never distinguishes "does not exist" from "exists but is not yours".
That is deliberate, and it means you cannot use these endpoints to discover
whether an id is real.
A malformed body is refused before the error format applies
Everything in the table above arrives as application/problem+json with a
code you can branch on. A body that cannot be read at all — an id sent as
a number, a missing required field, broken JSON — is refused earlier than
that, and comes back as a plain-text message instead.
So do not assume every failure parses as problem+json. Check the response
content type, or fall back on the status when parsing fails.
Create an account¶
POST /api/v1/accounts
curl -sS -X POST "$BASE/api/v1/accounts" \
-H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: 7f9c2e14-3b6d-4a51-9f0e-2c8d51a7b3e4' \
-H 'Content-Type: application/json' \
-d '{
"client_reference_id": "ACME-8891",
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Lovelace",
"phone": "+44 20 7946 0958",
"phone_region": "GB",
"country": "GB",
"language": "en",
"brand_id": "13001"
}'
| Field | Required | Notes |
|---|---|---|
client_reference_id |
Yes | Your own id for this person, 1–255 characters. Case-insensitive, and permanent — see below. |
email |
Yes | Must contain @. Used to detect that this person already exists at the brand. |
phone |
No | Any format. Normalized for you, never rejected for formatting. |
phone_region |
No | Two-letter country code, used as a hint when normalizing phone. |
first_name, last_name |
No | If both are omitted, the account is named after the email. |
country, language |
No | Stored as sent. |
brand_id |
Conditional | Required for a firm-wide credential. A brand-bound credential may only name its own brand. |
{
"id": "4210001",
"revision": 48815,
"client_reference_id": "ACME-8891",
"first_name": "Ada",
"last_name": "Lovelace",
"phone": "+442079460958",
"country": "GB",
"language": "en",
"status": "New",
"brand_id": "13001",
"firm_id": "10034"
}
| Status | Means |
|---|---|
201 |
Created. |
200 |
This client_reference_id already names an account. The body is that account — nothing was created. |
403 |
Missing accounts:write, or brand_id is not the brand this credential is bound to. |
409 invalid_state |
Someone with this email or phone number already exists at this brand. |
422 |
brand_id is missing on a firm-wide credential, the email is not an email, or client_reference_id is blank or over 255 characters. |
Three things happen that are worth knowing:
- The account is assigned an owner by your firm's own routing rules, exactly as a sign-up or a partner referral would be. This API does not get its own assignment rule, and you cannot set the owner on the request.
- It is recorded as having come from this API, so your operators can tell API-created accounts from the rest.
- No trading account is created. The account exists, with no place to trade yet.
client_reference_id is permanent
It decides which account a retry lands on. Take it from your own primary key for that customer and never regenerate it — a changed reference creates a second person. Idempotency and retries explains exactly what happens.
Update an account¶
PATCH /api/v1/accounts/{id}
Requires both an Idempotency-Key and an If-Match carrying the ETag from
an earlier response. See
compare-and-swap on updates.
Five fields can be changed, and only the ones you send are touched:
| Field | Notes |
|---|---|
first_name |
|
last_name |
|
country |
|
language |
|
status |
Stored as sent. It is not checked against the statuses your firm has configured, so send one that exists. |
A body that would change nothing is 422 rather than a no-op, because a silent
version bump would invalidate every other client's ETag for no reason.
Email and phone are not editable here
They identify the person, and moving an identity from one account to another is a different operation with different consequences. Do it in the Backoffice.
Create accounts in bulk¶
POST /api/v1/accounts/bulk
curl -sS -X POST "$BASE/api/v1/accounts/bulk" \
-H "Authorization: Bearer $TOKEN" \
-H 'Idempotency-Key: 3d1b8a06-11f4-4f7a-9c0b-6a2f4e88de10' \
-H 'Content-Type: application/json' \
-d '{
"accounts": [
{"client_reference_id": "ACME-8891", "email": "ada@example.com"},
{"client_reference_id": "ACME-8892", "email": "grace@example.com"}
]
}'
Each row is exactly the single-create body. Between 1 and 1000 rows; an empty
batch is 422 and more than 1000 is 413 scope_too_wide.
{
"total": 2,
"created": 1,
"failed": 1,
"rows": [
{"client_reference_id": "ACME-8891", "status": 201, "id": "4210001", "code": null, "detail": null},
{"client_reference_id": "ACME-8892", "status": 409, "id": null,
"code": "invalid_state", "detail": "an account with this identity already exists at this brand"}
]
}
- A batch is not all-or-nothing. Each row succeeds or fails on its own, and
the whole response is
200regardless. Readrows, not the status. rowsis in submission order. Rownof the response is rownof what you sent, whatever order the rows finished in.statusper row is what that row would have returned on the single-create endpoint, so one branch of code handles both.createdcounts rows that ended with an account id — including a row whose reference already named an account, which comes back200. It is "succeeded", not "newly created".- Rows are validated up front. A missing
client_reference_idor a brand the credential cannot use is refused for the whole batch before anything is created, and the message names the row index.
Resubmitting a partially-failed batch is safe: rows that already landed are recognized by their reference and are not duplicated.
Be precise about what "the same batch" means for the idempotency key, though. A batch is identified by its rows' references, emails and brands, in order — so adding, removing or reordering rows makes it a new batch that executes, while editing only a first name leaves it looking identical and replays the original response. See what a key is scoped to.