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 smoke: pay_… + confirmed
Pricing (three models). IAP 1% via pay() · Skill 10% (Playmos-operated 60/30/10 prize pool) · In-game economies per-call feeBps on transfer(). Most studios start with IAP — Start here. Details: Test mode & testnet.
T0 vs T1. T0 (smoke, ≤10 min): public pk_test_playmos_sandbox → real pay_… + confirmed + BaseScan — no secret key. T1 (your grant path): server verify() with sk_test_ — no self-serve secret mint yet; use your backend secret when you have one. T0 is not “production integration complete.” Node 18+ required.

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 (no wallet) Base Sepolia sandbox
sk_test_… (your studio secret) sk_test_ Server only — never Unity/Godot/Unreal client, never host page verify() · rounds.* · 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.

Start here — first payment (IAP)

The default path. Complete T0 in one scroll — you do not need Path B (skill) or Path C (in-game economies) for this. Node 18+.

1. Install

terminal
$ npm i @playmos/sdk@0.3.3

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}
You win (T0) when you see: payment.id = pay_… · payment.status = exactly "confirmed" · payment.txHash on sepolia.basescan.org. That is a sandbox smoke test, not production go-live.

3. Verify, then grant (server — T1)

Never grant on the client pay() return alone. Use a secret sk_test_ on your backend (see Keys). No self-serve secret mint yet.

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 / host — pk_test_

02
verify()

Server — sk_test_

03
Grant item

Only if confirmed

Runnable one-file quickstart. From the repo (with @playmos/sdk available):
terminal
$ node docs/examples/quickstart-iap.mjs
Pays $0.99 on the public sandbox, verifies, prints a real pay_… id, confirmed, and a Sepolia BaseScan link. No signup.
Next: money models below if you need skill pools or in-game economies · engine cards under Surfaces · full Path A detail including player UX.

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.

What are you building?

Pick your path below. Each is a self-contained journey — install, quickstart, and money model in one place. All three share the same core concepts (verify, webhooks, gas, payouts, errors) further down; the paths link into them so you never read them twice.

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, canonical pay(), and server verify() → grant. 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.
idempotencyKeystringSupply your own to make retries safe. Omit and the SDK generates a ULID. Same key ⇒ same payment (no double charge).
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

External studios: monetize with IAP today. The default product path is Start here (pay() → verify → grant). Skill prize pools below are the advanced path for Playmos-operated first-party contests — not the recommended first integration for a third-party studio.

Default for most studios: skip Path B until you need skill pools. Go to Start here (IAP, 1%). Keys: Keys & environments (do not re-learn them here).
V1 scope — prize pools are Playmos-operated (first-party), 60/30/10. In V1 the prize-pool contest lifecycle runs only on Playmos-operated first-party skill games on the fixed 60/30/10 model. Playmos provisions the PrizePool contract and holds the on-chain OPERATOR_ROLE key that opens / locks / settles rounds; the service enforces this — entry and round calls must target a Playmos-owned prizePool game. Your server supplies the ranking (and, in B2, its own on-chain entry check via the Playmos game-hub kit) — but it does not deploy its own pool, hold the operator key, or change the 60/30/10 split. External / third-party studios don't run their own prize pools in V1 — a studio that wants its own revenue cut needs a separate money-code contract that is not built yet (coming, not available). External studios: monetize with pay() (IAP, 1%) today.

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 — same PrizePool studio)

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" });

// 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"
});
// 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
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 a secret key on your server (never the browser) — see Keys.

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 });

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

enter.ts
const wallet   = await playmos.connect();
const identity = `${wallet}#${nonce}`;
const roundKey = `${onchainRoundId}:${tier}`;

