Beta · Testnet
On this page

Payments for games

Test-USDC payments for games on Base Sepolia1% IAP — try the sandbox: no wallet, no signup. Beta · testnet only (fake money, real on-chain ids). Mainnet / pk_live_ is not available yet.

1%IAP — you keep 99%
10%skill · Playmos-operated pool
$0custody — money never rests with us
~10 minT0: IAP + skill entry smokes
Pricing (three models). IAP 1% via pay() · Skill 10% (Playmos-operated 60/30/10 prize pool) · In-game economies per-call feeBps on transfer(). You can smoke both IAP and skill entry with the same public key in one session — IAP · Skill entry. Details: Test mode & testnet.
T0 vs T1 (two smokes, one key). T0-IAP: pay()verify() with public key → confirmed. T0-Skill: enterRound()verify() with the same public key → confirmed entry. No studio secret for either smoke. T1: mint your own secret via POST /v1/keys (self-serve — no email), then server verify() / grant; operator on your games with that sk_test_. T0 is not production go-live. Node 18+.

Start here — IAP smoke (Path A)

First track: sell an item. Complete T0-IAP in one scroll. Skill entry is the parallel track next — Start here · Skill. Same install · same public key. Node 18+.

1. Install

terminal
$ npm i @playmos/sdk

Latest on npm. Surfaces matrix below lists exact versions when you need a pin.

1b. Preflight — sandbox settle READY?

Server-settle needs a funded sandbox signer. Before debugging a 504:

terminal
curl -sS https://api.sandbox.playmos.io/health | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const p=JSON.parse(d).capabilities.payments;console.log(p.serverSettle);console.log(JSON.stringify(p.signerBalance));})"
READY + signerBalance.ok: true → proceed. DEGRADED / low_usdc → settle will 504 until ops tops up the signer (not your integration bug). A cold T0 can succeed, then Path B enterRound bursts deplete the floor mid-session. Preflight uses node (you already need Node 18+) so Windows works without python3.
If settle returns 504: check the preflight above — if the sandbox is not READY, stop and try later (not your integration bug). If READY, pass an idempotencyKey on pay() so the SDK can auto-retry safely; then poll verify() until confirmed.

2. Take a payment (client / sandbox — T0 smoke)

Public key — safe in demos and host pages. No wallet required on the sandbox server-settle path.

checkout.ts
import { Playmos } from "@playmos/sdk";

// Public sandbox key — client-safe, like Stripe's pk_test_.
// No wallet needed: Playmos server-settles the test payment for you.
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });

const payment = await playmos.pay({
  gameId: "game_sandbox_iap", // public sandbox IAP game — include it (this key spans IAP + skill)
  amount: "0.99",             // USD string — sandbox server-settle cap $1.00/request
  sku: "gems_500",            // your product id
  playerId: "player_abc",     // your opaque user id
});

payment.id;     // "pay_…"     — real, server-issued (ULID)
payment.status; // "confirmed" — real, on Base Sepolia
payment.txHash; // 0x…         — open on sepolia.basescan.org/tx/{txHash}
After pay(): you should have a pay_… id and usually a txHash. Next step (2b) confirms on-chain with verify() — that is the full T0 smoke, not production go-live.

2b. Confirm on-chain (sandbox — same public key)

On the sandbox, the public key can read a payment by id. Call verify() with the same client to see confirmed land — this is the win that sells the product. Still never grant an item from the client alone.

confirm.ts · sandbox only
// Same public client as step 2 — publishable keys may read GET /v1/payments/:id
const result = await playmos.verify(payment.id);
// Poll until status === "confirmed" (sandbox settle can be async ~3–6s)
console.log(result.status); // "confirmed"
// That is T0 complete: pay_… + confirmed + BaseScan. Do NOT grant yet from the client.
Sandbox T0 full story: pay()verify() with pk_test_playmos_sandboxconfirmed. No studio secret required for this smoke.

3. Grant on your server (T1 — secret key)

Never grant on the client pay() return alone. For production-shaped integrations, call verify() on your backend with a secret sk_test_ (see Keys), then grant. Get a secret: curl -sS -X POST https://api.sandbox.playmos.io/v1/keys -H 'content-type: application/json' -d '{"label":"my-studio"}' — self-serve, no email. Keep the secret server-only.

server.ts
import { Playmos } from "@playmos/sdk";

// Your server — the secret key lives here, never in a client bundle.
const server = new Playmos({ apiKey: process.env.PLAYMOS_SECRET! }); // sk_test_…

const result = await server.verify(payment.id); // on-chain read — idempotent, safe to retry

// Terminal success is exactly "confirmed" (IAP + entries) — not "settled" / "succeeded".
if (result.status === "confirmed") {
  grantItem(result.playerId, result.sku);
}
01
pay()

Client — pk_test_

02
verify()

Sandbox: same pk_test_ · prod: server sk_test_

03
Grant item

Server only if confirmed

Runnable from an empty folder (no Playmos monorepo needed):
terminal
$ mkdir playmos-t0 && cd playmos-t0 && npm init -y && npm i @playmos/sdk
$ cat > t0.mjs <<'EOF'
import { Playmos } from "@playmos/sdk";
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });
const payment = await playmos.pay({
  gameId: "game_sandbox_iap", amount: "0.99", sku: "gems_500",
  playerId: "player_" + Date.now(),
});
let result = await playmos.verify(payment.id);
for (let i = 0; i < 30 && result.status !== "confirmed"; i++) {
  await new Promise(r => setTimeout(r, 500));
  result = await playmos.verify(payment.id);
}
console.log({ id: payment.id, status: result.status, tx: payment.txHash });
EOF
$ node t0.mjs
You win when you see a pay_… id, status: "confirmed", and a Sepolia txHash. No signup.
IAP T0 done? Run the skill entry smoke next (same folder, same key) — or do skill first if that is your product. Full Path A detail (player UX, fee cards) continues below.

Start here — skill entry smoke (Path B)

Second track: player pays into a skill prize pool with enterRound(). Same public sandbox key as IAP — no wallet. Proves the entry money path on Base Sepolia. Not a full multi-player contest; not operator open/lock/settle (that needs your self-serve sk_test_ — see Advanced under Path B).

Honest scope — test this, not that. Today: player entry smoke on Playmos sandbox skill pools (60/30/10 first-party shape). Coming: third-party games declare their own economic model (round seed / prizes / studio cut) with always 1% to Playmos — the SDK will ask for / surface that model at game setup; it is not live yet. Dual test now = IAP smoke + skill entry smoke only.
Shared pool (not your studio pool). With the public sandbox key, entries land in the Playmos-operated shared PrizePool on Base Sepolia — the same pool used by first-party dogfood. That is correct for this smoke. A studio-owned pool is not the default public-docs path (tracked separately). Do not treat a successful entry as proof your own pool received funds.
Base Stack / Jump / Munch gameId. Docs smoke uses game_sandbox_skill. Product ids that also resolve: basestack, basejump, basemunch. Do not invent game_sandbox_base_stack or game_sandbox_base_jump — those ids are not found (only munch has a game_sandbox_base_munch alias). Full catalog under Path B.

1. Same install (skip if you already ran IAP)

terminal
$ npm i @playmos/sdk

Preflight sandbox READY the same way as IAP (see above). Skill entries also spend the sandbox settle budget.

2. Enter a round (client / sandbox — T0 skill)

Public key · no wallet · one paid entry. Skill is pay-per-play — re-running creates a new entry when you use a new idempotencyKey. Reusing the same key returns the same entry (not a second charge).

enter-smoke.ts
import { Playmos } from "@playmos/sdk";

const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });
const playerId = "player_" + Date.now();
const idempotencyKey = "entry-" + playerId + "-docs-smoke";

const entry = await playmos.enterRound({
  gameId: "game_sandbox_skill", // public sandbox skill game
  // Unique round per run so entry price matches amount (shared rounds pin first open price)
  roundId: "docs-smoke-" + playerId,
  amount: "1.00",               // USD string — sandbox cap $1.00/request
  playerId,
  idempotencyKey,               // same key re-run = same entry; new key = new paid entry
});

