On this page
Payments for games
Test-USDC payments for games on Base Sepolia — 1% IAP — try the sandbox: no wallet, no signup. Beta · testnet only (fake money, real on-chain ids). Mainnet / pk_live_ is not available yet.
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.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?
Buy an item (IAP)
pay() → confirmed. One-tap USD on Base Sepolia.
Skill pool entry
Playmos-operated 60/30/10. External studios: IAP first.
10% · red door for third-party poolsIn-game economies
transfer() + agents. Testnet beta.
x402-shaped API
Paid HTTP / agents. Not stock interop. sk_test_ + flag.
Your stack
Pick how you ship. Default: Web / JS + Start here. Engines use a host page or system browser.
Web / JS
Browser or Node. First smoke payment in minutes with the public sandbox key.
@playmos/sdk · npm live
Unity WebGL
Host page + bridge → server grant. Editor cannot real-pay — need WebGL + host.
git / UPM · 3 stepsGodot HTML5
Host bridge + server grant. Real pay needs web export + host page.
git / monorepoUnreal
System browser + server-mediated grant — not a WebGL shell.
git / monorepoC# · Python · Go
L2 REST verify/grant on your game server. Secrets stay here.
C# · Python · Gox402-shaped API
Paid HTTP / agents. Not Start here. Not stock interop. sk_test_ + flag.
Keys & environments
One table. Client-safe vs server-only — learn this once. Caps: sandbox server-settle $1 / request · 5 / min · $20 / day.
| Key | Prefix | Where it may live | Unlocks | Env |
|---|---|---|---|---|
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
$ 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.
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}
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.
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);
}
pay()
Client / host — pk_test_
verify()
Server — sk_test_
Grant item
Only if confirmed
@playmos/sdk available):
$ node docs/examples/quickstart-iap.mjs$0.99 on the public sandbox, verifies, prints a real pay_… id, confirmed, and a Sepolia BaseScan link. No signup.Three fee models, one SDK
On every purchase — you keep 99%. One-tap USD checkout with pay().
Via an on-chain prize pool, split 60 / 30 / 10 — this round's pool, next round's seed, Playmos.
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.
In-app purchases
Sell gems, skins, lives, boosts. One-tap USD checkout with pay(); USDC settles on-chain.
pay()
Stablecoin prizes
Playmos-operated pools. External studios: start with IAP. Multi-entrant needs own-wallet path.
10% · advanced · smoke ≠ poolIn-game economies
Commerce between players, NPCs, and agents (AI or scripted). One primitive: transfer(). Proven on Base Sepolia.
transfer() · agents.*
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).
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%.
await playmos.pay({ sku: "gems_100", amount: "0.99", // USD playerId, });
Flat 1% to Playmos — no 30% app-store cut.
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.
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.
Player taps Buy
Apple-Pay-style sheet, USD price, one tap.
pay() settles
Contract splits 99% you / 1% Playmos — atomically on Base.
Your server verifies
verify() or webhook — Playmos reads the chain, the source of truth.
You grant the item
Confirmed on-chain. Done.
Next: grant only after verify / webhook — same rule as Start here.
pay() reference
const payment = await playmos.pay(input); — Input:
| Field | Type | Req | Notes |
|---|---|---|---|
amount | string | ✔ | USD as a decimal string ("0.99"). Rejected: ≤ 0, non-numeric, more than 2 decimals. In the sandbox, ≤ $1.00 per request. |
sku | string | ✔ | Your product id. Echoed on the receipt + webhook. |
playerId | string | ✔ | Your opaque user id. |
gameId | string | — | Optional when your key maps to one game. Include for the public sandbox key ("game_sandbox_iap") — that key spans IAP + skill demos. |
studio | 0x… | — | The studio wallet that receives the 99%. Optional — falls back to the service default configured for your key. |
idempotencyKey | string | — | Supply your own to make retries safe. Omit and the SDK generates a ULID. Same key ⇒ same payment (no double charge). |
metadata | object | — | Up to 20 string key/values, echoed back. |
Returns Payment:
| Field | Type | Notes |
|---|---|---|
id | string | pay_…, unique, server-issued. |
status | "created" | "pending" | "confirmed" | "failed" | Transitions for real. |
amount / fee / net | string | USD. 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". |
txHash | 0x… | The on-chain settlement tx (once confirmed). |
chain | "base" | "base-sepolia" | Derived from your key. |
sku / playerId / metadata | Echoed from input. | |
createdAt | ISO-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.
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
| Mode | What it is | Pools 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 |
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.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.gameId | Resolves? | Use when |
|---|---|---|
game_sandbox_skill | ✅ | Generic Path B / docs smoke |
game_sandbox_base_munch | ✅ | Munch-shaped sandbox alias (#123) |
game_sandbox_base_jump | ❌ game not found | Use product id basejump |
game_sandbox_base_stack | ❌ game not found | Use product id basestack |
basejump / basemunch / basestack | ✅ | First-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.
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
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.
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.paymentProvider = playmos.entryProvider({ gameId });
Option 2 — call enterRound() yourself with the exact values:
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 + identity — enterRound 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).
| Step | Method | Purpose |
|---|---|---|
| 1. Confirm | POST /entry/confirm | hasEntered(roundKey, identity) on the configured pool. Body: { identity, wallet, roundId, tier, ref? }. Fail → 402. |
| 2. Session | POST /session | Ranked play session after confirm. Body: { identity, wallet, roundId, tier }. Fail → 409. |
| 3. Submit | POST /submit | { identity, log } — server re-sims; never trust client score alone. Fail → 422. |
| 4. Board | GET /leaderboard/:roundId/:tier | Ranked attempts for the window. |
// 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());
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.
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.// 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).
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 otherwise — never 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.$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.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).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.
This round’s pool
paid to winners
Next round’s seed
no round starts empty
Playmos
the take
Pay-per-play & best score
- Pay-per-play: every paid attempt is a fresh entry that grows the pool; replay as many times as you like to chase a higher score. The best score per wallet counts on the leaderboard.
- Free Practice mode has no stakes and no contract call — always on for grinding.
- The pool is trustless escrow — the stakes live in the contract until settlement.
settlecredits each winner inside the contract; each winner then callswithdraw()to collect (pull-payment). No one custodies players’ money.
Path C · In-game economies testnet beta
In-game economies = commerce between any actors that can hold a wallet — player ↔ 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).
| Path | Money flow | Primary call | Not this path |
|---|---|---|---|
| A · IAP | Player → studio (item purchase) | pay() | Not peer commerce; not a prize pool |
| B · Skill | Player → prize pool (entry stake) | enterRound() | Not a shop sale; not free peer transfer |
| C · In-game economies | Actor ↔ 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.
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 — createWallet → fund → transfer
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).
docs/examples/playmos-town-sdk.mjs.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).
- Status: transfer success =
settled(IAP/entry =confirmed). - Fee: per-call
feeBps0–10000;feeSinkoptional on sandbox, required on live. from/to:0xaddress oragentId.- Confirm:
transfers.wait(id)ortransfer(input, { confirm: true }). - Runnable:
PLAYMOS_SK_TEST=… PLAYMOS_FEE_SINK=0x… node docs/examples/playmos-town-sdk.mjs
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.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.
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.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.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().
mode are rejected (not silently paid by Playmos).PLAYMOS_X402_ENABLED on the sandbox (DO dashboard may override .do/app.yaml). Check GET /health → capabilities.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).
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 only — fulfill of that local requirement always 400s.
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
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.
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).
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);
});
| Event | Fires when |
|---|---|
payment.confirmed | settlement confirmed on-chain |
payment.failed | reverted, cancelled, or timed out |
payout.settled | (opt-in fiat) USD landed in your bank via Bridge |
refund.processed | a 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.
// 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).
# 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.)
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
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.
// 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.
| Error | Meaning | When |
|---|---|---|
PlaymosError | Base class — every SDK error extends it. Catch it last as your catch-all. | any |
InvalidAmountError | amount ≤ 0, non-numeric, > 2 dp | before any network call |
MissingFieldError | empty sku / playerId | before any network call |
InsufficientGasError | player ETH too low (gas: player) | pre-check, before the tx |
WalletConnectionError | player closed/failed the sheet | during connect |
PaymentFailedError | tx reverted or cancelled | on-chain |
AuthError | bad or wrong-environment key | on the API call |
ApiError | the service returned an error (includes a transfers.wait timeout) | on the API call |
ConfigError | invalid/unsupported client config — e.g. the deprecated webhooks.verify(), or a rounds.open missing its payout rule | before any network call |
NothingToWithdrawError | rounds.withdraw on a zero claimable balance | on 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).
pk_test_playmos_sandbox — never put sk_test_ in Unity assets. Caps + key rules: Keys & environments.Install
| Package | Role | How |
|---|---|---|
com.playmos.sdk 0.1.0-preview | Unity WebGL bridge | UPM from disk csharp/Playmos.Unity or git ?path=csharp/Playmos.Unity (not NuGet) |
Playmos.Sdk 0.1.1-preview | .NET game server | dotnet add package Playmos.Sdk --version 0.1.1-preview |
@playmos/sdk@0.3.3 | Host page pay UX | Loads 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
Player pays
Unity WebGL bridge → host pay()
Callback
OnPlaymosPayment with payment id
Server grants
VerifyAsync → grant if confirmed
// GameObject name must be "PlaymosBridge" using Playmos.Unity; public void OnBuyClicked() => GetComponent<PlaymosBridge>().Pay("0.99", "gems_500");
// 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); }
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.
Security
| Key | Unity client | Host page | Game server |
|---|---|---|---|
pk_test_playmos_sandbox | prefer host only | ✅ pay | optional sandbox verify |
sk_test_… | NEVER | NEVER | ✅ verify / agents / rounds |
server-settle pay | — | pk_test_ only | PlaymosClient.PayAsync also requires pk_test_ |
@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 success | Server verify → grant item in your game | Operator settle credits winners on-chain |
| Player cash-out | N/A (inventory) | Winner rounds.withdraw claims credit |
| Do not mix | Do not call withdraw for gem packs | Do not fake-grant prize value as inventory without the claim flow |
confirmed (created | pending | confirmed | failed).
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
| Method | Path | Purpose |
|---|---|---|
POST | /payments | create a payment intent (sandbox server-settle: settle:"server" + pk_test_) |
POST | /payments/:id/tx | attach client txHash — JS SDK owns this in the WebGL bridge flow |
GET | /payments/:id | verify a payment (on-chain read) — success = confirmed |
GET | /payments | list / filter payments |
POST | /rounds | open a contest round (operator, secret key) — gameId, roundId, entryAmount, payout |
POST | /rounds/:id/lock | lock a round — close entries, compute the payable pool (operator) |
POST | /rounds/:id/settle | settle — body results = { ranking } or { winners:[{wallet,amount}] }; credits winners, who withdraw() (operator) |
GET | /rounds/:id | fetch a round + its on-chain-reconciled status/pool |
POST | /transfers | move USDC wallet→wallet with a per-call fee (feeBps/feeSink) · sandbox testnet |
GET | /transfers/:id | reconcile a transfer's status against the chain |
POST | /escrows | hold: lock USDC into the escrow (payee, amount, feeBps?, feeSink?, resolver?, expiresIn?) · sandbox testnet |
POST | /escrows/:id/release | release a held deal to the payee (fee skimmed) |
POST | /escrows/:id/refund | refund a held deal to the payer (100%) |
GET | /escrows/:id | reconcile an escrow's on-chain state |
POST | /listings | list an off-chain item for sale (seller, item, price, feeBps?, deliveryWindow?) · sandbox testnet |
POST | /listings/:id/buy | lock the buyer's USDC in escrow (+ optional deliver:true → pay seller) |
POST | /listings/:id/confirm | release → seller paid (item delivered) |
POST | /listings/:id/refund | refund → buyer 100% (no delivery / dispute / timeout) |
POST | /listings/:id/cancel | delist an unsold listing |
GET | /listings/:id | listing + chain-verified sale status |
POST | /refunds | refund a payment |
POST | /webhook_endpoints | register a webhook URL |
POST | /payouts/onboarding_link | create 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).
| Surface | Version | Install status | Pay pattern |
|---|---|---|---|
| JS/TS · Node | 0.3.3 | Live on npm — npm i @playmos/sdk@0.3.3 · full card | Browser pay() · Node verify · host for engines |
| C# / .NET | 0.1.1-preview | Live on NuGet — dotnet add package Playmos.Sdk --version 0.1.1-preview | L2 server REST |
| Unity WebGL | 0.1.0-preview | Git / UPM disk only — csharp/Playmos.Unity (not Asset Store) | L3 host @playmos/sdk bridge → server grant |
| Godot HTML5 | 0.1.0-preview | Git / monorepo only — godot/playmos/addons/playmos | L3 host bridge → server grant |
| Unreal | 0.1.0-preview | Git / monorepo only — unreal/Playmos | System browser + server-mediated grant (not WebGL) |
| Python | 0.1.0a1 | Live on PyPI — pip install playmos==0.1.0a1 · optional monorepo pip install -e . for dev | L2 server REST |
| Go | 0.1.0-alpha.1 | On main · tag go/v0.1.0-alpha.1 on origin · monorepo go/playmos; go get …@go/v0.1.0-alpha.1 when module reachable | L2 server REST (stdlib) |
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.
| Install | npm i @playmos/sdk@0.3.3 — live on npm |
| Runtimes | Browser · Node · engines load this package on a host / system-browser page |
| Pay pattern | Client/host pay() with pk_test_ · server verify() with sk_test_ · grant only if confirmed |
| Webhooks | Verify signatures with @playmos/sdk/server (verifyWebhook) — not the browser entry |
Install
npm i @playmos/sdk@0.3.3
pay()
Browser or sandbox server-settle
verify → grant
Node + sk_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}
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.
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.
Host pay
window.PlaymosGodot.pay
Signal
payment_resolved(id, status) flat DTO
Server grant
Verify → grant if confirmed
Playmos.pay("0.99", "gems_500") # → request_server_grant(payment_id) — never grant in the client
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.
StartIap
LaunchURL + session on game server
Browser pay
Studio HostPage + @playmos/sdk (pk_test_)
Notify + Verify
Server grant · client polls
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
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]".
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).
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).
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.