Game economy infrastructure on Canton Network

Two ledger writes per game cycle. Nothing else is written.

Mint assets, run a marketplace, and settle staked gameplay on Canton - without running a validator, a registry, or writing Daml. Scores and sessions stay in your database. Only value reaches the ledger, and when it does it settles atomically.

Base URL https://sdk.arccade.io, running against Canton TestNet. JSON in, JSON out. Send every amount as a decimal string.

What goes on-chain

There is no endpoint that writes game data. "Record this score" does not exist, so it cannot be called, batched or abused. A cycle moves value twice: once when the stake is committed and once when it is settled.

Your database
  • Scores, levels, XP, quests
  • Matchmaking, sessions, replays
  • Item stats, art, descriptions
  • Leaderboards, achievements
The ledger
  • Stake committed and released
  • Ownership changing hands
  • Value transferred
  • Assets minted and burned
  • One digest per audit period
When the player signs with their own key Each of the two value writes is preceded by an offer contract that the platform creates and the player accepts. Canton's interactive submission carries one command authorised by one party, and a stake needs the player's funds plus the venue's authority in the same transaction; the offer is how that authority reaches it. The offer holds no value. See Player-signed cycle.

Notes for agents

Facts an autonomous integration needs before its first call.

QuestionAnswer
Is there a machine-readable index?Yes. GET /api/game needs no key and lists every route by the credential it takes, including the closed ones.
Are writes idempotent?No. Retrying a 5xx may double-write. See Idempotency.
Can I discover state?Yes. GET /assets/:party, GET /cycle/open/:party, GET /venue/entitlements/:party, GET /venues and GET /cycle/:updateId/outcome cost no quota and are safe to poll.
Is there a dry run?POST /preflight checks instrument ids against isolation rules without writing. For whole cycles, run a dry-run venue.
What is rate limited?Writes, per tenant. Write responses carry X-Quota-Remaining and X-Quota-Reset. A write that fails validation still counts.
Who signs a stake?For a party hosted on the participant, the service submits. For a player holding their own key: prepare, have the wallet sign the hash, execute.
Do contract ids persist?No. Any id you cache can be consumed by the next write, including a venue's. Re-read before use.
Are amounts floats?Never. Send decimal strings. Numbers are converted with String(), and a float can print as 1e-7, which the ledger rejects.
What fails closed?Namespace violations, quota, unknown tenant parties, expired prepared requests, policy outside bounds, digest mismatches.
Validate before you write POST /preflight costs no quota and touches no ledger. Run it whenever a plan was constructed from model output rather than from a previous response.

Authentication

Two credentials. Neither can perform the other's operations, with one exception noted below.

HeaderHeld byScope
X-Internal-Service-Key arCCade Tenant lifecycle, mint rights, registry creation, venue policy changes
X-Arccade-Key You Everything inside your namespace

Keys look like ags_yourgame_.... The tenant name inside is a lookup hint, not proof - authorisation is a constant-time hash comparison, and only the hash is stored. A wrong key and an unknown tenant return an identical 401, so tenant names cannot be enumerated.

POST /venue/init accepts either header. With a tenant key the venue must sit in your namespace and its policy must stay inside arCCade's bounds; with the internal key both checks are skipped.

Because every tenant shares one registry admin, your namespace is the isolation boundary. Instrument ids are always yourgame/..., venue ids yourgame/... or dryrun-yourgame-..., checked at the API and again in the contract. Shared assets such as Canton Coin carry no prefix.

Call sequence

Order matters. Each step depends on an id returned by the one before it.

Onboarding, once per tenant

  1. POST /registry/init -> contractId. Once per deployment, by arCCade.
  2. POST /tenants -> secret. Shown once. The tenant party must already exist.
  3. POST /tenants/:id/mint-right -> mintRightCid. Only if you mint assets.
  4. POST /venue/init -> venueCid. Only if you run staked cycles.

Per player, once

  1. POST /players/account -> accountCid. Needed for assets only.
  2. POST /venue/:venueCid/entitlements -> one entitlement per slot. Needed for cycles only.

Marketplace trade

  1. Both sides POST /assets/allocate -> one allocationCid per leg. Use the same settlementId.
  2. POST /trade/propose -> proposalCid.
  3. POST /trade/:cid/accept -> tradeCid.
  4. POST /trade/:cid/settle -> every leg executes in one transaction.

