One SDK gives your game players, balances, rewards, an NFT marketplace and a real order book. This page is the complete v1 surface — every endpoint, every field, and the reasoning behind the parts that look unusual.
Everything below runs against your own game server. GameFi Rails is a server-to-server API: your backend already knows who the player is, so there is no wallet connect, no signature prompt, and no key material anywhere near the game client.
npm install @gamefi-rails/sdk-js
baseUrl points at the API (port 7300 in the sandbox).
apiKey is optional: omit it and the request runs against the shared
sandbox tenant, which is exactly what the public demo pages do.
import { GameFiClient, GameFiError } from '@gamefi-rails/sdk-js'; const gamefi = new GameFiClient({ baseUrl: 'http://localhost:7300', apiKey: process.env.GAMEFI_API_KEY, // server-side only — never in a build retries: 2, // network + 5xx only; 4xx is terminal });
You identify players by whatever id your game already uses — a Steam id, an account
uuid, an email hash. The call is idempotent: the same
externalPlayerId always returns the same playerId, so you can
call it on every login without bookkeeping.
const player = await gamefi.createPlayer('steam:12345'); // → { playerId: '9f1c…', tenantPlayerRef: '4b7e…' } // Sandbox only: 100 USDC of play money so the player can trade. await gamefi.faucet(player.playerId, 'USDC');
Rewards are the mint path for your game token — server-side only, since calling this
creates supply. Each call posts a ledger transaction against
system:mint:DRAGON, so emissions are auditable rather than a counter
somebody incremented.
await gamefi.reward(player.playerId, 'boss-slain'); // → { earned: '25 DRAGON' } ('dragon-slain' pays 5) // Loot becomes a tradable asset the player owns outright const loot = await gamefi.mintNft(player.playerId, 'swords', 'Ember Fang'); await gamefi.listNft(player.playerId, loot.assetId, '15');
The player now has a balance worth selling. Orders go on a real price-time-priority
order book; the call returns as soon as funds are reserved and the command is durably
queued, which is why it answers 202 Accepted with
status: 'PENDING' rather than a fill.
try { const order = await gamefi.placeOrder({ playerId: player.playerId, pairId: 1, // DRAGON/USDC side: 'Sell', orderType: 'Limit', price: '2.40', // whole USDC per whole DRAGON qty: '10', // whole DRAGON }); // → { clientOrderId: '…', status: 'PENDING', reserved: '10000000 DRAGON (minor)' } } catch (e) { if (e instanceof GameFiError && e.code === 'INSUFFICIENT_FUNDS') { /* … */ } throw e; } // Watch it fill. The stream replays history, so this is complete. const stop = gamefi.events(({ pairId, event }) => { if (event.type === 'Trade') console.log(pairId, event.qty, event.price); });
createPlayer, faucet, getBalances,
placeOrder, studioSummary and events.
Rewards, NFT minting and the marketplace are plain HTTP calls documented below —
same auth, same error envelope.
Every authenticated request carries a studio API key in the x-api-key
header. The SDK adds it for you when you pass apiKey to the constructor.
POST /v1/players x-api-key: gfr_9c1f0b7a4e2d… content-type: application/json
Keys are created together with the tenant at
POST /v1/tenants. The response is the
only time the plaintext key exists outside your control: the server stores just
sha256(key) in api_keys.keyHash and compares hashes on every
request. There is no "show key again" endpoint because there is nothing left to show —
a leaked database gives an attacker hashes, not credentials. Lose the key and you mint
a new one.
{
"tenantId": "3f2a…",
"apiKey": "gfr_9c1f0b7a4e2d…",
"note": "store the key now — it is not shown again"
}
A valid key resolves to your tenantId, and that tenant scopes your players,
your fee accounts, your listings and your studio metrics. A key that is present but
unknown or revoked fails the request with UNAUTHORIZED (401). A request with
no key at all falls back to the shared sandbox tenant — convenient for prototyping,
but it means a silently dropped header looks like "it works" while writing into the
wrong tenant. Assert the header in your own integration tests.
Five ideas explain almost every surprising thing in the API. They are all downstream of one decision: this is accounting infrastructure that happens to serve games, not a game backend that happens to hold money.
Every amount on the wire and in the database is an integer count of minor units —
the smallest indivisible piece of the asset. USDC and DRAGON both use 6 decimals, so
1 USDC = 1_000_000 minor units. Floating point never touches a balance:
0.1 + 0.2 is a rounding curiosity in a chart and a lawsuit in a ledger.
The SDK surfaces decimal strings ("2.40") at the boundary and converts
to integers immediately, so your code never handles a float either.
Prices need more precision than an amount does, so they carry their own scale:
// wire price = quote minor units per ONE WHOLE base unit, × 1e8 2.5 USDC/DRAGON → 2.5 × 1e6 // 2_500_000 quote minor per whole DRAGON → 2_500_000 × 1e8 // PRICE_SCALE = 250_000_000_000_000 // and the one derivation everything settles on: quoteAmount = (price × qty) / (PRICE_SCALE × 10^baseDecimals)
You send price: "2.5" and the API does this conversion. It matters anyway,
because it is the number you will see on the WebSocket stream and in the engine's events.
Rounding on that division always goes against the taker — up when the taker pays
quote, down when the taker receives it — and the sub-unit residual is posted to
system:suspense:rounding rather than quietly vanishing.
POST /v1/orders does five things in a single Postgres transaction:
validate the order against the pair's rules, compute the required reserve, insert the
reservation row, post the ledger hold moving value from
user:{id}:available:{sym} to user:{id}:hold:{sym}, and enqueue
the NewOrder command in the transactional outbox. Then it commits.
The ordering is the whole point. The matching engine is fast and in-memory; it cannot ask
"does this player still have the money?" at match time without becoming slow and
stateful. So the money is already locked before the command can physically reach
it — if the hold fails, no command is ever queued, and if the command is queued, the
funds provably exist. A buy reserves worst-case notional plus taker fee in the quote
asset; a sell reserves the base quantity. That reserve is why the response is
202 PENDING with a reserved figure instead of a fill.
There is no balance column that code increments. Every movement of value —
reward, deposit, reserve, trade, royalty, fee — is a transaction whose entries
sum to zero per asset. A balance is the running total of entries against an
account handle, and a continuous job asserts the zero-sum invariant, so a wrong balance
cannot hide; it shows up as a broken book. Handles are readable and structural:
user:{playerId}:available:{symbol} // spendable
user:{playerId}:hold:{symbol} // reserved against an open order or listing
tenant:{tenantId}:fee:{symbol} // your studio's fee revenue
system:mint:{symbol} // token issuance (negative = supply outstanding)
platform:fee:{symbol}
User accounts are credit-normal, so raw balances are negative and the API negates them
for display. This is also why GET /v1/studio/economy can report emissions,
holders, velocity and GMV at all — they are derived from the ledger, which is the source
of truth rather than a cache of one.
An NFT is not a parallel money system. Minting inserts an asset row with
kind = 'NFT', decimals = 0 and a supply of exactly 1, and
then posts an ordinary ledger transaction. Because of that, listing an item is just a
hold (available → hold, which fails with
INSUFFICIENT_FUNDS if the seller doesn't actually own it) and a sale is a
single atomic multi-entry post: the buyer's USDC splits across seller proceeds, a 5%
creator royalty, and a 2% market fee shared 50/50 between your studio and the platform,
while the NFT moves whole in the same transaction. Royalties are enforced at settlement,
not by convention — there is no code path where the item transfers and the royalty
doesn't.
Base URL http://localhost:7300 in the sandbox. All request bodies are JSON;
all responses are JSON. Fields marked * are required. Errors use
the envelope { "error": CODE, "message": "…" } — see
Errors.
Create a studio tenant and mint its first API key. Open in the sandbox; production gates this behind the console.
| Field | Type | Notes |
|---|---|---|
| name * | string | 2–80 characters. Display name. |
| slug * | string | 2–40 chars matching ^[a-z0-9-]+$. Must be unique. |
{
"tenantId": "3f2a1c8e-…",
"apiKey": "gfr_9c1f0b7a4e2d…",
"note": "store the key now — it is not shown again"
}
Dashboard header numbers for the authenticated tenant. No request body.
{
"tenantId": "3f2a1c8e-…",
"feeBalances": { "USDC": "14.203000" }, // whole units, 6dp
"players": 1284,
"openReservations": 37 // reservations still HELD
}
Economy health derived from the ledger: sources vs. sinks, holders, velocity, GMV, and any circuit breaker that has fired. No request body.
{
"tokenEmissions": "48250.00", // DRAGON minted via REWARD
"feeRevenue": "14.20", // your tenant fee accounts
"tokenHolders": 612,
"tradingVolumeQuote": "93110.50", // settled TRADE volume in quote
"marketplaceGmv": "7420.00", // SOLD listings
"riskEvents24h": 3,
"haltedPairs": [ { "pairId": 1, "reason": "price_band" } ]
}
Register or fetch a player by your own external id. Idempotent — safe to call on every login.
| Field | Type | Notes |
|---|---|---|
| externalPlayerId * | string | 1–120 chars. Your id, e.g. steam:12345. Unique per tenant. |
{
"playerId": "9f1c2d70-…", // use this on every other call
"tenantPlayerRef": "4b7e…" // per-tenant opaque alias
}
tenantPlayerRef is sha256(tenantId:playerId) truncated to 32
hex chars. It is safe to write into game saves, logs and analytics because two tenants
holding refs for the same underlying player cannot join their datasets on it.
Reserve funds and submit an order to the matching engine. Returns once the reserve is committed and the command is durably queued — not once it fills.
| Field | Type | Notes |
|---|---|---|
| playerId * | uuid | From POST /v1/players. |
| pairId | int | Default 1. Pair 1 and 2 are both DRAGON/USDC on separate books. |
| side * | enum | Buy | Sell |
| orderType | enum | Limit (default) | Market | PostOnly | Ioc | Fok |
| price | string | Decimal string, whole quote per whole base, e.g. "2.5". Required for everything except Market. |
| qty * | string | Decimal string in whole base units, e.g. "10". |
| maxQuote | string | Required for market buys — the worst-case quote band to reserve. |
{
"clientOrderId": "c1a8…", // correlates with every stream event
"status": "PENDING",
"reserved": "25075000 USDC (minor)" // 25 USDC notional + 0.075 taker fee
}
A market buy has no price to compute a reserve from, so you must supply
maxQuote yourself: it is the ceiling the player agrees to spend, and it is
reserved in full up front. Anything unspent is released when the fill settles.
Orders are validated against the pair's limits at the edge using the exact same rules the
Rust engine applies, so a rejection is deterministic and arrives as a
VALIDATION error rather than an engine round-trip.
| Rule | Pair 1 & 2 (DRAGON/USDC) |
|---|---|
| minQty | 10 base minor units |
| qtyStep | 10 base minor units |
| priceStep | 1 000 000 (wire price units) |
| minNotional | 1 quote minor unit |
| takerFeeBps | 30 (0.30%) |
| makerFeeBps | 10 (0.10%) |
Mint a game item as a ledger asset (decimals 0, supply 1) straight into the player's available balance. The minting player is recorded as the creator and earns the royalty on every later resale.
| Field | Type | Notes |
|---|---|---|
| playerId * | uuid | Receives the item. |
| collection * | string | Slug, 2–24 chars matching ^[a-z0-9-]+$, e.g. swords. |
| name * | string | 1–60 chars, e.g. Ember Fang. |
{
"assetId": 318,
"symbol": "NFT#swords:1a2b3c4d"
}
Every NFT the player holds, in available or in hold. listed: true means it is parked in hold behind an active listing.
{
"nfts": [
{ "assetId": 318, "symbol": "NFT#swords:1a2b3c4d",
"name": "Ember Fang", "listed": false }
]
}
List an owned NFT for sale in USDC. The item moves into hold as part of the same transaction, so it cannot be listed twice or traded away behind the listing's back.
| Field | Type | Notes |
|---|---|---|
| playerId * | uuid | The seller. Must own the asset. |
| assetId * | int | From the mint response. Must be an NFT asset. |
| price * | string | Decimal string in whole USDC, e.g. "15". Must be > 0. |
{ "listingId": "7d40e1b2-…" }
Up to 50 active listings for the authenticated tenant, newest first. priceMinor is an integer string in quote minor units.
{
"listings": [
{ "listingId": "7d40e1b2-…", "assetId": 318,
"symbol": "NFT#swords:1a2b3c4d", "name": "Ember Fang",
"priceMinor": "15000000", "sellerId": "9f1c2d70-…" }
]
}
Buy a listing. Settles atomically: payment splits across seller, creator royalty and fees while the item moves. No pre-hold is needed because the whole sale is one transaction.
| Field | Type | Notes |
|---|---|---|
| playerId * | uuid | The buyer. Cannot be the seller. |
| listingId * | uuid | Must still be ACTIVE — otherwise NOT_FOUND. |
{
"bought": "NFT#swords:1a2b3c4d",
"paid": "15.00"
}
| Leg | Share | Goes to |
|---|---|---|
| royalty | 500 bps (5%) | The minting creator — only when the creator is not the seller, so self-resale churn earns nothing. |
| market fee | 200 bps (2%) | Split 50/50 between tenant:{you}:fee:USDC and the platform. |
| proceeds | remainder | The seller. |
Mint game tokens for a gameplay achievement. This is a real ledger issuance from system:mint:DRAGON, which is why it shows up in tokenEmissions.
| Field | Type | Notes |
|---|---|---|
| playerId * | uuid | Created on the fly if unknown. |
| kind * | enum | dragon-slain → 5 DRAGON · boss-slain → 25 DRAGON |
{ "earned": "25 DRAGON" }
A 3-second per-player cooldown is enforced server-side; a faster second call returns
429 RATE_LIMITED. It is deliberately crude — a client that can call
"I killed a boss" can call it in a loop, so the first brake has to live on the server.
The risk engine adds real behavioural scoring on top of it. Call this from your game
server after your own authoritative combat resolution, never from the client.
Every asset the player holds, split into spendable and reserved. Values are decimal strings at 6dp — hold is money locked behind open orders and listings, and it is not spendable until those resolve.
{
"balances": {
"USDC": { "available": "74.925000", "hold": "25.075000" },
"DRAGON": { "available": "25.000000", "hold": "0" }
}
}
Credits 100 whole units of play money so a fresh player can trade immediately. Development only — it posts a DEPOSIT against the testnet hot wallet handle.
| Field | Type | Notes |
|---|---|---|
| playerId * | uuid | Created on the fly if unknown. |
| symbol * | enum | USDC | DRAGON |
{ "credited": "100 USDC" }
Release a circuit breaker after review. The risk engine halts a pair; the edge enforces the halt by rejecting new orders with PAIR_HALTED. Clearing it is a deliberate human act, so there is no automatic expiry.
| Field | Type | Notes |
|---|---|---|
| pairId * | int | The halted pair. |
{ "unhalted": 1 }
/v1/tenants and /v1/admin/unhalt are unauthenticated in the
sandbox so the demo is self-serve. In production both sit behind the studio console's
operator auth. Do not expose them through your own game server.
The matching engine never talks to end users. A gateway consumes the shard's event topic
and fans it out as JSON on ws://{host}:7301 — no subscribe message, no auth
handshake, no channels. Connect and you receive events.
const stop = gamefi.events(({ pairId, event }) => { switch (event.type) { case 'OrderAccepted': /* resting on the book */ break; case 'Trade': /* a fill — settlement follows */ break; case 'OrderCanceled': break; case 'OrderRejected': /* event.reason tells you why */ break; } }); // later stop();
Every frame is { pairId, event }. The client derives the ws URL from
baseUrl by swapping the scheme and the port to 7301; override it
with wsUrl if your deployment differs. It reconnects automatically after
1.5s on close.
The gateway sends its entire event history to each new client before any live frame. That is intentional: a subscriber can rebuild the complete order book from nothing, so a dropped connection is not a correctness problem, just a reconnect. It also means you should treat your local state as derived — reset and rebuild on every open rather than trying to patch a partial view back together.
| type | Meaning |
|---|---|
| OrderAccepted | The order passed engine validation. booked_qty is how much rests on the book (0 for a fully-crossing taker). |
| Trade | A maker and a taker matched. Settlement posts to the ledger separately, keyed to seq. |
| OrderCanceled | An order left the book; canceled_qty is the unfilled remainder being released. |
| OrderRejected | The engine refused the order. reason is one of DuplicateClientOrderId, UnknownPair, PairHalted, InvalidPriceStep, InvalidQtyStep, BelowMinNotional, InsufficientReservation, PostOnlyWouldCross, FokUnfillable, SelfTradePrevented, Expired, EngineOverload. |
Trade fields| Field | Type | Notes |
|---|---|---|
| seq | u64 (string) | Per-pair sequence number. See the warning below. |
| trade_id | u64 (string) | Engine-assigned trade identifier. |
| taker_order_id / maker_order_id | u64 (string) | Engine order ids on each side. |
| taker_client_order_id / maker_client_order_id | uuid | The clientOrderId you got back from POST /v1/orders. |
| taker_account_id / maker_account_id | uuid | Player ids. |
| taker_tenant_id / maker_tenant_id | uuid | Tenant ids — the two sides may belong to different studios. |
| price | u64 (string) | Wire price: quote minor per whole base × 1e8. |
| qty | u64 (string) | Base minor units filled by this trade. |
| quote_amount | u128 (string) | Quote minor units exchanged, rounded against the taker. |
| taker_side | enum | Buy | Sell — the aggressor's direction. |
| taker_fee_amount | u128 (string) | Taker fee in quote minor units. |
| maker_fee_amount | i128 (string) | Maker fee — signed, so a negative value is a maker rebate. |
| taker_remaining_qty / maker_remaining_qty | u64 (string) | Unfilled remainder on each side after this trade. |
| maker_order_closed | bool | True when the maker order is now fully filled and off the book. |
All 64/128-bit integers arrive as JSON strings, not numbers — they overflow
IEEE-754 doubles. Parse them with BigInt, never Number.
seq is per pair, not per stream
Each pair runs its own engine instance with its own counter starting at 1, and the
gateway multiplexes every pair onto one socket. So seq: 42 appears once
for pair 1 and again for pair 2, and the sequence you observe on the wire is not
monotonic. Combined with full replay on reconnect — where you legitimately receive
frames you have already processed — a consumer that dedups on seq alone
will silently drop real events from other pairs and double-count on every reconnect.
Always key on the tuple (pairId, seq).
const seen = new Set<string>(); gamefi.events(({ pairId, event }) => { const key = `${pairId}:${event.seq}`; // NOT event.seq alone if (seen.has(key)) return; seen.add(key); apply(pairId, event); });
Failures return a JSON envelope with a stable machine-readable code. Branch on
error; the message is for your logs, not your control flow.
{ "error": "INSUFFICIENT_FUNDS", "message": "insufficient available balance" }
| Code | HTTP | When |
|---|---|---|
| VALIDATION | 400 | The request violates a rule: bad pair limits, non-positive price, buying your own listing, a market buy without maxQuote. |
| UNAUTHORIZED | 401 | x-api-key was supplied but is unknown or revoked. |
| FORBIDDEN | 403 | The key is valid but not permitted to perform this action. |
| NOT_FOUND | 404 | Unknown pair, unknown NFT, or a listing that is no longer ACTIVE. |
| CONFLICT | 409 | A uniqueness or state conflict — the resource moved under you. |
| INSUFFICIENT_FUNDS | 422 | The ledger hold failed. The player does not have the available balance (or does not own the NFT) being reserved. |
| PAIR_HALTED | 423 | A circuit breaker is open on that pair. Cleared via POST /v1/admin/unhalt after review. |
| RATE_LIMITED | 429 | The 3-second reward cooldown for that player has not elapsed. |
| INTERNAL | 500 | An unhandled server fault. Safe to retry. |
The SDK transport retries network failures and 5xx with exponential backoff —
250 ms, then 500 ms, two attempts by default, tunable with retries.
4xx responses are terminal and throw immediately as a GameFiError
carrying code, message and status. That split is
the useful one: a 422 will fail identically on the next attempt, while a dropped
connection or a restarting node usually won't.
import { GameFiError } from '@gamefi-rails/sdk-js'; try { await gamefi.placeOrder({ playerId, side: 'Buy', price: '2.5', qty: '10' }); } catch (e) { if (!(e instanceof GameFiError)) throw e; switch (e.code) { case 'INSUFFICIENT_FUNDS': return showTopUp(); case 'PAIR_HALTED': return showMarketPaused(); case 'VALIDATION': return showBadOrder(e.message); default: throw e; // already retried if retryable } }
500 INTERNAL rather than 400 VALIDATION, which means the SDK
will retry them before giving up. Validate shapes on your side; don't rely on the
status code to distinguish "I sent nonsense" from "the server is unwell".
The Unity package speaks the same v1 API with the same typed errors. It hides RPCs, gas, private keys and transaction tracking, so your players never see a wallet popup.
Window → Package Manager → + → Add package from git URL:
https://github.com/<org>/gamefi-rails.git?path=packages/sdk-unity
Or copy the folder into Packages/com.gamefios.sdk. Unity 2021.3+; a sample
scene lives in Samples~/Quickstart.
Every call is a coroutine taking an Action<T> success callback and an
optional Action<GameFiError> for failures — drive them with
StartCoroutine so nothing blocks the main thread.
using GameFiOS; public class Economy : MonoBehaviour { CreatePlayerResult _player; MintResult _loot; // Sandbox: no key. Production: your game SERVER holds the key. GameFiClient gamefi = new GameFiClient("http://localhost:7300"); void Start() { StartCoroutine(gamefi.CreatePlayer("steam:12345", r => _player = r)); } public void OnBossSlain() { StartCoroutine(gamefi.Reward(_player.playerId, "boss-slain", r => Debug.Log(r.earned), err => Debug.LogWarning(err.code))); // Loot becomes a tradable asset, then goes on the market. StartCoroutine(gamefi.MintNft(_player.playerId, "swords", "Ember Fang", r => _loot = r)); StartCoroutine(gamefi.ListNft(_player.playerId, _loot.assetId, "15", _ => {})); // Or cash the token out on the order book. DRAGON → USDC. StartCoroutine(gamefi.PlaceOrder(_player.playerId, "Sell", "2.4", "10", r => Debug.Log(r.clientOrderId))); } }
Available methods: CreatePlayer, Reward, MintNft,
ListNft, BuyNft, PlaceOrder and
GetBalancesRaw. Amounts are decimal strings on the boundary here too —
Unity's float has no business anywhere near a balance.
GameFiClient(baseUrl, apiKey) overload exists for editor tooling and
trusted server-side C#, not for shipping clients. Anything in a build is public: a
studio key extracted from an APK can mint your token and read your whole economy.
Put the key on your game server, have the client call your server, and use the
key-less sandbox only for prototyping.