// entry.id is entry_… (skill entry — not pay_…; IAP uses pay_). Confirm on-chain with the same public key:
let result = await playmos.verify(entry.id);
// Poll if still pending until status === "confirmed" (~3–6s)
// Terminal success for entry = "confirmed" — not "settled" (settled is the round-lifecycle word after operator settle)
console.log(result.status); // "confirmed"
console.log({ id: entry.id, tx: entry.txHash });
You win (T0-Skill) when: entry.id = entry_… · verify()"confirmed" · optional BaseScan txHash. That is a player entry smoke — not open/lock/settle of a multi-player contest. (IAP wins use pay_…; skill entries use entry_… by design.)
Path B (#484) — async confirm + idempotent grant belong together. Confirmation is asynchronous (poll verify(entry.id) until confirmed). Your grant / round-start / “admit player” endpoint must be idempotent before you retry it — key on entry.id / payment.id. Retrying a non-idempotent grant multiplies entitlements. Safe to retry: verify and webhooks deduped on data.id. A check-then-act across an await still races — use an atomic upsert / unique constraint on the Playmos id.
const result = await server.verify(payment.id); // safe to retry
if (result.status === "confirmed") {
  await grantItemOnce({ key: payment.id, playerId: result.playerId, sku: result.sku });
}

3. What skill T0 does not include

rounds.open / lock / settle / operator payout on-chain need your self-serve sk_test_ (mint via POST /v1/keys) — not the public key. Public pk_ on live /v1/rounds* gets a clean 403 (not an outage). Self-serve full lifecycle for a cold studio = mock: true — open→enter→lock→settle→prize→withdraw offline (no network). Pass wallet on mock withdraw (live uses the connected provider). Mock is not live money and may still diverge on edge cases — see mock callout under Keys. Full multi-entrant + hub kit: Path B · advanced.

01
enterRound()

Client — pk_test_

02
verify()

Same public key

03
Your game

Session / score / board (your server)

Runnable from the same empty folder (after npm i @playmos/sdk):
terminal
$ cat > t0-skill.mjs <<'EOF'
import { Playmos } from "@playmos/sdk";
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });
const playerId = "player_" + Date.now();
const entry = await playmos.enterRound({
  gameId: "game_sandbox_skill",
  roundId: "docs-smoke-" + playerId,
  amount: "1.00",
  playerId,
  idempotencyKey: "entry-" + playerId,
});
let result = await playmos.verify(entry.id);
for (let i = 0; i < 30 && result.status !== "confirmed"; i++) {
  await new Promise(r => setTimeout(r, 500));
  result = await playmos.verify(entry.id);
}
console.log({ id: entry.id, status: result.status, tx: entry.txHash });
EOF
$ node t0-skill.mjs
Run node t0.mjs (IAP) and node t0-skill.mjs (skill) back-to-back for dual testing.
Next: fee models · full Path A / Path B detail · engines under Surfaces. Dual-test checklist: complete T0-IAP and T0-Skill on this page (same public key) — no private repo file required.

Three fee models, one SDK

Standard games · IAP
1% flat

On every purchase — you keep 99%. One-tap USD checkout with pay().

Skill-based games
10%

Via an on-chain prize pool, split 60 / 30 / 10 — this round's pool, next round's seed, Playmos.

In-game economies
Per-call feeBps

Player ↔ player · player ↔ NPC · NPC ↔ NPC (AI or scripted). transfer() + agents.* wallets — testnet beta on Base Sepolia.

Default is Web / JS + IAP (above). Other engines and money paths are optional — pick a card only if you need them.

Pick a path

Most studios: Path A only. What is the player / client doing?

Your stack

Pick how you ship. Default: Web / JS + Start here. Engines use a host page or system browser.

Keys & environments

One table. Client-safe vs server-only — learn this once. Caps: sandbox server-settle $1 / request · 5 / min · $20 / day.

KeyPrefixWhere it may liveUnlocksEnv
pk_test_playmos_sandbox pk_test_ Client / host / browser OK — public, like Stripe publishable pay() · enterRound() demos · verify() read by id (sandbox T0 — no wallet) Base Sepolia sandbox
sk_test_… (yours — self-serve mint) sk_test_ Server only — never Unity/Godot/Unreal client, never host page Server verify() + grant · rounds.* operator (needs OPERATOR_ROLEGAP-09) · agents.* · NPC transfer Base Sepolia sandbox
sk_test_playmos_agents_sandbox sk_test_ Exception — shared public agent sandbox key. Still an sk_ secret shape, but this one specific string is Playmos-published for no-signup Town demos (fake money, shared tenancy, rate-limited). Do not treat other sk_test_ keys as client-safe. Prefer your own studio sk_test_ on a real backend when you have one. agents.* + NPC-funded transfer on the shared agent sandbox only Base Sepolia · shared agent sandbox
pk_live_… / sk_live_… *_live_ Same split (publishable vs secret) when mainnet ships Production — not available yet Mainnet gated

Status words: IAP / contest entry success = confirmed. Transfer success = settled. Do not mix them.

Path A · Standard game — in-app purchases

The default path for any studio selling items. The player taps once, USDC settles on-chain, and you keep 99% — flat 1% to Playmos, no ~30% app-store tax. Canonical install + pay() + verify: Start here (do not re-copy a drifted snippet).

First payment lives in Start here — install, pay(), sandbox verify() with the public key, then server grant with a secret when you have one. Below: player UX, fee split, and the pay() field reference.

Sandbox server-settle cap $1 / request (and rate limits) — full table: Keys & environments. Demo amounts use "0.99".

The player experience — one tap

You get this by calling pay() — you don't build any wallet UI. Your player sees a checkout they already know: dollars, one confirm, no crypto. USDC settles on Base in the background, and you keep 99%.

You write
await playmos.pay({
  sku:      "gems_100",
  amount:   "0.99",   // USD
  playerId,
});
$0.98
lands in your configured studio wallet the instant it confirms.
Flat 1% to Playmos — no 30% app-store cut.
your player taps
Coin DashStore · Gems
240+100
C
Coin Dashcoindash.game
100 GemsInstant top-up
$0.99
Pay with Base Account
Confirm with Face ID
No walletNo gasNo seed phrase

The same $0.99, two ways

A dollar of in-app spend, on the app store versus on Playmos. The difference is what reaches you.

App Store / Play Store
$0.69
reaches you, of every $0.99
30% platform tax + payout delays
Playmos on Base
$0.98
reaches you, of every $0.99
1% flat — settled on-chain, instantly

How it works — you keep 99%

Four steps. Playmos operates a small service and an on-chain contract; it never holds your money. The 99% is in your wallet the instant a purchase confirms.

01
Player taps Buy

Apple-Pay-style sheet, USD price, one tap.

02
pay() settles

Contract splits 99% you / 1% Playmos — atomically on Base.

03
Your server verifies

verify() or webhook — Playmos reads the chain, the source of truth.

04
You grant the item

Confirmed on-chain. Done.

Zero custody. USDC never rests in a Playmos account for your benefit — the contract pays you directly, and the 1% is Playmos’s own revenue. The only regulated step is optional fiat off-ramp, where you are Bridge’s KYC’d customer, not Playmos.

Next: grant only after verify / webhook — same rule as Start here.

pay() reference

const payment = await playmos.pay(input);Input:

