FortuneX
FortuneX › Operator API

FortuneX operator API: integration guide (4D)

Version 1 · base path /api/b2b/v1 · all money is in sen (RM 1.00 = 100) as whole numbers · currency MYR · times are Unix milliseconds unless a field says otherwise · draw dates and weeks are Malaysian time (Asia/Kuala_Lumpur, UTC+8).

Contents

At a glance

Base URLhttps://api.fortunex.bet
Version 1 base path/api/b2b/v1
API document version1.0.0
OpenAPI3.0.3
Signature headerX-FX-Signature

The 4D wager flow

  1. Read the open pools, draw dates and limits.
  2. Place the bet. Reuse the same ref to retry.
  3. Poll the bet until it is no longer pending or sending.
  4. Optionally cancel it before the draw is about to close.
  5. Read the result after the draw. A win is recorded, not paid.

Downloads

Files for your developers. They are the same documents FortuneX staff send to new operators.

The machine-readable description is operator-openapi.yaml (OpenAPI 3.0) next to this file. This guide explains how it all fits together.

1. What this API does

You place 4D bets for your players through FortuneX, and read back results, your balance and your weekly statements.

  • Your bets go to the 4D supplier through FortuneX's one house account. You do not need an account with the supplier.
  • FortuneX does not call your wallet for 4D. Your relationship with FortuneX is either a prepaid balance or a credit term (section 8).
  • FortuneX takes no automatic fee on 4D. Every bet and every result is recorded so that you and FortuneX can match your records against the supplier's. Each bet carries your own ref and subAccount for that.
  • Results are recorded, not paid. A win does not move money by itself (section 9).
  • There are no webhooks. You poll for status and results.

2. Getting access

FortuneX staff create your operator account and an API key in the console. For each key you receive:

  • a key ID (fxk_...), public;
  • a secret (fxs_...), shown once. FortuneX stores it encrypted and cannot show it again. If you lose it, ask for a rotation.
  • an IP allow-list: a key works only from the addresses you gave FortuneX. Tell FortuneX every address you call from, including fail-over servers.

You can have a separate sandbox key (section 11) for testing. A sandbox key never reaches the supplier and moves no money. Production and sandbox keys are different keys; neither works in the other's place.

Key rotation: FortuneX makes a new key and the old one keeps working for an agreed overlap (default 7 days), then stops. Staff can also revoke a key at once.

The API host is https://api.fortunex.bet (FortuneX tells you the final host). Until FortuneX switches the operator API on, every route answers 404 not_found.

3. Signing every request

Every request, GET or POST, carries four headers:

HeaderValue
X-FX-Keyyour key ID
X-FX-TimestampUnix seconds now. Must be within 5 minutes of FortuneX's clock.
X-FX-Nonce8 to 128 characters from A-Z a-z 0-9 _ - + = . :, new for every request. A nonce is accepted once.
X-FX-Signaturelower-case hex of HMAC-SHA256(secret, stringToSign)

stringToSign is six lines joined with a single newline (\n), with no trailing newline:

METHOD            upper case: GET or POST
path              the request path, exactly as sent, without the query
query             the raw query string without the "?"; an empty line if there is none
timestamp         the same value as X-FX-Timestamp
nonce             the same value as X-FX-Nonce
sha256(body)      lower-case hex SHA-256 of the raw body bytes; of the empty string for a GET

Rules that trip people up:

  • Sign the exact bytes you send. Do not re-serialise the JSON after signing. Do not decode, re-order or normalise the path or the query.
  • A path or query containing a line break is refused.
  • For a request with no query the third line is empty, so the string contains two newlines in a row.
  • Send the body with Content-Type: application/json. Maximum body: 64 KB.

Worked example

Secret (an example only, never use it): fxs_example_secret_never_use_in_production

Request body (exactly these bytes, no spaces added):

{"ref":"order-1001","subAccount":"site-A-user-77","drawDate":"2026-11-04","pools":["magnum"],"lines":["1234#1"]}
method     POST
path       /api/b2b/v1/4d/bets
query      (empty)
timestamp  1793700000
nonce      0f8e7d6c5b4a3921
sha256(body) = 86b0934218ce3411d21dff00918892f65ae64e1d2ba484d162736827178886ff

String to sign:

POST
/api/b2b/v1/4d/bets

1793700000
0f8e7d6c5b4a3921
86b0934218ce3411d21dff00918892f65ae64e1d2ba484d162736827178886ff

Signature: 5e55fd7bfac0380e225fdc9443f20be43d1e1018323b968263e21a3142981147

A GET with a query: GET /api/b2b/v1/4d/bets?drawDate=2026-11-04&limit=20, timestamp 1793700001, nonce 1a2b3c4d5e6f7081, empty body gives signature 53616fcf295cab5289249468d3c92d599592d577ed3d5f9d62293b67350780e1 with the same secret. Use these two as a test vector for your implementation. The nonces in the two vectors (16 hex characters) are fixed by hand so that the signature can be reproduced: to check your code, use the vector's timestamp and nonce instead of generating them. In real calls always generate a new nonce, as the code below does (24 hex characters).