Staked cycle, player holds their own key

  1. Compute entryDigest from your entry document before play begins.
  2. POST /cycle/offer/prepare -> requestId, preparedTransactionHash.
  3. The player's wallet signs the hash.
  4. POST /cycle/offer/execute -> stakeCid, lockedAmulet. Write 1.
  5. Run the game. No calls.
  6. POST /cycle/:stakeCid/settle/offer/prepare with lockedHoldingCid -> requestId, preparedTransactionHash.
  7. The player's wallet signs the hash.
  8. POST /cycle/settle/execute -> entitlementCid. Write 2; the lock opens in the same transaction.

Staked cycle, party hosted on the participant

  1. POST /cycle/commit -> stakeCid, lockedAmulet. Consumes the player's slot.
  2. Run the game. No calls.
  3. POST /cycle/:stakeCid/settle with lockedHoldingCid -> returns the slot. Fails before minCycleSeconds.

Either way, a cycle that cannot be settled has two exits: POST /cycle/:stakeCid/abort before settlement, and POST /cycle/:stakeCid/expire once the lock has expired, which the player authorises alone.

Audit period

  1. GET /report/period -> rows, leaves, merkleRoot for cycles that closed in the period.
  2. POST /venue/:venueCid/anchor with those rows -> anchorCid. Compare the returned merkleRootHex with your own.
  3. Publish the report. Anyone can rebuild the root from the ledger stream and check it against the anchor.

Tenant admin

POST/api/game/tenantsinternal

Opens a tenant and returns its key once. The key is not stored and cannot be retrieved again. quotaWindowSeconds and maxWrites are recorded, but the quota actually enforced is the service-wide one in Enforced limits.

{ "tenantId": "yourgame",        // [a-z0-9-], 3-32 chars, no leading/trailing or double dash
  "tenantParty": "yourgame::1220ab...",  // must already exist on the participant
  "note": "optional" }

201 -> { "tenantId", "tenantParty", "quotaWindowSeconds", "maxWrites",
        "suspended", "note", "createdAt", "secret" }
400 -> invalid tenantId, or tenantParty unknown to this participant
409 -> tenant already exists
GET/api/game/tenantsinternal

Lists tenants as { "tenants": [...] }. Key hashes are never included in any response.

POST/api/game/tenants/:tenantId/rotate-keyinternal

Returns { "tenantId", "secret" }. The previous key stops working on the same call - there is no grace period.

POST/api/game/tenants/:tenantId/suspendinternal

Blocks the tenant from acting; its key then answers 403. Players keep every asset they already own. Send "suspended": false to lift it.

{ "suspended": true, "reason": "under review" }

200 -> { "tenantId", "suspended" }
POST/api/game/tenants/:tenantId/mint-rightinternal

Grants minting inside the tenant's namespace, limited by a quota enforced in the contract.

{ "registryCid": "00d37a98...",
  "mintQuota": 100, "quotaWindowSeconds": 3600 }   // defaults shown

201 -> { "tenantId", "mintRightCid", "updateId" }
POST/api/game/registry/initinternal

Creates the ecosystem asset registry. Once per deployment: a second call creates a second registry. Body { "registryId" } is optional. Returns { "registryId", "contractId", "updateId" }.

Venues & audit

A venue is where staked cycles run: it holds the policy every stake is checked against. Its contract id changes whenever entitlements are issued or its policy is updated.

POST/api/game/venue/inittenantinternal

Creates a venue and its slot roster. A tenant's live venue id starts with yourgame/; a dry-run venue id with dryrun-yourgame-. Dry-run venues cannot charge fees or pay out, and live venues always require custody proof - both enforced in the contract. Costs no quota.

{ "venueId": "yourgame/arena", "mode": "ModeLive",      // default ModeDryRun
  "gameCodes": ["yourgame-runner-v1"],
  "policy": { "minStakeAmount": "10.0",   "maxStakeAmount": "100.0",
              "minPlatformFee": "1.0",    "maxPayoutAmount": "100.0",
              "minLockSeconds": "14400",  "maxLockSeconds": "691200",
              "minCycleSeconds": "60",    "maxCycleSeconds": "7200",
              "cooldownSeconds": "30",    "abortCooldownSeconds": "300",
              "concurrencyLimit": "1" },
  "shardCapacity": 64 }                              // instrumentId defaults to Canton Coin

