Atladix API
Atladix runs the risk engine behind your prop firm. This API is how your systems talk to it: create traders, open accounts under your products, and read what every account is doing right now — the same numbers our engine uses to decide whether to liquidate.
Getting started
Everything is REST over HTTPS with JSON bodies. There is one base URL and one version:
You authenticate with an API key in an Authorization
header. Keys are issued by Atladix, not self-served — talk to us and you get
one for your sandbox the same day.
Every response is JSON, including errors. Every error carries a stable
code you can branch on; the message is for humans and
may change.
# The base URL. Nothing else to configure.
https://api.atladix.com/api/v1
# Every request
curl https://api.atladix.com/api/v1/accounts \
-H "Authorization: Bearer atlk_test_…"
# Every error, same shape
{
"error": {
"code": "ACCOUNT_EXISTS",
"message": "account WB-7345 already exists",
"detail": { }
}
}
What you can and cannot do
The API covers the operational half of running a firm: people and accounts. Create a trader when they sign up, open an account under one of your products, link it, deactivate it, and read its live state for your dashboard.
The sandbox
Your firm gets a test IB — a complete, isolated copy of your world. Same endpoints, same validation, same errors. The traders and accounts you create there are real rows with real risk rules; they simply are not your production ones.
It exists because the alternative is testing an integration against people's money. A mistake in the sandbox costs nothing. The same mistake in production liquidates somebody's account.
You cannot reach production data with a test key, and you cannot reach the sandbox with a live one. This is not a convention — it is enforced on every request.
# Same call, different key, different world.
curl https://api.atladix.com/api/v1/accounts \
-H "Authorization: Bearer atlk_test_…"
# → only your sandbox accounts
curl https://api.atladix.com/api/v1/accounts \
-H "Authorization: Bearer atlk_live_…"
# → only your real accounts
# No test IB yet? The error says so.
{ "error": { "code": "IB_TEST_MISSING",
"message": "your firm has no test IB yet" } }
Going live
You do not reach production by being issued a live key. You reach it by integrating against the sandbox and asking.
- Atladix creates your test IB. Your
atlk_test_key starts working immediately. - You integrate. Every call that matters is counted automatically — users created, accounts opened, statuses read, webhooks registered.
- You request activation from the Developer screen in your panel, describing what you tested in your own words.
- Atladix reviews what you said next to what your key actually did.
- Approval switches on every pending live key you have.
A live key issued before approval exists but does not work: it answers
403 LIVE_NOT_APPROVED. That is deliberate — if it worked, approval
would be a formality after the fact instead of the thing that opens the door.
no sandbox → testing → pending → live
↓
rejected → fix it, ask again
# A live key before approval
{ "error": {
"code": "LIVE_NOT_APPROVED",
"message": "this key is valid but your firm
is not approved for production yet: finish
integrating with your atlk_test_… key and
request activation from the Developer
screen" } }
unauthorized here would send you hunting for a typo in a
credential that is spelled perfectly.
Authentication
Send your key as a Bearer token. Nothing else authenticates a request.
Never in the query string. We do not accept it there, and the
reason is not pedantry: a credential in a URL ends up in your proxy logs, in
your browser history, and in the Referer header of the next
request.
Keys are shown once, when issued. We store a SHA-256 hash and cannot read yours back — if it is lost, we revoke it and issue another.
# Correct
curl https://api.atladix.com/api/v1/accounts \
-H "Authorization: Bearer atlk_live_…"
# Rejected — 401
curl "https://api.atladix.com/api/v1/accounts?key=atlk_live_…"
The prefix tells you the environment
| Prefix | Reaches | Notes |
|---|---|---|
atlk_test_ |
Your test IB only | Works as soon as Atladix creates your sandbox. Never touches production. |
atlk_live_ |
Your real IBs only | Born inactive. Answers 403 LIVE_NOT_APPROVED until your firm is approved. |
atl_… token you use to log into the Atladix panel,
you get a 401 that says exactly that. The two credentials have
different lives — a panel token belongs to a person and expires in twelve hours;
an API key lives in a deployment file and does not expire on its own. Neither
opens the other's door, and the rejection happens on the prefix, before anything
touches the database.
A request with a live key that names your test IB gets
403 WRONG_ENVIRONMENT, and the other way round. If your firm is
suspended, every key answers 403 FIRM_SUSPENDED — the credential is
fine and will work again; this is not a problem with the key.
Guides
Create a trader
One call when somebody signs up on your site. username and either
an email or an external_id are what you will use to
refer to them afterwards — you never need to store our UUID, though we return it.
Omit password and we generate one and return it once.
We keep an argon2id hash; it cannot be read again.
With a test key the ib field is ignored: there is only one place
to practise.
curl -X POST https://api.atladix.com/api/v1/users \
-H "Authorization: Bearer atlk_test_…" \
-H "Content-Type: application/json" \
-d '{
"username": "asanchez",
"email": "ana@example.com",
"first_name": "Ana",
"last_name": "Sánchez",
"external_id": "crm-88213",
"address": { "country": "ES" }
}'
→ 201
{
"user": { "id": "01922e…", "username": "asanchez", … },
"password": "…", // only if we generated it
"note": "store the password now…"
}
Creating the same person twice returns 409 USER_EXISTS — same
username, same email, or same external_id. That is a
"already done, carry on", not a "your request is wrong": a
retrying client should treat it as success.
Open an account
account_id is yours: it is what your trader sees
in their platform and what your support team types when they call. We do not
invent it.
product is the slug of one of your products — not
a UUID. The slug is the stable key: it survives publishing a new version of the
product, which is what happens every time you change a rule.
owner is optional. An account can be created unowned and linked
later — which is what you want if you sell accounts before knowing who will
trade them.
curl -X POST https://api.atladix.com/api/v1/accounts \
-H "Authorization: Bearer atlk_test_…" \
-H "Content-Type: application/json" \
-d '{
"account_id": "WB-7345",
"product": "challenge_edge_50k",
"owner": "ana@example.com"
}'
→ 201
# Wrong slug? The error brings the right ones.
{ "error": { "code": "PRODUCT_NOT_FOUND",
"message": "no product with slug
\"challenge_50\" in this firm",
"detail": { "available": [
"challenge_edge_50k", "challenge_flex_25k" ] } } }
"available" comes back empty — which is honest, not broken. Publish a
new version of each product from the panel with its slug filled in, and it
appears here. Do this once, before your integration goes anywhere near live.
Link and deactivate
Give an existing account an owner. The account and the person must belong to the same IB — we enforce it, so you cannot accidentally hand a Premium account to a Standard trader.
Stop an account trading. It is reversible from the panel. An account the risk
engine already liquidated will answer 409 ACCOUNT_STATUS_FINAL —
reactivating that is a decision, not an API call.
curl -X POST \
https://api.atladix.com/api/v1/accounts/WB-7345/link \
-H "Authorization: Bearer atlk_test_…" \
-H "Content-Type: application/json" \
-d '{ "owner": "ana@example.com" }'
curl -X POST \
https://api.atladix.com/api/v1/accounts/WB-7345/deactivate \
-H "Authorization: Bearer atlk_test_…"
Read account status
This is the endpoint your dashboard lives on. Everything an account is doing, in one response — because making your server chain five calls is how you end up with a dashboard that takes two seconds to load.
The fields that matter
live tells you where the numbers came from, and it
is not an implementation detail. true means straight from the risk
engine, marked to the last tick — the same numbers we use to decide whether to
liquidate. false means the last persisted state.
It matters because open P&L does not exist in a database: it
is marked to market tick by tick and only lives in the engine. A dashboard
showing equity without open P&L tells a trader who is down on an open
position that they are flat. When the account is not live that field is zero,
with live: false next to it — an honest zero beats a figure from
three hours ago presented as current.
The shape is identical either way. You never have to parse two response formats depending on whether an account happens to be trading.
Every limit comes with used, limit,
remaining and used_pct_bps (basis points: 2190 =
21.90%) so you can draw a bar without doing arithmetic — and without your
arithmetic drifting from ours.
consistency is null when the product has no
consistency rule. null means does not apply, which is not
the same as fails.
curl \
https://api.atladix.com/api/v1/accounts/WB-7345/status \
-H "Authorization: Bearer atlk_live_…"
{
"account_id": "WB-7345",
"status": "active", // account lifecycle
"rms_status": "active", // is risk watching it
"live": true, // ← from the engine
"at": "2026-08-21T09:14:02Z",
"currency": "USD",
"balance_cents": 5012300, // realised
"equity_cents": 4998100, // balance + open
"open_pnl_cents": -14200, // engine only
"day_pnl_cents": -21900,
"daily_loss": {
"used_cents": 21900, "limit_cents": 100000,
"used_pct_bps": 2190, // 21.90 %
"remaining_cents": 78100
},
"drawdown": {
"used_cents": 41900, "limit_cents": 200000,
"used_pct_bps": 2095,
"remaining_cents": 158100,
"mode": "eod", // or "intraday"
"floor_cents": 4840000 // liquidate below this
},
"consistency": {
"type": "best_day_pct",
"best_day_pct_bps": 3800,
"limit_pct": 50,
"best_day_date": "2026-08-18",
"eligible": true
},
"profit_target": {
"profit_cents": -1900,
"target_cents": 300000, "pct_bps": 0
},
"trading_days": { "current": 4, "minimum": 3 },
"max_position": {
"used_units": 20, "limit_units": 50 }
}
Listing
Paginated, filterable by ?ib=. Use these to reconcile; use
/status for anything a person is looking at.
curl \
"https://api.atladix.com/api/v1/accounts?ib=premium" \
-H "Authorization: Bearer atlk_live_…"
Webhooks
Everything above can be polled, and for painting a screen that is fine. For the things that actually matter — emailing a trader the moment their account is liquidated, starting a payout when they hit the target — you need to know when it happens. We call you.
The four events
| Type | When |
|---|---|
account.liquidated |
The risk engine closed the account: a hard limit was crossed and its positions were flattened. |
account.breached |
A rule was violated without liquidation. |
session.closed |
End of the New York session, with the day's stable figures. |
account.passed |
The figures reached the product's objective. Sent once, when they cross. |
They are four types rather than one with a kind field because you
subscribe to what you care about. Nearly everyone wants liquidations within the
second; plenty of firms do not want a message every time somebody grazes a rule
that does not close the account.
account.breached arrives once per rule per session,
not once per tick. A product with liquidate_on_breach: false leaves
the account alive and below its floor, so every following tick violates again —
but the breach is one event and the ticks confirming it are not news.
account.passed is measurement, not approval —
hence the name. We do not mark the account as passed or stop watching it. It is
emitted at session close with the snapshot figures, and only when they cross: a
trader who qualified on Tuesday does not generate another event on Wednesday.
If you start a payout from this event, that is what stops you paying five times.
// Every event, same envelope.
// "id" is for deduplication, "type" for routing —
// both without opening "data".
{
"id": "01922f3c-…",
"type": "account.liquidated",
"created_at": "2026-08-21T14:03:11Z",
"data": {
"account": {
"id": "01922e…",
"number": "WB-7345",
"status": "liquidated",
"ib": "winbance-premium"
},
"rule": "daily_loss",
"event_type": "daily_loss_breach",
"occurred_at": "2026-08-21T14:03:11Z",
"limit_cents": 100000,
"floor_cents": 4900000,
"equity_cents": 4898100,
"excess_cents": 1900,
"liquidation": {
"cancelled_orders": 2,
"fills": 1,
"equity_after_cents": 4898100
}
}
}
session.closed carries the figures your evaluation runs on — the
ones that do not change if you look again next month, because they come from
the persisted snapshot rather than being recomputed on today's state.
It is sent for every close of a session. If we re-run a close —
correcting figures — you get it again with the same
account + trading_date. Deduplicate on that pair and
take the latest.
objectives.met is the same measurement as
account.passed: it is there so a single event tells you where the
account stands without a second call.
{
"id": "01922f9a-…",
"type": "session.closed",
"created_at": "2026-08-21T21:00:04Z",
"data": {
"account": {
"id": "01922e…", "number": "WB-7345",
"status": "active", "ib": "winbance-premium"
},
"trading_date": "2026-08-21",
"session_close_at": "2026-08-21T21:00:00Z",
"balance_cents": 5142300,
"equity_cents": 5142300,
"realized_pnl_cents": 64200,
"fills_count": 18,
"total_profit_cents": 142300,
"trading_days": 5,
"best_day_pct_bps": 3800, // 38.00 %
"objectives": {
"met": false,
"profit_target_cents": 300000,
"min_trading_days": 3
}
}
}
Verifying signatures
Every delivery carries three headers:
v1 is HMAC-SHA256 of the string
"<t>.<raw body>", in hex, using your endpoint's signing
secret.
The timestamp is inside what is signed, not just alongside it. If it only travelled in the header, anyone could change it, replay the same body with today's date, and your age check would be worthless.
Three rules, and all three matter:
- Check the age first — reject anything more than five minutes old or in the future. That is what stops a replay.
- Sign the raw body, exactly as it arrived. Do not re-serialize the JSON: a different key order or one extra space is a different hash.
- Compare in constant time. A normal
==returns faster the earlier it finds a difference, and that timing leaks the expected hash byte by byte.
Your secret is shown once, when the endpoint is created
(whsec_…), and never returned again. Lost it? Delete the endpoint
and create another.
X-Atladix-Signature: t=1787320991,v1=8f2a…c1
X-Atladix-Event-Id: 01922f3c-…
X-Atladix-Attempt: 1
# Python
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes) -> None:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
# 1. Age first — this is what stops a replay.
if abs(time.time() - t) > 300:
raise ValueError("stale or future signature")
# 2. The RAW body, exactly as received.
expected = hmac.new(secret.encode(),
f"{t}.".encode() + body,
hashlib.sha256).hexdigest()
# 3. Constant time.
if not hmac.compare_digest(expected, parts["v1"]):
raise ValueError("bad signature")
// Node
const crypto = require("crypto")
function verify(secret, header, body) {
const p = Object.fromEntries(
header.split(",").map(s => s.split("=")))
const t = parseInt(p.t, 10)
if (Math.abs(Date.now() / 1000 - t) > 300)
throw new Error("stale or future signature")
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".").update(body) // body: Buffer
.digest("hex")
if (!crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(p.v1)))
throw new Error("bad signature")
}
express.raw({type: "application/json"}) on the webhook route; in
Django read request.body, not request.POST.
Retries and back-off
| Timeout | 5 seconds. Acknowledge and process afterwards — do not do the work inside the handler. |
| Success | Any 2xx. A 202 Accepted is the right answer. |
| Retries | 5 attempts: 1 min · 5 min · 30 min · 2 h · 8 h, then it stops. |
| Auto-disable | After 20 consecutive failures the endpoint is switched off, with the reason recorded. One good delivery resets the counter. |
A retry sends the identical body — we store it and never recompute it. The signature is recalculated for each attempt, with that attempt's timestamp: this is what lets you reject stale messages without rejecting a legitimate retry eight hours later.
Failures are consecutive because a server that fails once a day for a month is alive and having problems; one that fails twenty times in a row is off, and continuing to call it just fills somebody's log.
id. At-least-once delivery means you will eventually
see the same event twice — a timeout on your side after you already processed it
looks exactly like a failure to us.
Managing endpoints
| Route | Does |
|---|---|
POST /api/v1/webhooks | Register one. Returns the secret, once. |
GET /api/v1/webhooks | The ones in your key's environment. |
GET /api/v1/webhooks/{id} | One. |
PATCH /api/v1/webhooks/{id} | URL, subscriptions, or status. |
DELETE /api/v1/webhooks/{id} | Removes it — or disables it, if it already has deliveries. |
POST /api/v1/webhooks/{id}/test | Queues a test.ping to that endpoint. |
GET /api/v1/webhooks/{id}/deliveries | The last 50, with what you answered. |
The environment comes from the key, not the body. There is no
env field: if there were, "create a live endpoint" signed with a
test key would be a request somebody had to decide how to answer. Without the
field, the question cannot be asked.
"events": [] — or leaving it out — means all of them,
which is what you want when you are starting and do not yet know which ones
matter.
https only, with an exception for localhost while
you develop. A webhook carries somebody's account state; over plain http that is
readable by whoever is in between.
curl -X POST https://api.atladix.com/api/v1/webhooks \
-H "Authorization: Bearer atlk_test_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourfirm.com/atladix/webhooks",
"events": ["account.liquidated", "account.passed"]
}'
→ 201
{
"webhook": {
"id": "01922f…",
"env": "test",
"url": "https://yourfirm.com/atladix/webhooks",
"events": ["account.liquidated","account.passed"],
"status": "active",
"consecutive_failures": 0
},
"secret": "whsec_yF3…", // ← store it now
"note": "…it is not returned again"
}
# Test it without waiting for a real liquidation.
curl -X POST \
https://api.atladix.com/api/v1/webhooks/01922f…/test \
-H "Authorization: Bearer atlk_test_…"
→ 202
{ "event_id": "01922f4a-…", "type": "test.ping" }
# Then look at what happened.
curl \
https://api.atladix.com/api/v1/webhooks/01922f…/deliveries \
-H "Authorization: Bearer atlk_test_…"
{ "deliveries": [{
"event_type": "test.ping",
"status": "delivered",
"attempt": 1,
"http_status": 202,
"delivered_at": "2026-08-21T21:24:00Z" }] }
The test ping goes to that endpoint, ignoring subscriptions — so you can check your URL responds and your signature verification works even if you have your event list wrong. It counts as a normal delivery: same retries, same back-off, same failure counter. A ping that never arrives has to disable the endpoint like anything else, or the button would say "fine" about a URL that does not exist.
Errors
Every error is JSON with the same shape. Branch on code, never on
message: the code is part of the contract and the message is not.
Some errors carry detail with what you need to fix it —
PRODUCT_NOT_FOUND brings the slugs that do exist, so you can correct
yourself without opening the panel.
{
"error": {
"code": "PRODUCT_NOT_FOUND",
"message": "no product with slug
\"challenge_50\" in this firm",
"detail": {
"available": [
"challenge_edge_50k", "challenge_flex_25k" ]
}
}
}
| Code | HTTP | When |
|---|---|---|
UNAUTHORIZED | 401 | No key, an unknown or revoked key, or a panel token. |
FIRM_SUSPENDED | 403 | Your firm is suspended. The key is fine and will work again. |
IB_TEST_MISSING | 403 | Test key, and your firm has no sandbox yet. |
WRONG_ENVIRONMENT | 403 | A live key asking for the sandbox, or the reverse. |
LIVE_NOT_APPROVED | 403 | Valid live key, firm not approved for production yet. |
NOT_FOUNDACCOUNT_NOT_FOUND | 404 | Does not exist — or belongs to another firm. From outside these are indistinguishable on purpose. |
WEBHOOK_NOT_FOUND | 404 | Unknown endpoint, another firm's, or the other environment's. |
USER_EXISTS | 409 | That username, email or external_id is taken. |
ACCOUNT_EXISTS | 409 | You already have an account with that account_id. |
ACCOUNT_STATUS_FINAL | 409 | The account is in a state the API does not bring it back from. |
WEBHOOK_EXISTS | 409 | That URL is already registered in this environment. |
PRODUCT_NOT_FOUND | 422 | Unknown product slug. detail.available has the real ones. |
MD_IDENTITY_INCOMPLETE | 422 | Market data requested for a trader whose subscriber details are incomplete. |
WEBHOOK_URL_INSECURE | 422 | The URL is not https (and is not localhost). |
INVALID_REQUEST | 422 | Validation. The message says which field. |
RATE_LIMITED | 429 | Over 10 requests per second on that key. |
409 ACCOUNT_EXISTS is "already done, carry on" — safe to treat as
success. 422 is "fix the request"; retrying it unchanged will fail
forever.
403 would confirm the resource exists, and with a handful of
requests you could enumerate a competitor's book. If it is not yours, it does not
exist as far as you can tell.
Rate limits
10 requests per second per key, as a token bucket: the bucket holds 10 and refills continuously, so a short burst of 10 goes straight through and a sustained 10/s is fine.
Over the limit you get 429 RATE_LIMITED with a
Retry-After header in seconds. Wait that long — do not retry
immediately, and do not retry in a tight loop.
The limit is per key. If you need more throughput for a batch job, ask us for a second key rather than sharing one across your fleet — that way a runaway job cannot starve your dashboard.
If you are polling /status for a dashboard, consider
webhooks instead for the events, and poll only what the
user is looking at.
HTTP/1.1 429 Too Many Requests
Retry-After: 1
{ "error": {
"code": "RATE_LIMITED",
"message": "rate limit exceeded: 10 requests
per second per key" } }