Binary options API and data reference
Every binary route — nineteen admin endpoints with their permission keys, twelve trader endpoints, the binaryOrder and binaryMarket columns, all five enums and the three settings rows.
Binary options is core, not an addon, so none of this is gated on an extension row. The routes live in three trees:
| Tree | Who calls it | Gate |
|---|---|---|
/api/admin/finance/binary/** |
operator — markets and settings | a permission key on every route |
/api/admin/finance/order/binary/** |
operator — the order book | a permission key on every route |
/api/exchange/binary/** |
the trading page and your customers | sign-in, and KYC on order placement |
Nineteen admin routes, twelve trader routes and one WebSocket. This page is the whole surface with its permission keys, the two tables behind it and every enum value those tables accept.
For what the screens do, see Binary markets, switching binary on and order types and payouts.
Admin — markets
Nine routes. The screen is /admin/finance/binary/market, whose own page gate is
access.binary.market — a separate key that no API route checks.
Two shapes to know before you script against these. Import is a GET that
creates rows — it is wired to a button, not to REST convention, so do not
pre-flight it expecting a read. And the update route silently ignores
source: it destructures seven named fields off the body and discards the
rest, so a PUT carrying a new price feed returns 200 and changes nothing.
Admin — settings
Three routes over one screen, /admin/finance/binary/settings, page gate
access.binary.settings.
Read and write are different keys. access.binary.settings covers the GET
and the presets list; only edit.binary.settings covers the PUT. A role holding
just the first loads the whole console and gets a 403 on Save.
The PUT returns 400 with a joined error list when validation fails, and writes nothing. Warnings never block a save.
Admin — orders
Seven routes over /admin/finance/order/binary, page gate
access.binary.order.
PUT .../status calls the platform's generic status helper: it writes the
status column and returns "updated successfully". It does not release a
hold, credit a payout, refund a stake or record platform profit — every one of
those lives in BinaryOrderService, which these routes never call.
Setting a PENDING order to WIN therefore produces a customer who is told
they won and whose stake is still sitting in inOrder on their wallet. The
same is true of the bulk variant and of the full PUT, which will happily
rewrite amount, profit and even isDemo on a settled row.
The screen does not expose any of this. /admin/finance/order/binary ships
with create, edit and delete all switched off — it is a read-only table with a
view dialog. These three write routes are reachable only by calling them
directly, and there is no supported reason to. To fix a contract that did not
settle, use Binary settlement
troubleshooting.
Two more things about that screen worth knowing before you go looking for a button that is not there:
- The status filter offers five of the six values.
PENDING,WIN,LOSS,DRAWandCANCELEDare in the dropdown;ERRORis not. Filter for it through the API, or read the Settlement errors backlog tile in the page's analytics, which counts it. - There is no order-type column.
sideis on the table (with onlyRISEandFALLin its filter),typeis not shown at all. If you run more than Rise/Fall, the table cannot tell two contract types apart.
GET on the list and on one order both carry demoMask: user.email. That masks
the customer's address only when the whole install runs with
NEXT_PUBLIC_DEMO_STATUS=true — it is the demo install flag and has nothing
to do with a customer's practice orders. See Practice mode and demo
orders.
Trader routes
Everything under /api/exchange/binary. Four are genuinely public — no
sign-in, no permission — because the market list, the payout ladder, the
published settings and the leaderboard are all read before a visitor has an
account.
| Method | Path | Auth |
|---|---|---|
GET |
/api/exchange/binary/market |
public |
GET |
/api/exchange/binary/duration |
public |
GET |
/api/exchange/binary/settings |
public |
GET |
/api/exchange/binary/leaderboard |
public |
GET |
/api/exchange/binary/health |
signed in |
GET |
/api/exchange/binary/leaderboard/me |
signed in |
GET |
/api/exchange/binary/order |
signed in |
GET |
/api/exchange/binary/order/last |
signed in |
POST |
/api/exchange/binary/order |
signed in + KYC |
GET |
/api/exchange/binary/order/{id} |
signed in |
DELETE |
/api/exchange/binary/order/{id} |
signed in |
WS |
/api/exchange/binary/order |
signed in |
None of them takes a permission key. Nothing on the Roles screen changes what a customer can call here.
What each one returns
/market — every binaryMarket row with status true, ordered
isTrending descending, then isHot, then currency. Disabled markets are
absent, so this is also the fastest check that a market you enabled is live.
/duration — the enabled durations out of the binarySettings JSON,
sorted ascending, each carrying five computed payouts:
profitPercentageRiseFall, profitPercentageHigherLower,
profitPercentageTouchNoTouch, profitPercentageCallPut,
profitPercentageTurbo, plus profitPercentage as an alias of the first for
older clients. The adjustments cascade: a −5% set on the 3-minute row
applies to every longer duration as well, and a further −10% on the 10-minute
row makes that one −15%. It reads no database table — the binaryDuration
model this endpoint replaced no longer exists.
/settings — a deliberately trimmed copy of the JSON: global, display,
orderTypes, durations and cancellation. riskManagement, _preset and
_lastModified are not published.
/health — five checks (system, database, durations, markets,
orders) folded into healthy / degraded / down. Two of the five are known
to misreport; read the settlement troubleshooting
page before you act on the
verdict.
/leaderboard and /leaderboard/me — period is daily, weekly
(default), monthly or alltime; metric is profit (default), winRate or
volume; limit defaults to 100 and is capped at 100. Both filter
isDemo: false, so practice trading never appears on a leaderboard.
/order — the caller's own orders for one symbol. The filter is built as
currency/pair, so both are needed — omit either and the query matches nothing
rather than erroring. type=OPEN returns PENDING rows; any other value
returns everything that is not pending. limit defaults to 50 and is capped at
200; offset paginates. It does not filter isDemo — a customer's history
mixes practice and live contracts.
/order/last — 30 days of non-pending orders, already split into
practiceOrders and nonPracticeOrders, plus livePercentageChange and
practicePercentageChange against the preceding 30 days.
DELETE /order/{id} — early cancellation. The body may carry a
percentage; the server ignores it and derives the penalty from the
Cancellation tab, precisely so a crafted request cannot buy a free exit.
The WebSocket carries ORDER_COMPLETED for the subscriber's own fills. It
requires auth, because the subscribe payload is { type: "order", symbol, userId } and without the gate any socket could name someone else's userId.
Placing an order
{
"currency": "BTC",
"pair": "USDT",
"amount": 25,
"side": "RISE",
"type": "RISE_FALL",
"durationId": "d_1m",
"closedAt": "2026-08-06T12:34:00.000Z",
"isDemo": false
}currency, pair, amount, side, closedAt, durationId and type are
required. durationType, barrier, barrierLevelId, strikePrice,
strikeLevelId, payoutPerPoint and isDemo are conditional — which ones
depends on type, and the rules are on order types and
payouts.
Two headers-and-limits facts that break integrations:
idempotency-keyis a required header. No header is a flat 400, "Missing idempotency-key header". Re-sending the same key for the same user returns the existing order rather than opening a second one.- Keys beginning
ct_are refused with 400. That namespace belongs to copy-trading replication, and a user-supplied key inside it could pre-empt a follower's copy. - Ten orders per minute per user, enforced at the route.
The refusals you will be asked about:
| Code | Message | Cause |
|---|---|---|
| 403 | Binary trading is currently disabled | binaryStatus is off |
| 403 | Binary practice mode is currently disabled | isDemo true while binaryPracticeStatus is off |
| 400 | Order type X is not available in demo/live mode |
that type's tradingModes switch |
| 400 | Amount must be between {min} and {max} {pair} |
the market's stake limits |
| 400 | Order must be placed at least N seconds before expiry |
global.orderExpirationBuffer |
| 429 | maximum of N concurrent open orders / daily limit of N / please wait N second(s) |
the three per-user throttles — live orders only |
| 400 | Binary market {symbol} is disabled |
status false on the market row |
| 404 | Ecosystem market {symbol} not found |
source is ECOSYSTEM with no matching ecosystem market |
The binary_order table
Model binaryOrder, table binary_order, paranoid — a delete sets
deletedAt and the row survives. (The admin table is configured
isParanoid: false, so it never shows you the deleted rows it is hiding.)
| Column | Type | Notes |
|---|---|---|
id |
UUID | primary key |
userId |
UUID | the trader. ON DELETE CASCADE — deleting a customer deletes their whole binary history |
symbol |
string(191) | free text BTC/USDT. There is no currency column and no market foreign key |
price |
double | entry price at placement |
amount |
double | the stake, denominated in the quote half of symbol |
profit |
double | payout amount. 0 until settlement |
side |
enum | direction — see below |
type |
enum | contract type — see below |
durationType |
enum | TIME or TICKS, default TIME |
barrier |
double, null | barrier level for HIGHER_LOWER, TOUCH_NO_TOUCH, TURBO |
strikePrice |
double, null | strike for CALL_PUT |
payoutPerPoint |
double, null | for CALL_PUT and TURBO |
profitPercentage |
double, null | the payout percentage this contract was sold at, frozen at placement |
status |
enum | lifecycle — see below |
isDemo |
boolean, default false | practice contract. No wallet is touched |
closedAt |
datetime(3) | the expiry instant, not the settlement instant |
closePrice |
double, null | settlement price |
metadata |
JSON, null | idempotencyKey, walletType, copiedFromOrderId, and on a cancel refundedAmount and cancelPenalty |
createdAt · updatedAt · deletedAt |
datetime | deletedAt is the soft-delete marker |
Indexes worth knowing when you write a report: (status, closedAt) — which is
what makes "unsettled past expiry" cheap — and (userId, metadata(255)) for the
idempotency lookup.
A binary stake is denominated in the quote half of symbol, and nothing on the
row records which asset that is. Summing amount across a book holding
BTC/USDT and ETH/EUR produces a number with no unit. The shipped analytics
render these as plain totals for exactly this reason — do not put a currency
symbol in front of them, and do not convert without joining on the symbol
yourself.
side — 10 values
Two per contract type, and they only ever appear with their own type.
type |
Its two sides |
|---|---|
RISE_FALL |
RISE, FALL |
HIGHER_LOWER |
HIGHER, LOWER |
TOUCH_NO_TOUCH |
TOUCH, NO_TOUCH |
CALL_PUT |
CALL, PUT |
TURBO |
UP, DOWN |
type — 5 values
RISE_FALL · HIGHER_LOWER · TOUCH_NO_TOUCH · CALL_PUT · TURBO.
Only RISE_FALL is enabled on a fresh install. TOUCH_NO_TOUCH and TURBO
are refused outright on an ECOSYSTEM-sourced market, because both settle on
intra-period highs and lows that only the exchange feed publishes.
durationType — 2 values
TIME is stored for every contract type except TURBO; the service forces it.
TICKS is reachable only on TURBO — validateCreateOrderInput rejects any
durationType other than TIME on every other type. The Turbo card's
allowTicksBased switch does not gate it: the flag exists in the settings
JSON, the presets and the admin toggle, and no backend path reads it, so a
TURBO order carrying durationType: "TICKS" is accepted with the switch off.
status — 6 values
| Value | Means | Money |
|---|---|---|
PENDING |
open, expiry not yet processed | stake held in inOrder |
WIN |
the prediction was correct | stake released, payout credited |
LOSS |
the prediction was wrong | stake executed from hold, platform profit recorded |
DRAW |
close equals entry, or the barrier/strike test tied | stake released, nothing else |
CANCELED |
closed early by the customer | stake released less the configured penalty |
ERROR |
commented on the model as "data fetch failed, needs manual review" | nothing |
ERROR is worth a note. It is a legal value, the admin analytics counts it as
Settlement errors backlog, and a repository-wide search finds no code path
in the current backend that writes it. A non-zero count means rows written by
an older version or by hand — investigate them, do not assume the settlement
path produced them.
The binary_market table
Model binaryMarket, table binary_market. Not paranoid and not
timestamped — no createdAt, no deletedAt, and a delete is gone.
| Column | Type | Default | Notes |
|---|---|---|---|
id |
UUID | generated | |
currency |
string(191) | — | base symbol, BTC |
pair |
string(191) | — | quote symbol, USDT |
source |
enum EXCHANGE|ECOSYSTEM |
EXCHANGE |
which price series backs entry, settlement and steering |
minAmount |
decimal(16,8), null | 1 |
smallest stake, in pair |
maxAmount |
decimal(16,8), null | 10000 |
largest stake, in pair |
isTrending |
boolean, null | false |
merchandising only |
isHot |
boolean, null | false |
merchandising only |
status |
boolean | true |
tradable |
(currency, pair) is a unique key, so a duplicate market is a constraint error
rather than a second row. minAmount and maxAmount are DECIMAL, which
mysql2 returns as strings — coerce before you do arithmetic.
source is a real column on the row — it is in the table above — but it is
invisible from the admin screen: the markets DataTable shows only id,
currency, pair, minAmount, maxAmount, isTrending, isHot and
status, so there is no source column and no source field on the edit
form, and the update route discards it. The create wizard is the only place a
human sets it. Full treatment on
Binary markets.
Permission keys
Twelve keys are seeded for binary, in
backend/seeders/20240402234643-permissions.js.
| Key | Guards |
|---|---|
access.binary.market |
the Binary Markets page URL and its menu entry |
view.binary.market |
the market list, one market, and the available-markets lookup |
create.binary.market |
creating a market, and Import from Exchange |
edit.binary.market |
editing, the status toggle, and the bulk status route |
delete.binary.market |
deleting a market |
access.binary.settings |
the Binary Settings page, the settings GET and the presets GET |
edit.binary.settings |
saving binary settings |
access.binary.order |
the Binary Orders page URL and its menu entry |
view.binary.order |
reading order rows |
edit.binary.order |
the three order write routes — the full PUT, the single status PUT and the bulk status PUT |
delete.binary.order |
deleting orders |
create.binary.order |
nothing |
The nine the audit trail and the API actually enforce are
access.binary.settings, edit.binary.settings, the four *.binary.market
verbs and the three *.binary.order verbs. Note that access.binary.settings
is among them: unlike the other two access.* keys it is a real route
permission, on the settings GET and the presets GET, as the settings
section above says.
The three keys no route checks are access.binary.market,
access.binary.order and create.binary.order. The first two are enforced by
the Next.js URL map and the menu — which is why granting access.binary.market
alone gives an operator the page and an empty table until view.binary.market
goes with it. The third is enforced by nothing at all.
It is seeded, it appears on the Roles multi-select, and the Binary Orders page
declares it as its create key — but the page sets canCreate={false} and no
backend route asks for it. There is no way to create a binary order from the
admin panel. Ticking it grants nothing.
Super Admin short-circuits every one of these by role name. See Roles and permissions for how a key is derived, the four places it is checked, and why a revocation needs a backend restart.
Settings storage
Three rows in the settings table, written together by one PUT, and
this screen is the only writer of any of them. None appears on
Admin → System → Platform Settings.
| Key | Shape | Holds |
|---|---|---|
binaryStatus |
"true" / "false" |
the master switch |
binaryPracticeStatus |
"true" / "false" |
practice trading |
binarySettings |
one JSON document | everything else |
The two flat rows are text, as every settings row is — false is the four-character
string, not a boolean. Neither row is seeded, and an absent row reads as off.
Every enforcement point tests CacheManager.getSetting(key) === "true", and a
missing row returns undefined, so on a fresh install
POST /api/exchange/binary/order refuses every order with 403 "Binary trading
is currently disabled" until this screen has been saved once. The one place an
absent row reads as on is the settings GET itself, which is why the
console's two switches show green before anything has been written — pressing
Save is what creates the rows.
The JSON document carries global, display, cancellation, orderTypes,
durations, riskManagement, and the two metadata fields _preset and
_lastModified. Its global.enabled and global.practiceEnabled duplicate the
two flat rows: the save route reads binaryStatus from the body and falls back
to global.enabled only when the body omits it. The order route checks the flat
rows. Keep them in agreement.
Binary settings are cached in memory per process, and the settings table has its own Redis layer in front of it. A hand-written SQL update invalidates neither, so every worker keeps paying out on the payout percentages it last read. The Save button publishes an invalidation on the settings bus, every process drops its copy, and the change lands platform-wide at once. If the bus cannot deliver — a Redis outage, since pub/sub has no store-and-forward — the in-memory TTL shortens so convergence is seconds instead of a minute.
Change these from /admin/finance/binary/settings, always.