201 -> { "venueId", "mode", "shardCapacity", "venueCid", "updateId" }
400 -> dry-run venueId without "dryrun-", policy outside bounds ({ "violations": [...] }),
       or minLockSeconds below maxCycleSeconds
403 -> venueId outside your namespace

Omitted policy fields take defaults inside the bounds, among them minLockSeconds 7200 and minCycleSeconds 60. With a tenant key, minLockSeconds must also be at least maxCycleSeconds, so a lock never expires while a cycle can still be settled. Bounds are listed in Enforced limits.

PUT/api/game/venue/:venueId/policyinternal

Changes a venue's policy. Only arCCade can call it: a tenant setting its own fee floor would set it to zero. The path takes the venue id, not the contract id. Send only the fields that change, plus gameCodes to replace that list. Bounds apply to this route even with the internal key, and minLockSeconds must be at least maxCycleSeconds, so a lock never expires before the claim window closes.

{ "minPlatformFee": "1.0" }

200 -> { "venueId", "previousCid", "venueCid", "policy", "updateId", "note" }
404 -> venue not found

The change does not reach open cycles, which carry the policy they committed under, nor entitlements already issued, which carry theirs. A new fee applies to entitlements issued after the update.

GET/api/game/venuestenant

Your venues and their current contract ids. No quota cost. Read venueCid here before any call that takes it.

200 -> { "venues": [ { "venueCid", "venueId", "mode", "gameCodes",
                       "sdkVersion", "concurrencyLimit", "rosterReady" } ] }
GET/api/game/report/period?fromMicros=&toMicros=tenant

Rebuilds the audit rows for your venues from the ledger's transaction stream. A cycle belongs to the period in which it closed: fromMicros <= settledAtMicros < toMicros. Settlements, aborts and expiries all close a cycle. No quota cost; a long period takes a while.

200 -> { "fromMicros", "toMicros", "cycleCount", "merkleRoot",
        "leaves": [ "<64-char sha256>", ... ],
        "rows": [ { "cycleId", "player", "gameCode", "concurrencyIndex",
                    "entryDigest", "outcomeDigest",
                    "committedUnits", "feeUnits", "returnedUnits",
                    "forfeitedUnits", "payoutUnits", "disposition",
                    "committedAtMicros", "settledAtMicros", "custodyTag" } ] }
400 -> fromMicros and toMicros required, toMicros > fromMicros

Units are integer strings in 1e-10 of the instrument. disposition is a label: returned-in-full, returned-with-forfeit, forfeited-in-full, aborted, expired-unsettled.

POST/api/game/venue/:venueCid/anchortenant

Anchors a period on the ledger. Send the rows from GET /report/period unchanged. The contract recomputes the Merkle root, the anchor digest, the cycle count and the unit totals from the rows; it does not verify reportUri, reportDigest, prevAnchorDigest or the transaction counts, which you declare.

{ "periodId": "2026-09-11",
  "periodStartMicros": "1789084800000000", "periodEndMicros": "1789171200000000",
  "rows": [ ... ],
  "qualifyingTxCount": "42", "nonQualifyingTxCount": "0",
  "reportUri": "https://yourgame.example/audit/2026-09-11.json",
  "reportDigest": "<64-char sha256>", "prevAnchorDigest": "<previous anchorDigest>" }

201 -> { "anchored": true, "anchorCid", "merkleRootHex", "anchorDigest",
        "reportDigest", "reportUri", "cycleCount", "updateId" }
200 -> { "anchored": false, "reason", "anchorCid", ... }   // this period is already anchored
400 -> periodId missing, or an unknown disposition label

One anchor per venue and period: an existing one is returned instead of a second write. A large anchor can outlast the request and still land, so after a timeout read GET /venue/anchors before retrying.

GET/api/game/venue/anchorstenant

Your venues' anchors, newest period first. No quota cost.

200 -> { "anchors": [ { "anchorCid", "venueId", "periodId",
                        "periodStartMicros", "periodEndMicros", "cycleCount",
                        "merkleRootHex", "anchorDigest", "prevAnchorDigest",
                        "reportUri", "reportDigest" } ] }

