Beta · Testnet
On this page

What Playmos is

The money layer for games.

We move the money. You keep the game.

We move

Buys, entries, payouts

Players buy items, pay to enter contests, and get paid as winners — one call, 1% on purchases.

You keep

Rules, scores, winning

Your game still decides what “winning” means. We never see a score. We move money when you say who won.

Start now

No game required

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.

First hour is Base Sepolia (testnet) only. Sandbox 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 faucetselect Base Sepolia every visit (the faucet often opens on the wrong network). Mainnet USDC will not pay here. See Troubleshooting.
1%IAP — you keep 99%
1%skill contests — default 60/30/9/1
~10 minto first test
Pricing (what Playmos charges you). IAP 1% via 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.
You won when you have a pay_… id and status === "confirmed". Then drop the same pay() call into a real game. Never grant an item from the client alone.

Your first payment — Base Sepolia (testnet) first the front door

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.

1. Install

terminal
$ mkdir playmos-t0 && cd playmos-t0 && npm init -y && npm i @playmos/sdk@0.3.20

2. Take a payment

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.

t0.mjs · empty folder
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
01
pay()

Public pk_test_

02
verify()

Same key · poll to confirmed

03
You won

pay_… + BaseScan

Win: id starts with 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.
Fill your own wallet — skip this if you used the sample on the first page. Playmos does not offer a testnet faucet. Fund Base Sepolia ETH from the Coinbase Faucet and official Sepolia USDC from the Circle test USDC faucet (select Base Sepolia — not Base mainnet). Mainnet USDC does not spend on this path. See Troubleshooting.

How a $0.99 buy looks

You get this by calling pay() — you don't build wallet UI. Players see dollars, one confirm, no crypto. You keep 99%.

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

The same $0.99, two ways

A dollar of in-app spend, on the app store versus on Playmos.

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

pay() reference

FieldTypeReqNotes
amountstringUSD as a decimal string ("0.99"). Rejected: ≤ 0, non-numeric, more than 2 decimals. Sandbox ≤ $1.00 / request.
skustringYour product id. Echoed on the receipt + webhook.
playerIdstringYour opaque user id.
gameIdstringInclude for the public sandbox key ("game_sandbox_iap").
studio0x…Wallet that receives the 99%. Optional — service default for your key.
idempotencyKeystringRecommended. Same key ⇒ same payment. Omit ⇒ SDK mints a ULID per call.
metadataobjectUp to 20 string key/values.
ReturnsTypeNotes
idstringpay_…, server-issued.
status"created" | "pending" | "confirmed" | "failed"Terminal success for IAP = confirmed.
amount / fee / netstringOn IAP, fee = the 1% and fee + net === amount. On a shared-sandbox entry, fee / split are not your published 1% take.
txHash0x…On-chain settlement once confirmed.
chain"base" | "base-sepolia"From your key.

Amounts are strings on purpose — "0.99", never 0.99.

Grant on your server

After the first win: mint a secret, put it on your server, verify again, then grant. Never grant on the client pay() return.

A secret key can only see its own studio's payments. The first-page sample uses 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.
terminal
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.

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

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

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

// Terminal success is exactly "confirmed" (IAP + entries) — not "settled" / "succeeded".
if (result.status === "confirmed") {
  grantItem(result.playerId, result.sku);
}
verify is safe to retry; your grant must be idempotent. Key on 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.

Put it on a web page

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.

Your stack

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.

Keys & environments

One table. pk_ is client-safe. sk_ is server-only. pk_live_ / sk_live_ = Mainnet Beta — gated (see below).

KeyPrefixWhere it may liveUnlocksEnv
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.

Actionpk_test_sk_test_
pay() / enterRound()YesYes
GET /v1/payments/:idYes — one idYes
GET /v1/payments (list)No — 400Yes
rounds.open / lock / settleNo — 403 (not an opaque 504)Yes (your studio’s games)
POST /v1/keysOpen mint on Sepolian/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.”

Mainnet Beta — gated

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 createpool ownseries 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 createpool ownseries 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.

Verify & webhooks

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.

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

const app = express();

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

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

At-least-once delivery. Dedup on data.id.

Gas & player experience

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.

Fill your own wallet — skip this if you used the sample on the first page. Playmos does not offer a testnet faucet. Fund Base Sepolia ETH from the Coinbase Faucet and official Sepolia USDC from the Circle test USDC faucet (select Base Sepolia — not Base mainnet). Mainnet USDC does not spend on this path.
config.ts
// testnet — live keys are Mainnet Beta (gated), not issued yet
new Playmos({
  apiKey: "pk_test_playmos_sandbox",
  gas: { mode: "sponsored", paymasterUrl: PAYMASTER_URL }
});

Test mode & testnet

pk_test → Base Sepolia + the live Playmos test service, available today. pk_liveMainnet 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.

Mock mode — CI / wiring only

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.

mock.ts
const playmos = new Playmos({ apiKey: "pk_test_playmos_sandbox", mock: true });
const payment = await playmos.pay({
  gameId: "game_sandbox_iap", sku: "gems_100", amount: "0.99", playerId: "player_abc"
});
// instant — payment.status === "confirmed", payment.mock === true