FieldTypeReqNotes
amountstringUSD as a decimal string ("0.99"). Rejected: ≤ 0, non-numeric, more than 2 decimals. In the sandbox, ≤ $1.00 per request.
skustringYour product id. Echoed on the receipt + webhook.
playerIdstringYour opaque user id.
gameIdstringOptional when your key maps to one game. Include for the public sandbox key ("game_sandbox_iap") — that key spans IAP + skill demos.
studio0x…The studio wallet that receives the 99%. Optional — falls back to the service default configured for your key.
idempotencyKeystringRecommended on money calls (pay / enterRound) — hold across outer re-runs. Same key ⇒ same payment. Omit ⇒ SDK auto-mints a ULID per call and auto-retries 504 inside that call. A full script re-run without a held key can still double-charge (#252). Dedicated RPC (#205) reduces 504s.
metadataobjectUp to 20 string key/values, echoed back.

Returns Payment:

FieldTypeNotes
idstringpay_…, unique, server-issued.
status"created" | "pending" | "confirmed" | "failed"Transitions for real.
amount / fee / netstringUSD. fee = the 1%. net = what you receive. Strings carry full precision (up to 6 dp) and fee + net === amount exactly — a sub-cent fee shows its real value (e.g. $0.50 → fee "0.005"), never a rounded "0.00".
txHash0x…The on-chain settlement tx (once confirmed).
chain"base" | "base-sepolia"Derived from your key.
sku / playerId / metadataEchoed from input.
createdAtISO-8601

Amounts are strings on purpose"0.99", never 0.99 — so floating-point rounding can never corrupt a price.

Path B · Skill-based games — stablecoin prizes

Player entry smoke is parallel to IAP. First hour: Start here · Skill (enterRoundverify with public key). Below: modes, gameIds, hub kit pins, and the advanced multi-entrant operator lifecycle.

Dual test: run IAP T0 and Skill entry T0 with the same install and pk_test_playmos_sandbox. Keys: Keys & environments.
V1 scope — prize pools are Playmos-operated (first-party), 60/30/10. Player entry on sandbox skill games works with the public key. Open / lock / settle use the Playmos service signer (on-chain OPERATOR_ROLE); you authorize with your self-serve sk_test_ for your studio’s games only. Your server still owns ranking / scores. Your own third-party prize-pool product (studio revenue cut) is not built yet. That does not block dual smoke testing of IAP + skill entry.

Smoke test vs multi-entrant pool read first

ModeWhat it isPools multiple players?
Unpinned smoke (no-wallet, no pin string) Service synthesizes round_<paymentId> only when POST body roundId is omitted/empty. One-shot single-entry probe. (JS SDK always sends a pin — see note below / #238.) No
Pinned server-settle (no-wallet + pin) Since #230, server-settle honors the client pin (POST roundId, e.g. "bmtest:T1"). SDK sends roundKey ?? roundId. Shared hub hasEntered key. Demo-only / griefable (#233); lock/settle need sk_test_ (GAP-09). Yes (shared key)
Multi-entrant contest (own-wallet) Players enter on the own-wallet (client-signed) path so roundId is honored. Then operator open → lock → settle → withdraw. Yes
What “unpinned” means (#238). The JS SDK requires enterRound({ roundId }) and posts roundId: roundKey ?? roundId — that string is the on-chain key. Service: roundId = clientRoundId ?? round_<paymentId>. Passing only a studio window id still pins that string (not a synthetic round_*). Mismatched pins (or settling a window that never received entries) yield empty-pool rejection: payable pool is zero — nothing to settle. To share a hub pool, pass the same pin every time (e.g. bmtest:T1 or rolling ${window}:T1). Since #230, pins land under the shared on-chain key your hub verifies via hasEntered. That shared-round public-key path is demo-only / griefable (#233), and the full operator lock → settle still needs a secret key (GAP-09) or the own-wallet path.
Sandbox skill gameId catalog (public pk_test_playmos_sandbox). Canonical rule: kit GameConfig.id slug → product id. Only munch has a game_sandbox_base_* alias today — do not invent jump/stack aliases.
gameIdResolves?Use when
game_sandbox_skillGeneric Path B / docs smoke
game_sandbox_base_munchMunch-shaped sandbox alias (#123)
game_sandbox_base_jump❌ game not foundUse product id basejump
game_sandbox_base_stack❌ game not foundUse product id basestack
basejump / basemunch / basestackFirst-party kit product ids (#212). Jump/Munch may share sandbox dogfood pool; Stack is own-v2 — not the same PrizePool (dual-pool #391)

Smoke test — one enterRound sandbox only

Proves the entry money path on Base Sepolia. Not a multi-player contest. Public sandbox key (see Keys) — no wallet required.

enter-smoke.ts
import { Playmos } from "@playmos/sdk";

const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });

// Recommended: hold across outer re-runs (#252). SDK auto-retries 504 inside one call.
// Omit → NEW key per call → full script re-run can double-enter.
const idempotencyKey = "entry-player_abc-bmtest:T1-smoke-1";

// SMOKE: one entry. Pass a roundKey to share an on-chain round; omit it for a one-shot.
const entry = await playmos.enterRound({
  gameId: "game_sandbox_skill",
  roundId: "5m-5944009",     // off-chain window id
  roundKey: "bmtest:T1",     // since #230, honored on no-wallet server-settle as the on-chain key
  amount: "1.00",
  playerId: "player_abc",
  idempotencyKey,             // recommended: hold across outer re-runs (#252)
});
// No-wallet server-settle is ASYNC (#221): status starts "pending"/"created" and confirms in ~3-6s.
const confirmed = await playmos.verify(entry.id); // poll → "confirmed"; 60/30/10 split on-chain
💰
Retries are money (#252). The SDK auto-generates an idempotencyKey when omitted and auto-retries gateway 504s inside a single call (http.ts — POST only when an idempotency key is present). You usually do not need to hand-roll retries. Holding your own key is recommended (not required) so a full script re-run after a thrown outer failure still maps to the same payment — omit mints a new ULID per call. Same rule for pay(). Reliability residual: dedicated Base Sepolia RPC (#205 / BB-GATEB-002) reduces settle + rounds 504s.
No-wallet smoke mechanics. On pk_test with no wallet, the SDK uses server-settle (service signs; real Sepolia txHash). Since #230, a roundKey pin you pass is honored as the on-chain key (omit it and the service derives a unique round_<paymentId>); identity is server-derived when you don't pin one. Confirmation is async (#221) — poll verify(entry.id) until "confirmed". The shared-round pin path is demo-only / griefable on the public key (#233). A pk_live key always requires a real wallet.

Advanced · multi-entrant skill lifecycle Playmos-operated

Only for contests that need a real pooled round. Requires the own-wallet (client-signed) entry path so players join the exact roundId you opened. Operator calls use your self-serve secret on your server (never the browser) — see Keys.

Public pk_test_ cannot exercise the live operator lifecycle (GAP-09). On-chain rounds.open / lock / settle need your self-serve sk_test_ (POST /v1/keys). Live pk_ on operator routes → clean 403. Cold-studio self-serve lifecycle = new Playmos({ apiKey: "pk_test_…", mock: true }) for open→enter→lock→settle→prize→withdraw({ roundId, wallet }). Remaining mock gaps (not 1:1 with live): no real chain/RPC; withdraw requires explicit wallet; series seed bank offline is a floor only. Player entry path stays fully exercisable with pk_test_ when settle is READY.
Using a real player wallet. Configure a connector: new Playmos({ apiKey, wallet: { connector: "base-account" } }) (or "injected"). With a wallet present the SDK uses the client-signed path instead of server-settle — required for multi-entrant pooling.

B2 · Game-hub kit (pin roundKey + identity)

If your server opens rounds and verifies entries — it reads hasEntered(roundKey, identity) on-chain (the pattern the Playmos game-hub kit uses) — then the on-chain entry must carry your exact roundKey and identity, or your server won't recognize it. Two ways:

Option 1 — the drop-in (recommended). entryProvider({ gameId }) returns a { connect, payEntry } that satisfies a game kit's payment-provider interface:

gameConfig.ts
gameConfig.paymentProvider = playmos.entryProvider({ gameId });
Path B honesty (#462) — easy path ≠ full production path. The drop-in gets you to “compiles + confirmed entry.” You still own: (1) same roundKey + identity on enter and hub POST /entry/confirm (hasEntered); (2) a real wallet host (Base App / injected provider) — plain browser with no wallet is server-settle smoke or a loud connect error, not a silent hang; wallet requests are time-bounded (wallet_timeout); (3) the same PrizePool the entry landed on (shared sandbox 0xF6F8… vs Stack own-v2 — see dual-pool table); (4) catalog gameIds that resolve — do not invent game_sandbox_base_stack / game_sandbox_base_jump.

Option 2 — call enterRound() yourself with the exact values:

enter.ts
const wallet   = await playmos.connect();
const identity = `${wallet}#${nonce}`;
const roundKey = `${onchainRoundId}:${tier}`;
// Hold across retries — omit mints a NEW key per call (double-enter on 504 re-run) (#252)
const idempotencyKey = `entry-${identity}-${roundKey}`;

const entry = await playmos.enterRound({
  gameId, roundId, amount: "4.99", playerId: identity,
  roundKey,
  identity,
  idempotencyKey,
});
roundKey + identityenterRound fields you pin so the on-chain entry matches your server's hasEntered(roundKey, identity) check. roundKey = string the contract hashes to bytes32 (defaults to roundId). Two shapes: rolling (live) — hub-minted skill-window keys (default 10m): Jump is un-prefixed (10m-<n>:T1); Munch/Stack are slug-prefixed (basemunch:10m-… / basestack:10m-…) — read from the hub, don't invent calendar dates or a basejump: prefix; and fixed pin (dogfood only)"bmtest:T1" / "bjtest:T1" for sandbox dogfood (#391). identity = per-attempt entrant id (required for hub confirm — #374).

B3 · Game-server loop (confirm → session → submit → leaderboard)

enterRound / entryProvider only moves USDC on-chain. A real skill game still needs your game server (Playmos game-hub kit) to admit the player, issue a ranked session, accept a score, and show the board. Wrong pool/round on the server → empty field / $0 pool (#237, #245).

StepMethodPurpose
1. ConfirmPOST /entry/confirmhasEntered(roundKey, identity) on the configured pool. Body: { identity, wallet, roundId, tier, ref? }. Fail → 402.
2. SessionPOST /sessionRanked play session after confirm. Body: { identity, wallet, roundId, tier }. Fail → 409.
3. SubmitPOST /submit{ identity, log } — server re-sims; never trust client score alone. Fail → 422.
4. BoardGET /leaderboard/:roundId/:tierRanked attempts for the window.
hub-loop.ts
// After SDK enterRound is confirmed:
const HUB = process.env.GAME_HUB_URL;

await fetch(`${HUB}/entry/confirm`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ identity, wallet, roundId: onchainRoundId, tier: "T1" }),
});

const session = await fetch(`${HUB}/session`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ identity, wallet, roundId: onchainRoundId, tier: "T1" }),
}).then((r) => r.json());

await fetch(`${HUB}/submit`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ identity, log: runLog }),
});