Players & assets

GET/api/game/whoamitenant

Confirms the key. Returns your tenant record without the key hash, plus namespace and the write quota as { "windowSeconds", "maxWrites" }. No quota cost; use it as a health check.

POST/api/game/preflighttenant

Validates instrument ids against isolation rules. No quota, no ledger write.

{ "instrumentIds": [ { "admin": "reg::1220ee...", "id": "yourgame/gold" } ] }

200 -> { "ok": true, "tenantId", "quotaRemaining", "quotaResetAt" }
403 -> isolation violation, with the offending id named
POST/api/game/assets/validatetenant

Parses one instrument id and reports its shape. Body { "instrumentId": { "admin", "id" } }.

200 -> { "ok": true, "tenantId": "yourgame",
        "localId": "sword-of-dawn", "instanceId": "4a91c8f2",
        "assetClass": "unique" }
POST/api/game/players/accounttenant

Opens a player's asset account, after which the registry can mint to them without a signature per reward. The service submits this on the player's behalf, so the player party must be hosted on this participant.

{ "playerParty": "alice::1220ab...", "accountId": "alice-1" }   // accountId optional

201 -> { "playerParty", "accountCid", "updateId" }
POST/api/game/assets/minttenant

Mints into a player account. localId is namespaced automatically - send gold, receive yourgame/gold. Append #instance for a unique asset, whose amount must be "1.0".

{ "accountCid": "00c8a438...",
  "localId": "sword-of-dawn#4a91c8f2",
  "amount": "1.0",
  "assetMeta": { "attack": "9", "tier": "legendary" } }

201 -> { "instrumentId": { "admin", "id" }, "assetCid", "updateId" }
409 -> no mint right granted yet
500 -> mint quota for this window exhausted (contract); minting above it needs arCCade's approval
GET/api/game/assets/:partytenant

A player's holdings, filtered to your namespace. Safe to poll; costs no quota.

200 -> { "party", "count",
        "assets": [ { "contractId", "instrumentId", "amount" } ] }
POST/api/game/assets/allocatetenant

Sets an asset aside for a settlement. Both sides of a trade must allocate with the same settlementId, using legId "offer" and "ask". An allocated asset cannot be allocated again.

{ "registryCid": "00d37a98...", "assetCid": "00b480a9...",
  "sender": "alice::1220ab...", "receiver": "bob::1220cd...",
  "localId": "sword-of-dawn#4a91c8f2", "amount": "1.0",
  "settlementId": "trade-8814", "legId": "offer",
  "allocateBeforeSeconds": 600, "settleBeforeSeconds": 1800 }   // defaults shown

201 -> { "allocationCid", "legId", "instrumentId", "updateId" }

Trade

POST/api/game/trade/proposetenant

Two legs minimum - a one-sided gift cannot be written with this primitive. Set taker to null for an open offer. Every leg's instrument must be in your namespace.

{ "tradeId": "trade-8814", "maker": "alice::...", "taker": "bob::...",
  "tradeDigest": "<64-char sha256>",
  "expiresInSeconds": 600, "settleInSeconds": 1800,   // defaults shown
  "legs": {
    "offer": { "sender": "alice::...", "receiver": "bob::...",
                "instrumentId": {...}, "amount": "1.0" },
    "ask":   { "sender": "bob::...", "receiver": "alice::...",
                "instrumentId": {...}, "amount": "300.0" } } }

201 -> { "tradeId", "proposalCid", "updateId" }
400 -> a leg without instrumentId
403 -> a leg's instrument outside your namespace; "leg" names it
POST/api/game/trade/:proposalCid/accepttenant

Body { "acceptor": "bob::..." }. Returns { "tradeCid", "updateId" }. Fails after the proposal expires.

POST/api/game/trade/:tradeCid/settletenant

Executes every leg in one transaction. Allocation keys must match leg keys exactly - a missing leg is rejected, never silently skipped.

{ "allocations": { "offer": "00fb0ae4...", "ask": "00881beb..." },
  "parties": ["alice::...", "bob::..."] }

200 -> { "settled": true, "updateId",
        "created": [ { "contractId", "instrumentId", "amount", "owner" } ] }

Cycle