Code

Node.js (no dependencies):

const crypto = require('crypto');
function signedFetch(base, keyId, secret, method, path, query, bodyObj) {
  const body = bodyObj == null ? '' : JSON.stringify(bodyObj);
  const ts = String(Math.floor(Date.now() / 1000)), nonce = crypto.randomBytes(12).toString('hex');
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const toSign = [method, path, query || '', ts, nonce, bodyHash].join('\n');
  const sig = crypto.createHmac('sha256', secret).update(toSign).digest('hex');
  return fetch(base + path + (query ? '?' + query : ''), {
    method, body: method === 'GET' ? undefined : body,
    headers: { 'Content-Type': 'application/json', 'X-FX-Key': keyId, 'X-FX-Timestamp': ts, 'X-FX-Nonce': nonce, 'X-FX-Signature': sig },
  });
}

Python 3:

import hashlib, hmac, json, os, time, urllib.request
def signed(base, key_id, secret, method, path, query='', body_obj=None):
    body = b'' if body_obj is None else json.dumps(body_obj, separators=(',', ':')).encode()
    ts, nonce = str(int(time.time())), os.urandom(12).hex()
    to_sign = '\n'.join([method, path, query, ts, nonce, hashlib.sha256(body).hexdigest()])
    sig = hmac.new(secret.encode(), to_sign.encode(), hashlib.sha256).hexdigest()
    req = urllib.request.Request(base + path + ('?' + query if query else ''), data=body or None, method=method,
        headers={'Content-Type': 'application/json', 'X-FX-Key': key_id, 'X-FX-Timestamp': ts, 'X-FX-Nonce': nonce, 'X-FX-Signature': sig})
    return urllib.request.urlopen(req)

4. Answers and errors

Success: { "ok": true, ... }. Failure: { "ok": false, "error": { "code": "...", "message": "..." } } with the HTTP status below. Always branch on code, not on the message text.

Authentication failures carry a deliberately generic 401 unauthorized (wrong key, bad signature, unknown key all look the same). Once a request is correctly signed you get specific codes.

HTTPcodeMeaning
401unauthorizedKey unknown or signature wrong. Check the string to sign.
401key_revoked, key_expiredThe key no longer works.
401stale_timestampYour clock differs from FortuneX by more than 5 minutes.
401replayThat nonce was already used.
403ip_not_allowedThe request came from an address not on the key's allow-list.
403operator_suspendedYour account is suspended: no new bets. Reads and cancels still work.
403bets_disabledReal 4D bets are switched off at FortuneX.
403sandbox_key / sandbox_onlyA sandbox key on a production call, or a production key on a sandbox call.
404not_foundUnknown route (or the API is not switched on).
404unknown_bet, unknown_statementNot found for your account.
400bad_json, bad_ref, bad_sub_account, bad_ticket, bad_date, bad_status, bad_cursor, bad_outcome, bad_winThe request is malformed. bad_ticket carries the reason in message (same wording FortuneX players see).
400stake_too_small, stake_too_largeThe ticket total is outside the limits in GET /4d/games.
402insufficient_prepaidPrepaid operators: the balance does not cover the stake.
402credit_limit_exceededCredit-term operators: unsettled credit plus this stake would pass your limit.
402credit_not_configuredCredit-term operators: no credit limit is set for 4D. Ask FortuneX.
409idempotency_mismatchThat ref was already used for a different bet.
409game_unavailableA pool you chose is switched off or not open for real bets.
409capacityFortuneX is at its overall limit for open stakes. Try later.
409cannot_cancel, cancel_pendingSee section 7.
409not_placed, already_settledSandbox result calls only.
409use_4d_apiYou used the generic wager call for a 4D game.
405method_not_allowedOnly GET and POST are used.
429rate_limited, operator_limitSlow down. See section 12.
503maintenance, busyTry again shortly.
500internal_errorOur fault. Retry with the same ref (section 5).

5. Idempotency and retries

ref is your idempotency key for a bet: 1 to 100 characters from A-Z a-z 0-9 : _ . -. It is unique per operator (production and sandbox each have their own). You may send the same value in an Idempotency-Key header instead; if both are present they must be equal.

  • Send the same ref with the same ticket again: you get 200 and the same bet, nothing is charged twice. A first placement returns 201.
  • Send the same ref with a different ticket (draw date, pools, lines or subAccount): **409 idempotency_mismatch**.
  • If a placement times out or answers 5xx, **retry with the same ref**. Never make up a new ref for the same player action; that would place a second bet.
  • Each retry needs a fresh nonce and timestamp (it is a new signed request).
  • 4xx answers other than 429 will not change by retrying; fix the request.