const board = await fetch(`${HUB}/leaderboard/${onchainRoundId}/T1`).then((r) => r.json());
Live env — per-game PrizePool / round (fail-CLOSED, #246 / #32 / #391). Each game is checked against its own expected pool — not a global sandbox assumption. Dual-pool: Jump/Munch may share sandbox dogfood pool 0xF6F8…; Stack is own-v2 0xCb518B… — do not repoint Stack to the shared sandbox. Live Jump/Munch/Stack: hub-minted rolling skill windows (default 10m via SKILL_WINDOW_MINUTES) — Jump key is bare 10m-<n>:T1; Munch/Stack are slug-prefixed; ONCHAIN_ROUND_ID_BASE_JUMP, …_MUNCH, …_STACK must all be UNSET. Do not invent calendar dates or a basejump: prefix for live confirm — read the active key from the hub. Fixed bjtest/bmtest pins are dogfood-only. Ops order: correct per-game env before redeploy. Health: GET /health must include prizePools.

Full lifecycle — open → enter → lock → settle → withdraw

enterRound() is one step of five. The round lifecycle — open, lock, settle — is driven from your backend with your secret key, but the on-chain rounds are opened/locked/settled by the Playmos service's OPERATOR_ROLE key against the Playmos-operated PrizePool (the V1 first-party, 60/30/10 model — see the V1 scope note at the top of this section). Never put a secret key in the browser.

Who calls what. enterRound() is the client / player path (publishable pk_test, or the player's own wallet). rounds.open / rounds.lock / rounds.settle / rounds.get are server-side operator calls — your self-serve secret sk_ (GAP-09; not public sandbox). Signed on-chain by the Playmos service's operator role. Run them from your backend, never the browser. The lifecycle sample below is for that secret — not a cold-studio pk_test_ walk.
round-lifecycle.ts (your server)
// Operator client — SECRET key, backend only.
const ops = new Playmos({ apiKey: process.env.PLAYMOS_SECRET }); // sk_test_…

// 1) OPEN with YOUR payout rule (no scores on-chain)
await ops.rounds.open({
  gameId: "game_sandbox_skill",
  roundId: "round-42",
  entryAmount: "4.99",
  payout: { kind: "top-n", splitsBps: [5000, 3000, 2000] }, // top 3 · sum = 10000
});

// 2) Players ENTER on the OWN-WALLET path (not no-wallet smoke) → pool grows, 10% fee at entry

// 3) Your server ranks ENTRANT wallets (best-first). Scores stay yours.

// 4) LOCK — closes entries, splits the pot 60/30 (this pool / next seed)
await ops.rounds.lock({ roundId: "round-42" });

// 5) SETTLE — ranking (SDK applies your rule) or exact winners amounts
const result = await ops.rounds.settle({
  roundId: "round-42",
  results: { ranking: ["0xaaa…", "0xbbb…", "0xccc…"] },
});
// result → { roundId, txHash, poolPaid, winners:[{wallet,amount}], status:"settled" }
// Note: settle status "settled" is the ROUND lifecycle word — IAP/entry success remains "confirmed"

Payout rules for open / settle: { kind:"winner-take-all" } · { kind:"top-n", splitsBps:[…] } (bps must sum to 10000) · { kind:"custom", amounts:["1.50", …] }.

Pure helper (positional only — do not pass an object as the first arg): computePayout(pool: bigint, ranking: 0x…[], rule). Example: computePayout(150_000n, ["0xaaa…"], { kind: "winner-take-all" })pool is micro-USDC bigint.

Response shapes. rounds.open / rounds.lock / rounds.get all resolve the same plain RoundState: { roundId, gameId, roundKey, status, entryAmount, pool, entrants, payout, closeAt?, prizePoolAddress?, openTxHash?, lockTxHash?, settleTxHash? } — there is no top-level via on rounds.get. rounds.settle returns { roundId, txHash, poolPaid, winners:[{wallet,amount}], status:"settled" }. pool is the payable pool in USD — the amount settle distributes (the 60% share carved out at lock), read from chain. The contract computes it at lock, so it is a string only once the round is locked or settled, and null otherwisenever a fabricated "0.00" (issue #42). A null means one of two things, and neither is "empty": (1) the round isn't locked yet (open) — there is no payable pool to report because the carve hasn't happened on-chain, and this holds even when the chain read succeeded; or (2) the chain read didn't reconcile (the service wire also returns via: "cache"; the SDK returns a bare RoundState). pool === null on an open round is expected and correct — do not read it as "nobody has entered." entrants is different: it is a real count from chain, so a freshly-opened round returns entrants: 0, not null; it is null only when the chain read didn't reconcile. 0 = "chain says zero"; null = "we don't know." For the explicit-winners settle form, read pool from rounds.get (or the lock response) after lock; your amounts must sum to it exactly (the on-chain check is in micro-USDC, so match to the cent). prizePoolAddress is the PrizePool contract for the round's game — winners need it to claim (see below); in the Playmos-operated model the studio itself never has to touch it.
Why $4.99 here but $1.00 in the smoke test. $4.99 is the standard entry baseline and runs on the own-wallet (client-signed) path, which has no per-request cap. The $1.00/request limit is a sandbox no-wallet (server-settle) abuse guard only — it applies to the smoke test above, not to this lifecycle. On the public no-wallet sandbox key keep amounts $1.00; on the own-wallet path use your real entry price. Each $4.99 entry splits 60/30/10 on-chain → pool $2.994 · next-round seed $1.497 · Playmos $0.499.
How winners get paid — settle credits, winners withdraw (issue #41 · proven on Base Sepolia, #62). settle does not push USDC out — it credits withdrawable[winner] inside the PrizePool. Winners claim with the SDK: enterRound returns prizePoolAddress + roundKey; rounds.prize({ roundId, wallet }) reads claimable balance; rounds.withdraw({ prizePoolAddress }) (or { roundId }) has the player sign PrizePool.withdraw(). Zero balance → typed NothingToWithdrawError. Proven: settle→prize→withdraw→balance verified on Base Sepolia (operator proof harness; not a third-party install path). Testnet only. REST read: GET /v1/rounds/:id/prize?wallet=0x…. Own-wallet path only (not sandbox no-wallet server-settle).
Edge cases. Idempotent — a retried open/lock/settle replays the same on-chain result; a second settle is never broadcast. Ties — the contract has no tie logic; it credits exactly the winners/amounts you submit, so resolve ties in your ranking first. Winners must be entrants and the amounts must sum to the payable pool exactly. Cancel / timeout refunds each entrant their escrowed ~90% as a withdrawable credit (the 10% entry fee is non-refundable); it's a contract operator action, with no rounds.cancel wrapper in V1. Tenancy — a studio can only open/lock/settle/get its own rounds (ids are namespaced per studio), never another studio's pool.

Then: your server issues the play

The SDK settles the payment on-chain — it does not run your game. After a confirmed entry, your server still owns the ranked-ticket / session logic: confirm the entry (call verify(entry.id) — an on-chain read — or handle the payment.confirmed webhook), then issue your single-use session ticket. verify() re-reads the chain to prove the entry is real; it does not mint your game's ticket — that logic stays yours.

The 60/30/10 split

Each entry is split on-chain, automatically. Players get an effective 90% back, smoothed across rounds.

60%
This round’s pool

paid to winners

30%
Next round’s seed

no round starts empty

10%
Playmos

the take

Pay-per-play & best score

Next: external studios — back to Start here (IAP). Skill operators — confirm entries with Verify & grant, and set payouts in Getting paid.

Path C · In-game economies testnet beta

In-game economies = commerce between any actors that can hold a walletplayer ↔ player, player ↔ NPC, NPC ↔ NPC (AI-driven or scripted). Not an “AI-only” product: the money primitive is entity-agnostic transfer(); wallet assignment stays on agents.* (API names unchanged).

PathMoney flowPrimary callNot this path
A · IAPPlayer → studio (item purchase)pay()Not peer commerce; not a prize pool
B · SkillPlayer → prize pool (entry stake)enterRound()Not a shop sale; not free peer transfer
C · In-game economiesActor ↔ actor (player/NPC/agent wallets)transfer() + agents.*Not IAP grant · not contest enter/settle/withdraw

Pick one money model per product action. Do not use transfer() to fake an IAP receipt, and do not use pay() for NPC↔NPC commerce.

Live on testnet — transfer layer proven. In-game economies on Base Sepolia: NPC/agent wallets + USDC with per-call feeBps. This is the commerce layer (not a claim that a full MMO product is shipped). Server secrets only — see Keys (includes sk_test_playmos_agents_sandbox exception). Transfer terminal = settled. Mainnet agent wallets not live yet.

Town golden path — createWalletfundtransfer

The first thing to run for Path C. Playmos Town: five NPC wallets trading real test USDC on Base Sepolia. Fee is per-call feeBps (0 = bounty, 500 = 5% P2P, 10000 = 100% shop).

Proven, on-chain (Base Sepolia). P2P · feeBps 500 · bounty · feeBps 0 · shop · feeBps 10000. Full script: docs/examples/playmos-town-sdk.mjs.
town.ts
import { Playmos } from "@playmos/sdk";

// Server only — see Keys for sk_test_ vs agents sandbox exception.
const playmos = new Playmos({ apiKey: "sk_test_playmos_agents_sandbox" });

// 1) Give an NPC a wallet (pure — never moves money). Idempotent.
await playmos.agents.createWallet({ agentId: "npc_pico_miner" });

// 2) Fund it from the sandbox treasury.
await playmos.agents.fund({ agentId: "npc_pico_miner", amount: "0.50" });

// 3) Actor → actor with per-call fee (P2P 5%).
const t = await playmos.transfer({
  from: "npc_pico_miner",
  to:   "npc_bruna_blacksmith", // 0x address OR agentId
  amount: "0.10",
  feeBps: 500,                // 0 = bounty · 500 = 5% · 10000 = 100% shop
  feeSink: "0xYourTreasury",  // optional on sandbox; required on live
});

// 4) Terminal success for transfer = "settled" (not IAP "confirmed").
const done = t.status === "settling" ? await playmos.transfers.wait(t.id) : t;
console.log(done.status, done.txHash); // settled + 0x…

Custody: the organization launching the game owns its agents' wallets. Playmos assigns wallets and settles transfers but never holds the keys. Production key custody is post-V1. NPCs are gasless on the sandbox path (EIP-3009; relay pays gas).

agents.pay — the by-id alias of transfer. playmos.transfer(input, opts?) is the canonical value-movement call. playmos.agents.pay({ from, to, amount, feeBps?, feeSink? }) is a thin by-id alias that forwards straight to it — same feeBps (integer 0–10000; outside that range → ConfigError), same feeSink rule (optional on sandbox — the service supplies a default sink, advertised on /health — and required on live, where an omitted sink surfaces as a service 400), same TransferResult, same terminal status settled, same sk_test_ secret-key requirement when from is an NPC. They settle identically. Reach for agents.pay when your call site already thinks in agent ids, and transfer() when it thinks in addresses — both accept either.
Next for most studios: if you only need item sales, use Start here (IAP). Escrow/marketplace for fair-exchange deals are under Advanced below — not required for the Town path.

Advanced · transfer reference, escrow, marketplace testnet

Optional value-movement surfaces on top of the Town path. Still Base Sepolia only — not a claim that a full marketplace product is shipped.

Value-movement transfer() testnet. Move USDC wallet→wallet (player, NPC, or agent) with a per-call fee. playmos.transfer({ to, amount, feeBps, feeSink, memo }) (or POST /v1/transfers) — feeBps 0–10000 (0 = untaxed reward/faucet, 500 = 5% P2P, 10000 = 100% first-party shop), feeSink optional on the sandbox (service default on /health) and required on live. Both to and from accept a 0x address or an agentId. Sandbox guards: ≤ $1.00 per transfer, 5/min, $20/day. Retries are safe with idempotencyKey. Terminal status settled. GET /v1/transfers/:id or transfers.wait(id); transfer(input, { confirm: true }) blocks inline. Auto-retries 429 (opt out: retry: false). Response fee/net full precision; fee + net === amount.
Value-movement escrow testnet. Fair exchange when neither side trusts the other: the payer's USDC is locked on-chain the instant the deal opens, then moves exactly once — release (→ payee, fee skimmed) or refund (→ payer, 100%). Three calls:
playmos.escrow.hold({ payee, amount, feeBps?, feeSink?, resolver?, expiresIn? }) → locks the funds; returns { id }. feeBps 0–10000 taken at release only; feeSink optional on sandbox, required on live. resolver may release/refund (defaults to payer). expiresIn ("30s"|"15m"|"1h"|"7d", default "1h", max 30 d) = auto-refund deadline.
playmos.escrow.release({ escrowId }) → payee + feeSink.
playmos.escrow.refund({ escrowId }) → 100% to payer.
REST: POST /v1/escrows, …/release, …/refund, GET /v1/escrows/:id. Hold charged against sandbox guards; non-custodial in TradeEscrow. previewEscrowFee(amount, feeBps) for local split.
Value-movement marketplace testnet. "List an item; the seller is paid only when it's bought." Built on escrow for off-chain items:
playmos.marketplace.list({ seller?, item:{ kind:"offchain", sku }, price, feeBps?, feeSink?, deliveryWindow?, expiresIn? }) — no money moves.
playmos.marketplace.buy({ listingId, buyer? }) — lock purchase USDC; optional { deliver:true } lock+pay.
playmos.marketplace.confirm({ listingId }) — pay seller after delivery.
playmos.marketplace.refund({ listingId }) / cancel / get.
Fee: flat per-listing feeBps (default 1%), seller-pays on completed sale only. Item delivery is your server's responsibility (delivery oracle). On-chain items are a fast-follow. Non-custodial in TradeEscrow.

Path D · x402-shaped HTTP payments testnet

Not Start here. Default studios still use IAP. Path D is for HTTP clients and agents that pay for a request over an x402-shaped envelope (headers + PaymentRequirement) into the same settlement core as transfer().

!
Claim language (locked). Say x402-shaped API — never “x402 interop”, “works with any x402 agent”, or “x402 certified” until Gate X X3b passes after #161. Stock clients without Playmos mode are rejected (not silently paid by Playmos).
Enablement. PLAYMOS_X402_ENABLED on the sandbox (DO dashboard may override .do/app.yaml). Check GET /healthcapabilities.x402.enabled. Keys: sk_test_ only · Base Sepolia only.

Buyer · playmos.x402.pay (≤15 lines)

pay({ payTo, amount }) wraps POST /v1/x402/challenges then /settle. USD decimal in the SDK; wire amount is atomic ("0.10""100000"). Status word: settled (not IAP confirmed).

buyer.ts · Path D
import { Playmos } from "@playmos/sdk";

// x402 V1: Base Sepolia + sk_test_ only (not pk_test_, not mainnet).
const playmos = new Playmos({
  apiKey: process.env.PLAYMOS_SK!, // sk_test_…
  network: "base-sepolia",
});

// pay() = POST /v1/x402/challenges → settle (server-signer in sandbox).
// Fee terms are server-authoritative at mint — do not "trust" client fee fields.
const result = await playmos.x402.pay({
  payTo: "0x2222222222222222222222222222222222222222",
  amount: "0.10", // USD decimal — wire maxAmountRequired is atomic "100000"
  feeBps: 0,
});

result.status; // "settled"  (not IAP "confirmed")
result.txHash; // 0x… — sepolia.basescan.org/tx/{txHash}

Seller · 402 response helper (no x402.verify in V1)

There is no playmos.x402.verify(req). Settle only after a server-minted challenge (playmos.x402.challenge / pay). Local createX402Challenge is response-shape onlyfulfill of that local requirement always 400s.

seller.ts · Path D
import { createPaymentRequirement, createX402Challenge } from "@playmos/sdk";

// Build a Playmos requirement, then a 402 body + PAYMENT-REQUIRED header.
const requirement = createPaymentRequirement({
  payTo: "0x2222222222222222222222222222222222222222",
  amount: "0.10",
  network: "base-sepolia",
});
const challenge = createX402Challenge(requirement, { feeBps: 0 });

// Express-shaped example:
// res.status(challenge.status).set(challenge.headers).json(challenge.body);
// This LOCAL challenge is response-shape only. To settle on Playmos rails, mint a SERVER
// challenge first: playmos.x402.challenge({ payTo, amount }) → then
// playmos.x402.fulfill(result.requirement, { mode }). (playmos.x402.pay does both.)
void challenge.status;  // 402
void challenge.headers; // { "PAYMENT-REQUIRED": "(base64)", "Content-Type": "application/json" }
void challenge.body.paymentRequired.maxAmountRequired; // "100000" atomic USDC
Gate X proof strip. X3 0x7d78… · X5 concurrent one-broadcast 0xc8e8… · X3b stock interop expected FAIL until full client interop ships. Proof txs above are public on Basescan — no private evidence file required.
Next: most studios stay on Start here (IAP). Town/NPC commerce: Path C. Markdown twin: docs/DEVELOPER-DOCS.md §9.2.

Core · Verify a payment, then grant

Shared by every path — an IAP pay(), a skill-game enterRound(), or any confirmation you act on. Reads the on-chain settlement so it’s the source of truth. Canonical snippet: Start here · step 3.

Golden rule: grant the item on the webhook or verify() — never on the client pay() alone. A client can be spoofed; the blockchain can’t.

verify() reads the settlement transaction on-chain, so it’s correct even after a restart or a retry. Idempotent — call it as often as you like. Secret key on the server only — see Keys.

server.ts · same as Start here
import { Playmos } from "@playmos/sdk";

// Your server — the secret key lives here, never in a client bundle.
const server = new Playmos({ apiKey: process.env.PLAYMOS_SECRET! }); // sk_test_…

const result = await server.verify(payment.id); // on-chain read — idempotent, safe to retry

// Terminal success is exactly "confirmed" (IAP + entries) — not "settled" / "succeeded".
if (result.status === "confirmed") {
  grantItem(result.playerId, result.sku);
}

Core · Webhooks

Let Playmos push you the truth instead of polling. Verify the signature, then act. Signature verification is server-only — import from @playmos/sdk/server (uses Node crypto; not in the browser entry).

webhook.ts
import express from "express";
import { verifyWebhook } from "@playmos/sdk/server"; // server-only — uses Node crypto

const app = express();

// express.raw is required: the signature is HMAC'd over the exact raw bytes.
app.post("/playmos/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const event = verifyWebhook(               // throws if the signature is bad
    req.body,
    req.headers["x-playmos-signature"],
    process.env.PLAYMOS_WEBHOOK_SECRET,
  );

  if (event.type === "payment.confirmed") {
    grantItem(event.data.playerId, event.data.sku);
  }
  res.sendStatus(200);
});
EventFires when
payment.confirmedsettlement confirmed on-chain
payment.failedreverted, cancelled, or timed out
payout.settled(opt-in fiat) USD landed in your bank via Bridge
refund.processeda refund completed

At-least-once delivery, up to 5 exponential-backoff retries. Events are idempotent by data.id — dedup so a retry never grants twice.

Core · Gas & player experience

Players never see or pay gas. You choose who covers it — gas on Base is a fraction of a cent.

sponsored (recommended): gasless for the player. player: the player’s own ETH pays — and the SDK pre-checks their balance, returning a typed InsufficientGasError before a failed transaction so you can prompt a top-up.

config.ts
// you sponsor gas (recommended)
new Playmos({
  apiKey: "pk_live_xxx",
  gas: { mode: "sponsored", paymasterUrl: PAYMASTER_URL }
});

// or the player pays gas — with a pre-check
new Playmos({ apiKey: "pk_live_xxx", gas: { mode: "player" } });

Core · Test mode & testnet

Your key picks the environment — pk_test → Base Sepolia + the Playmos test service, available today. pk_live → Base mainnet is the intended promotion path (swap the key, no code change) but is coming soon, not live yet: nothing is deployed on mainnet, and production is gated on an audit + legal/compliance + a hardening pass. Build on testnet now; don't route real user funds. For offline CI, new Playmos({ apiKey, mock: true }) returns instant deterministic results with no network (clearly labeled — never confuse it with test mode, which is real).

fund a test wallet
# Circle faucet → https://faucet.circle.com
# select "Base Sepolia" (the dropdown resets each visit — reselect it),
# paste your address → 20 test USDC / 2h. Grab a little test ETH for gas too.

Mock mode — validate your integration instantly

new Playmos({ apiKey, mock: true }) lets you validate your integration wiring instantly — no wallet, no network, no crypto. Calls return deterministic results synchronously, so you can prove your code path end-to-end (in a unit test or on first run) before you ever touch the chain. Results carry mock: true / onchain: false and use the real status union, so your handling code exercises the exact shape production returns. Always check mock === true / onchain: false so a mock success is never mistaken for real USDC moving. (npm installs ship built dist. If you develop against a private monorepo checkout, rebuild sdk after source changes.)

mock.ts
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox", mock: true });

const payment = await playmos.pay({
  gameId: "game_sandbox_iap", sku: "gems_100", amount: "0.99", playerId: "player_abc"
});
// instant, deterministic — payment.status === "confirmed", payment.mock === true
Mock mode is not test mode. Mock is offline and fabricated — perfect for CI and wiring checks. Test mode is real: it settles against the live sandbox on Base Sepolia with real ids, statuses, and txHashes. Use mock to prove your code compiles and flows; use test mode to prove it actually pays.

Core · Getting paid — your choice at onboarding

When you onboard, you make one choice: how do you want your revenue? No wrong answer, and you can change it anytime with one setting.

Option 1 — Stablecoin to your own wallet (default). Designate a wallet; the contract pays it directly at purchase time. Instant, non-custodial, free. (Self-off-ramp via Coinbase Prime / Kraken whenever you like.)

Option 2 — Dollars to your bank via Bridge (a Stripe company). Complete a KYC link once; your USDC off-ramps to USD over ACH/SEPA/SWIFT, T+1–T+3, with a payout.settled webhook when it lands.

Either way, Playmos never holds your money — Option 1 pays your wallet on-chain; Option 2 hands the fiat step to Bridge with you as the KYC’d customer.

payouts.ts
// Option 1 — pay me in stablecoin, to my wallet (default)
await playmos.payouts.setMode("usdc");

// Option 2 — pay me in dollars, to my bank
const { url } = await playmos.payouts.createOnboardingLink();
// send the studio owner to `url` to complete KYC with Bridge
await playmos.payouts.setMode("fiat");

Core · Errors

Every error is typed and actionable, thrown as early as the SDK can tell.

ErrorMeaningWhen
PlaymosErrorBase class — every SDK error extends it. Catch it last as your catch-all.any
InvalidAmountErroramount ≤ 0, non-numeric, > 2 dpbefore any network call
MissingFieldErrorempty sku / playerIdbefore any network call
InsufficientGasErrorplayer ETH too low (gas: player)pre-check, before the tx
WalletConnectionErrorplayer closed/failed the sheetduring connect
PaymentFailedErrortx reverted or cancelledon-chain
AuthErrorbad or wrong-environment keyon the API call
ApiErrorthe service returned an error (includes a transfers.wait timeout)on the API call
ConfigErrorinvalid/unsupported client config — e.g. the deprecated webhooks.verify(), or a rounds.open missing its payout rulebefore any network call
NothingToWithdrawErrorrounds.withdraw on a zero claimable balanceon the withdraw call

The other nine all extend PlaymosError, so catch (e) { if (e instanceof PlaymosError) … } catches every SDK-thrown error — and nothing else.

Core · REST API & other languages

Everything the SDK does is REST. Here’s the one call every backend makes — verify a payment — in five languages.

curl https://api.sandbox.playmos.io/v1/payments/$ID \
  -H "Authorization: Bearer $PLAYMOS_SECRET"
# → { "status":"confirmed", "amount":"0.99", "fee":"0.01", "net":"0.98", "txHash":"0x…" }
// Prefer SDK (npm i @playmos/sdk@0.3.10) — raw fetch also fine
import { Playmos } from "@playmos/sdk";
const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SECRET }); // sk_test_… server only
const p = await playmos.verify(id);
if (p.status === "confirmed") grantItem(p.playerId, p.sku);
import os, requests
r = requests.get(
    f"https://api.sandbox.playmos.io/v1/payments/{payment_id}",
    headers={"Authorization": f"Bearer {os.environ['PLAYMOS_SECRET']}"},
)
p = r.json()
if p["status"] == "confirmed": grant_item(p["playerId"], p["sku"])
req, _ := http.NewRequest("GET", "https://api.sandbox.playmos.io/v1/payments/"+id, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("PLAYMOS_SECRET"))
resp, _ := http.DefaultClient.Do(req)
var p Payment
json.NewDecoder(resp.Body).Decode(&p)
if p.Status == "confirmed" { grantItem(p.PlayerID, p.SKU) }
// Playmos.Sdk 0.1.1-preview — SERVER only (secrets stay here)
// dotnet add package Playmos.Sdk --version 0.1.1-preview
using Playmos;

