Buys, entries, payouts
Players buy items, pay to enter contests, and get paid as winners — one call, 1% on purchases.
What Playmos is
We move the money. You keep the game.
Players buy items, pay to enter contests, and get paid as winners — one call, 1% on purchases.
Your game still decides what “winning” means. We never see a score. We move money when you say who won.
No contest. No wallet. No signup. First real test payment in under five minutes with the key on this page.
Test-USDC payments for games on Base Sepolia — fake money, real ids. Official Circle test USDC on Base Sepolia (chain id 84532), not Base mainnet. You keep 99%. Mainnet / pk_live_ is not available yet.
pk_test_ lives on Base Sepolia (chain id 84532). Official Circle test USDC: 0x036CbD53842c5426634e7929541eC2318f3dCF7e. Test the full loop here — you cannot lose real USDC. Base mainnet is later, after you succeed on Sepolia, and it is real money (Issa's track). If your wallet shows USDC on Base (mainnet) and $0 on Base Sepolia, you are on the wrong chain. Get official Sepolia USDC from the Circle test USDC faucet — select Base Sepolia every visit (the faucet often opens on the wrong network). Mainnet USDC will not pay here. See Troubleshooting.pay() (you keep 99%) · Skill contests — flat 1% to Playmos; you set your own take and prize split on your own contest pool (not live until you have one) · In-game economies per-call feeBps on transfer(). Public-key skill entry is a Playmos shared sandbox — not your contest pool and not your published take. Your 1% is IAP pay() and your own registered pool. If rounds.open refused: If open refused / your pool.pay_… id and status === "confirmed". Then drop the same pay() call into a real game. Never grant an item from the client alone.Empty folder. Node 18+. Public key already in the sample. No engine, no secret, no health-check ritual. This first hour is Base Sepolia (testnet) only — official Circle test USDC, chain id 84532. You can run the full loop here without spending real USDC. Already have a game? Jump to I already have a game.
$ mkdir playmos-t0 && cd playmos-t0 && npm init -y && npm i @playmos/sdk@0.3.20
Public key — safe in demos. Playmos server-settles official Circle test USDC on Base Sepolia. That is not Base mainnet and not the USDC you already hold on mainnet. One file: paste, run, you won when you see pay_… + confirmed. Never grant from the client.
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", settle: "server" });
const payment = await playmos.pay({
gameId: "game_sandbox_iap", // required for the public sandbox key
amount: "0.99", // USD string — sandbox server-settle cap $1.00/request
sku: "gems_500", // your product id
playerId: "player_" + Date.now(),
});
let result = await playmos.verify(payment.id);
let tries = 30;
while (result.status !== "confirmed" && tries !== 0) {
await new Promise((r) => setTimeout(r, 500));
result = await playmos.verify(payment.id);
tries -= 1;
}
if (result.status !== "confirmed") {
throw new Error(`not confirmed yet: ${result.status}`);
}
console.log({ id: payment.id, status: result.status, tx: payment.txHash });
// You won: pay_… + confirmed on Base Sepolia
Public pk_test_
Same key · poll to confirmed
pay_… + BaseScan
pay_ · status === "confirmed" · optional sepolia.basescan.org/tx/{tx}. Sandbox caps: $1 / request · 5 / min · $20 / day. Typical confirm is 14–17 s. The 500 ms × 30 poll is a 15 s budget and can exit before confirmed — the sample checks the status after the loop.You get this by calling pay() — you don't build wallet UI. Players see dollars, one confirm, no crypto. You keep 99%.
await playmos.pay({ sku: "gems_100", amount: "0.99", // USD playerId, });
A dollar of in-app spend, on the app store versus on Playmos.
pay() reference| Field | Type | Req | Notes |
|---|---|---|---|
amount | string | ✔ | USD as a decimal string ("0.99"). Rejected: ≤ 0, non-numeric, more than 2 decimals. Sandbox ≤ $1.00 / request. |
sku | string | ✔ | Your product id. Echoed on the receipt + webhook. |
playerId | string | ✔ | Your opaque user id. |
gameId | string | — | Include for the public sandbox key ("game_sandbox_iap"). |
studio | 0x… | — | Wallet that receives the 99%. Optional — service default for your key. |
idempotencyKey | string | — | Recommended. Same key ⇒ same payment. Omit ⇒ SDK mints a ULID per call. |
metadata | object | — | Up to 20 string key/values. |
| Returns | Type | Notes |
|---|---|---|
id | string | pay_…, server-issued. |
status | "created" | "pending" | "confirmed" | "failed" | Terminal success for IAP = confirmed. |
amount / fee / net | string | On IAP, fee = the 1% and fee + net === amount. On a shared-sandbox entry, fee / split are not your published 1% take. |
txHash | 0x… | On-chain settlement once confirmed. |
chain | "base" | "base-sepolia" | From your key. |
Amounts are strings on purpose — "0.99", never 0.99.
After the first win: mint a secret, put it on your server, verify again, then grant. Never grant on the client pay() return.
pk_test_playmos_sandbox (Playmos's demo studio). If you then verify() that pay_… with a new sk_test_ you minted, you get payment not found. That is expected. For the smoke, verify with the same public key. For grant: set a payoutAddress, pay again with your publishable key, then verify with that studio's secret. Do not mix the demo payment id with your new key.curl -sS -X POST https://api.sandbox.playmos.io/v1/keys \
-H 'content-type: application/json' \
-d '{"label":"my-studio","payoutAddress":"0xYOUR_WALLET"}'
# Store keys.secret (sk_test_…) on the server only. Never in the HTML sample.
# Already minted without a wallet? Set it once:
curl -sS -X POST https://api.sandbox.playmos.io/v1/studio/payout \
-H 'authorization: Bearer sk_test_…' \
-H 'content-type: application/json' \
-d '{"address":"0xYOUR_WALLET"}'
Self-serve IAP refuses until that address is set. There is no SDK helper — this HTTP call is the path.
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);
}
payment.id. A check-then-act across an await still races — use an atomic upsert.Mint creates your studio + keys. It does not return a game-project dashboard. There is no public POST /v1/games. Catalog id game_sandbox_iap remaps to a studio-owned demo game after mint.
The sandbox pay() call is the same in the browser. In Vite / Phaser / any bundler: npm i @playmos/sdk@0.3.20, then the same first-page sample — including settle: "server". That ask is what keeps MetaMask (or any injected wallet) from popping Connect on the no-wallet sandbox path. Omit it only when you want the player to sign.
@playmos/sdk depends on viem. Named import import { Playmos } from "@playmos/sdk" tree-shakes; a default Vite single chunk can still exceed 500 kB gzip-warn. Split the game from the SDK chunk if you ship to phones.
Pick a first-hour path. Default: Web / JS + first payment. Unity is a first-class host-page path (zero → paid entry). C# is the public .NET server path. Godot, Unreal, and Three.js are later.
Browser or Node. First smoke payment in minutes with the public sandbox key.
@playmos/sdk · npm live
Host page, zero → paid entry. No public UPM. Editor Play Mode cannot pay.
Host page + server grantNuGet Playmos.Sdk. Sandbox T0 with the public key, then verify/grant on your server.
One table. pk_ is client-safe. sk_ is server-only. pk_live_ / sk_live_ = Mainnet Beta — gated (see below).
| Key | Prefix | Where it may live | Unlocks | Env |
|---|---|---|---|---|
pk_test_playmos_sandbox |
pk_test_ |
Client / host / browser OK | pay() · enterRound() demos · verify() read by id |
Base Sepolia sandbox |
sk_test_… (yours — self-serve mint) |
sk_test_ |
Server only — never a game client, never a host page | Server verify() + grant · rounds.* operator · agents |
Base Sepolia sandbox |
pk_live_… / sk_live_… |
*_live_ |
Same split, Base mainnet | Mainnet Beta — gated; not issued yet | Mainnet gated |
Status words: IAP / contest entry success = confirmed. Transfer success = settled. Do not mix them.
| Action | pk_test_ | sk_test_ |
|---|---|---|
pay() / enterRound() | Yes | Yes |
GET /v1/payments/:id | Yes — one id | Yes |
GET /v1/payments (list) | No — 400 | Yes |
rounds.open / lock / settle | No — 403 (not an opaque 504) | Yes (your studio’s games) |
POST /v1/keys | Open mint on Sepolia | n/a |
Identity on no-wallet / settle: "server": the service returns sandbox#<paymentId> (IAP) or sandbox#entry_… (contest entry) — not the player's 0x. Persist that id for ranking when there is no wallet. ranking: ["0x…"] only applies when the player signed. Pass an explicit identity when you mean “this player.”
After you succeeded on Sepolia — not the first hour. This path is real USDC. Live keys are not issued until the Founder sign-off and the compliance gate close. Nothing below is available today; it is written down so the switch is one switch, not a second integration. Do not start a stranger pay on mainnet.
Pricing does not change with the chain. IAP is a flat 1% Playmos take (pay()), and a contest on your own pool is a flat 1% Playmos take (feeBps 100). The rest of an entry on your pool — prize, seed, your own studio share — is yours to set when you create the series.
Step 6a — key swap (the studio path). Same code, same calls. Three things change and only these three: the key (pk_test_/sk_test_ → pk_live_/sk_live_), the chain (Base Sepolia → Base mainnet), and USDC (follows the chain). The SDK refuses a key whose environment disagrees with a network you pass, at construction, not at settle time. A supplied rpcUrl (or PLAYMOS_RPC_URL) must serve the key's chain — the SDK proves it once and refuses otherwise, so a leftover Sepolia URL with a live key fails loudly instead of reading “no prizes” from the wrong chain. On mainnet a dedicated RPC is required; there is no public fallback for money reads.
Step 6b — own-pool redeploy. A mainnet contest pool is a new deploy of the pinned post-#775 EpochPrizePool bytecode through the same three commands you ran on Sepolia — playmos-studio pool create → pool own → series create — with sk_live_ in PLAYMOS_SECRET and PLAYMOS_RPC_URL on a dedicated Base mainnet endpoint, with the same admin EOA + operator EOA model. Every prepare locks the chain id and the CLI refuses an RPC that disagrees, so a leftover Sepolia URL cannot send; series create --dry-run prints the chain, the split and the hasRole reads and sends nothing. Existing Sepolia pools are not migrated and their bytecode is not eligible for mainnet. This runs only inside the Founder-gated funded step.
Operator gas on mainnet — Playmos-operated. Your operator EOA signs the settlement typed data (settlementTypedData); Playmos relays and pays gas through POST /v1/epochs/:epochId/settle/signed (SDK epochs.executeSettlement). Your operator does not need mainnet ETH, and Playmos holds no operator power on your pool — the studio key signs the result, Playmos only broadcasts it.
Migration steps, when the gate opens: 1) request live keys (issued after sign-off); 2) swap pk_test_/sk_test_ for the live pair and drop any network: "base-sepolia" override; 3) point rpcUrl / PLAYMOS_RPC_URL at a dedicated Base mainnet endpoint; 4) for contests, redeploy your pool with pool create → pool own → series create (6b), --dry-run first; 5) run the same pay → verify and enter → settle → claim flow you proved on Sepolia and reconcile it on chain. Until then: Base Sepolia is the only live environment.
The chain is truth. The DB is a cache. Grant on verify() or the webhook — never on the local pay() return.
Register a URL with your sk_test_ (no dashboard): POST /v1/webhook_endpoints with {"gameId":"game_sandbox_iap","url":"https://your-server.example/playmos/webhook"}. IAP uses game_sandbox_iap (set payoutAddress at mint or POST /v1/studio/payout first). Skill uses game_sandbox_skill (needs your registered pool). The 201 endpoint.gameId is your real game id — store that, not the catalog alias. That returns { endpoint, signingSecret }. Store signingSecret (whsec_…) as PLAYMOS_WEBHOOK_SECRET on your server — it is shown at registration; there is no endpoint that reads it back later. Inspect the registration with GET /v1/webhook_endpoints (same sk_test_; optional ?gameId=). The body lists endpoints and deliveryStatus (no_deliveries_recorded or attempt_recorded). An attempt row is not proof your URL received the POST — do not treat inspect as a live delivery receipt. Every delivery is signed with it: the X-Playmos-Signature header carries t=<unixSeconds>,v1=<hexHmac>, where v1 is HMAC-SHA256 over t + . + the exact raw body. Verify with the snippet below. Honesty note: today one Playmos-level secret signs all sandbox studios (every registration returns the same value), so a verified signature proves the delivery came from Playmos — it is not a per-studio credential. If the Playmos operator has not set WEBHOOK_SIGNING_SECRET, registration returns 503 webhook_signing_unconfigured instead of a secret — the dev fallback is committed to the public repo, so it is never handed out as a credential. For the grant itself, verifying the webhook or calling server verify() are both fine; the chain is still the truth.
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. Dedup on data.id.
On the default no-wallet path you can skip this entirely; it applies only once you connect your own wallet. Sandbox no-wallet settle: Playmos pays gas. With a connected wallet you can set gas.mode: "player" or "sponsored" (you pass paymasterUrl — no default studio paymaster). Live keys in the snippet below are Mainnet Beta — gated — use pk_test_ on Sepolia.
// testnet — live keys are Mainnet Beta (gated), not issued yet new Playmos({ apiKey: "pk_test_playmos_sandbox", gas: { mode: "sponsored", paymasterUrl: PAYMASTER_URL } });
pk_test → Base Sepolia + the live Playmos test service, available today. pk_live → Mainnet Beta — gated: same code, one switch, not issued until the sign-off (Mainnet Beta — gated). For offline CI, mock: true is a wiring check — never your first payment.
new Playmos({ apiKey, mock: true }) returns deterministic results with no network. Results carry mock: true / onchain: false. Method inputs also accept cosmetic mock?: true — same meaning, not a second mode. Not your first contest. Not your first payment.
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 — payment.status === "confirmed", payment.mock === true
Wallet now. Bank (Bridge) later — stub-honest. Playmos never holds your money. Pricing is flat 1% — on IAP and on contests on your own pool alike; there is no other Playmos take in these docs.
Wallet (default). IAP to your studio needs a payout wallet first. Pass payoutAddress at POST /v1/keys, or POST /v1/studio/payout with { "address": "0x…" } and your sk_test_. Until that is set, pay with your own key fails (look for payoutAddress / payout in the error). The public sandbox key does not need this. Then payouts.setMode("usdc") if you use the helper. The contract pays you at purchase.
Bank later. Bridge KYC + ACH/SEPA is a stub on the service today. Do not treat it as a live off-ramp.
await playmos.payouts.setMode("usdc"); // Bank / Bridge — stub on sandbox; not a live off-ramp yet const { url } = await playmos.payouts.createOnboardingLink(); await playmos.payouts.setMode("fiat");
Just taking payments is a complete, valid integration. Contests, economies, and timers are optional extras.
pay() → confirmed. 1%. No game required.
You set name, fee, split, close time on Playmos. Scores stay in your game.
Framework optionaltransfer() between player / NPC wallets. Testnet beta.
There is no public studio dashboard. “On Playmos” means API + self-serve keys.
| Thing | Who | Where | First 5 min? |
|---|---|---|---|
pay() / verify() | You | Your files | Yes |
Item name / sku / amount | You | Arguments on pay() | Yes (sample hardcodes) |
| Scores, ranking, leaderboard | You | Your game server | No |
| Game loop / engine project | You | Your game files | No |
| Grant after pay | You | Your server after verify() | No (next step) |
| Publishable key | Playmos issues | This page, or your mint | Yes |
| Secret key | Playmos issues; you store | Server env only | No |
| Game id | Playmos issues; you pass it | game_sandbox_iap or auto-provisioned after mint | Yes |
| Prize pool address | Playmos operates | Service resolves it — do not hardcode a second copy | No |
| Contest name / entry fee / winner split / close time | You, defined on Playmos | epochs.prepareSeries (you sign) — not rounds.open on your pool | No |
| Playmos take on IAP | Playmos, 1%, immutable | Contract | Shown, not a setting |
| Timers if you only take payments | You | Your game | No |
Same pay() → server grant. Public path today is npm @playmos/sdk. Unity is a first-class first-hour path — host page, zero → paid entry. There is no public git UPM; Editor Play Mode cannot pay. Godot, Unreal, and Three.js are later — see Appendix.
Browser or Node. Public npm package.
@playmos/sdk · npm live
Host page, zero → paid entry. No public UPM.
Host page + server grantVerify/grant on your game server. Secrets stay here.
NuGet live · server RESTSecond path. Not the front door. You can just take payments.
A contest is a timed prize pot. Players pay to enter, they play your game, and the top players win money from the pot. You decide the rules.
There is one public contest API. Four verbs. No game-named doors.
Your own pool (the live studio path) is epochs. The clock opens and closes the window. rounds.open / rounds.lock / rounds.settle return 400 on a studio EpochPrizePool.
| You do | SDK call |
|---|---|
| Open the first series (once) | epochs.prepareSeries — you sign the returned tx |
| Enter (player pays) | epochs.enter |
| Close the window | the clock — epochs run themselves (no lock) |
| Pay winners | operator settleEpoch or epochs.executeSettlement |
The rounds.* snippet below is the shared sandbox / older PrizePool operator path — not your studio pool.
Needs your sk_test_ to pay winners on that older path. Public pk_ can collect an entry fee and cannot pay winners. Public pk_ on /v1/rounds* is a clean 403 (not an opaque 504). closeAt is an optional ISO-8601 advisory timestamp — not a hard on-chain clock. If you settle a window that never received entries you get payable pool is zero. On a studio pool, enter and settle your series — not game_sandbox_skill.
const ops = new Playmos({ apiKey: process.env.PLAYMOS_SECRET }); // sk_test_… await ops.rounds.open({ gameId: "your_game_id", // YOUR game — not game_sandbox_skill roundId: "round-42", entryAmount: "1.00", payout: { kind: "top-n", splitsBps: [7000, 2000, 1000] }, // 70 / 20 / 10 of the prize pot closeAt: "2026-09-09T18:00:00.000Z", // when the contest ends — target time, not a hard cutoff }); // Players enterRound with the same roundId. Scores stay on YOUR server. await ops.rounds.lock({ roundId: "round-42" }); // close const result = await ops.rounds.settle({ roundId: "round-42", results: { ranking: ["0xaaa…", "0xbbb…", "0xccc…"] }, // ordered 0x wallets — not names }); // result.status is "settled" | "settling". "settling" = sent, waiting on chain — already accepted. // Do not call settle again. Poll GET /v1/rounds/:id (or rounds.get) until the round is "settled". // IAP/entry success stays "confirmed".
"settling" is already accepted. The payout was sent and is waiting on chain. Do not call rounds.settle again — a retry can double-pay. Poll GET /v1/rounds/:id (or rounds.get) until the round is "settled".
If sandbox settle returns 502 or 504 and the round is already locked: that is our server hiccup, not a bad payload (a bad payload is 4xx). The JSON keeps { error: { code, message } } and adds nextAction (wait_then_check | retry_same_key | fix_payload), requestId, round, and locked. Do not blindly retry — a 5xx settle may already have landed. Wait, then check status (GET /v1/rounds/:id). If the round is still unlocked / unsettled, retry once with the same roundId + results. 4xx → fix_payload. retrySafe is present only when that same retry is safe.
mock: true can walk open→enter→close→pay winners offline for CI (rounds.lock / rounds.settle). That is a wiring check, not your first contest.
sk_test_ (round-lifecycle.ts). A browser-only public-key integration can collect an entry fee and cannot pay winners.rounds.settle, the player-visible notice is How a player learns they were paid. Today's notice is claimable — next the player pulls: read withdrawable() on a Sepolia-pinned RPC, then rounds.withdraw from their wallet. Other exports (connect(), payouts.*, agents.*) are not this contest path.After a settled round, the player does not guess. They read this round's payout notices. This is the last step on the shared-sandbox / older PrizePool path: pay / enter → lock → settle → payout notices. It is GET /v1/rounds/:id/payout-notices. There is no epochs twin of this route on the public API — do not call it for epochs.enter / settleEpoch.
Endpoint: GET /v1/rounds/:id/payout-notices?wallet=0x…
Optional wallet must be a 0x-prefixed 20-byte address; when set, only that wallet's rows return. Call it with your studio key (pk_ or sk_). First read after the round is settled runs the same auto-push as the operator POST below.
Response: { notices, roundId, status } — top-level status is the round status (for example "settled"). Each notice is:
| Field | Live shape |
|---|---|
roundId | this round |
wallet | 0x… |
amountMicro | integer micro-USDC (6dp), as a string |
status | "funded" | "claimable" — this is the notice field (not a key named state) |
txHash | present only when status === "funded" |
reason | optional; present on some claimable rows (why the push did not fund) |
Two notice statuses:
claimable — no push tx. Do not tell the player they were paid. txHash is absent. Today's shared sandbox always returns this (reason is "prizepool_pull_credit"). The pot credited the player; they pull next: read withdrawable() on a Sepolia-pinned RPC, then rounds.withdraw from the player's wallet. A claimable row with amountMicro: "0" is not a win — do not treat notices.length > 0 as a payout (a settled round with no winners synthesizes a zero row for any wallet you pass).funded — a real transfer txHash exists. Never labeled funded without that tx. A missing or zero hash is not paid. This is a real status when an operator pusher lands a transfer. Today's shared sandbox does not emit it — do not treat the funded + txHash shape as what curl returns on this host.import { Playmos } from "@playmos/sdk";
const playmos = new Playmos({ apiKey: process.env.PLAYMOS_KEY }); // pk_ or sk_
const { notices, roundId, status } = await playmos.rounds.payoutNotices({
roundId: "round-42",
wallet: "0xYOUR_PLAYER",
});
// status = round status. notices[i].status = "funded" | "claimable"
for (const n of notices) {
if (n.status === "funded" && n.txHash) {
// Only this branch is paid. Today's sandbox does not take it.
console.log({ wallet: n.wallet, amountMicro: n.amountMicro, txHash: n.txHash });
} else if (n.status === "claimable" && n.amountMicro !== "0") {
// Live sandbox shape. Next: withdrawable() then rounds.withdraw — see #own-pool-claim.
console.log({ wallet: n.wallet, amountMicro: n.amountMicro, reason: n.reason });
}
}
curl -sS "https://api.sandbox.playmos.io/v1/rounds/round-42/payout-notices?wallet=0xYOUR_PLAYER" \
-H "Authorization: Bearer $PLAYMOS_KEY"
# Today's sandbox (winner): { "notices": [{ "roundId":"round-42", "wallet":"0x…", "amountMicro":"600000", "status":"claimable", "reason":"prizepool_pull_credit" }], "roundId":"round-42", "status":"settled" }
# Zero row is not a win: { "notices": [{ …, "amountMicro":"0", "status":"claimable" }], … }
Operator push: POST /v1/rounds/:id/push-winnings — sk_ only, settled rounds only, idempotent (existing notices replay). Same response shape as GET. Same auto-push as the first GET after settle. 409 if the round is not settled. On today's sandbox this POST still returns claimable (prizepool_pull_credit) — it does not mint a funded row.
const pushed = await playmos.rounds.pushWinnings({ roundId: "round-42" }); // sk_ only
// pushed.notices — same { wallet, amountMicro, status, txHash? } rows
game_sandbox_skill smoke is enough to see an entry land. Build your own pool only when you want your series, your operator, and the flat 1% take on your contest. Playmos never holds your key and never presses send.| Key | Who holds it | What it does |
|---|---|---|
Server sk_test_ | You (your backend) | preparePool, registerPool, prepareSeries |
| Admin EOA | You (studio machine / wallet) | Deploy the pool, sign the ownership proof, sign the first series. grantRole(OPERATOR_ROLE) only for a pool that was already deployed with operator = studio wallet. |
| Operator EOA | You (studio machine — never Playmos) | settleEpoch or sign executeSignedSettlement. Must be a normal wallet (ECDSA). |
Coinbase Faucet — Base Sepolia ETH · Circle test USDC faucet — select Base Sepolia
rounds.open refused / you need a pool. Four steps — then enter your game, not the shared sandbox.
POST /v1/epochs/pools/prepare (epochs.preparePool). Bytecode is in that response (live API). It is not in the npm tarball.unsignedTx in your wallet and send it.POST /v1/epochs/pools + walletProof, then enter YOUR game. Details: Your own contest pool.This is a money contract, not a sample game. Playmos moves entries and payouts. You own the game.
settle: "server": identity comes back as sandbox#entry_…. Persist that id for ranking when there is no wallet. Do not invent an 0x for a player who never signed.0x the player paid with. ranking: ["0x…"] only applies on that path.idempotencyKey before enterRound. Same key ⇒ same entry. Do not invent a new key on retry.epochs.prepareSeries → epochs.enter → the clock closes → operator settleEpoch / epochs.executeSettlement). Shared-sandbox / older PrizePool still uses rounds.open → enterRound → rounds.lock → rounds.settle → payout notices.0x wallet addresses (1st, 2nd, 3rd…) when the player signed. Not names, emails, or game nicknames.The sandbox enterRound smoke below is NOT A GAME TEMPLATE — money-path only.
Honesty: do not expect Playmos to auto-create your contest pool — you prepare, you sign, you register. After register, the next step is enter on your pool. The public-key game_sandbox_skill smoke is a Playmos shared sandbox — not your contest pool and not your published take. Your 1% is IAP pay() and your own registered pool.
How long. You choose. No Playmos-mandated length. Three hours, a day, a weekend — whatever you want. Playmos does not set a 24-hour / 3-hour / 5-hour rule. The end time you tell Playmos is a target, not a hard cutoff.
How many win. You choose. One winner takes all, or split among the top 2 or the top 3.
How the prize splits. You choose. One winner = 100%. Three winners = for example 70 / 20 / 10 (first / second / third) in dollars. Example: $1 entry, 100 players → about a $100 pot, split 70/20/10 if you pick top 3.
What “winning” means. Your game decides. Playmos never sees scores. You report who won; Playmos moves the money. Paid (confirmed) → contest finished and winners paid (settled).
Studio contests: Playmos take is a flat 1% (locked feeSink) — same take as IAP. Default own-pool split is 60/30/9/1: 60% this epoch, 30% next seed, 9% to studioSink on enter, 1% Playmos. Playmos 1% is fixed. You can change the first three buckets; you cannot change the 1%. Your contest is not live until you have your own contest pool and it is registered. Public-key skill entry is a Playmos shared sandbox — not your contest pool and not your published take. previewPoolSplit(amount) previews that own-pool default (Playmos take 1%, studio 9%). Pass { poolBps, seedBps, feeBps, studioBps } to match a series you created.
New rules = new series. Clock, entry, and split lock at createSeries (once per id; splits are immutable). New rules = create a new series. A new studio sink or fee sink = a new pool deploy. Do not mutate a live pot.
Seed is optional. seedBps may be 0 — for example 80/19/1 (80% this epoch, 19% studio, 1% Playmos, no next-round seed). That is permitted, not the default. Legs must sum to 10000 bps; Playmos 1% is the floor.
Your sink is your wallet. studioSink is the pool admin — the studio wallet you named at pools/prepare. The 9% (or whatever you set) pays there at enter. Playmos never routes or holds it.
Local keys — two EOAs, both yours. A studio pool uses an admin EOA (deploys the pool, holds DEFAULT_ADMIN_ROLE) and an operator EOA (settles epochs). Generate both with your own wallet tooling and keep the private keys on your machine. Never commit them, never paste them, never send them to Playmos — Playmos never holds a studio key and never presses send. epochs.preparePool({ studioWallet, operator }) accepts that dedicated operator, so a pool created this way needs no later grantRole.
playmos-studio init is in @playmos/sdk. There is no playmos-studio package on npm — npx playmos-studio 404s. Run npx --yes --package=@playmos/sdk@0.3.20 playmos-studio init to write a local admin + operator key file. Pin the version; a bare --package=@playmos/sdk can install an old build with no CLI. After the same pin, playmos-studio pool create deploys your pool contract from your machine and records poolAddress + txHash; playmos-studio pool own runs the register → sign the proof → register handshake that owns it; playmos-studio series create --entry <micro> --epoch <seconds> prepares the first series, proves your RPC serves the chain the prepare locked, reads hasRole for your admin and operator on the pool, signs createSeries with the admin key, sends it once, and polls GET /v1/epochs/series until created=true (--dry-run stops after the reads and sends nothing; the command also sends nothing when the series already exists). What is CLI today and what is still script-only: init, pool create, pool own, and series create are commands. The operator settle loop is script-only today — you call it from your own script with @playmos/sdk and sign with your operator key; there is no operator run command yet. Two things a stranger hits: the init funding hint is far above the real deploy cost (a pool deploy is a few thousandths of an ETH at today's Base Sepolia gas — fund a little, not 0.02), and inside a Docker container that runs as a non-root user a key file copied in as root is unreadable until you chown it to that user. Full operator settle path: Next: enter on your pool.
Playmos prepares a ready-to-sign EpochPrizePool deploy. You sign in your wallet and broadcast. Playmos never holds your key and never presses send. This is not auto-create. Fund the studio wallet first — Playmos has no faucet; use the Coinbase Faucet for Base Sepolia ETH and the Circle test USDC faucet (select Base Sepolia). Creating or running a studio pool needs a normal wallet (EOA), not a Base App or MetaMask smart wallet.
Bytecode is returned by prepare (live API: POST /v1/epochs/pools/prepare / epochs.preparePool). It is not in the npm tarball. Do not look for a bytecode file in the package.
POST /v1/epochs/pools/prepare (or epochs.preparePool) with your studio wallet. The response is bytecode + locked constructor args. Playmos 1% feeSink is treasury 0xD84c190085aa59c48a9B478Ea333D50B8DF4aD42 (Base Sepolia). You are admin; operator defaults to the same wallet unless you pass operator to pin a dedicated operator EOA (How to get an operator).POST /v1/epochs/pools (epochs.registerPool) yourself with the deployed address and walletProof. Playmos does not register from the deploy receipt. Then enter YOUR game — not the shared sandbox.walletProof. Registering a pool is how Playmos records that your API key owns that contract. The chain can show the wallet is admin; it cannot show that the person holding the key also holds that wallet. So the API asks the studio wallet to sign a short EIP-191 personal_sign message (proof.message — it names your studio, the pool, the wallet, and the chain). You send that signature as walletProof: { signature }. Without it, register returns ownership: "pending" and does not record you as owner. This is the existing register field — not a new login.
Not live until registered. A prepared payload is not a contest. After register, enter on this pool — do not treat the shared sandbox smoke as done. Tracked on sdk#607 — do not treat this path as done without your own paste.
import { Playmos } from "@playmos/sdk";
const ops = new Playmos({ apiKey: process.env.PLAYMOS_SECRET }); // sk_test_…
const prepared = await ops.epochs.preparePool({ studioWallet: "0xYOUR_WALLET" });
// prepared.broadcast === false — you sign prepared.unsignedTx / prepared.bytecode + constructorArgs
// After YOUR wallet deploys:
const pending = await ops.epochs.registerPool({
studioWallet: "0xYOUR_WALLET",
poolAddress: "0xYOUR_DEPLOYED_POOL",
});
// pending.ownership === "pending" until the studio wallet signs pending.proof.message
const confirmed = await ops.epochs.registerPool({
studioWallet: "0xYOUR_WALLET",
poolAddress: "0xYOUR_DEPLOYED_POOL",
walletProof: { signature: "0x…" }, // EIP-191 personal_sign of pending.proof.message
});
// confirmed.ownership === "confirmed" — next step is enter on THIS pool
After ownership === "confirmed", your contest is epochs on your EpochPrizePool at flat 1%. Use your sk_test_ and your series — not game_sandbox_skill. That catalog id on the public sandbox key is the Playmos shared sandbox — not your contest pool and not your published take. Epochs run themselves: no rounds.open, no lock. Players use epochs.enter (wallet), not the shared-sandbox smoke below.
Creating or running a studio pool needs a normal wallet (EOA), not a Base App or MetaMask smart wallet. The contract recovers the operator with ECDSA.recover. A smart wallet cannot send the to: null create and cannot pass as the operator signer.
epochs.prepareSeries (or POST /v1/epochs/series/prepare) with the pool, series name, window, and entry. Playmos returns a ready-to-sign tx (broadcast: false) with feeBps 100 (the locked 1% take) and fills the prize/seed split so the three legs sum to 10000. You sign unsignedTx — Playmos never holds the key and never broadcasts. Then GET /v1/epochs/series until created=true. There is no POST that creates the series for you. From the CLI, playmos-studio series create --entry <micro> --epoch <seconds> does exactly this from your recorded key file — prepare → prove the chain → hasRole reads → sign → send → poll — and --dry-run prints the reads and sends nothing. Snippet: First series.epochs.enter against your series. Pass wallet to the Playmos client — entry money goes to the contract; Playmos never holds it. The player pays into whichever window the clock says is current. Chrome Coinbase Wallet extension / EOA: epochs.enter works. If the wallet cannot batch, the SDK sends the same calls with eth_sendTransaction — one confirm when USDC allowance already covers the entry, two when it does not. You do not hand-build calldata.settleEpoch on the pool for a past epoch, or sign the settlement typed data (settlementTypedData) and have it relayed: POST /v1/epochs/:epochId/settle/signed (SDK epochs.executeSettlement) — Playmos's gas key relays and pays gas, your operator signs the result and Playmos holds no operator power on your pool. On mainnet the relay is the path (your operator needs no ETH). Do not POST /v1/epochs/:epochId/settle for a pool you operate — the service signer is not your operator.rounds.* is refused on a studio pool. rounds.open / rounds.lock / rounds.settle return 400 — epochs on your own EpochPrizePool run themselves (no open/lock). Use epochs.prepareSeries → epochs.enter → operator settleEpoch.
For production you want a dedicated operator EOA that only settles. Which route you take depends on whether the pool exists yet.
operator at prepare. epochs.preparePool({ studioWallet, operator }) pins that address as OPERATOR_ROLE in the constructor. No grantRole needed. (Generate the admin + operator pair with your own wallet tooling — see Local keys.)grantRole(OPERATOR_ROLE, operatorAddress) from the admin EOA, then check hasRole is true.Either route, the rest is the same:
generatePrivateKey / any wallet that can export a private key). Keep the key in a file only your process can read (chmod 600 on macOS/Linux — no-op on Windows, so protect it yourself). Playmos never holds it and never presses send.settleEpoch from that operator, or sign settlementTypedData and relay executeSignedSettlement.withdrawable() on a chain-pinned RPCPrize credit lives on Base Sepolia. Call withdrawable() through a Sepolia RPC you pin (https://sepolia.base.org or the SDK test RPC). Use the wallet provider only as a fallback — Base App often sits on mainnet, so Rewards says “no prizes” while the money is on Sepolia. In the SDK, pass rpcUrl to the Playmos client (server-side PLAYMOS_RPC_URL): the SDK proves once that the URL serves your key's chain and refuses with ConfigError otherwise, so a leftover URL for the wrong chain fails loudly instead of reading “no prizes”. On mainnet a dedicated RPC is required — no public fallback for money reads. A payout notice with status: "claimable" continues here: read withdrawable(), then rounds.withdraw from the player's wallet.
Regular EOA on Base Sepolia — same wallet you used at pool setup. You choose the window and entry. Playmos take is feeBps 100; the prize/seed split is filled so the three legs sum to 10000. Or from the CLI: playmos-studio series create --entry <micro> --epoch <seconds> (--dry-run sends nothing).
import { Playmos } from "@playmos/sdk";
const ops = new Playmos({ apiKey: process.env.PLAYMOS_SECRET }); // sk_test_…
const prepared = await ops.epochs.prepareSeries({
epochPrizePool: "0xYOUR_DEPLOYED_POOL",
series: "your-series",
epochDuration: 3600,
entry: "250000", // 0.25 USDC — integer micro-USDC; you choose
});
// prepared.broadcast === false — sign prepared.unsignedTx in your EOA
// Playmos never holds the key and never broadcasts.
// Then GET /v1/epochs/series?series=your-series&epochPrizePool=0xYOUR_DEPLOYED_POOL
// until created === true (404 = not created yet). There is no POST that creates the series for you.
import { Playmos, settlementTypedData } from "@playmos/sdk";
const playmos = new Playmos({
apiKey: process.env.PLAYMOS_SECRET, // sk_test_…
wallet: { provider }, // player EIP-1193 — required for epochs.enter
contracts: { epochPrizePool: "0xYOUR_DEPLOYED_POOL" },
});
// After you sign the prepared series tx on YOUR pool (not rounds.open):
const entry = await playmos.epochs.enter({
series: "your-series",
identity: playerId,
});
console.log({ epochId: entry.epochId, status: entry.status, tx: entry.txHash });
// Studio operator EOA settles a *past* epoch (Current is still taking entries):
// pool.settleEpoch(series, pastEpochId, winners, amounts)
// or sign + relay executeSignedSettlement:
const typed = settlementTypedData({
pool: "0xYOUR_DEPLOYED_POOL",
chainId: 84532,
series: "your-series",
epochId: pastEpochId,
winners: ["0xaaa…"],
amounts: [winnerShareMicro], // integer micro-USDC strings — never USD
});
// operator signs `typed`; anyone may relay:
await playmos.epochs.executeSettlement({
series: "your-series",
epochId: pastEpochId,
winners: ["0xaaa…"],
amounts: [winnerShareMicro],
signature, // operator EIP-712 signature of `typed`
});
Optional public-key smoke only. NOT A GAME TEMPLATE. Not your contest. Not your published take. Your 1% is IAP pay() and your own registered pool. The fee / split numbers on this shared-sandbox object are not that 1%. Id is entry_…, not pay_…. Unique roundId per run so the open price matches your amount. Persist idempotencyKey before enterRound. Same sandbox cap as pay(): ≤ $1.00 / request.
import { Playmos } from "@playmos/sdk";
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox" });
const playerId = "player_" + Date.now();
const idempotencyKey = "entry-" + playerId; // persist BEFORE enterRound
const entry = await playmos.enterRound({
gameId: "game_sandbox_skill",
roundId: "docs-smoke-" + playerId,
amount: "1.00",
playerId,
idempotencyKey,
});
let result = await playmos.verify(entry.id);
for (let i = 0; i < 30 && result.status !== "confirmed"; i++) {
await new Promise(r => setTimeout(r, 500));
result = await playmos.verify(entry.id);
}
if (result.status !== "confirmed") {
throw new Error(`not confirmed yet: ${result.status}`);
}
console.log({ id: entry.id, status: result.status, tx: entry.txHash });
enterRound() / verify() fieldsenterRound() returns the same Payment shape as pay(), plus entry-only fields below. verify() is a narrower VerifyResult — not every Payment field. Extra JSON keys on a live response are not your published take.
| Field | On enterRound() (Payment) | On verify() (VerifyResult) | Notes |
|---|---|---|---|
kind | ✔ "iap" | "entry" | — | Entries are "entry". |
net | ✔ | ✔ | Same money convention as pay(). |
split | optional { pool, seed, rake } | — | Entry bookkeeping preview on some objects. Not your published 1% take. Reconcile money with chainAmount. |
identity | optional | optional | Wallet-less: sandbox#entry_…. Persist this id when there is no wallet. |
roundKey | optional | — | On-chain round pin. May equal roundId. |
chainAmount | optional | optional | On-chain entry amount (USD) when known. |
chainAmountMicro | optional | optional | Same amount, micro-USDC integer string. |
verifiedVia | — | optional "chain" | "cache" | "degraded" | How the service derived status. |
chainReads | — | optional "enabled" | "degraded" | Whether chain reads are configured. |
createdAt | ✔ | — | ISO timestamp on the payment object. |
splitBps is not a typed field on Payment or VerifyResult.
The four-verb contest API (open / enter / close / pay winners) is at One contest API.
Move test USDC between player / NPC wallets with transfer(). Not IAP. Not a contest. Terminal = settled.
sk_test_playmos_agents_sandbox is a Playmos-published exception for no-signup Town demos (fake money, shared tenancy). It is still an sk_ shape — do not treat other secrets as client-safe. Prefer your own sk_test_ on a real backend.import { Playmos } from "@playmos/sdk"; const playmos = new Playmos({ apiKey: "sk_test_playmos_agents_sandbox" }); await playmos.agents.createWallet({ agentId: "npc_pico_miner" }); await playmos.agents.fund({ agentId: "npc_pico_miner", amount: "0.50" }); const t = await playmos.transfer({ from: "npc_pico_miner", to: "npc_bruna_blacksmith", amount: "0.10", feeBps: 500, }); const done = t.status === "settling" ? await playmos.transfers.wait(t.id) : t; console.log(done.status, done.txHash); // settled
| Error | Meaning |
|---|---|
PlaymosError | Base class — catch last |
InvalidAmountError | amount ≤ 0, non-numeric, > 2 dp |
MissingFieldError | empty sku / playerId |
InsufficientGasError | player ETH too low (gas: player). Fill your own wallet — skip this if you used the sample on the first page. Playmos has no faucet — fund Base Sepolia ETH from the Coinbase Faucet and test USDC from the Circle test USDC faucet (select Base Sepolia). |
WalletConnectionError | player closed the sheet |
PaymentFailedError | tx reverted or cancelled. If the wallet shows USDC but pay fails, you are almost always on the wrong chain — sandbox needs official Base Sepolia USDC, not Base mainnet USDC. See Troubleshooting. |
RoundNotOpenError | contest round is not open on-chain — nothing was charged. The wallet sheet is not shown. |
AuthError | bad or wrong-environment key |
ApiError | service error (includes wait timeout) |
ConfigError | bad client config |
NothingToWithdrawError | rounds.withdraw on a zero balance |
rounds.open refused / need a pool | You need your own contest pool. See If open refused / your pool. Bytecode comes from prepare (live API), not the npm package. |
Everything the SDK does is REST. 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.20) — raw fetch also fine import { Playmos } from "@playmos/sdk"; const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SECRET }); 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 using Playmos; var playmos = new PlaymosClient(new PlaymosClientOptions { ApiKey = Environment.GetEnvironmentVariable("PLAYMOS_SECRET") }); var p = await playmos.VerifyAsync(id); if (p.IsConfirmed) GrantItem(p.PlayerId, p.Sku);
Do this after a failed first payment — not before.
| What you see | What it means |
|---|---|
| I have USDC but pay fails | Wrong chain / wrong USDC. Sandbox spends official Circle test USDC on Base Sepolia (0x036CbD53842c5426634e7929541eC2318f3dCF7e). USDC on Base mainnet does not spend here — a wallet can show $4+ on mainnet and $0 on Sepolia. Switch the wallet to Base Sepolia and refill from the Circle faucet (select Base Sepolia every visit). |
| Wallet is on Base / shows mainnet USDC | You are not on the test network. Playmos pk_test_ is Base Sepolia only. Mainnet is gated. |
| Wallet says insufficient funds on a wallet-pay path | Check Sepolia USDC first (row above), then Sepolia ETH for gas. The no-wallet sample on this page does not need your USDC. |
| READY health + pay still fails | Pass idempotencyKey on retry. If health is DEGRADED / low_usdc, the Playmos test signer needs a top-up — not your wallet. |
curl -sS https://api.sandbox.playmos.io/health | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const p=JSON.parse(d).capabilities.payments;console.log(p.serverSettle);console.log(JSON.stringify(p.signerBalance));})"
READY + signerBalance.ok: true → retry. DEGRADED / low_usdc → sandbox signer needs a top-up (not your integration). Pass idempotencyKey on retry. This is Playmos’s test signer, not your wallet.
A 504 can happen on a slow sandbox route. Wait, check status, one retry with the same key. The SDK does not double-charge. For a pool you operate, a 504 on POST /v1/epochs/:id/settle means sign yourself from the operator EOA — the service signer is not your operator.
| What you see | What it means |
|---|---|
400 pool_not_deployed when you register an address | “deploy the prepare tx first — this address has no EpochPrizePool bytecode.” Sign and send the unsignedTx from prepare, wait for the receipt, then register the mined address. |
| Wallet says insufficient funds on enter | Often RoundNotOpen (window closed / series not created), not an empty wallet. Check created=true and the current window. |
| Deploy / create fails from Base App or MetaMask | Smart wallet / EIP-7702 cannot send to: null. Use a normal EOA (Phantom or a generated key). |
POST /v1/epochs/:id/settle returns 504 | Sign settleEpoch yourself from the operator EOA. Do not wait on the Playmos relay. |
Unity is a first-class first-hour path, next to Web / JS. There is no public Unity git UPM (the repo is private). The public path is a host page that calls pay() — same win as the JS front door: pay_… + confirmed. Godot, Unreal, and Three.js are later.
| Package | Role | How |
|---|---|---|
com.playmos.sdk | Unity WebGL bridge | Not public — partner access required. Use host page + NuGet server package. |
Playmos.Sdk 0.1.1-preview | .NET game server | dotnet add package Playmos.Sdk --version 0.1.1-preview |
@playmos/sdk@0.3.20 | Host page pay UX | Loads next to your WebGL / HTML5 / system-browser pay page |
com.playmos.sdk git UPM. Paste the host page below, install @playmos/sdk@0.3.20, open it in a browser. You won when you see pay_… + confirmed. Then grant on your server. Never put sk_test_ on the host page or in Unity assets. After you have a WebGL build, embed it on this same page — pay stays on the host page.$ mkdir playmos-unity-host && cd playmos-unity-host && npm init -y && npm i @playmos/sdk@0.3.20
host.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Playmos host (sandbox)</title>
</head>
<body>
<p>Editor Play Mode cannot pay. Open this page in a browser.</p>
<button id="pay">Buy gems $0.99</button>
<pre id="out">ready</pre>
<script type="module">
import { Playmos } from "https://esm.sh/@playmos/sdk@0.3.20";
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox", settle: "server" });
document.getElementById("pay").onclick = async () => {
const payment = await playmos.pay({
gameId: "game_sandbox_iap",
amount: "0.99",
sku: "gems_500",
playerId: "player_" + Date.now(),
});
let result = await playmos.verify(payment.id);
let tries = 30;
while (result.status !== "confirmed" && tries !== 0) {
await new Promise((r) => setTimeout(r, 500));
result = await playmos.verify(payment.id);
tries -= 1;
}
document.getElementById("out").textContent =
JSON.stringify({ id: payment.id, status: result.status, tx: payment.txHash }, null, 2);
};
</script>
</body>
</html>
$ npx --yes serve .
Open host.html. Click Buy gems $0.99. Typical confirm is 14–17 s.
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);
You already won if you have pay_… + confirmed. Godot, Unreal, and Three.js are later — not this version. Those engine packages are not a public stranger install. Unity’s first-hour path is above, not here.
Every surface is testnet-only. Public for third parties today: npm @playmos/sdk, NuGet Playmos.Sdk, PyPI playmos.
| Surface | Version | Install status | Pay pattern |
|---|---|---|---|
| JS/TS · Node | 0.3.20 | Live on npm — npm i @playmos/sdk@0.3.20 | Browser pay() · Node verify |
| C# / .NET | 0.1.1-preview | Live on NuGet | L2 server REST |
| Unity WebGL | 0.1.0-preview | First-hour path — host page + server grant (no public UPM) | L3 host bridge |
| Godot HTML5 | 0.1.0-preview | Later — not this version | L3 host bridge |
| Unreal | 0.1.0-preview | Later — not this version | System browser |
| Python | 0.1.0a1 | Live on PyPI — pip install playmos==0.1.0a1 | L2 server REST |
| Go | 0.1.0-alpha.1 | Not public — use JS npm | L2 server REST |
dotnet add package Playmos.Sdk --version 0.1.1-preview pip install playmos==0.1.0a1 # Python 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)
Not the front door. An x402-shaped envelope into the same settlement core as transfer(). Say x402-shaped — never claim stock interop. sk_test_ only · Base Sepolia. Status word: settled. payTo is the required recipient wallet (0x…) on x402.pay / x402.challenge.
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}
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