6. Placing a bet

First read GET /4d/games for the pools that are open, the next draw dates with their closing times, and the limits.

POST /4d/bets

{ "ref": "order-1001", "subAccount": "site-A-user-77", "drawDate": "2026-11-04", "pools": ["magnum", "damacai"], "lines": ["1234#1", "*2345#1"] }
  • subAccount: required. Your own reference for the player or site behind the bet (1 to 100 characters). It is stored with the bet and returned in every view so FortuneX can match your sales to the supplier's record per sub-account.
  • drawDate: YYYY-MM-DD. Bets for a draw close at 18:59 Malaysian time on the draw day. A date more than 30 days ahead is refused.
  • pools: one or more of magnum, damacai, toto, singapore, sabah, sarawak, gd (Grand Dragon), nine (9Lotto). sandakan is not active with the supplier. Use GET /4d/games for what is open now.
  • lines: 1 to 50 lines of the same text format as FortuneX's own bet slip. The ticket cost is each line's stake multiplied by the numbers it expands to and by the number of pools. Examples:
LineMeaningCost per pool
1234#1number 1234, Big, RM 1RM 1.00
1234#1#1Big RM 1 and Small RM 1RM 2.00
1234#1#1#1Big, Small and A, RM 1 eachRM 3.00
123#1#13D: C RM 1 and A RM 1RM 2.00
1234**#1#1#1#1#1RM 1 on each prize position A1 to A5RM 5.00
*2345#1box: all 24 arrangements, Big RM 1 eachRM 24.00
800020#16D, RM 1RM 1.00