var playmos = new PlaymosClient(new PlaymosClientOptions {
  ApiKey = Environment.GetEnvironmentVariable("PLAYMOS_SECRET") // sk_test_…
});
var p = await playmos.VerifyAsync(id);
// Terminal success is exactly "confirmed"
if (p.IsConfirmed) GrantItem(p.PlayerId, p.Sku);

Unity & C# — ship a paid item in your WebGL game in 3 steps

Pay → verify → grant. One golden path for Unity / .NET. No wallet ceremony in the happy path. Testnet only (Base Sepolia).

Non-custodialmoney never sits in a Playmos account for you
On-chain & auditableevery sample has a live BaseScan receipt
3 stepsclient pay · callback · server grant
0 secretsin the Unity client build
Testnet only · free test money. Mainnet not available. Host page uses public pk_test_playmos_sandbox — never put sk_test_ in Unity assets. Caps + key rules: Keys & environments.

Install

PackageRoleHow
com.playmos.sdk 0.1.0-previewUnity WebGL bridgeNot public for third parties yet — use host page @playmos/sdk (npm) + NuGet server package
Playmos.Sdk 0.1.1-preview.NET game serverdotnet add package Playmos.Sdk --version 0.1.1-preview
@playmos/sdk@0.3.10Host page pay UXLoads next to your WebGL build