Getting paid

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.

payouts.ts
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");

What you can set up when you're ready

Just taking payments is a complete, valid integration. Contests, economies, and timers are optional extras.

File vs Playmos

There is no public studio dashboard. “On Playmos” means API + self-serve keys.

ThingWhoWhereFirst 5 min?
pay() / verify()YouYour filesYes
Item name / sku / amountYouArguments on pay()Yes (sample hardcodes)
Scores, ranking, leaderboardYouYour game serverNo
Game loop / engine projectYouYour game filesNo
Grant after payYouYour server after verify()No (next step)
Publishable keyPlaymos issuesThis page, or your mintYes
Secret keyPlaymos issues; you storeServer env onlyNo
Game idPlaymos issues; you pass itgame_sandbox_iap or auto-provisioned after mintYes
Prize pool addressPlaymos operatesService resolves it — do not hardcode a second copyNo
Contest name / entry fee / winner split / close timeYou, defined on Playmosepochs.prepareSeries (you sign) — not rounds.open on your poolNo
Playmos take on IAPPlaymos, 1%, immutableContractShown, not a setting
Timers if you only take paymentsYouYour gameNo
You own the call, your players, your items, your scores, and your game. Playmos owns the keys, the money rails, the pool, and the 1%. Contest name, entry fee, winner split, and close time are things you tell Playmos later — none of them block the first payment.

I already have a game optional

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.

Add a contest optional

Second 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.

One contest API — open / enter / close / pay winners

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 doSDK call
Open the first series (once)epochs.prepareSeries — you sign the returned tx
Enter (player pays)epochs.enter
Close the windowthe clock — epochs run themselves (no lock)
Pay winnersoperator 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.

round-lifecycle.ts (your server)
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. 4xxfix_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.

With the public key you can take entries. Ranking and payout require your server + sk_test_ (round-lifecycle.ts). A browser-only public-key integration can collect an entry fee and cannot pay winners.
Prize balance must be read on Base Sepolia. If you read mainnet, Rewards says “no prizes” while the money is there. After 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.

How a player learns they were paid

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:

FieldLive shape
roundIdthis round
wallet0x…
amountMicrointeger micro-USDC (6dp), as a string
status"funded" | "claimable" — this is the notice field (not a key named state)
txHashpresent only when status === "funded"
reasonoptional; present on some claimable rows (why the push did not fund)

Two notice statuses:

payout-notices.ts (your server)
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
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-winningssk_ 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.

push-winnings.ts (your server)
const pushed = await playmos.rounds.pushWinnings({ roundId: "round-42" }); // sk_ only
// pushed.notices — same { wallet, amountMicro, status, txHash? } rows
Stay on the shared sandbox until you want your own 1% pool. The public key + 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.

Who holds what