Slots, the participant-submitted stake and settlement, the two exits, and the reads that find a cycle again. For a player who signs with their own key, the stake and the settlement go through Player-signed cycle instead; everything else here applies to both.

POST/api/game/venue/:venueCid/entitlementstenant

Issues player slots. The venue's concurrencyLimit decides how many slots each player gets; a second commit on a consumed slot is rejected by the ledger, not by this service. Issuing again to a player who already has slots at this venue is rejected.

{ "grants": [ { "player": "alice::...", "tier": "silver" } ] }   // tier defaults to "default"

201 -> { "venueCid", "updateId",
        "entitlements": [ { "contractId", "player", "concurrencyIndex" } ] }
400 -> grants missing, or a grant still sends a non-zero concurrencyIndex

The venue's contract id changes: use the returned venueCid from here on.

GET/api/game/venue/entitlements/:partytenant

A player's slots at your venues, lowest index first. No quota cost.

200 -> { "player", "entitlements": [ { "contractId", "venueId", "mode",
        "concurrencyIndex", "tier", "nextEligibleAt", "cyclesCompleted" } ] }
POST/api/game/cycle/committenant

Write 1, submitted by the service for a player hosted on this participant. Fix entryDigest before play so entry conditions cannot be retrofitted afterwards. On a live venue the player's Canton Coin is locked and the stake committed in one transaction.

{ "entitlementCid": "002769ea...", "player": "alice::...",
  "gameCode": "yourgame-runner-v1", "cycleId": "run-3f2a...",
  "entryDigest": "<64-char sha256>",
  "stakeAmount": "100.0", "feeAmount": "1.0", "lockSeconds": 14400 }

201 -> { "cycleId", "stakeCid", "custodyTag", "lockExpiresAt", "mode",
        "lockedAmulet", "inputAmount", "updateId" }   // lockedAmulet, inputAmount: live venues
POST/api/game/cycle/:stakeCid/settletenant

Write 2. On a live venue send lockedHoldingCid: the contract checks the lock against the stake and the unlock runs in the same transaction. Without it only a dry-run venue settles. If you send revealedOutcome or revealedEntry, the ledger recomputes the hash and rejects a mismatch.

{ "player": "alice::...", "disposition": "ReturnedInFull",
  "returnedAmount": "100.0", "forfeitedAmount": "0.0", "payoutAmount": "0.0",
  "outcomeDigest": "<64-char sha256>",
  "revealedOutcome": "{\"score\":4200,\"result\":\"win\"}",
  "lockedHoldingCid": "00a1f3..." }

200 -> { "settled": true, "entitlementCid", "unlockedAmulet", "updateId" }

The contract requires returnedAmount + forfeitedAmount to equal the stake, allows no forfeit on a time-locked stake, caps payoutAmount at the policy's maxPayoutAmount, and rejects settlement outside minCycleSeconds..maxCycleSeconds of the commit.

POST/api/game/cycle/:stakeCid/aborttenant

Closes a cycle without counting it. There is no minimum duration - this is the way out of a cycle that should not have started. The slot comes back after the policy's abortCooldownSeconds. Send lockedHoldingCid to unlock in the same transaction; otherwise the lock stays until it expires.

{ "player": "alice::...", "reason": "client crashed", "lockedHoldingCid": "00a1f3..." }

200 -> { "aborted": true, "reason", "entitlementCid", "unlockedAmulet", "updateId" }
POST/api/game/cycle/:stakeCid/expiretenant

Closes an unsettled cycle once lockExpiresAt has passed, authorised by the player alone: neither arCCade nor the venue is needed on the ledger. With lockedHoldingCid the funds return to the player in the same transaction.

{ "player": "alice::...", "lockedHoldingCid": "00a1f3..." }

200 -> { "expired": true, "entitlementCid", "unlockedAmulet", "updateId" }
GET/api/game/cycle/open/:partytenant

A player's open cycles at your venues, each with the lock that belongs to it. No quota cost. This is where recovery starts: a stake listed here was committed and has not been closed.

200 -> { "player", "openCycles",
        "cycles": [ { "stakeCid", "cycleId", "venueId", "gameCode", "custodyTag",
                      "lockedAmulet", "concurrencyIndex", "committedAt",
                      "stakeAmount", "lockExpiresAt" } ] }
GET/api/game/cycle/:updateId/outcometenant

