Atladix API
https://api.atladix.com

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.

Risk rules are not in this API, on purpose. Daily loss limits, drawdown, position caps, commissions and profit targets are changed in the Atladix panel, by a person, with their name in the audit log. Those routes do not exist here — which is the strongest form of "not permitted": there is no endpoint to call by mistake, and no leaked key can move a trader's loss limit.

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.

  1. Atladix creates your test IB. Your atlk_test_ key starts working immediately.
  2. You integrate. Every call that matters is counted automatically — users created, accounts opened, statuses read, webhooks registered.
  3. You request activation from the Developer screen in your panel, describing what you tested in your own words.
  4. Atladix reviews what you said next to what your key actually did.
  5. 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 → liverejected → 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" } }
The message tells you which of the two it is. A generic 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

PrefixReachesNotes
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.
A panel token is not an API key. If you paste the 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

POST /api/v1/users

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

POST /api/v1/accounts

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" ] } } }
Your products need slugs before you can open accounts against them. Products created before slugs existed do not have one, and "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.
POST /api/v1/accounts/{id}/link

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.

POST /api/v1/accounts/{id}/deactivate

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

GET /api/v1/accounts/{id}/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 }
}
Profit target and trading days are measurement, not a verdict. Our engine does not mark an account as passed and does not stop watching it when it hits the target. Those figures are what you need in order to decide, and the decision is yours. The same numbers arrive by webhook at session close, where they are stable.

Listing

GET /api/v1/accounts
GET /api/v1/users

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

TypeWhen
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
    }
  }
}
What we do not send is as deliberate as what we do. Events carry account ids and figures, and nothing else — no trader names, no email addresses, no credentials. You already know who your trader is; what you did not know is that they just got liquidated. The full breach snapshot, position by position with the exact tick, stays in our audit trail and is available through the API. A webhook is a notification, not a data dump.

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:

  1. Check the age first — reject anything more than five minutes old or in the future. That is what stops a replay.
  2. 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.
  3. 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")
}
Reading the raw body is the part people get wrong. Most frameworks parse JSON for you and hand you an object. Signing a re-serialized object will not match. In Express use 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.

Nothing waits for you. The event is written in the same database transaction as the fact itself — so an account cannot end up liquidated with its firm never notified — and delivered afterwards, on its own clock. A liquidation never waits for anyone's HTTP.
Be idempotent. Deduplicate on 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

RouteDoes
POST /api/v1/webhooksRegister one. Returns the secret, once.
GET /api/v1/webhooksThe 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}/testQueues a test.ping to that endpoint.
GET /api/v1/webhooks/{id}/deliveriesThe 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" ]
    }
  }
}
CodeHTTPWhen
UNAUTHORIZED401 No key, an unknown or revoked key, or a panel token.
FIRM_SUSPENDED403 Your firm is suspended. The key is fine and will work again.
IB_TEST_MISSING403 Test key, and your firm has no sandbox yet.
WRONG_ENVIRONMENT403 A live key asking for the sandbox, or the reverse.
LIVE_NOT_APPROVED403 Valid live key, firm not approved for production yet.
NOT_FOUND
ACCOUNT_NOT_FOUND
404 Does not exist — or belongs to another firm. From outside these are indistinguishable on purpose.
WEBHOOK_NOT_FOUND404 Unknown endpoint, another firm's, or the other environment's.
USER_EXISTS409 That username, email or external_id is taken.
ACCOUNT_EXISTS409 You already have an account with that account_id.
ACCOUNT_STATUS_FINAL409 The account is in a state the API does not bring it back from.
WEBHOOK_EXISTS409 That URL is already registered in this environment.
PRODUCT_NOT_FOUND422 Unknown product slug. detail.available has the real ones.
MD_IDENTITY_INCOMPLETE422 Market data requested for a trader whose subscriber details are incomplete.
WEBHOOK_URL_INSECURE422 The URL is not https (and is not localhost).
INVALID_REQUEST422 Validation. The message says which field.
RATE_LIMITED429 Over 10 requests per second on that key.
409 and 422 mean different things to a retrying client. 409 ACCOUNT_EXISTS is "already done, carry on" — safe to treat as success. 422 is "fix the request"; retrying it unchanged will fail forever.
404 for another firm's data is not an accident. A 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" } }