Third-party path does not require Unity package samples. Host page loads npm @playmos/sdk; Unity only needs a bridge to the host (your own thin glue). Unity 2022.3 LTS recommended when you do embed WebGL.

Three steps

STEP 1
Player pays

Unity WebGL bridge → host pay()

STEP 2
Callback

OnPlaymosPayment with payment id

STEP 3
Server grants

VerifyAsync → grant if confirmed

Step 1 · Unity client (no secrets)
// GameObject name must be "PlaymosBridge"
using Playmos.Unity;

public void OnBuyClicked() =>
    GetComponent<PlaymosBridge>().Pay("0.99", "gems_500");
Step 2 · Callback (still no grant)
// SendMessage: GameObject "PlaymosBridge" · method "OnPlaymosPayment"
// PaymentDto = flat strings { id, status } only — JsonUtility-safe
public void OnPlaymosPayment(string json) {
  var p = JsonUtility.FromJson<PaymentDto>(json);
  if (!string.IsNullOrEmpty(p.id)) RequestServerGrant(p.id);
}
Step 3 · Game server (sk_test_ only here)
using Playmos;

var playmos = new PlaymosClient(new PlaymosClientOptions {
  ApiKey = Environment.GetEnvironmentVariable("PLAYMOS_SECRET")
});
var payment = await playmos.VerifyAsync(paymentId);
if (payment.IsConfirmed) GrantItem(payment.PlayerId, payment.Sku);