Reads the settlement out of the transaction's exercise node. This data does not appear in the default ledger event stream - this endpoint is how you get it. Only settlements at your venues are returned; anything else, including aborts and expiries, returns 404.

200 -> { "choice": "GameStake_Settle", "consuming", "disposition",
        "returnedAmount", "forfeitedAmount", "payoutAmount",
        "outcomeDigest", "revealedOutcome" }

Player-signed cycle

For a player who holds their own key. Each write takes two calls: a prepare that returns a hash for the wallet to sign, and an execute that submits the signature. The prepared transaction itself never leaves the service, so what the player signs is exactly what is submitted.

What to sign Base64-decode preparedTransactionHash, sign those bytes with Ed25519 using the player party's key, and send the signature base64-encoded. Do not send signedBy: the service derives the key fingerprint from the party id (the part after ::) and rejects a body that names one.
import nacl from 'tweetnacl'

const sig = nacl.sign.detached(Buffer.from(preparedTransactionHash, 'base64'), secretKey)
const body = { requestId, signature: Buffer.from(sig).toString('base64') }

A prepared request is single-use and lives 5 minutes. It is consumed before submission, so an execute that fails needs a fresh prepare. The offer each prepare creates also expires on the ledger after 5 minutes.

POST/api/game/cycle/offer/preparetenantplayer signs next

Prepares write 1. The fee and the lock come from the slot's policy, not from the request: the fee is minPlatformFee and the lock runs minLockSeconds plus 600 seconds. The player is an observer of the offer, so a wallet can read these terms from the ledger instead of trusting this response.

{ "entitlementCid": "002769ea...", "player": "alice::...",
  "gameCode": "yourgame-runner-v1", "cycleId": "run-3f2a...",
  "entryDigest": "<64-char sha256>", "stakeAmount": "100.0" }

200 -> { "requestId", "preparedTransactionHash", "offerCid", "cycleId",
        "stakeAmount", "feeAmount", "lockExpiresAt", "expiresInSeconds": 300 }
403 -> the entitlement belongs to another player, or to another tenant's venue
404 -> entitlement not found
POST/api/game/cycle/offer/executetenant

Submits write 1 with the player's signature: the Canton Coin lock, the platform fee and the stake commit land in one transaction.

{ "requestId": "p9Wk...", "signature": "<base64 Ed25519>" }

201 -> { "cycleId", "stakeCid", "custodyTag", "lockExpiresAt", "mode",
        "lockedAmulet", "updateId", "contractsResolved" }
400 -> requestId or signature missing, or signedBy present
404 -> prepared request unknown, expired, or prepared with another tenant's key: prepare again

201 with "contractsResolved": false means the ledger accepted the write but the new contracts were not visible within 10 seconds. Do not resubmit; find the stake with GET /cycle/open/:party. updateId can be null when the ledger's completion was not seen in time; the write itself is unaffected.

POST/api/game/cycle/:stakeCid/settle/offer/preparetenantplayer signs next

Prepares write 2. The body is the same as POST /cycle/:stakeCid/settle, and lockedHoldingCid is required: without a lock there is nothing for the player to sign, and the settlement belongs on the participant-submitted route.

{ "player": "alice::...", "disposition": "ReturnedInFull",
  "returnedAmount": "100.0", "outcomeDigest": "<64-char sha256>",
  "lockedHoldingCid": "00a1f3..." }

200 -> { "requestId", "preparedTransactionHash", "stakeCid",
        "lockedHoldingCid", "cycleId", "offerCid", "expiresInSeconds": 300 }

Compare the echoed stakeCid and lockedHoldingCid with the ones you hold before asking the player to sign.

POST/api/game/cycle/settle/executetenant

Submits write 2 with the player's signature: the settlement and the unlock land in one transaction, and the slot comes back.

{ "requestId": "Qz3r...", "signature": "<base64 Ed25519>" }

200 -> { "settled": true, "stakeCid", "cycleId", "entitlementCid",
        "unlockedAmulet": null, "updateId", "contractsResolved" }

unlockedAmulet is always null: nothing on the returned coin ties it to this cycle, so read the player's balance from their wallet. With "contractsResolved": false the stake's archival was not confirmed in time. Check GET /cycle/open/:party; if the settlement did not land nothing is lost, because the player can expire the cycle once the lock runs out.

