Live demo

SkyExch Iframe

Add a complete sports exchange to your site with one iframe. Live odds, in-play markets, bookmaker and fancy markets, bet placement, settlement and full player reports all run on our side. You add a button, one server-side API call, and a way to move money.

One API call to launch

Your server calls POST /v1/sessions and gets a one-time launchUrl. No passwords, no second login.

Three wallet modes

Move money yourself (TRANSFER) or let us pull it from your wallet (AUTO, SEAMLESS).

Everything inside

Cricket, soccer, tennis and more; My Bets, P&L, account statement, activity log.

Mobile first

Built for phones; fills any width. Works in Chrome, Safari (iOS) and in-app web views.

API base URLhttps://fair247.vip/sportsframe-gw
Your iframe hostAssigned at onboarding, e.g. https://acme-ai.6xwkts.com
AuthHMAC-SHA256 signed requests, server to server (details)
FormatJSON over HTTPS, UTF-8
CurrencyAgreed at onboarding (e.g. BDT), amounts with at most 2 decimals
SDK/sdk/skyexch-iframe.js (no dependencies, ~4 KB)

Live demo #

Click Demo login. This page asks our demo backend for a launchUrl, exactly as your server would with POST /v1/sessions, and mounts it with the SDK. You get a demo player with 1,000 play balance and can browse and place bets on live markets. The panel logs every event the iframe sends.

Demo player: – · Balance at launch: –
Send to the iframe:

The demo player is kept in this browser and topped back up to 1,000 on each demo login (a few times a day). Demo balances are play money for evaluation only. Your production integration uses your own credentials and players.

Press  Demo login  to open the exchange here.