The smallest stake on one number is RM 0.10 (1234#0.1). The smallest ticket and the largest ticket are in GET /4d/games (limits). 5D and 6D tickets follow FortuneX's own switch for them and are refused when it is off. When they are on, a 5D line is accepted only with the pool toto, and a 6D line only with toto, gd or nine; a ticket that puts such a line together with other pools is refused with 400 bad_ticket ("Untick the other pools"). Random ("R") lines must be drawn by you before you place the ticket.

The server works out the cost itself. You cannot send a price.

Response 201:

{ "ok": true, "created": true, "bet": { "ref": "order-1001", "subAccount": "site-A-user-77", "sandbox": false, "funding": "prepaid", "drawDate": "2026-11-04",
  "pools": ["magnum"], "lines": ["1234#1"], "stakeSen": 100, "refundedSen": 0, "status": "pending", "statusText": "Waiting for approval", "reason": null,
  "result": { "settled": false, "winSen": null, "wins": null, "settledAt": null }, "canCancel": true, "cancelState": null, "cancelNote": null, "createdAt": 1793700000123, "orders": [] } }

Bet lifecycle

pending ──approved by FortuneX (or automatically)──▶ sending ──supplier answers──▶ accepted | partial | refused | check
   │                                                                                    │
   ├─ rejected (a reason is given) ─▶ rejected (stake returned)                         ├─ cancelled (you cancelled: stake returned)
   └─ you cancel ─▶ cancelled (stake returned)                                          └─ settled: the result is recorded (win or loss)
statusMeaningStake
pendingWaiting for FortuneX to send it to the supplier.Held
sendingBeing sent.Held
acceptedPlaced with the supplier. orders[] shows the supplier references.Held
partialSome numbers were sold out; the rejected part has been returned (refundedSen).Part returned
refusedThe supplier did not accept it.Returned
rejectedFortuneX did not approve it (the reason is shown) or betting closed before it was approved.Returned
cancelledCancelled.Returned
checkAn order has an unknown outcome and a person at FortuneX is checking it with the supplier.Held until resolved

Whether a bet waits for a person to approve it or is sent at once is a FortuneX setting; poll GET /4d/bets/{ref} until status is no longer pending or sending.

orders[] has one entry per pool: providerRef (the reference FortuneX used for this order with the supplier), betNo and receiptNo (the supplier's numbers, once accepted), and the accepted/rejected amounts. Use these when you reconcile with FortuneX or the supplier.

Reading bets

  • GET /4d/bets/{ref} one bet.
  • GET /4d/bets?drawDate=&status=&subAccount=&limit=&before= your bets, newest first. limit up to 200 (default 50). If next is not null, pass it as before for the next page.

7. Cancelling

POST /4d/bets/{ref}/cancel (empty body or {}). The rules are the same as for FortuneX's own players:

  • A bet still pending: cancelled at once, the full stake returned.
  • A placed bet (accepted or partial) before the draw is about to close (until 2 minutes before 18:59 on the draw day): a cancel request. The answer shows cancelState: "requested". FortuneX approves it (or it is automatic), the supplier cancels each order, and only then does the status become cancelled and the stake return. If the supplier refuses, cancelState becomes failed or declined with a cancelNote and the bet stays placed. Poll the bet.
  • After the draw is about to close, or once a result is recorded: 409 cannot_cancel.
  • Cancelling twice: 409 cannot_cancel (or cancel_pending while a request waits).

8. Prepaid and credit-term accounts

FortuneX sets, for you and for each kind of game (4D, sports, slots, live casino), whether the game is prepaid or on credit term. Only 4D is available through this API today. GET /balance and GET /4d/games (funding) show what applies to you.

Prepaid

  • Your prepaid balance is kept by FortuneX. Staff add to it when you pay in (they record the bank reference).
  • When you place a bet the stake is deducted at once. If the balance is short the bet is refused with 402 insufficient_prepaid and nothing is recorded. Concurrent bets can never take the balance below zero.
  • A stake returned (cancel, refusal, sold-out part) goes back to the balance exactly once.
  • A win is not paid to your balance automatically. Once a week FortuneX credits your profit by hand, using the recorded sales and results as the reference.

Credit term

  • Nothing is deducted when you bet. The sale is recorded against your credit exposure: sales minus refunds that have not yet been settled by a paid statement.
  • You have a credit limit per game kind. A bet is refused with 402 credit_limit_exceeded if your exposure plus the new stake would exceed it. A game on credit with no limit (or 0) refuses every bet (credit_not_configured).
  • Every Monday FortuneX issues a weekly statement for the finished week (Monday 00:00 to Monday 00:00, Malaysian time): total sales, refunds, results recorded as wins, the net and who owes whom, due 4 days after the period (the number of days can change). Statements are final once issued. Payment is recorded by FortuneX staff when the money has moved; that frees your credit room again.
  • GET /statements lists your statements; GET /statements/{number} shows one. They are read only.

GET /balance:

{ "ok": true, "balance": { "sandbox": false, "currency": "MYR", "prepaidBalanceSen": 250000,
  "games": [ { "category": "4d", "mode": "credit", "creditLimitSen": 1000000, "exposureSen": 420000, "availableSen": 580000 },
             { "category": "sports", "mode": "prepaid", "creditLimitSen": null, "exposureSen": null, "availableSen": null } ], "asOf": 1793700000123 } }

9. Results

Results are recorded automatically after the draw, from the supplier's official result, using the same rules as FortuneX's own players (the payout table FortuneX publishes; for 6D a number that fits several prizes is paid its highest prize only). Some cases wait for a person (a 6D first prize the supplier does not publish, a very large win, an order that needs checking); the bet then stays unsettled until it is entered.

  • GET /4d/bets/{ref}/result: state is pending (not placed with the supplier yet, or being checked), open (placed, draw not closed), awaiting_result (closed, not recorded yet), settled (see winSen and wins[] with the prize and number) or void (refunded or never placed).
  • GET /4d/results/{YYYY-MM-DD}: the draw's official numbers per pool (null if not published yet), the 6D first prize where known, your bets of that draw date with their stake and winSen, and totals.

A win does not move any money. It is a record. Prepaid accounts are credited by hand weekly; credit-term accounts settle the net on the weekly statement. winSen is the winning amount for that bet worked out from FortuneX's payout table.

10. Reconciliation

Every bet has your ref, your subAccount, FortuneX's providerRef per order and the supplier's betNo/receiptNo. For a day or a draw date use GET /4d/bets?drawDate= or GET /4d/results/{date}. Statement CSVs (from FortuneX staff) list every sale, refund and result with time, ref and sub-account.

11. Sandbox

A sandbox key behaves like a production key but with simulated bets:

  • Same signing, same endpoints under /4d/*, GET /balance and GET /health, with a "sandbox": true marker. All other production routes answer 403 sandbox_key.
  • A sandbox bet is checked with the same ticket rules, limits and open pools as a production bet, then accepted immediately. It is never sent to the supplier and it touches no balance, no credit and no statement.
  • Sandbox refs are separate from production refs.
  • Simulate a provider refusal: use a subAccount that starts with sbx-refuse. The bet becomes refused with the stake "returned" (refundedSen).
  • Simulate an empty prepaid balance: a subAccount that starts with sbx-nofunds answers 402 insufficient_prepaid.
  • Simulate the result: POST /sandbox/4d/bets/{ref}/result with { "outcome": "win", "winSen": 350000 } (default winSen is ten times the stake) or { "outcome": "loss" }. Only for an accepted sandbox bet, once; then GET .../result shows state: "settled" and simulated: true.
  • Sandbox cancel is instant.
  • A production key cannot call /sandbox/* (403 sandbox_only).

12. Limits

  • Per IP address: 600 requests a minute (answer 429 rate_limited, header Retry-After).
  • Per key: the limit set on your key (default 600 requests a minute).
  • Per operator: at most 600 new bets a minute (429 operator_limit). Production and sandbox bets are counted separately: sandbox bets never use up the production limit.
  • Body at most 64 KB.
  • Staff may limit the global open stake across all bets (409 capacity).

On 429 wait for Retry-After seconds and retry the same request with a fresh nonce.

13. Security checklist

  • Keep the secret in a secret store, never in browser or mobile code. Sign requests from your server only.
  • Call only from the IP addresses on your allow-list; tell FortuneX before they change.
  • Use HTTPS only. Verify the server certificate.
  • Make nonces unpredictable and never reuse them; keep your clock correct (NTP).
  • Treat ref as sensitive to duplication: derive it from your own order id so a retry reuses it.
  • Log request ids and refs, never the secret or signatures.

14. What this API does not do (yet)

Webhooks (poll instead), sports, slots and live-casino wagers, and money movement of any kind: payments, top-ups and profit credits are recorded by FortuneX staff outside the API.

Endpoint reference

The endpoint reference below is generated from the OpenAPI file and is in English.

Account

GET /api/b2b/v1/health

Signed ping

Responses
200 OK
Response shape (example values)
{
  "ok": true,
  "time": 1793700000123,
  "operatorId": 1,
  "keyId": "fxk_example",
  "sandbox": true
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

GET /api/b2b/v1/balance

Your prepaid balance and credit position

Responses
200 OK
Response shape (example values)
{
  "ok": true,
  "balance": {
    "sandbox": true,
    "currency": "MYR",
    "prepaidBalanceSen": 1793700000123,
    "games": [
      {
        "category": "4d",
        "mode": "prepaid",
        "creditLimitSen": 1793700000123,
        "exposureSen": 1793700000123,
        "availableSen": 1793700000123
      }
    ],
    "asOf": 1793700000123,
    "note": "string"
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

4D bets

GET /api/b2b/v1/4d/games

Pools, draw dates, minimum stakes and limits

Responses
200 OK
Response shape (example values) · GamesInfo
{
  "ok": true,
  "sandbox": true,
  "enabled": true,
  "funding": "prepaid",
  "currency": "string",
  "games": [
    {
      "id": "magnum",
      "name": "string",
      "open": true,
      "reason": "string",
      "supports5d6d": true
    }
  ],
  "drawDates": [
    {
      "drawDate": "2026-11-04",
      "day": "string",
      "closesAt": "2026-11-04T10:59:00Z",
      "pools": [
        "string"
      ],
      "special": true
    }
  ],
  "limits": {
    "minTicketSen": 100,
    "maxTicketSen": 1793700000123,
    "minStakePerNumberSen": 100,
    "maxLines": 1,
    "closesAt": "string",
    "cancelUntil": "string"
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

GET /api/b2b/v1/4d/bets

List your bets, newest first

Parameters
NameInRequiredTypeDescription
drawDatequerynostring (date)
statusquerynopending | sending | accepted | partial | refused | rejected | cancelled | check
subAccountquerynostringmax length 100
limitquerynointegermin 1, max 200, default 50
beforequerynointegerPass next from the previous page.
Responses
200 OK
Response shape (example values)
{
  "ok": true,
  "bets": [
    {
      "ref": "string",
      "subAccount": "string",
      "sandbox": true,
      "funding": "prepaid",
      "drawDate": "2026-11-04",
      "pools": [
        "string"
      ],
      "lines": [
        "string"
      ],
      "stakeSen": 100,
      "refundedSen": 100,
      "status": "pending",
      "statusText": "string",
      "reason": "string",
      "result": {
        "settled": true,
        "winSen": 1793700000123,
        "wins": [
          {}
        ],
        "settledAt": 1793700000123
      },
      "canCancel": true,
      "cancelState": "requested",
      "cancelNote": "string",
      "createdAt": 1793700000123,
      "orders": [
        {
          "pool": "string",
          "status": "sending",
          "stakeSen": 100,
          "acceptedSen": 1793700000123,
          "rejectedSen": 1793700000123,
          "providerRef": "string",
          "betNo": 1,
          "receiptNo": 1
        }
      ]
    }
  ],
  "next": 1
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

POST /api/b2b/v1/4d/bets

Place a 4D bet

Prepaid: the stake is deducted at once (402 insufficient_prepaid if short). Credit term: the sale is recorded against your credit (402 credit_limit_exceeded / credit_not_configured). Sandbox key: the bet is simulated (see the Sandbox tag).

Parameters
NameInRequiredTypeDescription
Idempotency-Keyheadernostringmax length 100
Request body · PlaceBet
{
  "ref": "order-1001",
  "subAccount": "site-A-user-77",
  "drawDate": "2026-11-04",
  "pools": [
    "magnum"
  ],
  "lines": [
    "1234#1"
  ]
}
Responses
200 The same ref was already placed with this ticket (nothing charged again)
Response shape (example values) · BetEnvelope
{
  "ok": true,
  "created": true,
  "bet": {
    "ref": "string",
    "subAccount": "string",
    "sandbox": true,
    "funding": "prepaid",
    "drawDate": "2026-11-04",
    "pools": [
      "string"
    ],
    "lines": [
      "string"
    ],
    "stakeSen": 100,
    "refundedSen": 100,
    "status": "pending",
    "statusText": "string",
    "reason": "string",
    "result": {
      "settled": true,
      "winSen": 1793700000123,
      "wins": [
        {}
      ],
      "settledAt": 1793700000123
    },
    "canCancel": true,
    "cancelState": "requested",
    "cancelNote": "string",
    "createdAt": 1793700000123,
    "orders": [
      {
        "pool": "string",
        "status": "sending",
        "stakeSen": 100,
        "acceptedSen": 1793700000123,
        "rejectedSen": 1793700000123,
        "providerRef": "string",
        "betNo": 1,
        "receiptNo": 1
      }
    ]
  }
}
201 Placed
Response shape (example values) · BetEnvelope
{
  "ok": true,
  "created": true,
  "bet": {
    "ref": "string",
    "subAccount": "string",
    "sandbox": true,
    "funding": "prepaid",
    "drawDate": "2026-11-04",
    "pools": [
      "string"
    ],
    "lines": [
      "string"
    ],
    "stakeSen": 100,
    "refundedSen": 100,
    "status": "pending",
    "statusText": "string",
    "reason": "string",
    "result": {
      "settled": true,
      "winSen": 1793700000123,
      "wins": [
        {}
      ],
      "settledAt": 1793700000123
    },
    "canCancel": true,
    "cancelState": "requested",
    "cancelNote": "string",
    "createdAt": 1793700000123,
    "orders": [
      {
        "pool": "string",
        "status": "sending",
        "stakeSen": 100,
        "acceptedSen": 1793700000123,
        "rejectedSen": 1793700000123,
        "providerRef": "string",
        "betNo": 1,
        "receiptNo": 1
      }
    ]
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

GET /api/b2b/v1/4d/bets/{ref}

One bet with its orders

Parameters
NameInRequiredTypeDescription
refpathyesstringYour bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$
Responses
200 OK
Response shape (example values) · BetEnvelope
{
  "ok": true,
  "created": true,
  "bet": {
    "ref": "string",
    "subAccount": "string",
    "sandbox": true,
    "funding": "prepaid",
    "drawDate": "2026-11-04",
    "pools": [
      "string"
    ],
    "lines": [
      "string"
    ],
    "stakeSen": 100,
    "refundedSen": 100,
    "status": "pending",
    "statusText": "string",
    "reason": "string",
    "result": {
      "settled": true,
      "winSen": 1793700000123,
      "wins": [
        {}
      ],
      "settledAt": 1793700000123
    },
    "canCancel": true,
    "cancelState": "requested",
    "cancelNote": "string",
    "createdAt": 1793700000123,
    "orders": [
      {
        "pool": "string",
        "status": "sending",
        "stakeSen": 100,
        "acceptedSen": 1793700000123,
        "rejectedSen": 1793700000123,
        "providerRef": "string",
        "betNo": 1,
        "receiptNo": 1
      }
    ]
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

POST /api/b2b/v1/4d/bets/{ref}/cancel

Cancel a bet

Parameters
NameInRequiredTypeDescription
refpathyesstringYour bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$
Request body (optional)
{}
Responses
200 The bet after the cancel (or the request)
Response shape (example values) · BetEnvelope
{
  "ok": true,
  "created": true,
  "bet": {
    "ref": "string",
    "subAccount": "string",
    "sandbox": true,
    "funding": "prepaid",
    "drawDate": "2026-11-04",
    "pools": [
      "string"
    ],
    "lines": [
      "string"
    ],
    "stakeSen": 100,
    "refundedSen": 100,
    "status": "pending",
    "statusText": "string",
    "reason": "string",
    "result": {
      "settled": true,
      "winSen": 1793700000123,
      "wins": [
        {}
      ],
      "settledAt": 1793700000123
    },
    "canCancel": true,
    "cancelState": "requested",
    "cancelNote": "string",
    "createdAt": 1793700000123,
    "orders": [
      {
        "pool": "string",
        "status": "sending",
        "stakeSen": 100,
        "acceptedSen": 1793700000123,
        "rejectedSen": 1793700000123,
        "providerRef": "string",
        "betNo": 1,
        "receiptNo": 1
      }
    ]
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

Results

GET /api/b2b/v1/4d/bets/{ref}/result

The result of one bet

Parameters
NameInRequiredTypeDescription
refpathyesstringYour bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$
Responses
200 OK
Response shape (example values)
{
  "ok": true,
  "result": {
    "ref": "string",
    "drawDate": "2026-11-04",
    "state": "pending",
    "closed": true,
    "status": "pending",
    "stakeSen": 100,
    "refundedSen": 100,
    "settled": true,
    "winSen": 1793700000123,
    "wins": [
      {}
    ],
    "settledAt": 1793700000123,
    "simulated": true
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

GET /api/b2b/v1/4d/results/{drawDate}

The official numbers of a draw and your bets of that date

Parameters
NameInRequiredTypeDescription
drawDatepathyesstring (date)
Responses
200 OK
Response shape (example values) · DrawResults
{
  "ok": true,
  "drawDate": "2026-11-04",
  "closed": true,
  "pools": {
    "example": {
      "name": "string",
      "first": "string",
      "second": "string",
      "third": "string",
      "special": [
        "string"
      ],
      "consolation": [
        "string"
      ],
      "source": "string"
    }
  },
  "sixFirst": {
    "example": "string"
  },
  "bets": [
    {
      "ref": "string",
      "subAccount": "string",
      "status": "pending",
      "stakeSen": 100,
      "refundedSen": 100,
      "settled": true,
      "winSen": 1793700000123
    }
  ],
  "totals": {
    "bets": 1,
    "stakeSen": 100,
    "refundedSen": 100,
    "settled": 1,
    "awaiting": 1,
    "winSen": 100
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

Statements

GET /api/b2b/v1/statements

Your weekly credit statements (production keys, read only)

Responses
200 OK
Response shape (example values)
{
  "ok": true,
  "statements": [
    {
      "number": "FXS-acme-20261026",
      "periodStart": 1793700000123,
      "periodEnd": 1793700000123,
      "periodLabel": "2026-10-26 to 2026-11-01",
      "salesSen": 100,
      "refundsSen": 100,
      "winsSen": 100,
      "netSen": 1793700000123,
      "saleCount": 1,
      "refundCount": 1,
      "resultCount": 1,
      "winCount": 1,
      "byCategory": {
        "example": {
          "salesSen": 100,
          "refundsSen": 100,
          "winsSen": 100,
          "netSen": 1793700000123
        }
      },
      "owes": "operator",
      "amountSen": 100,
      "dueAt": 1793700000123,
      "status": "issued",
      "overdue": true,
      "payment": {
        "direction": "received",
        "amountSen": 100,
        "reference": "string",
        "recordedAt": 1793700000123
      },
      "issuedAt": 1793700000123
    }
  ]
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

GET /api/b2b/v1/statements/{number}

One statement

Parameters
NameInRequiredTypeDescription
numberpathyesstringThe statement number (for example FXS-acme-20261026) or its id.
Responses
200 OK
Response shape (example values)
{
  "ok": true,
  "statement": {
    "number": "FXS-acme-20261026",
    "periodStart": 1793700000123,
    "periodEnd": 1793700000123,
    "periodLabel": "2026-10-26 to 2026-11-01",
    "salesSen": 100,
    "refundsSen": 100,
    "winsSen": 100,
    "netSen": 1793700000123,
    "saleCount": 1,
    "refundCount": 1,
    "resultCount": 1,
    "winCount": 1,
    "byCategory": {
      "example": {
        "salesSen": 100,
        "refundsSen": 100,
        "winsSen": 100,
        "netSen": 1793700000123
      }
    },
    "owes": "operator",
    "amountSen": 100,
    "dueAt": 1793700000123,
    "status": "issued",
    "overdue": true,
    "payment": {
      "direction": "received",
      "amountSen": 100,
      "reference": "string",
      "recordedAt": 1793700000123
    },
    "issuedAt": 1793700000123
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

Sandbox

POST /api/b2b/v1/sandbox/4d/bets/{ref}/result

Simulate the result of a sandbox bet (sandbox key only)

Parameters
NameInRequiredTypeDescription
refpathyesstringYour bet reference. pattern ^[A-Za-z0-9:_.-]{1,100}$
Request body
{
  "outcome": "win",
  "winSen": 350000
}
Responses
200 The bet, now settled
Response shape (example values) · BetEnvelope
{
  "ok": true,
  "created": true,
  "bet": {
    "ref": "string",
    "subAccount": "string",
    "sandbox": true,
    "funding": "prepaid",
    "drawDate": "2026-11-04",
    "pools": [
      "string"
    ],
    "lines": [
      "string"
    ],
    "stakeSen": 100,
    "refundedSen": 100,
    "status": "pending",
    "statusText": "string",
    "reason": "string",
    "result": {
      "settled": true,
      "winSen": 1793700000123,
      "wins": [
        {}
      ],
      "settledAt": 1793700000123
    },
    "canCancel": true,
    "cancelState": "requested",
    "cancelNote": "string",
    "createdAt": 1793700000123,
    "orders": [
      {
        "pool": "string",
        "status": "sending",
        "stakeSen": 100,
        "acceptedSen": 1793700000123,
        "rejectedSen": 1793700000123,
        "providerRef": "string",
        "betNo": 1,
        "receiptNo": 1
      }
    ]
  }
}
Error Failure. error.code is stable; message is for people. Common codes: unauthorized, stale_timestamp, replay, ip_not_allowed (401/403); bad_ticket, bad_ref, bad_sub_account, bad_date, stake_too_small, stake_too_large (400);
{
  "ok": false,
  "error": {
    "code": "insufficient_prepaid",
    "message": "The balance does not cover this stake."
  }
}

Data types

Sen

Whole sen (RM 1.00 = 100).

integer (int64)

BetStatus

pending | sending | accepted | partial | refused | rejected | cancelled | check

PlaceBet

FieldTypeDescription
ref requiredstringYour idempotency key and reference.
subAccount requiredstringYour own reference for the player or site behind the bet. Stored and returned for matching.
drawDate requiredstring (date)Bets close 18:59 Malaysian time on the draw day.
pools requiredarray of magnum | damacai | toto | singapore | sabah | sarawak | gd | ninePools to bet on (each pool costs the ticket total once). A 5D line is accepted only with toto; a 6D line only with toto, gd or nine (otherwise 400 bad_ticket).
lines requiredarray of stringBet slip lines in the FortuneX text format. Smallest stake on one number is RM 0.10.

Order

FieldTypeDescription
poolstring
statussending | accepted | refused | uncertain | cancelled
stakeSeninteger (sen)Whole sen (RM 1.00 = 100).
acceptedSeninteger (int64), may be nullWhole sen (RM 1.00 = 100).
rejectedSeninteger (int64), may be nullWhole sen (RM 1.00 = 100).
providerRefstringThe reference FortuneX used for this order with the supplier.
betNointeger, may be nullThe supplier's bet number, once accepted.
receiptNointeger, may be null

Bet

FieldTypeDescription
refstring
subAccountstring
sandboxboolean
fundingprepaid | credit | sandbox
drawDatestring (date)
poolsarray of string
linesarray of string
stakeSeninteger (sen)Whole sen (RM 1.00 = 100).
refundedSeninteger (sen)Whole sen (RM 1.00 = 100).
statuspending | sending | accepted | partial | refused | rejected | cancelled | check
statusTextstring
reasonstring, may be null
resultobject
canCancelboolean
cancelStaterequested | cancelling | declined | failed, may be null
cancelNotestring, may be null
createdAtinteger (int64)
ordersarray of Order

BetEnvelope

FieldTypeDescription
okboolean
createdbooleanOnly on placement.
betBet Bet

BetResult

FieldTypeDescription
refstring
drawDatestring (date)
statepending | open | awaiting_result | settled | void
closedboolean
statuspending | sending | accepted | partial | refused | rejected | cancelled | check
stakeSeninteger (sen)Whole sen (RM 1.00 = 100).
refundedSeninteger (sen)Whole sen (RM 1.00 = 100).
settledboolean
winSeninteger (int64), may be nullWhole sen (RM 1.00 = 100).
winsarray of object, may be null
settledAtinteger (int64), may be null
simulatedbooleanTrue for a sandbox bet.

Balance

FieldTypeDescription
sandboxboolean
currencystring
prepaidBalanceSeninteger (int64), may be nullWhole sen (RM 1.00 = 100).
gamesarray of object
asOfinteger (int64)
notestring

GamesInfo

FieldTypeDescription
okboolean
sandboxboolean
enabledbooleanFalse when real 4D bets are off or FortuneX is in maintenance.
fundingprepaid | credit | sandbox
currencystring
gamesarray of object
drawDatesarray of object
limitsobject

DrawResults

FieldTypeDescription
okboolean
drawDatestring (date)
closedboolean
poolsobjectOfficial numbers per pool id, or null when not published yet.
sixFirstobjectThe 6D first prize for gd / nine where known.
betsarray of object
totalsobject

Statement

Final once issued. net = sales - refunds - wins. Positive net: you owe FortuneX; negative: FortuneX owes you.

FieldTypeDescription
numberstring
periodStartinteger (int64)
periodEndinteger (int64)
periodLabelstring
salesSeninteger (sen)Whole sen (RM 1.00 = 100).
refundsSeninteger (sen)Whole sen (RM 1.00 = 100).
winsSeninteger (sen)Whole sen (RM 1.00 = 100).
netSeninteger (int64)
saleCountinteger
refundCountinteger
resultCountinteger
winCountinteger
byCategoryobjectSales, refunds, wins and net per game category (today only 4d).
owesoperator | fortunex | nobody
amountSeninteger (sen)Whole sen (RM 1.00 = 100).
dueAtinteger (int64)
statusissued | paid
overdueboolean
paymentobject, may be null
issuedAtinteger (int64)

Versioning

This document describes version 1.0.0 of the operator API. Its base path is versioned (/v1); the OpenAPI file carries the document version, and this page is rebuilt from it whenever the documents change.

Back to top