Closed routes POST /cycle/commit/prepare, POST /cycle/commit/execute and POST /cycle/:stakeCid/settle/prepare return 410 with the route to use in use. Each put two commands and several authorising parties into one interactive submission, which a Canton participant rejects (FAILED_TO_PREPARE_TRANSACTION). The offer routes above replace them.

Errors

Every error returns { "error": "..." }, some with details carrying the ledger's own response. Branch on the status code; the text is for people and may change.

CodeMeaningWhat to do
400Malformed body, bad digest, unknown tenant party, signedBy in an execute body, policy outside bounds (violations lists each)Fix and resend. Never retry unchanged.
401Missing or invalid keyDo not retry. Same response for unknown tenants.
403Namespace violation, or tenant suspendedDo not retry. The body names the offending id.
404Contract, tenant or venue not found; prepared request unknown or expiredRe-read state; a cached id is likely consumed. For a prepared request, prepare again.
409Tenant exists, or no mint right yetResolve the prerequisite first.
410Route closed. use names its replacementSwitch to that route.
429Quota exhausted. Body carries remaining and resetAtWait until X-Quota-Reset. Backoff will not help before then.
500Ledger rejected the command, an upstream call failed, or the ledger request timed outRead details. A timeout may still have committed: read state before retrying.
502GET /report/period could not read part of the rangeRetry, or use transport=stream.
503Registry not configured, or the report's page budget ran outFor the registry, operator action is needed. For the report, use transport=stream.

Common ledger causes inside details

CauseMeans
UNKNOWN_INFORMEESA party in the command does not exist on this participant.
CONTRACT_NOT_FOUNDThe id was already consumed by an earlier write.
dongu asgari suresini doldurmadiSettlement attempted before minCycleSeconds.
dongu azami sureyi astiSettlement attempted after maxCycleSeconds. Abort it, or expire it once the lock runs out.
giris / sonuc belgesi saklanan digest ile uyusmuyorThe revealed entry or outcome document does not hash to the committed digest.
custody kaniti zorunlu ama holding referansi verilmediA live venue was settled without lockedHoldingCid.
oyuncu cooldown suresindeThe slot is cooling down after its last cycle; see nextEligibleAt.
kilit suresi henuz dolmadiExpire attempted before lockExpiresAt.
teklifin suresi dolduA stake offer was signed after its 5 minutes. Prepare again.
kapanis teklifinin suresi dolduA settlement offer was signed after its 5 minutes. Prepare again.
oyuncuya bu venue'da zaten slot verilmisThis player already has slots at this venue.
basim kotasi doldu (...)Mint quota for this window exhausted; minting above it needs arCCade's approval.

Idempotency & retries

Writes are not idempotent. A timed-out request may have committed. Before retrying any write, read state and check whether it already succeeded.

After a failed...Check
mintGET /assets/:party - look for the instrument id
allocateRe-read the asset; if it is gone, the allocation exists
trade settleGET /assets/:party for both parties
cycle commit or offer executeGET /cycle/open/:party - the stake is listed if the write landed
cycle settle or settle executeGET /cycle/open/:party - the stake is gone if the write landed; then GET /cycle/:updateId/outcome
anchorGET /venue/anchors - a second call for the same period returns the existing anchor
a prepareNothing to check. Prepare again; the unused offer expires on its own.
Do not cache contract ids across writes Most writes consume the contract they act on and create a replacement. A cached mintRightCid, entitlementCid or venueCid goes stale after one use. The API re-reads these for you; if you build ledger commands directly, do the same.

Types & encoding

FieldTypeRule
amountstringDecimal string. Never a JSON number.
*DigeststringExactly 64 lowercase hex characters.
cycleId / tradeIdstring<= 64 chars, no : or |. Generate, never reuse.
localIdstring[a-z0-9._-], plus optional #instance.
instrumentIdobject{ admin, id } - id is tenant/local.
*CidstringContract id from a prior response. Opaque; never construct one.
dispositionenumReturnedInFull, ReturnedWithForfeit, ForfeitedInFull when settling. Report rows use labels such as returned-in-full.
policy integersstringSeconds and counts as integer strings, e.g. "14400".
*MicrosstringInteger microseconds since the Unix epoch.
requestIdstringOpaque, single-use, valid 5 minutes.
preparedTransactionHashstringBase64. Decode before signing.
signaturestringBase64 Ed25519 signature over the decoded hash.