Quick start #

  1. Get credentials. We send you an API key, an API secret and your iframe host (onboarding). Keep the secret on your server only.
  2. Add one backend endpoint on your site, e.g. POST /api/sports/launch, that runs for the logged-in player and calls our POST /v1/sessions:
    // npm i express   (Node 18+, uses the built-in fetch)
    const crypto = require("crypto");
    const API = "https://fair247.vip/sportsframe-gw";
    const KEY = process.env.SKYEXCH_API_KEY, SECRET = process.env.SKYEXCH_API_SECRET;
    
    async function skyexch(method, path, body) {
      const raw = body ? JSON.stringify(body) : "";
      const ts = String(Math.floor(Date.now() / 1000));
      const nonce = crypto.randomBytes(16).toString("hex");
      const sig = crypto.createHmac("sha256", SECRET).update(`${ts}\n${nonce}\n${method}\n${path}\n${raw}`).digest("hex");
      const res = await fetch(API + path, { method, body: body ? raw : undefined, headers: {
        "Content-Type": "application/json", "X-Operator-Key": KEY,
        "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig } });
      const json = await res.json();
      if (!json.ok) throw Object.assign(new Error(json.error.code), { status: res.status, error: json.error });
      return json.data;
    }
    
    // Your route. req.user is YOUR logged-in player (never trust an id from the browser).
    app.post("/api/sports/launch", requireLogin, async (req, res) => {
      const data = await skyexch("POST", "/v1/sessions", {
        externalPlayerId: String(req.user.id),
        parentOrigin: "https://www.acme.com",
        displayName: req.user.name,
      });
      res.json({ launchUrl: data.launchUrl });
    });
    <?php
    const SX_API = 'https://fair247.vip/sportsframe-gw';
    
    function skyexch($method, $path, $body = null) {
        $raw = $body === null ? '' : json_encode($body);
        $ts = (string) time();
        $nonce = bin2hex(random_bytes(16));
        $sig = hash_hmac('sha256', "$ts\n$nonce\n$method\n$path\n$raw", getenv('SKYEXCH_API_SECRET'));
        $ch = curl_init(SX_API . $path);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Operator-Key: ' . getenv('SKYEXCH_API_KEY'),
                "X-Timestamp: $ts", "X-Nonce: $nonce", "X-Signature: $sig"],
        ]);
        if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $raw);   // the SAME bytes you signed
        $json = json_decode(curl_exec($ch), true);
        if (empty($json['ok'])) throw new Exception($json['error']['code'] ?? 'NETWORK_ERROR');
        return $json['data'];
    }
    
    // Your route, for the logged-in player only.
    $data = skyexch('POST', '/v1/sessions', [
        'externalPlayerId' => (string) $currentUser->id,
        'parentOrigin' => 'https://www.acme.com',
        'displayName' => $currentUser->name,
    ]);
    header('Content-Type: application/json');
    echo json_encode(['launchUrl' => $data['launchUrl']]);
    # pip install requests
    import hmac, hashlib, json, os, secrets, time, requests
    API = "https://fair247.vip/sportsframe-gw"
    
    def skyexch(method, path, body=None):
        raw = "" if body is None else json.dumps(body, separators=(",", ":"))
        ts, nonce = str(int(time.time())), secrets.token_hex(16)
        sig = hmac.new(os.environ["SKYEXCH_API_SECRET"].encode(),
                       f"{ts}\n{nonce}\n{method}\n{path}\n{raw}".encode(), hashlib.sha256).hexdigest()
        r = requests.request(method, API + path, data=raw.encode() if body is not None else None, timeout=10, headers={
            "Content-Type": "application/json", "X-Operator-Key": os.environ["SKYEXCH_API_KEY"],
            "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig})
        j = r.json()
        if not j.get("ok"):
            raise RuntimeError(j["error"]["code"])
        return j["data"]
    
    # Your view, for the logged-in player only.
    data = skyexch("POST", "/v1/sessions", {"externalPlayerId": str(user.id),
                   "parentOrigin": "https://www.acme.com", "displayName": user.name})
    return {"launchUrl": data["launchUrl"]}
  3. Show the iframe on your Sports page with the SDK:
    <div id="sports" style="height: calc(100dvh - 56px)"></div>
    <script src="https://docs.6xwkts.com/sdk/skyexch-iframe.js"></script>
    <script>
      async function newLaunchUrl() {
        const r = await fetch("/api/sports/launch", { method: "POST", credentials: "include" });
        return (await r.json()).launchUrl;
      }
      (async () => {
        const frame = SkyExchIframe.mount({
          container: "#sports",
          launchUrl: await newLaunchUrl(),
          onSessionEnded: async () => frame.relaunch(await newLaunchUrl()),
        });
      })();
    </script>
  4. Move money. TRANSFER mode: call /v1/wallet/deposit and /v1/wallet/withdraw from your "Transfer to Sports" screen. AUTO / SEAMLESS: expose the four wallet callbacks instead.
  5. Run the test plan on staging, then go live.

How it works #

Player's browserYour backendSkyExch APIIframe (your host) 1. player taps "Sports"2. POST /v1/sessions (signed) 3. one-time launchUrl (60 s)4. launchUrl 5. <iframe src=launchUrl>6. exchange code (Origin checked) 7. session: player logged in

Onboarding #

You send us

ItemExampleWhy
Operator code (2–8 letters/digits)ACMEPrefix of your players' ids on our side
Every page origin that embeds the iframehttps://www.acme.com, https://m.acme.comOnly these can launch sessions and receive events
Server egress IPs (recommended)203.0.113.10API calls from other IPs are refused
CurrencyBDTWallet currency
Wallet modeTRANSFER / AUTO / SEAMLESSWallet modes
Callback base URL (AUTO / SEAMLESS)https://api.acme.com/skyexchWhere we call your wallet endpoints
Idle timeout (AUTO, optional)10 minutesWhen an inactive session's money goes back
Staging origin (optional)https://staging.acme.comTesting before going live

You receive

ItemExample
API base URLhttps://fair247.vip/sportsframe-gw
API keysif_… (public identifier)
API secret43 random characters. Server-side only. Shown once.
Callback secret (AUTO / SEAMLESS)We sign our calls to your wallet with it. Server-side only.
Your iframe hosthttps://acme-ai.6xwkts.com
Operator float (TRANSFER)Prepaid balance credited by our team

Wallet modes #

Each player has a sports wallet with us. Bets are placed from it and settle into it. You choose at onboarding how money gets in and out:

ModeWho moves moneyPlayer seesYou build
TRANSFERYou, with deposit / withdraw API calls, from your operator floatTwo balances: main wallet and sports walletAPI calls + a "Transfer to Sports" screen
AUTOWe do. When the iframe opens we take the player's whole balance from your wallet; when they leave (you call revoke, or idle timeout) we give back everything not in an open bet. Later settlements follow automatically.One balance4 wallet endpoints
SEAMLESSWe do, per bet. Before each bet we take what the bet can lose at most; seconds later we return what wasn't needed; winnings follow as bets settle.One balance, always in your wallet4 wallet endpoints (fast: called on every bet)

Which one? TRANSFER if your site already has wallet transfer screens. AUTO for one balance with the fewest calls to you (one debit per session). SEAMLESS if money must stay in your wallet until the moment of a bet.

Authentication #

Every /v1/* request is signed with HMAC-SHA256 using your API secret.

HeaderValue
X-Operator-KeyYour API key
X-TimestampCurrent Unix time in seconds; must be within ±300 s of our clock (keep NTP on)
X-Nonce8–64 random chars [A-Za-z0-9_-], new for every request
X-SignatureLowercase hex HMAC-SHA256(apiSecret, canonical)
Content-Typeapplication/json

canonical is five parts joined by a newline (\n):

<X-Timestamp>
<X-Nonce>
<HTTP method, upper case>
<path + query string, starting at /v1, e.g. /v1/players/p1/statement?from=2026-10-01&to=2026-10-31>
<the exact request body bytes; empty string for GET>

The path in the signature starts at /v1/…, without the /sportsframe-gw prefix of the base URL. Sign the exact body string you send: serialising the JSON twice can change the bytes and break the signature.

const crypto = require("crypto");
function signedHeaders(apiKey, apiSecret, method, path, rawBody = "") {
  const ts = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(16).toString("hex");
  const sig = crypto.createHmac("sha256", apiSecret)
    .update(`${ts}\n${nonce}\n${method}\n${path}\n${rawBody}`).digest("hex");
  return { "Content-Type": "application/json", "X-Operator-Key": apiKey,
           "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig };
}
function sx_headers($apiKey, $apiSecret, $method, $path, $rawBody = '') {
    $ts = (string) time();
    $nonce = bin2hex(random_bytes(16));
    $sig = hash_hmac('sha256', "$ts\n$nonce\n$method\n$path\n$rawBody", $apiSecret);
    return ['Content-Type: application/json', "X-Operator-Key: $apiKey",
            "X-Timestamp: $ts", "X-Nonce: $nonce", "X-Signature: $sig"];
}
import hmac, hashlib, secrets, time
def sx_headers(api_key, api_secret, method, path, raw_body=""):
    ts, nonce = str(int(time.time())), secrets.token_hex(16)
    msg = f"{ts}\n{nonce}\n{method}\n{path}\n{raw_body}".encode()
    sig = hmac.new(api_secret.encode(), msg, hashlib.sha256).hexdigest()
    return {"Content-Type": "application/json", "X-Operator-Key": api_key,
            "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig}
KEY=sif_xxx; SECRET=xxx; API=https://fair247.vip/sportsframe-gw
BODY='{"externalPlayerId":"player42","parentOrigin":"https://www.acme.com"}'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" POST /v1/sessions "$BODY" \
      | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s "$API/v1/sessions" -H "Content-Type: application/json" -H "X-Operator-Key: $KEY" \
     -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Signature: $SIG" -d "$BODY"

Test vector: secret test-secret, timestamp 1700000000, nonce abcdef0123456789, GET /v1/operator, empty body → canonical "1700000000\nabcdef0123456789\nGET\n/v1/operator\n" → signature 1f577ee544ee42d975ee48b2b1d11b67a5c7344e51e1dee7feccf3a9097afaf6. Check your helper against it before the first real call.

Requests & responses #

Every response is JSON with ok:

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "PLAYER_NOT_FOUND", "message": "Unknown externalPlayerId", "details": {} } }

Branch on error.code, never on message.

FieldRule
externalPlayerIdYour player id. 1–64 chars A-Z a-z 0-9 _ . @ : -. Case-sensitive. Never reuse one for a different person.
transactionIdYour unique id for a transfer. 1–64 chars A-Z a-z 0-9 _ . : -.
amountPositive number, at most 2 decimals.
parentOriginOrigin of the page that embeds the iframe, exactly as the browser reports it: https://www.acme.com (no path, no trailing slash). Must be registered.

POST/v1/sessions #

Launch the iframe for a player. Creates the player on first use. Call it each time the player opens Sports.

FieldType
externalPlayerIdstring, requiredYour player id
parentOriginstring, requiredThe page origin that embeds the iframe
displayNamestring, optionalUp to 64 chars, used when the player is first created
startPagestring, optionalDefault in-play; see start pages
// request
{ "externalPlayerId": "player42", "parentOrigin": "https://www.acme.com", "displayName": "Ravi", "startPage": "in-play" }

// 200 response
{ "ok": true, "data": {
    "launchUrl": "https://acme-ai.6xwkts.com/launch#code=Qm9...",
    "expiresInSeconds": 60, "playerCreated": false, "clientId": "ACME_PLAYER42" } }

Errors: PARENT_ORIGIN_NOT_ALLOWED, PLAYER_BLOCKED, PLAYER_BUSY (retry in a second).

POST/v1/sessions/revoke #

Log a player out of sports. Call it when the player logs out of your site, closes Sports, or when you block them. The open iframe stops working on its next request and sends session-ended.

{ "externalPlayerId": "player42" }
// 200
{ "ok": true, "data": { "revoked": true, "returnedToOperator": 650 } }

AUTO / SEAMLESS: revoke also returns the player's withdrawable sports balance to your wallet immediately (a credit callback); returnedToOperator is that amount (always 0 in TRANSFER). Money in open bets follows as bets settle.

POST/v1/wallet/deposit #

TRANSFER mode only. Moves amount from your operator float to the player's sports wallet. Creates the player if needed. In AUTO / SEAMLESS it answers 403 WALLET_MODE_MISMATCH.

// request
{ "externalPlayerId": "player42", "amount": 500, "transactionId": "dep-7f3c2a" }

// 200 response
{ "ok": true, "data": {
    "transactionId": "dep-7f3c2a", "externalPlayerId": "player42", "direction": "DEPOSIT",
    "amount": 500, "status": "SUCCESS", "failCode": null, "playerBalance": 500,
    "createdAt": "2026-10-31T10:15:00.000Z", "replayed": false } }

playerBalance is the player's withdrawable balance afterwards. Errors: OPERATOR_INSUFFICIENT_FUNDS (422, top up your float), TRANSACTION_ID_REUSED (409). See safe transfers.

POST/v1/wallet/withdraw #

TRANSFER mode only. Same body and response as deposit, with "direction": "WITHDRAW". Moves money from the player's sports wallet back to your float. You can withdraw up to the player's withdrawable balance: money in open bets and bonus-locked money stay.

Errors: PLAYER_NOT_FOUND (404), PLAYER_INSUFFICIENT_FUNDS (422), TRANSACTION_ID_REUSED (409).

Withdraw everything: read the balance, then withdraw withdrawable. If a bet settles in between you get PLAYER_INSUFFICIENT_FUNDS and nothing moves: read again and retry with a new transactionId.

GET/v1/wallet/transactions/{transactionId} #

Status of a transfer: the same object as the deposit/withdraw response. 404 TRANSACTION_NOT_FOUND means we never received it, so it is safe to send again with the same id. In AUTO / SEAMLESS you can also look up the transactionIds we sent you in callbacks (in-, bet-, out-).

GET/v1/players/{externalPlayerId}/balance #

{ "ok": true, "data": { "externalPlayerId": "player42", "currency": "BDT",
    "available": 1250, "exposure": 300, "bonusLocked": 0, "withdrawable": 1250 } }
FieldMeaning
availableWhat the player can still bet with (the iframe header balance)
exposureMoney held against open bets (worst-case loss)
bonusLockedBonus money not yet released by wagering
withdrawableWhat /wallet/withdraw accepts now: available − bonusLocked

GET/v1/players/{externalPlayerId}/statement #

Query: from=YYYY-MM-DD&to=YYYY-MM-DD&limit=100&offset=0. The player's balance history (deposits, withdrawals, bet settlements), newest first. Dates are IST (Asia/Kolkata), to is inclusive, limit max 500. Remember to include the query string in the signed path.

{ "ok": true, "data": { "externalPlayerId": "player42", "timezone": "Asia/Kolkata", "limit": 100, "offset": 0,
  "rows": [ { "time": "2026-10-31 15:45:00", "type": "Deposit", "credit": 500, "debit": 0, "balance": 500, "remark": "Deposit" } ] } }

GET/v1/operator #

Your operator float (TRANSFER). Alert yourself well before it runs out: deposits fail with OPERATOR_INSUFFICIENT_FUNDS once it does.

{ "ok": true, "data": { "operatorCode": "ACME", "currency": "BDT", "float": 248500 } }

POST/v1/players #

Optional pre-creation. Sessions and deposits already create players.

{ "externalPlayerId": "player42", "displayName": "Ravi" }
// 200
{ "ok": true, "data": { "externalPlayerId": "player42", "clientId": "ACME_PLAYER42", "created": true } }

Error codes #

HTTPcodeMeaning / what to do
400INVALID_REQUESTA field is missing or invalid; see message
401INVALID_SIGNATUREBad key or signature, clock off by more than 300 s, or a reused nonce
403IP_NOT_ALLOWEDCall from an unregistered IP
403OPERATOR_SUSPENDEDYour account is suspended; contact us
403PARENT_ORIGIN_NOT_ALLOWEDparentOrigin is not registered for you
403WALLET_MODE_MISMATCHDeposit/withdraw called in AUTO / SEAMLESS
403PLAYER_BLOCKEDThe player is blocked on our side
404PLAYER_NOT_FOUNDNo such externalPlayerId yet
404TRANSACTION_NOT_FOUNDWe never received this transactionId
409TRANSACTION_ID_REUSEDSame transactionId with a different player, direction or amount
409PLAYER_BUSYPlayer being created by a parallel request; retry in a second
422OPERATOR_INSUFFICIENT_FUNDSFloat too low for this deposit
422PLAYER_INSUFFICIENT_FUNDSAmount is more than withdrawable
422PLAYER_NOT_UNDER_OPERATORAccount configuration issue; contact us
429–Rate limit (per IP); back off and retry
500INTERNAL_ERRORSafe to retry (transfers: with the same transactionId)

A 422 transfer is final. Retrying the same transactionId returns the same 422 and moves no money. To try again, use a new id.

Embedding the iframe #

Use the SDK (recommended) or a plain iframe:

<iframe src="LAUNCH_URL" title="Sports" style="border:0;width:100%;height:100dvh"
        allow="autoplay; fullscreen; encrypted-media; picture-in-picture; clipboard-write"
        allowfullscreen referrerpolicy="no-referrer"></iframe>

JavaScript SDK #

Load https://docs.6xwkts.com/sdk/skyexch-iframe.js or host a copy yourself (no dependencies). It exposes window.SkyExchIframe (alias window.SportsFrame) and works as a CommonJS/ESM import.

const frame = SkyExchIframe.mount({
  container: "#sports",          // element or CSS selector
  launchUrl,                     // from your backend (POST /v1/sessions)
  autoHeight: false,             // true: iframe grows with its content and your page scrolls
  minHeight: 600,                // with autoHeight
  onEvent: (type, payload) => console.log(type, payload),
  onSessionEnded: async (payload, type) => frame.relaunch(await newLaunchUrl()),
});

frame.navigate("my-bets");       // open a page (see Start pages)
frame.refreshBalance();          // reload the header balance (after a deposit/withdraw)
frame.logout();                  // end the session in the iframe (also call /v1/sessions/revoke)
frame.relaunch(newUrl);          // new launchUrl after session-ended / launch-failed
frame.destroy();                 // remove the iframe and listeners

Rate-limit automatic relaunches (e.g. once per 30 s) so a persistent failure cannot loop. Each relaunch() needs a new launchUrl.

Events (postMessage) #

Messages from the iframe are { source: "sportsframe", type, payload }, sent only to the session's parentOrigin. The SDK checks the origin for you; without it, check event.origin === "https://acme-ai.6xwkts.com" and event.source === iframe.contentWindow.

From the iframe

typepayloadWhen
ready{ path, page }App loaded with a session
route{ path, page }The player moved to another page
resize{ height }Content height changed (used by autoHeight)
session-ended{ reason }Session replaced by a newer launch, revoked, expired, or logged out
launch-failed{ code }LAUNCH_CODE_INVALID (expired/used), NETWORK_ERROR, STORAGE_UNAVAILABLE, LAUNCH_CODE_MISSING
navigation-blocked{ path }The player hit a link outside sports (e.g. the home logo). You may route them to your own page.

To the iframe

iframe.contentWindow.postMessage({ source: "sportsframe", type, payload }, iframeOrigin) (the SDK methods wrap these):

typepayloadEffect
navigate{ page }Opens a page
refresh-balance{}Reloads the header balance
logout{}Ends the session in the iframe

Start pages #

Values for startPage and navigate:

PageShows
in-playLive events (default)
sportsAll sports and upcoming events
multi-marketPinned markets
accountAccount menu
my-betsOpen bets
bet-historySettled bets
profit-lossProfit & loss by market
account-statementAccount statement
balance-overviewBalance overview
activity-logLogin / activity log
my-profileProfile

TRANSFER: safe transfers #

transactionId is the idempotency key. Sending the same deposit or withdrawal again (same id, player, direction and amount) moves money once; repeats return the first result with "replayed": true.

Deposit (main wallet → sports)

  1. In your database, in one transaction: debit the player's main wallet and insert a pending transfer row with a new transactionId.
  2. Call POST /v1/wallet/deposit with that id.
  3. 200 → mark the row done. 422 → final failure: refund the main wallet, mark failed.
  4. Timeout, network error or 5xx → do not refund yet. Retry the same call, or check GET /v1/wallet/transactions/{id}: 200 = it happened, 404 = it did not, resend.
  5. Call frame.refreshBalance() so the iframe header updates at once.

Withdraw (sports → main wallet)

The mirror: call us first; credit the main wallet only after a 200. On timeout, resolve with the status lookup before crediting.

// Node.js deposit with a pending row (pseudo-DB calls)
async function transferToSports(user, amount) {
  const transactionId = `dep-${crypto.randomUUID()}`;
  await db.tx(async (t) => {
    await t.debitMainWallet(user.id, amount);
    await t.insertTransfer({ transactionId, userId: user.id, amount, status: "PENDING" });
  });
  try {
    await skyexch("POST", "/v1/wallet/deposit", { externalPlayerId: String(user.id), amount, transactionId });
    await db.markTransfer(transactionId, "DONE");
  } catch (e) {
    if (e.status === 422) {                       // final: nothing moved on our side
      await db.tx(async (t) => { await t.creditMainWallet(user.id, amount); await t.markTransfer(transactionId, "FAILED"); });
      throw e;
    }
    // timeout / 5xx: leave PENDING; a job resolves it with GET /v1/wallet/transactions/{id}
    throw e;
  }
}

AUTO / SEAMLESS: wallet callbacks #

In these modes we call you. Expose four POST endpoints under your callback base URL:

EndpointWe sendYou do
{base}/balanceexternalPlayerId, currencyReturn the player's spendable balance. No side effects.
{base}/debitexternalPlayerId, currency, amount, transactionId, reasonTake amount from the player's wallet
{base}/creditexternalPlayerId, currency, amount, transactionId, reason, refTransactionIdAdd amount to the player's wallet
{base}/rollbackexternalPlayerId, currency, transactionId, refTransactionId, reasonUndo debit refTransactionId

reason: launch (AUTO, iframe opened), bet (SEAMLESS, before a bet), session-end (revoke), idle (AUTO timeout), settlement (bets settled / unused bet money), refund (a debit we could not use).

Authentication of our calls

Same signing scheme as your calls to us, with your callback secret as the key, X-Operator-Key = your API key, and the path = the full path of your endpoint (e.g. /skyexch/debit). Reject bad signatures, timestamps outside ±300 s and reused nonces with HTTP 401.

Responses

200 { "ok": true,  "data": { "balance": 1350.00 } }              // applied; balance after the operation
4xx { "ok": false, "error": { "code": "INSUFFICIENT_FUNDS" } }   // refused, NOT applied
You answerWe treat it as
200 with ok: trueApplied. data.balance is required on every endpoint.
4xx with an error.codeDefinitely not applied (INSUFFICIENT_FUNDS, PLAYER_BLOCKED, TRANSACTION_ROLLED_BACK…)
5xx, timeout (8 s), invalid bodyUnknown: it may or may not have been applied

Answer within 2 seconds (SEAMLESS: the player is waiting on a bet).

Rules your wallet must follow

  1. Idempotent by transactionId. The same id again must not apply twice; return the original answer, including an earlier INSUFFICIENT_FUNDS.
  2. Credits must always succeed (except signature errors). We retry a credit until you return 200: after 10 s, 20 s, 40 s … up to every 15 minutes, same transactionId.
  3. Rollback: if debit refTransactionId was applied, refund it; if not, do nothing. Either way answer 200, and from then on refuse that debit with TRANSACTION_ROLLED_BACK if it arrives late. A rollback has its own idempotent transactionId.
  4. We roll back unknown debits: a debit answered with 5xx or a timeout is rolled back, never retried, so it can't take money twice.
  5. Never go negative: answer INSUFFICIENT_FUNDS instead.

What happens when

AUTOSEAMLESS
Iframe opensbalance, then debit of the whole balance (launch). Refused / no answer → player starts with 0 in sports.No money moves.
Each betNothing (money is already in sports).balance, then debit of min(bet's max loss − money already in sports, your balance) (bet).
Player leavesYou call revoke, or idle timeout → credit of everything withdrawable (session-end / idle).~20 s after the last bet: credit of everything withdrawable (settlement).
Bets settle latercredits with settlement.credits with settlement.

Example handler (Node.js)

// Express. Every operation is ONE database transaction; walletTx.transactionId is UNIQUE.
app.post("/skyexch/:op(balance|debit|credit|rollback)", express.text({ type: "*/*" }), async (req, res) => {
  if (!verifySignature(req, CALLBACK_SECRET)) return res.status(401).json({ ok: false, error: { code: "INVALID_SIGNATURE" } });
  const b = JSON.parse(req.body);
  const out = await db.tx(async (t) => {
    if (req.params.op === "balance") return [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }];
    const seen = await t.findWalletTx(b.transactionId);          // idempotency
    if (seen) return [seen.status, seen.body];
    let r;
    if (req.params.op === "debit") {
      if (await t.isRolledBack(b.transactionId)) r = [409, { ok: false, error: { code: "TRANSACTION_ROLLED_BACK" } }];
      else if (await t.balance(b.externalPlayerId) < b.amount) r = [422, { ok: false, error: { code: "INSUFFICIENT_FUNDS" } }];
      else { await t.debit(b.externalPlayerId, b.amount); r = [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }]; }
    } else if (req.params.op === "credit") {
      await t.credit(b.externalPlayerId, b.amount);
      r = [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }];
    } else {                                                        // rollback
      await t.refundDebitIfApplied(b.refTransactionId);             // and mark it rolled back
      r = [200, { ok: true, data: { balance: await t.balance(b.externalPlayerId) } }];
    }
    await t.saveWalletTx(b.transactionId, r[0], r[1]);
    return r;
  });
  res.status(out[0]).json(out[1]);
});