KeyWho holds itWhat it does
Server sk_test_You (your backend)preparePool, registerPool, prepareSeries
Admin EOAYou (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 EOAYou (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

If rounds.open refused / you need a pool. Four steps — then enter your game, not the shared sandbox.
  1. Fund the studio wallet. Playmos has no faucet — Base Sepolia ETH from the Coinbase Faucet (free Coinbase Developer Platform login), test USDC from the Circle test USDC faucet (select Base Sepolia).
  2. Call POST /v1/epochs/pools/prepare (epochs.preparePool). Bytecode is in that response (live API). It is not in the npm tarball.
  3. Sign unsignedTx in your wallet and send it.
  4. Register with POST /v1/epochs/pools + walletProof, then enter YOUR game. Details: Your own contest pool.

Contest integrator contract

This is a money contract, not a sample game. Playmos moves entries and payouts. You own the game.

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.

Your own contest pool (your code calls prepare, you sign)

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.

  1. Fund the studio wallet from the Coinbase Faucet (ETH — free Coinbase Developer Platform login) and the Circle test USDC faucet (select Base Sepolia).
  2. Call 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).
  3. Sign the unsigned tx in your wallet and send it. No Solidity and no Hardhat on your machine.
  4. Call 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.

prepare-pool.ts (your server)
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

Next: enter on your 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.

  1. First series on your pool, once, from that EOA. Call 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.
  2. Players enter with 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.
  3. You settle. From the operator EOA, call 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.prepareSeriesepochs.enter → operator settleEpoch.

How to get an operator

For production you want a dedicated operator EOA that only settles. Which route you take depends on whether the pool exists yet.

Either route, the rest is the same:

  1. Generate the operator EOA on your machine (viem 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.
  2. Fund that operator with Base Sepolia ETH from the Coinbase Faucet (free Coinbase Developer Platform login) — gas only; settle does not spend USDC from the operator.
  3. After a window closes, call settleEpoch from that operator, or sign settlementTypedData and relay executeSignedSettlement.

Read withdrawable() on a chain-pinned RPC

Prize 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.

First series (you sign one returned tx)

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).

own-pool-createseries.ts
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.
own-pool-epochs.ts
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`
});

Playmos shared sandbox — not your contest pool

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.

enter-smoke.ts · NOT A GAME TEMPLATE
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() fields

enterRound() 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.

FieldOn enterRound() (Payment)On verify() (VerifyResult)Notes
kind"iap" | "entry"Entries are "entry".
netSame money convention as pay().
splitoptional { pool, seed, rake }Entry bookkeeping preview on some objects. Not your published 1% take. Reconcile money with chainAmount.
identityoptionaloptionalWallet-less: sandbox#entry_…. Persist this id when there is no wallet.
roundKeyoptionalOn-chain round pin. May equal roundId.
chainAmountoptionaloptionalOn-chain entry amount (USD) when known.
chainAmountMicrooptionaloptionalSame amount, micro-USDC integer string.
verifiedViaoptional "chain" | "cache" | "degraded"How the service derived status.
chainReadsoptional "enabled" | "degraded"Whether chain reads are configured.
createdAtISO 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.

In-game economies optional · testnet beta

Move test USDC between player / NPC wallets with transfer(). Not IAP. Not a contest. Terminal = settled.

Town demo key (fenced). 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.
town.ts
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

Errors

ErrorMeaning
PlaymosErrorBase class — catch last
InvalidAmountErroramount ≤ 0, non-numeric, > 2 dp
MissingFieldErrorempty sku / playerId
InsufficientGasErrorplayer 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).
WalletConnectionErrorplayer closed the sheet
PaymentFailedErrortx 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.
RoundNotOpenErrorcontest round is not open on-chain — nothing was charged. The wallet sheet is not shown.
AuthErrorbad or wrong-environment key
ApiErrorservice error (includes wait timeout)
ConfigErrorbad client config
NothingToWithdrawErrorrounds.withdraw on a zero balance
rounds.open refused / need a poolYou need your own contest pool. See If open refused / your pool. Bytecode comes from prepare (live API), not the npm package.

REST API & other languages

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

Troubleshooting

Do this after a failed first payment — not before.

What you seeWhat it means
I have USDC but pay failsWrong 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 USDCYou are not on the test network. Playmos pk_test_ is Base Sepolia only. Mainnet is gated.
Wallet says insufficient funds on a wallet-pay pathCheck 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 failsPass idempotencyKey on retry. If health is DEGRADED / low_usdc, the Playmos test signer needs a top-up — not your wallet.
terminal
curl -sS https://api.sandbox.playmos.io/health | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const p=JSON.parse(d).capabilities.payments;console.log(p.serverSettle);console.log(JSON.stringify(p.signerBalance));})"

READY + signerBalance.ok: true → 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.

Own-pool troubleshooting

What you seeWhat 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 enterOften RoundNotOpen (window closed / series not created), not an empty wallet. Check created=true and the current window.
Deploy / create fails from Base App or MetaMaskSmart wallet / EIP-7702 cannot send to: null. Use a normal EOA (Phantom or a generated key).
POST /v1/epochs/:id/settle returns 504Sign settleEpoch yourself from the operator EOA. Do not wait on the Playmos relay.

Unity — zero to a paid entry

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.

PackageRoleHow
com.playmos.sdkUnity WebGL bridgeNot public — partner access required. Use host page + NuGet server package.
Playmos.Sdk 0.1.1-preview.NET game serverdotnet add package Playmos.Sdk --version 0.1.1-preview
@playmos/sdk@0.3.20Host page pay UXLoads next to your WebGL / HTML5 / system-browser pay page
Editor Play Mode cannot pay. There is no public 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.

1. Install

terminal
$ mkdir playmos-unity-host && cd playmos-unity-host && npm init -y && npm i @playmos/sdk@0.3.20

2. Save host.html

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>

3. Open in a browser

terminal
$ npx --yes serve .

Open host.html. Click Buy gems $0.99. Typical confirm is 14–17 s.

4. Grant on your server

server grant (any engine)
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);

Appendix — later, not the first hour

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.

Surfaces — honest install matrix (Base Sepolia)

Every surface is testnet-only. Public for third parties today: npm @playmos/sdk, NuGet Playmos.Sdk, PyPI playmos.

SurfaceVersionInstall statusPay pattern
JS/TS · Node0.3.20Live on npmnpm i @playmos/sdk@0.3.20Browser pay() · Node verify
C# / .NET0.1.1-previewLive on NuGetL2 server REST
Unity WebGL0.1.0-previewFirst-hour pathhost page + server grant (no public UPM)L3 host bridge
Godot HTML50.1.0-previewLater — not this versionL3 host bridge
Unreal0.1.0-previewLater — not this versionSystem browser
Python0.1.0a1Live on PyPIpip install playmos==0.1.0a1L2 server REST
Go0.1.0-alpha.1Not public — use JS npmL2 server REST
C# / Python verify
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)

x402-shaped HTTP payments advanced · not onboarding

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.

buyer.ts
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.ts
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
Honesty footer. Testnet only. No mainnet. No dashboard yet. In scope: IAP (1%) · skill contests (flat 1% to Playmos; you set take & split; not live until you have your own contest pool) · in-game economies. Unity first hour: host page (no public UPM). Later, not this version: Godot / Unreal / Three.js. Not public: Godot/Unreal/Go source. Not available: native mobile SDKs, subscriptions, Bridge virtual accounts.