Sequence

Player → Unity → host JS → Playmos API → Base Sepolia → callback → your server verifies → grant.

Player Unity WebGL @playmos/sdk Playmos API Base Sepolia Your server 1 pay 2 settle 3 VerifyAsync → grant if confirmed SendMessage("PlaymosBridge","OnPlaymosPayment", json)

Security

KeyUnity clientHost pageGame server
pk_test_playmos_sandboxprefer host only✅ payoptional sandbox verify
sk_test_…NEVERNEVER✅ verify / agents / rounds
server-settle paypk_test_ onlyPlaymosClient.PayAsync also requires pk_test_
Editor Play Mode cannot run a real pay. Build WebGL and host it in a page that loads @playmos/sdk (your own host page). That is expected — not a product bug.

IAP grant vs contest withdraw

IAP (this path)Skill contest
After successServer verify → grant item in your gameOperator settle credits winners on-chain
Player cash-outN/A (inventory)Winner rounds.withdraw claims credit
Do not mixDo not call withdraw for gem packsDo not fake-grant prize value as inventory without the claim flow
Proven receipts (Base Sepolia). Unity host-pay + verify: tx 0xff3809e3…be12 · C# REST Pay+Verify: tx 0x1182405f…d070. Status vocabulary: terminal success is exactly confirmed (created | pending | confirmed | failed).
Who attaches the tx? In the WebGL bridge flow, the host @playmos/sdk owns pay (and any POST /payments/:id/tx). Your C# server only needs GET /payments/:id (VerifyAsync). If status stays created/pending, wait for the host path — don't invent a second attach from the server.

Endpoints