function verifySignature(req, secret) {
  const ts = req.get("X-Timestamp"), nonce = req.get("X-Nonce"), sig = req.get("X-Signature") || "";
  if (req.get("X-Operator-Key") !== API_KEY || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  if (!rememberNonce(nonce)) return false;                          // reject reused nonces
  const expected = crypto.createHmac("sha256", secret).update(`${ts}\n${nonce}\nPOST\n${req.originalUrl}\n${req.body}`).digest("hex");
  return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Reconciliation #

Security checklist #

Test plan #

On staging (we register your staging origin and fund a test float):

  1. A new player opens Sports → playerCreated: true; the iframe opens logged in on In-Play.
  2. Deposit 100 → the header shows it after refreshBalance().
  3. Same deposit again (same transactionId) → replayed: true, float unchanged.
  4. Withdraw more than withdrawable → PLAYER_INSUFFICIENT_FUNDS, nothing moved.
  5. Place a bet; check My Bets; after settlement check Profit & Loss and Account Statement.
  6. Open Sports in a second tab → the first gets session-ended; your page relaunches.
  7. Reuse an old launchUrl → launch-failed with LAUNCH_CODE_INVALID.
  8. Safari (iOS) and Chrome (Android): open, bet, background the app, come back.
  9. Kill the network during a deposit → resolve it with GET /v1/wallet/transactions/{id}.

AUTO / SEAMLESS (instead of 2–4 and 9):

  1. AUTO: open Sports with 500 in your wallet → your wallet 0, iframe header 500; revoke → 500 back (± results).
  2. AUTO: leave the iframe open in a hidden tab past the idle timeout → money returns, the iframe sends session-ended.
  3. SEAMLESS: place a 100 bet → one debit of at most the max loss; within ~30 s a credit of the unused part.
  4. Answer one debit with HTTP 500 → you receive a rollback; your balance is unchanged.
  5. Answer one credit with HTTP 500 → the same transactionId comes again and is applied once.
  6. Call your endpoints with a wrong signature → you answer 401.

Go-live: production credentials, production origins registered, IP allowlist set, float funded (TRANSFER), reconciliation running.

Full example (Node.js, TRANSFER) #

A complete minimal operator: login stub, launch endpoint, transfer endpoints, and the Sports page.

// server.js — node server.js   (Node 18+, npm i express)
const express = require("express");
const crypto = require("crypto");
const app = express();
app.use(express.json());

const API = "https://fair247.vip/sportsframe-gw";
const KEY = process.env.SKYEXCH_API_KEY, SECRET = process.env.SKYEXCH_API_SECRET;
const ORIGIN = process.env.PUBLIC_ORIGIN;              // e.g. https://www.acme.com (registered with us)

async function skyexch(method, path, body) {
  const raw = body ? JSON.stringify(body) : "";
  const ts = String(Math.floor(Date.now() / 1000)), nonce = crypto.randomBytes(16).toString("hex");
  const sig = crypto.createHmac("sha256", SECRET).update(`${ts}\n${nonce}\n${method}\n${path}\n${raw}`).digest("hex");
  const res = await fetch(API + path, { method, body: body ? raw : undefined, signal: AbortSignal.timeout(10000),
    headers: { "Content-Type": "application/json", "X-Operator-Key": KEY, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig } });
  const json = await res.json();
  if (!json.ok) throw Object.assign(new Error(json.error.code), { status: res.status });
  return json.data;
}

// Replace with your real session middleware.
const requireLogin = (req, res, next) => { req.user = { id: "player42", name: "Ravi" }; next(); };

app.post("/api/sports/launch", requireLogin, async (req, res) => {
  try {
    const d = await skyexch("POST", "/v1/sessions", { externalPlayerId: req.user.id, parentOrigin: ORIGIN, displayName: req.user.name });
    res.json({ launchUrl: d.launchUrl });
  } catch (e) { res.status(502).json({ error: e.message }); }
});

app.post("/api/sports/deposit", requireLogin, async (req, res) => {
  // Debit your main wallet + insert a PENDING row first (see "TRANSFER: safe transfers").
  const transactionId = `dep-${crypto.randomUUID()}`;
  try { res.json(await skyexch("POST", "/v1/wallet/deposit", { externalPlayerId: req.user.id, amount: Number(req.body.amount), transactionId })); }
  catch (e) { res.status(e.status || 502).json({ error: e.message }); }
});

app.post("/api/sports/withdraw", requireLogin, async (req, res) => {
  const transactionId = `wd-${crypto.randomUUID()}`;
  try { res.json(await skyexch("POST", "/v1/wallet/withdraw", { externalPlayerId: req.user.id, amount: Number(req.body.amount), transactionId })); }
  catch (e) { res.status(e.status || 502).json({ error: e.message }); }
});

app.post("/api/logout", requireLogin, async (req, res) => {
  await skyexch("POST", "/v1/sessions/revoke", { externalPlayerId: req.user.id }).catch(() => {});
  res.json({ ok: true });
});

app.get("/sports", (req, res) => res.send(`<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<body style="margin:0">
<div id="sports" style="height:100dvh"></div>
<script src="https://docs.6xwkts.com/sdk/skyexch-iframe.js"></script>
<script>
  const newUrl = async () => (await (await fetch("/api/sports/launch", { method: "POST" })).json()).launchUrl;
  let last = 0;
  (async () => {
    const frame = SkyExchIframe.mount({ container: "#sports", launchUrl: await newUrl(),
      onSessionEnded: async () => { if (Date.now() - last > 30000) { last = Date.now(); frame.relaunch(await newUrl()); } } });
  })();
</script>`));

app.listen(8080, () => console.log("http://localhost:8080/sports"));

FAQ #

Do players need a separate login?

No. Your backend vouches for the player with POST /v1/sessions.

What about third-party cookie blocking?

The iframe uses no cookies. It keeps its session in its own storage; if a browser blocks that too, it keeps it in memory and everything works until a reload, then you get session-ended and relaunch.

Can a player have money in sports and in my main wallet at once?

In TRANSFER mode, yes: the two wallets are separate until you move money. In AUTO / SEAMLESS the player sees one balance.

Where do I see a player's bets?

Inside the iframe (My Bets, Bets History, Profit & Loss). For your back office use the statement endpoint; contact us if you need a bet-level feed.

My requests fail with INVALID_SIGNATURE

Check, in order: the clock (±300 s), a new nonce per request, the signed path starts at /v1 and includes the query string, and you sign the exact body bytes you send.

Which languages are supported?

The UI is English today. lang is reserved in /v1/sessions.