Skip to content

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 200 regardless. Read rows, not the status.
  • rows is in submission order. Row n of the response is row n of what you sent, whatever order the rows finished in.
  • status per row is what that row would have returned on the single-create endpoint, so one branch of code handles both.
  • created counts rows that ended with an account id — including a row whose reference already named an account, which comes back 200. It is "succeeded", not "newly created".
  • Rows are validated up front. A missing client_reference_id or 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.