MethodPathPurpose
POST/paymentscreate a payment intent (sandbox server-settle: settle:"server" + pk_test_)
POST/payments/:id/txattach client txHash — JS SDK owns this in the WebGL bridge flow
GET/payments/:idverify a payment (on-chain read) — success = confirmed
GET/paymentslist / filter payments
POST/roundsopen a contest round (operator, your sk_ — GAP-09; not public pk_test_) — gameId, roundId, entryAmount, payout
POST/rounds/:id/locklock a round — close entries, compute the payable pool (operator · your secret)
POST/rounds/:id/settlesettle — body results = { ranking } or { winners:[{wallet,amount}] }; credits winners, who withdraw() (operator · your secret)
GET/rounds/:idfetch a round + its on-chain-reconciled status/pool
POST/transfersmove USDC wallet→wallet with a per-call fee (feeBps/feeSink) · sandbox testnet
GET/transfers/:idreconcile a transfer's status against the chain
POST/escrowshold: lock USDC into the escrow (payee, amount, feeBps?, feeSink?, resolver?, expiresIn?) · sandbox testnet
POST/escrows/:id/releaserelease a held deal to the payee (fee skimmed)
POST/escrows/:id/refundrefund a held deal to the payer (100%)
GET/escrows/:idreconcile an escrow's on-chain state
POST/listingslist an off-chain item for sale (seller, item, price, feeBps?, deliveryWindow?) · sandbox testnet
POST/listings/:id/buylock the buyer's USDC in escrow (+ optional deliver:true → pay seller)
POST/listings/:id/confirmrelease → seller paid (item delivered)
POST/listings/:id/refundrefund → buyer 100% (no delivery / dispute / timeout)
POST/listings/:id/canceldelist an unsold listing
GET/listings/:idlisting + chain-verified sale status
POST/refundsrefund a payment
POST/webhook_endpointsregister a webhook URL
POST/payouts/onboarding_linkcreate a Bridge KYC link
Operator REST is not public-sandbox (#253 / GAP-09 / #491). /v1/rounds* needs your self-serve sk_test_ (POST /v1/keys). Public pk_test_ is rejected with a clean 403 (capability miss — not an opaque 504). Mock operator offline with mock: true.

Surfaces — honest install matrix (Base Sepolia)

Every surface below is testnet-only. Registry “live” only when a stranger can install it. Public for third parties today: npm @playmos/sdk, NuGet Playmos.Sdk, PyPI playmos. Unity/Godot/Unreal/Go source installs are private monorepo (not offered on the stranger path) — use the JS host + server-grant pattern instead. Unreal ≠ WebGL.

SurfaceVersionInstall statusPay pattern
JS/TS · Node0.3.10Live on npmnpm i @playmos/sdk@0.3.10 · full cardBrowser pay() · Node verify · host for engines
C# / .NET0.1.1-previewLive on NuGetdotnet add package Playmos.Sdk --version 0.1.1-previewL2 server REST
Unity WebGL0.1.0-previewNot public for third parties yet — host page @playmos/sdk (npm) + server grantL3 host @playmos/sdk bridge → server grant
Godot HTML50.1.0-previewNot public for third parties yet — use JS host @playmos/sdk + server grantL3 host bridge → server grant
Unreal0.1.0-previewNot public for third parties yet — use system browser + server grant (JS/npm host pattern)System browser + server-mediated grant (not WebGL)
Python0.1.0a1Live on PyPIpip install playmos==0.1.0a1 (public). Editable monorepo install is private-source only.L2 server REST
Go0.1.0-alpha.1Not public for strangers yet — use JS @playmos/sdk on npm. Go lives in the private monorepo.L2 server REST (stdlib)
Cross-cutting (all surfaces): Base Sepolia only · mainnet not available · grant only on confirmed · IAP = verify→grant · contest = settle→withdraw · never put secrets in engine clients. Keys + caps: Keys & environments (sk_test_ server-only; server-settle pay = pk_test_ only; caps $1 / req · 5 / min · $20 / day).

JS / Node @playmos/sdk — L2 + browser (npm live)

The reference implementation. Live on npm as 0.3.10. Browser player sheet · Node game server · host page for Unity/Godot · pay page for Unreal. Same security and caps as every other surface.

Installnpm i @playmos/sdk@0.3.10live on npm
RuntimesBrowser · Node · engines load this package on a host / system-browser page
Pay patternClient/host pay() with pk_test_ · server verify() with sk_test_ · grant only if confirmed
WebhooksVerify signatures with @playmos/sdk/server (verifyWebhook) — not the browser entry
1
Install

npm i @playmos/sdk@0.3.10

2
pay()

Browser or sandbox server-settle

3
verify → grant

Node + sk_test_ only

browser / host — pk_test_ only
import { Playmos } from "@playmos/sdk";

// Public sandbox key — client-safe, like Stripe's pk_test_.
// No wallet needed: Playmos server-settles the test payment for you.
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });

const payment = await playmos.pay({
  gameId: "game_sandbox_iap", // public sandbox IAP game — include it (this key spans IAP + skill)
  amount: "0.99",             // USD string — sandbox server-settle cap $1.00/request
  sku: "gems_500",            // your product id
  playerId: "player_abc",     // your opaque user id
});

payment.id;     // "pay_…"     — real, server-issued (ULID)
payment.status; // "confirmed" — real, on Base Sepolia
payment.txHash; // 0x…         — open on sepolia.basescan.org/tx/{txHash}
Node server — sk_test_ only
import { Playmos } from "@playmos/sdk";

// Your server — the secret key lives here, never in a client bundle.
const server = new Playmos({ apiKey: process.env.PLAYMOS_SECRET! }); // sk_test_…

const result = await server.verify(payment.id); // on-chain read — idempotent, safe to retry

// Terminal success is exactly "confirmed" (IAP + entries) — not "settled" / "succeeded".
if (result.status === "confirmed") {
  grantItem(result.playerId, result.sku);
}

Full reference for pay / verify / webhooks / agents is Path A–C above. This card is the surface entry point that matches the C# / Python / Go cards.

C# Playmos.Sdk — L2 server REST

Live on NuGet as 0.1.1-preview. Server only for secrets. Pairs with Unity WebGL client above.

install + verify
dotnet add package Playmos.Sdk --version 0.1.1-preview

using Playmos;
var playmos = new PlaymosClient(new PlaymosClientOptions {
  ApiKey = Environment.GetEnvironmentVariable("PLAYMOS_SECRET") // sk_test_…
});
var p = await playmos.VerifyAsync(paymentId);
if (p.IsConfirmed) GrantItem(p.PlayerId, p.Sku);

Server-settle pay uses pk_test_ only (PlaymosClient.ForSandboxPublicKey().PayAsync(...)). Use NuGet package Playmos.Sdk (public). Monorepo path is private-source only.

Godot HTML5 — L3 host bridge

0.1.0-preview · engine addon is private monorepo only (not a public registry). Third-party path: Godot 4.2+ Web export + host page with public npm @playmos/sdk + server grant.

1
Host pay

window.PlaymosGodot.pay

2
Signal

payment_resolved(id, status) flat DTO

3
Server grant

Verify → grant if confirmed

iap_demo.gd
Playmos.pay("0.99", "gems_500")
# → request_server_grant(payment_id) — never grant in the client
Editor / non-web cannot run a real pay (stub). Engine samples live in the private monorepo — third parties use the JS host + server grant pattern above.

Unreal — system browser + server-mediated grant

0.1.0-preview · Unreal plugin is private monorepo only (not Fab). This is not a WebGL shell. Third-party path: open the system browser to a host that uses public npm @playmos/sdk; the game server verifies and grants.

1
StartIap

LaunchURL + session on game server

2
Browser pay

Studio HostPage + @playmos/sdk (pk_test_)

3
Notify + Verify

Server grant · client polls

UPlaymosPaySubsystem
Pay->Configure(Cfg);  // PayPageBaseUrl + GameServerBaseUrl (URLs must resolve)
Pay->StartIap(TEXT("0.99"), TEXT("gems_500"), TEXT("player_1"));
// Deep link returnUrl is optional sugar — never the sole grant channel
// WebView (CEF) = follow-up, not MVP
Proven (Base Sepolia): tx 0xedf02220…ba25 · pay_01KXBZYHMG1FW9HF86YCD0S78D. Engine samples are private monorepo only — third parties use studio HostPage + @playmos/sdk + server grant. No canonical dead pay.playmos.io — studio hosts its own page with @playmos/sdk.

Python playmos — L2 server REST (live on PyPI)

0.1.0a1 is live on PyPI (Trusted Publishing; install-proven). Primary install: pip install playmos==0.1.0a1. Optional monorepo dev: cd python && pip install -e ".[dev]".

install + verify
pip install playmos==0.1.0a1

from playmos import PlaymosClient
client = PlaymosClient(api_key=os.environ["PLAYMOS_SECRET"])
p = client.verify(payment_id)
if p.is_confirmed: grant_item(p.player_id, p.sku)

Go playmos — L2 server REST

0.1.0-alpha.1 · not a public Go module for third parties yet. Prefer npm @playmos/sdk for public integrations.

verify
// Go import path requires private monorepo access — prefer @playmos/sdk (npm) publicly
import "playmos"

c, _ := playmos.New(playmos.Options{APIKey: os.Getenv("PLAYMOS_SECRET")})
p, _ := c.Verify(ctx, paymentID)
if p.IsConfirmed() { grantItem(p.PlayerID, p.SKU) }

Stdlib only — no go-ethereum. Pay rejects secret keys at runtime (use pk_test_ for server-settle).

In scope (testnet): IAP (1%) · skill prize pools (10%, Playmos-operated) · in-game economies (transfer() + agents.*). Public clients: JS/Node npm (@playmos/sdk) + C# NuGet + Python PyPI. Not public yet: Unity/Godot/Unreal/Go source packages (private monorepo — use JS host + server grant). Not available: native mobile SDKs, mainnet, subscriptions, Bridge virtual accounts & cards, Nethereum-in-engine.