const entry = await playmos.enterRound({
  gameId, roundId, amount: "4.99", playerId: identity,
  roundKey,
  identity
});
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, both first-class: rolling window`${window}:T1` (e.g. "2026-07-15T20:00:T1"), a fresh key each window (Base Stack); and fixed pin — a stable string like "bmtest:T1" / "bjtest:T1" (Base Munch / Base Jump). onchainRoundId / tier = segments of a composed key; identity = per-attempt entrant id.

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). Each game is checked against its own expected pool — not a global sandbox assumption. Jump/Munch (shared dogfood): PRIZEPOOL_ADDRESS_BASE_*=0xF6F8… + ONCHAIN_ROUND_ID_BASE_JUMP=bjtest / …_MUNCH=bmtest. Stack (own-v2): PRIZEPOOL_ADDRESS_BASE_STACK=0xCb518B714aA546A278a85bD575BA0a9BE43469c3 and ONCHAIN_ROUND_ID_BASE_STACK must be UNSET (rolling window id; setting it is a reconciler footgun). Do not repoint stack to the shared sandbox. Ops order: correct per-game env before redeploy. Health: GET /health must include prizePools (pre-#30 bare {ok:true} is not pool proof).

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 (secret sk_ key) — signed on-chain by the Playmos service's operator role. Run them from your backend, never the browser.
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: computePayout(pool, ranking, rule).

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 (service/scripts/prove-withdraw-claim.ts). 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 #161. Full table: repo docs/design/PHASE-4-X402-GATE-X-EVIDENCE.md.
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. (Monorepo file: installs rebuild dist on install; after pulling SDK source run cd sdk && npm i so dist matches — #211.)

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.3) — 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 bridgeUPM from disk csharp/Playmos.Unity or git ?path=csharp/Playmos.Unity (not NuGet)
Playmos.Sdk 0.1.1-preview.NET game serverdotnet add package Playmos.Sdk --version 0.1.1-preview
@playmos/sdk@0.3.3Host page pay UXLoads next to your WebGL build

Clone-and-run: Package Manager → Playmos SDK → Samples → IAP Demo (Samples~/IapDemo). Unity 2022.3 LTS recommended (min 2021.3).

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 (sample HostPage/). That is expected — not a product bug. Keep package link.xml so IL2CPP does not strip the bridge.

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, secret key) — gameId, roundId, entryAmount, payout
POST/rounds/:id/locklock a round — close entries, compute the payable pool (operator)
POST/rounds/:id/settlesettle — body results = { ranking } or { winners:[{wallet,amount}] }; credits winners, who withdraw() (operator)
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

Surfaces — honest install matrix (Base Sepolia)

Every surface below is testnet-only. Registry “live” only when a stranger can install it. Engines are git / monorepo unless noted. Unreal ≠ WebGL. Marketing homepage chips are Boz-owned (flag Godot/Unreal separately — this page does not edit marketing).

SurfaceVersionInstall statusPay pattern
JS/TS · Node0.3.3Live on npmnpm i @playmos/sdk@0.3.3 · 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-previewGit / UPM disk onlycsharp/Playmos.Unity (not Asset Store)L3 host @playmos/sdk bridge → server grant
Godot HTML50.1.0-previewGit / monorepo onlygodot/playmos/addons/playmosL3 host bridge → server grant
Unreal0.1.0-previewGit / monorepo onlyunreal/PlaymosSystem browser + server-mediated grant (not WebGL)
Python0.1.0a1Live on PyPIpip install playmos==0.1.0a1 · optional monorepo pip install -e . for devL2 server REST
Go0.1.0-alpha.1On main · tag go/v0.1.0-alpha.1 on origin · monorepo go/playmos; go get …@go/v0.1.0-alpha.1 when module reachableL2 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.3. 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.3live 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.3

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(...)). See monorepo csharp/Playmos.Sdk.

Godot HTML5 — L3 host bridge

0.1.0-preview · copy godot/playmos/addons/playmos into your project (git/monorepo — not a registry). Godot 4.2+. Real pay requires Web export + host page with @playmos/sdk.

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). Sample: godot/playmos/Samples/IapDemo. Secret grep: godot/playmos/scripts/assert-no-sk-test.sh.

Unreal — system browser + server-mediated grant

0.1.0-preview · plugin at unreal/Playmos (git/monorepo — not Fab). This is not a WebGL shell and not a Unity port. Packaged games open the system browser; 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. Sample: unreal/Samples/IapDemo. Secret grep: unreal/scripts/assert-no-sk-test.sh. 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 · module github.com/playmos-labs/playmos-sdk/go · tag go/v0.1.0-alpha.1 on origin. Monorepo path always works. go get …@go/v0.1.0-alpha.1 when the module is reachable (do not claim a public pkg.go.dev listing until proxy/sumdb are clean).

verify
import "github.com/playmos-labs/playmos-sdk/go/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.* — player/NPC/agent commerce). Clients: JS/Node npm live (@playmos/sdk@0.3.3) + C# NuGet live + Python PyPI live (playmos==0.1.0a1); Unity/Godot/Unreal as git/monorepo previews; Go monorepo + git tag. Not available: native mobile SDKs, mainnet, subscriptions, Bridge virtual accounts & cards, Nethereum-in-engine. Spec: SDK-SPEC v1.2.3 · multi-surface DX #80 · Node polish #83.