Enforced limits

Where each rule actually lives. Anything marked contract cannot be bypassed by calling the ledger directly.

RuleEnforced byValue
Namespace isolationAPI + contracttenant/ prefix
Write quotaAPI60 per 60 s, per tenant
Venue policy boundsAPItable below
Mint quotaContractper mint right
Concurrent cyclesContractslot token
Lock & cycle durationContractledger time only
Lock outlasts the claim windowAPI, on policy updateminLock >= maxCycle
Offer validityContract300 s
Prepared requestAPI300 s, single use
Unique asset amountAPI + contractexactly 1
Trade atomicityCanton engineall legs or none
BurnContractowner + registry

Venue policy bounds

Checked for tenant keys on POST /venue/init and for everyone on PUT /venue/:venueId/policy. A venue outside them needs arCCade.

FieldBound
maxStakeAmount<= 1000
minPlatformFee<= 10
maxPayoutAmount<= 1000
minLockSeconds>= 300
maxLockSeconds<= 691200 (8 days)
minCycleSeconds>= 60
maxCycleSeconds<= 604800 (7 days)
concurrencyLimit<= 5
cooldownSeconds>= 0
abortCooldownSeconds>= 60

Players always have an exit. Once a lock expires, a player recovers their funds and their slot with their own signature alone. arCCade cannot strand either.

@arccade/game-sdk

The HTTP API is sufficient on its own. Use the library when you need the canonical encoding locally - to compute a digest before sending it, or to verify one a player disputes. npm install @arccade/game-sdk gives you the encoding, cycle ids and the cycle command builders. Version 1.6.0 adds the venue policy document, the period anchor and settlement invariants; it is in the repository and its npm release is pending.

import { newCycleId, canonDocument, canonText, canonDecimal, textDigest }
  from '@arccade/game-sdk'

const cycleId = newCycleId('run')        // always generate; ids must be unique

const doc = canonDocument('yourgame-entry', 1, [
  ['cycle-id',  canonText(cycleId)],
  ['stake',     canonDecimal('25.0')],   // strings, never floats
  ['seed-hash', canonText(seedHash)],
])

const entryDigest = textDigest(doc)      // send this; publish doc

Field names must be ASCII so sort order is identical in every language; anything else is rejected rather than allowed to diverge silently. A third party verifies your published document with no library at all:

curl https://yourgame.example/cycles/8814/entry.json | sha256sum
# matches entryDigest on the ledger
Byte-identical across four implementations The encoding exists in Daml, JavaScript, Python and Java, pinned to shared test vectors that fail the build if any drifts. Lengths count Unicode code points - a new port must use codePointCount, never String.length().

Version 1.6.0 also brings an independent verifier for audit anchors. Given the ledger's transaction trees, or your published rows, it rebuilds the root and checks it against the anchor, trusting nothing arCCade published; given one row and its proof, it shows that cycle is in the report.

Sharp edges

BehaviourConsequence
Settlement results are not in the default event stream They live in the exercise node, visible only under TRANSACTION_SHAPE_LEDGER_EFFECTS. Use GET /cycle/:updateId/outcome.
An accepted write can come back without contract ids 201 or 200 with "contractsResolved": false. Do not resubmit; read GET /cycle/open/:party.
A venue's contract id changes Issuing entitlements and updating the policy both replace the venue contract. Read GET /venues before using a venueCid.
Policy changes do not reach issued slots A slot keeps the policy it was issued under, and each cycle keeps the policy it committed under. A new fee reaches players on slots issued after the change.
A timeout is not a failure A large anchor or a slow commit can land after the request gives up. Read state before retrying.
Tenant parties are validated at registration Previously an unknown party failed days later on first mint with UNKNOWN_INFORMEES. Now it fails immediately.
Contract ids are consumed by writes Cached ids go stale after one use. Re-read before every write.
Dry-run venues cannot charge or pay out Contract-level rule. Dry-run activity can never be presented as qualifying economic activity.
Trade allocations are withdrawable before settlement Deliberate: a trade is not escrow. Stake locks behave the opposite way and are binding.