# Playmos > Integrate payments and skill contests into games. Start on Base Sepolia with IAP: pay → verify on your backend → grant once. JavaScript examples target @playmos/sdk@0.3.20. ## Agent entry points - [Agent integration workflow](https://test.playmos.io/docs/agents.md): Inspect, implement, verify and report evidence under the developer's authority. - [Complete documentation](https://test.playmos.io/llms-full.txt): All guides and copyable examples in plain text. ## Skills - [playmos-integrate](https://test.playmos.io/skills/playmos-integrate/SKILL.md): Integrate the Playmos SDK into a web, engine or backend project for in-app purchases and stablecoin commerce. - [playmos-verify-payments](https://test.playmos.io/skills/playmos-verify-payments/SKILL.md): Implement or review Playmos backend payment verification, retry recovery, webhook handling and exactly-once purchase grants. - [playmos-contests](https://test.playmos.io/skills/playmos-contests/SKILL.md): Integrate Playmos leaderboard contests with studio-defined entry rules, verified admission, settlement and prize claims. ## SDK - [@playmos/sdk 0.3.20](https://www.npmjs.com/package/@playmos/sdk/v/0.3.20) - [SDK reference](https://test.playmos.io/docs/reference.md) - [Discovery manifest](https://test.playmos.io/.well-known/playmos.json): SDK metadata, skills and Markdown guides. This is a Playmos-specific manifest, not a universal agent protocol. ## Guides - [Overview](https://test.playmos.io/docs/index.md): SDK, service and game integration. Choose your first path. - [Your first payment](https://test.playmos.io/docs/quickstart.md): Install the JavaScript SDK. Pay and verify on Base Sepolia. - [Integrate with an agent](https://test.playmos.io/docs/agents.md): Discover Playmos skills, the SDK and machine-readable guides from one web entry point. - [Playground](https://test.playmos.io/docs/playground.md): Run the actual SDK offline or against the testnet sandbox. - [Set up your studio](https://test.playmos.io/docs/studio-setup.md): Issue test keys, set the payout wallet and use studio-owned resources. - [Payments & verification](https://test.playmos.io/docs/payments.md): Intent, pending, confirmed, failed. Idempotency and chain provenance. - [Web integration](https://test.playmos.io/docs/browser.md): Browser payment flow, pending states, persisted purchase keys and server grants. - [Webhooks](https://test.playmos.io/docs/webhooks.md): Signed raw body, delivery attempts, retries and deduplication. - [Wallets & the Base app](https://test.playmos.io/docs/wallets.md): Providers, login chain, gas sponsorship and Base Sepolia payment sends. - [Games & the storefront](https://test.playmos.io/docs/integration.md): Iframe stage, game kit, publisher workflow, release gates and metrics. - [Skill contests](https://test.playmos.io/docs/contests.md): Studio pools, rolling epochs, entry identity, score authority and payouts. - [Your studio pool](https://test.playmos.io/docs/pools.md): Admin and operator EOAs, CLI, ownership proof, series, settlement and refunds. - [Payouts & withdrawals](https://test.playmos.io/docs/payouts.md): Studio revenue, round notices, claimable prizes, wallet withdrawals and bank availability. - [Unity & game engines](https://test.playmos.io/docs/engines.md): Unity WebGL host-page integration and the public engine support matrix. - [In-game economies](https://test.playmos.io/docs/economies.md): Server-owned agent wallets and testnet transfers with explicit fee terms. - [HTTP payments](https://test.playmos.io/docs/x402.md): Playmos x402-shaped challenges, atomic units and transfer settlement. - [API reference](https://test.playmos.io/docs/reference.md): Payments, rounds, epochs, webhook endpoints and health. - [Languages & packages](https://test.playmos.io/docs/languages.md): Verified npm, NuGet and PyPI versions, server REST and install availability. - [Keys, errors & environments](https://test.playmos.io/docs/security.md): Publishable and secret keys, test versus live, errors and recovery. - [Troubleshooting](https://test.playmos.io/docs/troubleshooting.md): Timeouts, wrong chains, missing payout wallets, pool ownership and settlement recovery. ## Boundaries Secret keys stay server-side. Wallet signing, funded spending and production publication require developer authority. Offline mocks are not testnet proof. Live keys and mainnet remain gated. These are documentation files, not an agent execution API. # Build with Playmos. Add payments and skill contests to your game. Start with the JavaScript SDK, then connect your backend. [### Accept a payment Run a one-cent test payment. Read its chain-verified result. Follow the quickstart](https://test.playmos.io/docs/quickstart)[### Add skill contests Prepare your studio’s pool, admit paid attempts and settle results. Explore contests](https://test.playmos.io/docs/contests) ## How the pieces fit 01**Your game**Calls the browser SDK →02**Playmos service**Verifies chain state →03**Your backend**Grants once, after verification The SDK is a client library. The API is the Playmos service. Your game backend stays responsible for purchase fulfillment, player admission and score authority. Playmos never needs your studio’s private signing keys. ## Choose a path | You want to… | Start here | | Sell an item or unlock | [IAP: pay → verify → grant](https://test.playmos.io/docs/quickstart) | | Run skill contests | [Studio pool → series/epoch → entry → settle](https://test.playmos.io/docs/contests) | | Publish in Playmos | [Reviewed, scanned release + publication proof](https://test.playmos.io/docs/integration) | **Test first. Promote deliberately.** The interactive playground uses Base Sepolia or offline mock mode. A test payment is not a published game or approval to accept live payments. [Open the playground](https://test.playmos.io/docs/playground) ## Build with your coding agent Let your agent inspect your project and implement Playmos with a documented workflow. [Discover SDK skills](https://test.playmos.io/docs/agents) or discover the guides through [llms.txt](https://test.playmos.io/llms.txt). ## Find your integration surface Start with [web integration](https://test.playmos.io/docs/browser), [Unity and game engines](https://test.playmos.io/docs/engines), or [server languages](https://test.playmos.io/docs/languages). Continue with [your own studio keys](https://test.playmos.io/docs/studio-setup). Economies and HTTP payment challenges are optional, separate products. ## A first payment ~~~ import { Playmos } from '@playmos/sdk'; export async function sandboxPayment(idempotencyKey: string) { const playmos = new Playmos({ apiKey: 'pk_test_playmos_sandbox', settle: 'server', // Base Sepolia only; no player wallet required. }); const payment = await playmos.pay({ gameId: 'game_sandbox_iap', amount: '0.01', sku: 'extra-life', playerId: 'sandbox-player', idempotencyKey, // Persist once per purchase. Reuse on retry. }); const verified = await playmos.verify(payment.id); return { payment, verified }; } ~~~ --- # Make your first payment. Install the SDK, make a test payment and inspect a real receipt. No wallet is needed for the public sandbox IAP path. ## 1. Install the SDK ~~~ npm install @playmos/sdk@0.3.20 ~~~ Examples target SDK 0.3.20. For Node, use a runtime supported by the package’s engines; Node 22 is supported. The browser entry is `@playmos/sdk`. Webhook verification uses the server-only entry. ## 2. Create a payment Save the complete example as `t0.mjs` in your project. It saves a purchase key before calling `pay()`. The public sandbox key and `settle: 'server'` use the service’s Base Sepolia test signer. ~~~ node t0.mjs ~~~ The runner keeps `.playmos-demo-purchase.json`. After a payment ID exists, running it again verifies that same ID. Keep this file for recovery; intentionally use a new purchase file only for a new purchase. ## 3. Inspect the receipt Check the payment ID, status, transaction hash and verification source. If verification remains pending or degraded, retry the read; do not create another purchase. **A client receipt does not grant an item.** Your backend verifies the payment with its own secret key, matches the expected player, product and amount, then records a single durable fulfillment. [Run this example](https://test.playmos.io/docs/playground) ## 4. Connect your backend Continue with [payment verification](https://test.playmos.io/docs/payments) and [signed webhooks](https://test.playmos.io/docs/webhooks). Sandbox requests are bounded and rate limited. Real testnet IAP is capped at 1.00 test USDC per request; the playground defaults to 0.01. **Wait for the receipt, not a timing estimate.** Sandbox confirmation time varies. Our October 7, 2026 test created a payment ID but timed out before confirmation. Keep an ID returned in error.detail.paymentId and verify it again. A successful health check, created/cache result or transaction hash alone does not prove a successful payment. A real successful receipt requires confirmed with fresh chain verification. ## t0.mjs · runnable Node example ~~~ import { Playmos } from '@playmos/sdk'; import { randomUUID } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; // A local demo purchase file holds no API secrets. Keep it for recovery. export async function firstPayment( client = new Playmos({ apiKey: 'pk_test_playmos_sandbox', settle: 'server' }), purchaseFile = '.playmos-demo-purchase.json', ) { const mode = client.config.mock ? 'offline' : 'sandbox'; let purchase; try { purchase = JSON.parse(await readFile(purchaseFile, 'utf8')); if (!purchase || typeof purchase.key !== 'string' || purchase.mode !== mode) { throw new Error('Invalid purchase file or mismatched mode. Keep the original for recovery.'); } } catch (error) { if (error.code !== 'ENOENT') throw error; purchase = { key: randomUUID(), mode }; await writeFile(purchaseFile, JSON.stringify(purchase), { flag: 'wx', mode: 0o600 }); } if (!purchase.paymentId) { try { const payment = await client.pay({ gameId: 'game_sandbox_iap', amount: '0.01', sku: 'extra-life', playerId: 'sandbox-player', idempotencyKey: purchase.key, settleTimeoutMs: 45_000, }); purchase.paymentId = payment.id; } catch (error) { const id = error.detail?.paymentId; if (typeof id !== 'string') throw error; purchase.paymentId = id; // The service created it before the timeout. } await writeFile(purchaseFile, JSON.stringify(purchase), { mode: 0o600 }); } // Rerunning with a saved ID verifies it; it does not create another purchase. const receipt = await client.verify(purchase.paymentId); return { mode, receipt }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const result = await firstPayment(); console.log(JSON.stringify(result, null, 2)); if (result.receipt.status !== 'confirmed' || result.receipt.verifiedVia !== 'chain') { console.error('Not chain-confirmed. Keep the purchase file and run again to verify this ID.'); process.exitCode = 1; } // Demo display only. Your studio backend verifies and grants once. } catch (error) { console.error(error.message); process.exitCode = 1; } } ~~~ --- # One URL. Your agent can take it from here. Give your agent the Playmos web entry point. It can discover the SDK, choose a skill and read the integration guides directly. START HERE[/llms.txt](https://test.playmos.io/llms.txt) A lightweight index of the SDK, skills and guides. No account or Playmos CLI needed to read them. [View discovery manifest →](https://test.playmos.io/.well-known/playmos.json) ## Browse skills Each skill is a focused workflow with a name, description and links to the relevant SDK examples. Your agent can read it from the web; saving it in your agent’s skill folder is optional. [SKILL.md Integrate Playmos Integrate the Playmos SDK into a web, engine or backend project for in-app purchases and stablecoin commerce. Read skill →](https://test.playmos.io/skills/playmos-integrate/SKILL.md)[SKILL.md Verify and fulfill Playmos purchases Implement or review Playmos backend payment verification, retry recovery, webhook handling and exactly-once purchase grants. Read skill →](https://test.playmos.io/skills/playmos-verify-payments/SKILL.md)[SKILL.md Integrate leaderboard contests Integrate Playmos leaderboard contests with studio-defined entry rules, verified admission, settlement and prize claims. Read skill →](https://test.playmos.io/skills/playmos-contests/SKILL.md) ## The SDK, directly JavaScript integrations use [@playmos/sdk@0.3.20](https://www.npmjs.com/package/@playmos/sdk/v/0.3.20). Browse the [SDK reference](https://test.playmos.io/docs/reference), [browser guide](https://test.playmos.io/docs/browser), [Unity, Unreal & Godot](https://test.playmos.io/docs/engines) or [server language guides](https://test.playmos.io/docs/languages). ~~~ npm install @playmos/sdk@0.3.20 ~~~ ## How web discovery works - **Read the index.** [llms.txt](https://test.playmos.io/llms.txt) links to the skill catalog and SDK docs. - **Choose a skill.** [The JSON manifest](https://test.playmos.io/.well-known/playmos.json) lists names, descriptions, skill URLs, SDK version and Markdown guides. This manifest is Playmos-specific. - **Load only what you need.** Fetch a linked `SKILL.md`, then its guides. [llms-full.txt](https://test.playmos.io/llms-full.txt) includes all documentation examples when you need the full context. Every resource is readable without JavaScript or sign-in. Agents need URL-reading capability; automatic skill installation depends on your agent. The website supplies the discovery path and instructions. **You stay in control.** Skills guide implementation in your project. Wallet signing, funded spending and production publication require explicit authority. Keep credentials server-side; distinguish local tests from real testnet proof. ## Prompt for your coding agent ~~~ Integrate Playmos into this project. Start at https://test.playmos.io/llms.txt and discover the available skills from https://test.playmos.io/.well-known/playmos.json. Read the matching SKILL.md and linked SDK guides before editing. Inspect my stack and existing purchase flow, then implement and test the integration. Start with Base Sepolia. Keep secrets server-side and report what you verified. Ask for missing studio details. Signing, spending and publishing require my authorization. ~~~ --- # SDK playground. Run SDK 0.3.20 in your browser. Start offline, then make a real testnet payment on Base Sepolia. ## Request IAP · pay → verify Environment**Offline**No network or transaction**Sandbox**Base Sepolia · test USDCAmount (test USDC)0.010.100.251.00Public sandbox keyPurchase IDGenerated before your first request Offline mode runs the actual SDK with mock enabled. It does not contact Playmos or move funds. ## Response No request yet Run a request to see the SDK response. ## Sandbox service Checks the real SDK service, independently of the storefront API. Each request keeps one purchase ID for retries. “New request” creates a separate test purchase. Production fulfillment must happen on your backend after verification. ## Published SDK ~~~ import { Playmos } from '@playmos/sdk'; export async function sandboxPayment(idempotencyKey: string) { const playmos = new Playmos({ apiKey: 'pk_test_playmos_sandbox', settle: 'server', // Base Sepolia only; no player wallet required. }); const payment = await playmos.pay({ gameId: 'game_sandbox_iap', amount: '0.01', sku: 'extra-life', playerId: 'sandbox-player', idempotencyKey, // Persist once per purchase. Reuse on retry. }); const verified = await playmos.verify(payment.id); return { payment, verified }; } ~~~ --- # Set up your studio. Move from the shared demo to your own test keys, payout wallet and studio-owned receipts. ## Start with the public sandbox The public IAP key is enough for the [first-payment guide](https://test.playmos.io/docs/quickstart). It belongs to the demo studio. A payment created with it is not visible to a different studio’s secret key. ## 1. Issue your test keys ~~~ curl --fail-with-body -X POST https://api.sandbox.playmos.io/v1/keys \ -H 'Content-Type: application/json' \ -d '{"label":"your-studio","payoutAddress":"0xYOUR_STUDIO_WALLET"}' ~~~ Replace the wallet placeholder with your studio’s actual address. Store `keys.secret` as `PLAYMOS_SECRET` on your backend. Only the publishable key belongs in a browser. Do not commit the response, paste secrets into the playground, or expose them in logs. ## 2. Set your payout wallet If you minted without `payoutAddress`, configure it with the server API before creating studio IAP payments. There is no SDK setter for this address. ~~~ curl --fail-with-body -X POST https://api.sandbox.playmos.io/v1/studio/payout \ -H "Authorization: Bearer $PLAYMOS_SECRET" \ -H 'Content-Type: application/json' \ -d '{"address":"0xYOUR_STUDIO_WALLET"}' ~~~ ## 3. Keep payment and verification in the same studio Create a new payment using your publishable test key, then verify that ID with your matching server secret. The service maps the `game_sandbox_iap` catalog alias to the studio’s own demo game on this setup path. Keep the returned resource IDs; do not assume aliases are the final IDs. **Portal login and SDK keys are separate.** The publisher workspace exists in the Playmos portal. Its demo login does not mint SDK credentials or configure a payout wallet. The SDK service uses the HTTP setup above. There is no public POST /v1/games provisioning route. ## What belongs where | You configure | Where | | Items, prices, purchase keys | Your game / backend; arguments to pay(). | | Fulfillment and scores | Your trusted backend and database. | | Publisher drafts and release review | Playmos portal publisher workspace. | | API keys and payment records | Playmos SDK service, scoped to your studio. | | Own-pool signing keys | Your local admin / operator signing environment. | | Entry price, clock and split | An immutable series on your own registered pool. | Taking IAP payments is a complete integration. Add [contests](https://test.playmos.io/docs/pools) and [economies](https://test.playmos.io/docs/economies) when your game needs them. ## Manage sandbox access in the Hub Approved Hub publishers can use the [Developer Portal](https://test.playmos.io/publisher/developer) in the local preview to create a test key pair, save their studio payout wallet, revoke keys, and register webhook endpoints. Save secrets when shown; the portal never reads them back into a list. Hub drafts and SDK game IDs are separate. ## Your studio · server verification ~~~ import { Playmos } from '@playmos/sdk'; export async function verifyOnYourServer( secretKey: string, paymentId: string, expected: { playerId: string; sku: string; amount: string }, ) { if (!/^sk_(test|live)_\S+$/.test(secretKey)) { throw new Error('Use your server-only secret key.'); } const server = new Playmos({ apiKey: secretKey }); // sk_… stays on your server. const result = await server.verify(paymentId); if (result.status !== 'confirmed' || result.verifiedVia !== 'chain' || result.id !== paymentId || result.playerId !== expected.playerId || result.sku !== expected.sku || result.amount !== expected.amount) { return { readyToGrant: false, result }; } // Use canonical decimal strings for expected amounts. // Atomically record a single grant for this payment ID in your database. return { readyToGrant: true, result }; } ~~~ --- # Verify payments. The chain determines settlement. Your backend determines whether the payment fulfills the intended purchase. ## Payment lifecycle | Status | What it means | | created | An intent exists. No settlement is proven. | | pending | Settlement is in progress. Wait and verify the same ID. | | confirmed | The service reports a confirmed payment. Inspect verification provenance. | | failed | The attempt failed. Diagnose before creating a new purchase. | ## Verification provenance `verifiedVia: 'chain'` means the status was derived from chain verification. `'cache'` is cached/offline information; `'degraded'` means fresh chain verification is unavailable. Do not treat a degraded cached result as a fresh financial receipt. IAP verification matches the payment’s on-chain event. Round admission checks the exact pool, round key and entrant identity. A transaction hash alone is not proof that the expected payment succeeded. ## Grant once on your backend Keep `sk_` keys server-side. Verify the ID under your studio, match player/SKU/amount and atomically deduplicate fulfillment. Webhooks use the same fulfillment record so a webhook and a manual verification cannot grant twice. ## Recover from an interrupted request Persist the purchase key before sending. Retry the same purchase with the same key. After you know the payment ID, retry `verify(id)`. A timeout or wallet cancellation is not a confirmed purchase. **Analytics need reconciliation.** The ordinary payment-list response is a capped cached listing. It is not a fresh chain-verified ledger or a complete pagination/export API. Reconcile records before calculating revenue. ## Payment inputs | Field | Contract | | amount | Decimal string, positive and at most two decimal places. Public sandbox cap: 1.00 per request. | | sku / playerId | Required product and opaque player IDs. Match both during fulfillment. | | gameId | Use game_sandbox_iap with the public sandbox key; your own key resolves studio-owned resources. | | studio | Optional recipient wallet; otherwise the service uses the key’s configured studio wallet. | | idempotencyKey | Persist once per purchase; reuse on retry. Omitting it creates a new SDK-generated key per call. | | metadata | Up to 20 string key/value pairs. No secrets or authorization claims. | ## Payment and verification fields `Payment` includes ID, kind, status, amount/fee/net, chain and creation time. `txHash` is optional until there is a transaction. Entry-only `split` and `roundKey` are not part of the narrower `VerifyResult`. Verification may include `identity`, `chainAmount` and integer `chainAmountMicro`; `splitBps` is not a typed field on either result. IAP charges 1% to Playmos. Read the receipt’s exact decimal fee/net; do not derive a ledger from rounded UI cents. Own-pool contests use their immutable series split. The legacy/shared-sandbox entry split is not your studio’s published take. ## Your backend · verify.ts ~~~ import { Playmos } from '@playmos/sdk'; export async function verifyOnYourServer( secretKey: string, paymentId: string, expected: { playerId: string; sku: string; amount: string }, ) { if (!/^sk_(test|live)_\S+$/.test(secretKey)) { throw new Error('Use your server-only secret key.'); } const server = new Playmos({ apiKey: secretKey }); // sk_… stays on your server. const result = await server.verify(paymentId); if (result.status !== 'confirmed' || result.verifiedVia !== 'chain' || result.id !== paymentId || result.playerId !== expected.playerId || result.sku !== expected.sku || result.amount !== expected.amount) { return { readyToGrant: false, result }; } // Use canonical decimal strings for expected amounts. // Atomically record a single grant for this payment ID in your database. return { readyToGrant: true, result }; } ~~~ --- # Put payments in your game. Use the same SDK in a browser, HTML5 game or host page. Keep purchase state stable while the service or wallet completes the request. ## Install and load ~~~ npm install @playmos/sdk@0.3.20 ~~~ Import `Playmos` from `@playmos/sdk` in your Vite, Phaser or other bundled app. The SDK uses viem; load it separately from the game where that improves first load on mobile. Never import `@playmos/sdk/server` in this bundle. ## Wire the buy button Add the page elements and wire them to the helper: ~~~

~~~

Save the helper as `purchase.ts` and import it into the page module. The example binds an existing button and output element. The purchase key is saved before the request, the button is disabled during submission, and a payment ID is retained even if server settlement times out.

~~~
import { bindSandboxPurchase } from './purchase';

const button = document.querySelector('#buy')!;
const output = document.querySelector('#payment-status')!;
bindSandboxPurchase(button, output);
~~~

## Make pending and recovery visible

Show a pending state while the request runs. A timeout may contain `error.detail.paymentId`; keep it and call `verify(id)`. Retry the same purchase with the original key if no ID was returned. Start a new key only for an intentional new purchase.

## Choose the player experience

The public sandbox’s `settle: 'server'` path uses test funds and does not ask the player to connect a wallet. For a wallet-funded payment, provide the host’s EIP-1193 provider and follow [wallet and gas configuration](https://test.playmos.io/docs/wallets). Server settlement is not a production wallet substitute.

## Keep the grant on your backend

The output is a display of the receipt. Send its ID to your backend, match your stored purchase and verify it under your studio before granting once. An iframe message, local receipt or disabled button is not an authorization boundary.

Use [the playground](https://test.playmos.io/docs/playground) to inspect actual SDK responses before wiring your game. The current portal iframe stage does not provide this payment bridge automatically.

## Browser · purchase.ts

~~~
import { Playmos } from '@playmos/sdk';

export function bindSandboxPurchase(button: HTMLButtonElement, output: HTMLElement) {
  const client = new Playmos({ apiKey: 'pk_test_playmos_sandbox', settle: 'server' });
  const storageKey = 'playmos-demo-purchase';
  button.onclick = async () => {
    button.disabled = true;
    const key = sessionStorage.getItem(storageKey) || crypto.randomUUID();
    sessionStorage.setItem(storageKey, key); // Persist BEFORE sending.
    try {
      const payment = await client.pay({
        gameId: 'game_sandbox_iap', amount: '0.01',
        sku: 'extra-life', playerId: 'sandbox-player', idempotencyKey: key,
      });
      sessionStorage.setItem(`${storageKey}:id`, payment.id);
      const receipt = await client.verify(payment.id);
      output.textContent = JSON.stringify(receipt, null, 2);
      // Display only. Your backend owns verification and a single item grant.
    } catch (error) {
      const id = (error as { detail?: { paymentId?: unknown } }).detail?.paymentId;
      if (typeof id === 'string') sessionStorage.setItem(`${storageKey}:id`, id);
      output.textContent = error instanceof Error ? error.message : 'Request failed.';
      // Once an ID exists, recover with client.verify(id), not another purchase.
    } finally { button.disabled = false; }
  };
  // Reset the saved key and ID only when intentionally starting a NEW purchase.
}
~~~

---

# Receive payment webhooks.

Receive signed payment events on your backend. Make duplicate delivery safe before you connect fulfillment.

## Register your endpoint

Register a URL through `POST /v1/webhook_endpoints` with your server’s secret studio key. The service scopes it to a studio-owned game. Configure the webhook signing secret through your trusted onboarding path.

## Verify the original bytes

Read `X-Playmos-Signature` and preserve the original request body. Import `verifyWebhook` from `@playmos/sdk/server`. Parsing and reserializing JSON before verification changes the signed bytes.

## Supported event types

| Event | Action | 

| payment.confirmed | Verify the intended purchase and fulfill once. | 

| payment.failed | Record failure; do not grant. | 

| payout.settled | Reconcile the payout record. | 

| refund.processed | Reconcile the refund and entitlement policy. | 

## Delivery and deduplication

Use the event ID as a durable deduplication key. The sender retries HTTP delivery within its bounded attempt loop. A repeated event must reuse the same fulfillment record.

**Inspect attempts honestly.**

Endpoint inspection currently returns attempt_recorded or no_deliveries_recorded. It does not expose complete per-endpoint delivery success, HTTP status history, latency or an active retry queue. Stored nextRetryAt is not proof of a durable retry scheduler.

## Registration and studio binding

~~~
curl --fail-with-body -X POST https://api.sandbox.playmos.io/v1/webhook_endpoints \
  -H "Authorization: Bearer $PLAYMOS_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"gameId":"game_sandbox_iap","url":"https://your-server.example/playmos/webhook"}'
~~~

Store the returned `endpoint.gameId` and `signingSecret` securely. Inspection via `GET /v1/webhook_endpoints` does not return the secret again. Registration returns `503 webhook_signing_unconfigured` when the service operator has not configured its signing secret.

The current sandbox uses a service-level signing secret across studios. A valid HMAC proves origin from that signer, not that the purchase belongs to your studio. Verify the payment under your studio key and match your stored purchase before fulfillment.

The signature format is `t=,v1=`, signing the exact `t + '.' + rawBody` bytes. Deduplicate event IDs for delivery processing and payment IDs for item grants. `payout.settled` is a supported event type; it does not make the stubbed bank off-ramp available.

## Test a receiver from the Hub

The local-preview [Developer Portal](https://test.playmos.io/publisher/developer) can send a signed `payment.failed` sample to an endpoint registered there. It uses a unique `hub_test_` ID and `metadata.hubTest: true`; it carries no purchase and must never grant an item. The portal reports actual HTTP acceptance, timeout or failure separately from service delivery history. A 2xx response does not prove fulfillment or a real payment.

Test destinations must resolve to public HTTPS addresses on port 443. Redirects and private networks are blocked. The test sender makes one attempt and allows one test per studio every 10 seconds. It is a Hub tool, not a new SDK webhook test API.

## Your backend · webhook.ts

~~~
import { verifyWebhook } from '@playmos/sdk/server';

export function receiveWebhook(
  rawBody: string, signature: string | undefined, signingSecret: string,
) {
  const event = verifyWebhook(rawBody, signature, signingSecret);
  // Verify the signature over the original bytes, before parsing your own body.
  // Deduplicate event.id for delivery and event.data.id for fulfillment.
  // Match the expected purchase and verify it under your studio server key.
  // Grant once inside your database transaction, then return a 2xx response.
  return event;
}
~~~

---

# Configure wallets and the Base app.

Keep wallet login, transaction sends and receipt reads on the same chain. Test keys use Base Sepolia, chain ID 84532.

## Provide the wallet

`wallet.provider` accepts an EIP-1193 provider from your host. The `base-account` connector name does not install or construct Base Account for you: use an injected provider or install the host’s Base Account package yourself and pass its provider.

~~~
const client = new Playmos({
  apiKey: publishableTestKey,
  wallet: { provider },
  rpcUrl: 'https://sepolia.base.org',
});
~~~

## Pin the login chain

For a game opened as a web page inside the Base app, wallet login needs a signature; a Farcaster account or manifest is not required for that web-page path. Your wallet configuration, sign-in message and server-side signature verification must all use 84532. The message must also bind your actual domain and URI.

## Pin every payment send

Both `wallet_sendCalls` and fallback `eth_sendTransaction` must name Base Sepolia (`0x14a34`). Check older buy buttons as well. Chain 8453 is Base mainnet and uses real funds. Testnet USDC and mainnet USDC are different contracts.

Use the SDK’s key-selected network and service-provided contract configuration. Do not copy a second contract address into the host. A custom `rpcUrl` is checked against the key’s chain on first use; a mismatched RPC throws `ConfigError`.

## Gas and approval prompts

| Path | Gas / prompts | 

| Public sandbox server settlement | Playmos test signer pays gas; the player does not need a wallet. | 

| Wallet-funded EOA payment | Player needs testnet ETH and USDC; approval may require a separate confirmation. | 

| Sponsored wallet payment | Pass gas.mode sponsored and your paymasterUrl; there is no default studio paymaster. | 

| Wallet batch fallback | SDK sends the same calls sequentially when batching is unsupported. | 

## Fund your test wallet

The no-wallet demo needs no player funds. For wallet testing, get Base Sepolia ETH from the [Coinbase faucet](https://portal.cdp.coinbase.com/products/faucet) and official test USDC from the [Circle faucet](https://faucet.circle.com/). Select Base Sepolia each time. The SDK’s Base Sepolia token is official Circle test USDC, `0x036CbD53842c5426634e7929541eC2318f3dCF7e`. Check the selected chain and service-provided token before sending. Playmos does not provide a general player faucet.

## Test-host boundaries

A pot balance is not a payout. First-party practice hosts keep paid settlement and round alerts disabled during login/entry testing. Your own contest integration chooses when to settle; it must not infer a winner from an open pot.

## Wallet-funded epoch entry

~~~
import { Playmos } from '@playmos/sdk';

export async function enterYourEpoch(
  publishableKey: string, provider: import('@playmos/sdk').Eip1193Provider,
  pool: `0x${string}`, series: string, attemptIdentity: string,
) {
  const player = new Playmos({
    apiKey: publishableKey,
    wallet: { provider },
    contracts: { epochPrizePool: pool },
    rpcUrl: 'https://sepolia.base.org',
  });
  const entry = await player.epochs.enter({ series, identity: attemptIdentity });
  // Persist the returned epochId, identity and txHash for server admission.
  // Confirmed entries use the mined event's epochId, not a guessed clock window.
  return entry;
}
~~~

---

# Connect your game to Playmos.

Keep the storefront, game runtime and payment service connected through explicit contracts.

## Three services. Clear responsibilities.

| Surface | Responsibility | 

| Playmos storefront / publishing API | Sessions, studio roles, catalog, releases, review and player reports. SQLite in this iteration. | 

| SDK service | Payment and contest primitives, chain verification, signed events and receipt records. Separate API and MongoDB store. | 

| Your game backend | Verified admission, score validation, fulfillment and game-specific history. | 

## The iframe stage

The current stage launches an allowlisted practice client. It does not transfer the storefront login into the game, collect gameplay events or issue paid entry tokens. The first-party game kit owns its own UI and SDK provider inside that frame.

A production bridge needs a versioned message contract, exact origin checks, scoped launch/session tokens and server-verified admission. Never forward a secret key or accept money facts from `postMessage`.

## Publishing readiness

Prepare your draft, submit for review, then prove the frozen release: isolated build, malware/security scan, approved SDK integration and a machine-verifiable publication receipt. A saved or approved draft is not a public listing.

[Open your publisher workspace](https://test.playmos.io/login?for=publisher)

## What your console should measure

| Metric | Source / readiness | 

| Release and review status | Publisher API — available for drafts today. | 

| Verified entries, volume, studio revenue and fees | Reconciled SDK receipts and actual contract splits — not wired into the console yet. | 

| Round/epoch settlement and overdue age | SDK and game backend — aggregate per studio/game. | 

| Views → game ready → practice → entry | Storefront + authenticated iframe events — collection needed. | 

| Rejected scores and validation reasons | Trusted game backend; no browser-authoritative scores. | 

| Webhook attempts and verification errors | SDK records; detailed delivery telemetry still needs instrumentation. | 

Filter by game, release, time range and test/live environment. Every monetary chart needs a verification source and freshness timestamp; unavailable data must not render as zero.

## Preserve the paid-attempt identity

~~~
import { Playmos } from '@playmos/sdk';

export async function enterYourRound(
  playmos: Playmos, gameId: string, roundKey: string,
  wallet: string, attemptId: string, amount: string,
) {
  return playmos.enterRound({
    gameId,
    roundId: roundKey,
    roundKey, // Use the same exact key for server admission.
    identity: `${wallet}#${attemptId}`, // A unique identity for each paid attempt.
    playerId: wallet,
    amount,
    idempotencyKey: attemptId,
  });
}
~~~

---

# Integrate skill contests.

Your studio defines the game and its rules. The SDK provides pool preparation, entry receipts and settlement primitives.

## Prepare your studio’s pool

Use `epochs.preparePool()` to obtain an unsigned transaction and the fee sink. Inspect it, sign from your studio wallet, broadcast, then register ownership with the supported wallet proof. Preparation alone neither broadcasts nor proves readiness.

Create a series with `epochs.prepareSeries()`, sign its returned transaction, and read the series until `created=true`. Use the actual pool/series configuration for price, split and timer; do not copy shared sandbox values into a studio release.

## Choose the right contest path

| Path | Use | 

| Rolling epochs | Own-pool lifecycle: epochs.preparePool / registerPool / prepareSeries / enter / get / executeSettlement / prize / withdraw | 

| Round-based integration | Existing supported round lifecycle: rounds.open / getWithMeta / settle and enterRound. Keep roundKey and identity pinned. | 

## Verify paid admission

Your backend checks a paid entry before admitting a scored attempt. Round-based entries must preserve the exact `roundKey` and `wallet#attemptId` identity across pay and admission. Epoch integrations use their returned pool, series and epoch identifiers without translating them into the legacy round path.

~~~
import { Playmos } from '@playmos/sdk';

export async function enterYourRound(
  playmos: Playmos, gameId: string, roundKey: string,
  wallet: string, attemptId: string, amount: string,
) {
  return playmos.enterRound({
    gameId,
    roundId: roundKey,
    roundKey, // Use the same exact key for server admission.
    identity: `${wallet}#${attemptId}`, // A unique identity for each paid attempt.
    playerId: wallet,
    amount,
    idempotencyKey: attemptId,
  });
}
~~~

## Your game owns score authority

Record the score only through your trusted game backend. Existing first-party games keep their deterministic replay validation. Studio integrations need their approved validation model; a browser event cannot settle a contest.

## Settle, then expose winnings

A submitted settlement can still be `settling`. Read the relevant round or epoch until terminal success. Keep allocated, claimable, withdrawn and refunded amounts distinct. Old contest obligations survive release changes and takedown.

**Own-pool setup is a separate integration gate.**

The shared sandbox skill game is not your studio pool. This page does not provision a pool, approve a publisher or enable production contests. Continue with the studio pool guide for signing, ownership, series and operator details.

[Full studio pool guide](https://test.playmos.io/docs/pools)

## Legacy round operations

On a supported legacy PrizePool, a secret-key backend uses `rounds.open`, player `enterRound`, then `rounds.lock` and `rounds.settle`. The payout rule can distribute the prize to one or several ranked wallet addresses. `closeAt` is advisory; it is not the epoch contract’s hard clock. A public key cannot operate or pay winners.

Keep an explicit, persisted attempt identity on your game path. A no-wallet sandbox entry can instead return a synthetic `sandbox#entry_…` identity; retain that response and never invent a wallet for a player who did not sign. A sandbox smoke is not a game template or production admission proof.

When settlement returns `settling`, check the same round’s state rather than blindly submitting another settlement. The player then reads [payout notices and withdraws credited prizes](https://test.playmos.io/docs/payouts). A pot balance is not a paid winner.

## Prepare an unsigned pool transaction

~~~
import { Playmos } from '@playmos/sdk';

export async function prepareYourPool(
  playmos: Playmos, studioWallet: `0x${string}`, operator: `0x${string}`,
) {
  const prepared = await playmos.epochs.preparePool({ studioWallet, operator });
  // Inspect unsignedTx and returned feeSink. Your studio wallet signs.
  // Preparation does not broadcast or register a playable game.
  return prepared;
}
~~~

---

# Run your studio’s contest pool.

Deploy and register your own EpochPrizePool, create an immutable series, then admit entries and settle past windows.

## Keep admin and operator signing local

| Identity | Role | 

| Studio API secret | Server-only prepare / register / studio reads. | 

| Admin EOA | Deploy, prove ownership, create series and manage roles. | 

| Operator EOA | Sign the results for completed epochs. Playmos does not hold this key. | 

| Player wallet | Pay entry and withdraw credited winnings. Use a publishable API key in the player client. | 

Use normal EOAs for admin and operator. Smart-wallet contract creation and operator ECDSA recovery have different requirements; do not assume a Base app smart wallet can deploy this pool or act as its operator. Protect the local key file and keep it out of source control. For an existing pool deployed with the admin as operator, the admin calls `grantRole(OPERATOR_ROLE, operatorAddress)` and verifies `hasRole` on-chain before using a new dedicated operator. For a new pool, pass the operator at prepare so no later role grant is needed.

## 1. Create the local setup

~~~
npx --yes --package=@playmos/sdk@0.3.20 playmos-studio init
# Fund the admin EOA with Base Sepolia ETH.
# Set PLAYMOS_SECRET securely in this process environment.
npx --yes --package=@playmos/sdk@0.3.20 playmos-studio pool create \
  --series your-series --entry 250000 --epoch 3600
npx --yes --package=@playmos/sdk@0.3.20 playmos-studio pool own
~~~

The CLI is shipped inside `@playmos/sdk`; there is no separate `playmos-studio` npm package. `init` writes local admin/operator material. `pool create` prepares, signs locally, deploys and records the pool/transaction. `pool own` completes or resumes the ownership handshake. Inspect command output before proceeding.

## 2. Confirm ownership

The prepare API returns unsigned transaction data and deployment bytecode. Preparing is not broadcasting. After the deployed address has a successful receipt, registration first returns a proof message. Inspect its studio, pool, wallet and chain, sign it locally with the admin, then register the signature. Continue only when `ownership === 'confirmed'`.

~~~
import { Playmos } from '@playmos/sdk';

export async function registerYourPool(
  ops: Playmos, prepared: import('@playmos/sdk').EpochPreparedPool,
  poolAddress: `0x${string}`,
  signMessage: (message: string) => Promise<`0x${string}`>,
) {
  // Preserve the same dedicated operator and sinks used for deployment.
  const input = { studioWallet: prepared.admin, admin: prepared.admin,
    operator: prepared.operator, feeSink: prepared.feeSink, poolAddress };
  const pending = await ops.epochs.registerPool(input);
  if (pending.ownership === 'confirmed') return pending;
  if (!pending.proof) throw new Error('No ownership challenge returned.');
  // Inspect the studio, pool, wallet and chain named in this message.
  const signature = await signMessage(pending.proof.message);
  const owned = await ops.epochs.registerPool({
    ...input, walletProof: { signature },
  });
  if (owned.ownership !== 'confirmed') throw new Error('Ownership is not confirmed.');
  return owned;
}
~~~

## 3. Create the first series

~~~
npx --yes --package=@playmos/sdk@0.3.20 playmos-studio series create \
  --name your-series --entry 250000 --epoch 3600 --dry-run
# Review chain, roles and split; remove --dry-run to sign and send.
~~~

The CLI proves the RPC chain, checks admin/operator roles and avoids resending a recorded series transaction. Programmatically, call `epochs.prepareSeries`, inspect and sign `unsignedTx`, then call `epochs.getSeries` until `created === true`. A prepare response alone does not open a series.

## Use the actual series terms

| Bucket | Default basis points | 

| Current epoch prize | 6000 (60%). | 

| Next-window seed | 3000 (30%). | 

| Studio sink | 900 (9%). | 

| Playmos fee sink | 100 (1%), locked. | 

All four buckets sum to 10000. Seed can be zero and the first three shares can change at series creation; Playmos’s 100 bps remains fixed. Entry is integer micro-USDC: `250000` means 0.25 USDC. A new clock, entry or split requires a new series. New immutable sink addresses require a new pool. Read addresses and fee terms from the prepare/chain response rather than copying them from another game.

## 4. Enter the current window

Players call `epochs.enter` with their wallet provider, pool, series and a persisted attempt identity. The returned `epochId` comes from the mined entry event when confirmed. Preserve it across admission and scoring; the clock may advance before the transaction mines. Re-entry is supported and `identityEntryCount` records the paid count.

~~~
import { Playmos } from '@playmos/sdk';

export async function enterYourEpoch(
  publishableKey: string, provider: import('@playmos/sdk').Eip1193Provider,
  pool: `0x${string}`, series: string, attemptIdentity: string,
) {
  const player = new Playmos({
    apiKey: publishableKey,
    wallet: { provider },
    contracts: { epochPrizePool: pool },
    rpcUrl: 'https://sepolia.base.org',
  });
  const entry = await player.epochs.enter({ series, identity: attemptIdentity });
  // Persist the returned epochId, identity and txHash for server admission.
  // Confirmed entries use the mined event's epochId, not a guessed clock window.
  return entry;
}
~~~

A client-supplied transaction hash or browser `confirmed` flag does not authorize a paid round. The trusted backend must match a successful receipt from the expected chain, pool and series, with the exact attempt identity and the authenticated player's event payer. Refuse bytes32-shaped proof identities; the SDK's low-level compatibility hasher passes those through. Identity strings can be predictable and are received/echoed by the service, so they are not authentication.

Your backend must atomically consume each paid event once, using chain + pool + transaction hash + `logIndex`; a smart-wallet bundle can contain several entries. Persist the paid epoch for scoring and settlement. Define sale, admission and score deadlines explicitly; the contract's epoch end is not a studio grace-period policy. The Hub uses its paid-seat horizon for score/settlement eligibility.

SDK 0.3.20 uses the service for epoch views and planning. RPC-backed views, backend `verifyEntry`, exported event/error ABIs and read-only `epochs.resumeEntry` are in the next SDK patch, pending publication. Until that release, do direct receipt verification on your backend and retain the existing payment's hash/identity when a read fails; do not charge again to retry admission. Support both the original seven-field and studio-share eight-field `Entered` signatures.

## 5. Settle a completed window

Your trusted backend validates scores and selects winners. The operator signs `settlementTypedData`. In SDK 0.3.20, `epochs.executeSettlement` sends the signed result directly through the client’s configured wallet; that wallet pays gas. The separate server HTTP route `POST /v1/epochs/:epochId/settle/signed` accepts a studio secret and relays the operator signature through the service when that capability is configured. Do not treat these as the same transport. For an own pool, do not use the service-signer `epochs.settle` path as a substitute for your operator. The current window is still accepting entries; settle a past one.

~~~
import { Playmos } from '@playmos/sdk';

import { settlementTypedData } from '@playmos/sdk';

export function operatorSettlement(
  pool: `0x${string}`, series: string, pastEpochId: string,
  winners: `0x${string}`[], amountsMicro: string[],
) {
  return settlementTypedData({
    pool, chainId: 84532, series, epochId: pastEpochId,
    winners, amounts: amountsMicro, // Integer micro-USDC; never USD decimals.
  });
}

export async function executeSignedSettlement(
  client: Playmos, pool: `0x${string}`, series: string, pastEpochId: string,
  winners: `0x${string}`[], amountsMicro: string[], signature: `0x${string}`,
) {
  // Your operator EOA signs operatorSettlement(...) locally.
  // client needs a wallet provider: this SDK method sends from that wallet.
  // The separate HTTP /settle/signed service relay is not called here.
  const receipt = await client.epochs.executeSettlement({
    epochPrizePool: pool, series, epochId: pastEpochId,
    winners, amounts: amountsMicro, signature,
  });
  // A broadcast is not terminal settlement. Read epochs.get for this window.
  return receipt;
}
~~~

A transaction hash or `pending` response is not terminal success. Read `epochs.get`, its `terminal` state and read provenance. Empty-window roll and refund are different outcomes from a winner payment.

## 6. Withdraw or refund

`epochs.prize` reads the contest pot. Read the pool's `withdrawable(wallet)` for a wallet's credited balance and use `epochs.withdraw` for its wallet-signed pull. A timeout does not activate refunds automatically: the protocol's permissionless `refundEpoch` must first make the oldest eligible paid window Refunded. Then `claimableRefund` / `claimRefund` credit the retained pool share; the player withdraws afterward. Fee, studio revenue and seed already carried onward are excluded. This protocol escape hatch is separate from discretionary refunds. Missing `amountMicro` is unknown, not zero. See [payout and withdrawal states](https://test.playmos.io/docs/payouts).

**Epochs and legacy rounds are different contracts.**

Own pools use prepareSeries → enter → the clock closes → operator settlement. rounds.open / lock / settle are refused on a studio EpochPrizePool. The public shared-sandbox skill entry is an integration smoke, not your pool or your published fee split.

Current pools move seed into carry at entry time. It can attach to a later paid epoch before the source settles; settlement pays `pool + incomingSeed`. `outgoingSeed` is a compatibility slot that stays zero.

The CLI supports init, pool create, pool own and series create. There is no operator run command; the operator loop remains your backend/script.

## Prepare an immutable series

~~~
import { Playmos } from '@playmos/sdk';

export async function prepareYourSeries(ops: Playmos, pool: `0x${string}`) {
  return ops.epochs.prepareSeries({
    epochPrizePool: pool,
    series: 'your-series',
    epochDuration: 3600,
    entry: '250000', // Integer micro-USDC: 0.25 USDC.
    poolBps: 6000, seedBps: 3000, studioBps: 900, feeBps: 100,
  });
  // Inspect and sign unsignedTx locally, then broadcast on its chainId.
  // Read epochs.getSeries until created === true before accepting entries.
}
~~~

---

# Show what was paid.

Separate studio revenue, credited player prizes, withdrawal transactions and completed transfers.

## Studio IAP revenue

Configure your [payout wallet](https://test.playmos.io/docs/studio-setup) before paying with your own studio key. IAP’s Playmos fee is 1%; use the receipt’s exact `amount`, `fee` and `net` rather than rounding displayed cents into a ledger. A studio pool has its own immutable split; its studio share is paid to the configured sink at entry.

## Legacy round payout notices

`rounds.payoutNotices({ roundId, wallet })` reads `GET /v1/rounds/:id/payout-notices`. The optional wallet filter must be a 20-byte address. Top-level `status` is the round’s status; each notice has its own status and integer `amountMicro`.

| Notice | What the player sees | 

| claimable, positive amount | Available to withdraw. No transfer to the player is proven. | 

| funded, positive amount, valid nonzero txHash | A recorded funding transaction; reconcile that transfer. | 

| Zero amount | No prize, even if a notice row exists. | 

| Missing or invalid funding hash | Unverified; do not label paid. | 

The shared sandbox’s documented path uses pull credits (`prizepool_pull_credit`). A settled round can still have claimable prizes. Never infer payment from `notices.length`.

## Player withdrawals

Read the balance through an RPC pinned to the key’s chain. The player uses `rounds.withdraw` for legacy PrizePool or `epochs.withdraw` for EpochPrizePool. The round notice route has no epochs twin; epochs use `epochs.prize` for the pot and `withdrawable(wallet)` for the pool’s credited balance.

Render pending, confirmed and failed withdrawal outcomes separately. A returned hash is not confirmation. Never turn an unavailable amount read into $0.00.

## Operator push

`rounds.pushWinnings` is a secret-key operation for settled legacy rounds and replays recorded notices. A 409 means the round is not settled. Calling it does not guarantee a funded notice; the pull-credit path stays claimable until a player withdrawal.

## Bank payouts are not available

Wallet revenue uses the configured payout address. SDK 0.3.20’s `payouts.setMode('usdc')` does not forward the service-required `payoutWalletAddress`; do not use that helper as a complete wallet setup. Configure IAP through `POST /v1/studio/payout`, or explicitly send both `mode` and `payoutWalletAddress` to `POST /v1/payouts/mode` on your backend. The SDK exposes `createOnboardingLink` and fiat mode, but the sandbox’s Bridge bank onboarding/off-ramp is a stub. Do not offer it as working ACH/SEPA or infer bank completion from a returned link.

## Read round payout notices

~~~
import { Playmos } from '@playmos/sdk';

export async function readRoundPayouts(
  client: Playmos, roundId: string, wallet: `0x${string}`,
) {
  const result = await client.rounds.payoutNotices({ roundId, wallet });
  return result.notices.map(notice => {
    const positive = /^\d+$/.test(notice.amountMicro) && BigInt(notice.amountMicro) > 0n;
    const transaction = /^0x[0-9a-f]{64}$/i.test(notice.txHash || '')
      && !/^0x0{64}$/i.test(notice.txHash || '');
    return { ...notice,
      display: !positive ? 'No prize'
        : notice.status === 'funded' && transaction ? 'Funded'
        : notice.status === 'claimable' ? 'Available to withdraw' : 'Unverified',
    };
  });
  // Legacy PrizePool only. Epoch prizes use epochs.prize / epochs.withdraw.
}
~~~

---

# Integrate your game engine.

Keep payment UI in a supported browser host and fulfillment on your game server.

## Unity WebGL host-page path

The public Unity path uses `@playmos/sdk` alongside the WebGL build. Install it in a Vite host app, put a Buy button and payment-status output beside the canvas, and use the [browser purchase example](https://test.playmos.io/docs/browser). Open the host in a browser; Unity Editor Play Mode does not exercise this payment flow.

~~~
npm create vite@latest playmos-unity-host -- --template vanilla-ts
cd playmos-unity-host
npm install
npm install @playmos/sdk@0.3.20
npm run dev
~~~

Keep your Unity build on that page. If you pass receipt IDs to the engine, treat them as display/input only: the trusted game server verifies them before granting an item. Never put an `sk_` key in Unity assets, JavaScript or a bridge message.

## Choose the game-server language

Use the published .NET package, Node SDK, Python package or direct HTTP for server verification. The [language guide](https://test.playmos.io/docs/languages) lists pinned versions and each surface’s limitations. A thin server client’s confirmed flag alone is not sufficient to match a purchase or deduplicate a grant.

## Engine availability

| Surface | Current public path | 

| Web / Phaser / HTML5 | Bundled JavaScript SDK and a trusted backend. | 

| Unity WebGL | Browser host + server verification. com.playmos.sdk is not a public UPM install. | 

| Godot HTML5 | No public Playmos engine package; use a separately integrated web host if building your own adapter. | 

| Unreal | No public Playmos engine package; a system-browser flow needs your own secure callback/session design. | 

| Three.js | Use the web SDK as application code; there is no separate engine package. | 

| Native mobile, subscriptions, Bridge virtual accounts | No public supported SDK/product path in this iteration. | 

Publisher release review and the portal’s iframe launch are separate from engine SDK availability. A local WebGL build is not a published listing.

## Unity host · payment controls

~~~
import { Playmos } from '@playmos/sdk';

export function bindSandboxPurchase(button: HTMLButtonElement, output: HTMLElement) {
  const client = new Playmos({ apiKey: 'pk_test_playmos_sandbox', settle: 'server' });
  const storageKey = 'playmos-demo-purchase';
  button.onclick = async () => {
    button.disabled = true;
    const key = sessionStorage.getItem(storageKey) || crypto.randomUUID();
    sessionStorage.setItem(storageKey, key); // Persist BEFORE sending.
    try {
      const payment = await client.pay({
        gameId: 'game_sandbox_iap', amount: '0.01',
        sku: 'extra-life', playerId: 'sandbox-player', idempotencyKey: key,
      });
      sessionStorage.setItem(`${storageKey}:id`, payment.id);
      const receipt = await client.verify(payment.id);
      output.textContent = JSON.stringify(receipt, null, 2);
      // Display only. Your backend owns verification and a single item grant.
    } catch (error) {
      const id = (error as { detail?: { paymentId?: unknown } }).detail?.paymentId;
      if (typeof id === 'string') sessionStorage.setItem(`${storageKey}:id`, id);
      output.textContent = error instanceof Error ? error.message : 'Request failed.';
      // Once an ID exists, recover with client.verify(id), not another purchase.
    } finally { button.disabled = false; }
  };
  // Reset the saved key and ID only when intentionally starting a NEW purchase.
}
~~~

---

# Move value in your game economy.

Use server-owned agent wallets and transfers on testnet. This is a separate flow from IAP and contest entry.

## Keep agent operations on the backend

`agents.createWallet`, `agents.fund` and transfers from NPC wallets require your secret test key. The old public Town demo secret is a fenced shared-demo exception; it does not make studio secrets safe in a client. This guide uses your server key instead.

## Create and fund a test fixture

Create both NPC wallets, then fund them as a separate test setup step. The fund helper is a sandbox treasury-to-agent operation, not a player faucet or production funding promise. Do not call it on every item purchase or retry.

~~~
await server.agents.createWallet({ agentId: 'npc_miner' });
await server.agents.createWallet({ agentId: 'npc_shop' });
await server.agents.fund({ agentId: 'npc_miner', amount: '0.50' });
~~~

## Define transfer terms

`amount` is a decimal USD string. Persist `idempotencyKey` once per transfer. `feeBps` is per-call pricing, unlike fixed IAP/pool fees; a positive fee requires an explicit `feeSink`. A zero-fee transfer needs no fee destination.

## Reconcile the transfer

Transfers use `settling`, `settled` and `failed`. Read `transfers.get` or `transfers.wait` for the same ID if still settling. Confirm the actual hash and amounts before updating your game’s ledger. IAP’s `confirmed` status is not the transfer status.

**Custody differs from studio pool keys.**

Studio admin/operator keys stay with the studio. Agent wallets are created through the service, which holds their signing capability and relays their transfers. Do not describe the agent economy as having the same key-custody boundary as your own contest pool.

## Server · agent transfer

~~~
import { Playmos } from '@playmos/sdk';

export async function transferBetweenAgents(secretKey: string, purchaseKey: string) {
  const server = new Playmos({ apiKey: secretKey }); // sk_test_ on your backend.
  await server.agents.createWallet({ agentId: 'npc_miner' });
  await server.agents.createWallet({ agentId: 'npc_shop' });
  // Fund separately once for your test fixture; never on every purchase retry.
  const transfer = await server.transfer({
    from: 'npc_miner', to: 'npc_shop', amount: '0.10',
    feeBps: 0, idempotencyKey: purchaseKey,
  });
  return transfer.status === 'settling'
    ? server.transfers.wait(transfer.id) : transfer;
  // Transfer terminal success is settled, not payment confirmed.
}
~~~

---

# Use HTTP payment challenges.

An advanced Playmos x402-shaped envelope over the transfer settlement core.

## Choose this after the basic payment flow

This service product is server-only: use your `sk_test_` key, Base Sepolia and an enabled x402 settlement capability. Public `pk_` keys and live/mainnet x402 are refused. Use [IAP pay → verify](https://test.playmos.io/docs/quickstart) for game purchases. This route is for HTTP services that deliberately integrate Playmos challenges. It is not a claim of interoperability with every stock x402 client or facilitator.

## Local shape versus service challenge

`createPaymentRequirement` and `createX402Challenge` build a local 402 response with a `PAYMENT-REQUIRED` header. They do not register a settleable challenge on the service. Use `client.x402.challenge` and `fulfill`, or `client.x402.pay` which performs that sequence.

## Keep units explicit

The SDK takes `amount: '0.10'`. The envelope’s `maxAmountRequired` is atomic USDC: `'100000'`. Network is key-selected Base Sepolia for test keys. Decode and validate challenge inputs; server-issued terms and fees are authoritative, not edits from the client.

## Inspect the settlement result

The result can remain `pending` or `settling`; only `settled` is terminal success, and `failed` is failure and the actual transaction hash. A locally constructed 402 body is not evidence that money moved. Persist a stable requirement ID before creating the challenge and retain the returned requirement to reconcile interrupted fulfillment with the same terms. A new ID creates a new operation.

## Challenge shape and service payment

~~~
import { Playmos } from '@playmos/sdk';

import { createPaymentRequirement, createX402Challenge } from '@playmos/sdk';

export function localHttpChallenge(payTo: `0x${string}`) {
  const requirement = createPaymentRequirement({
    payTo, amount: '0.10', network: 'base-sepolia',
  });
  return createX402Challenge(requirement, { feeBps: 0 });
  // Response shape only: 402, PAYMENT-REQUIRED, atomic maxAmountRequired.
}

export async function payHttpRequirement(
  client: Playmos, payTo: `0x${string}`, savedRequirementId: string,
) {
  // The SDK mints a service challenge, then fulfills it on Playmos rails.
  return client.x402.pay({ payTo, amount: '0.10', feeBps: 0, id: savedRequirementId });
  // Inspect settled / settling / failed and the actual transaction hash.
}
~~~

---

# API reference.

SDK methods call the versioned Playmos service. Use the SDK for typed inputs, retries and payment flow orchestration.

## API origins

| Environment | Origin | 

| Test / Base Sepolia | `https://api.sandbox.playmos.io/v1` | 

| Live / Base | `https://api.playmos.io/v1` — capability and production approval required | 

Authenticate with `Authorization: Bearer `. The key prefix selects the environment. Explicit network overrides must agree.

## Core endpoints

POST`/v1/payments`

**pay() / enterRound()**

Create an IAP or entry intent. Explicit server settlement is test-only.

GET`/v1/payments/:id`

**verify(id)**

Studio-scoped payment read with verification provenance.

POST`/v1/payments/:id/tx`

**SDK-managed**

Report a transaction for verification; reporting is not confirmation.

GET`/v1/payments`

**Server API · sk_ required**

Filter studio payments; capped cached listing, not a complete ledger.

GET`/v1/rounds/:id`

**rounds.getWithMeta()**

Read round state and chain-read metadata.

POST`/v1/epochs/pools/prepare`

**epochs.preparePool()**

Prepare an unsigned studio pool transaction.

GET`/v1/epochs/:epochId`

**epochs.get()**

Read the relevant epoch using its pool and series context.

GET`/v1/webhook_endpoints`

**Server API · sk_ required**

Inspect studio-owned endpoint registrations and recorded attempts.

GET`/v1/health`

**Service diagnostics**

Capability, chain-read and sandbox signer readiness. Global health is not per-studio analytics.

**Read the relevant product’s status.**

IAP / round-entry payments use confirmed. Round settlements can use settling → settled. Epoch and transfer results have their own typed statuses. Do not collapse these into a generic success flag.

[Languages and package support](https://test.playmos.io/docs/languages)

## Studio setup and webhook API

| Method / path | Purpose | 

| POST /v1/keys | Self-serve test studio/key issuance; live keys remain gated. | 

| POST /v1/studio/payout | Set payout wallet with your server secret. | 

| GET /v1/keys/me · POST /v1/keys/revoke | Inspect or revoke studio credentials. | 

| POST /v1/webhook_endpoints | Register endpoint and return its signing secret. | 

| GET /v1/webhook_endpoints | Inspect registration and recorded attempts; no secret readback. | 

## Contest API

| Method / path | Contract | 

| POST /v1/rounds · POST /v1/rounds/:id/lock · POST /v1/rounds/:id/settle | Legacy operator lifecycle; sk_ required. Not studio EpochPrizePool. | 

| POST /v1/rounds/:id/cancel | Legacy cancellation; reconcile its terminal state. | 

| GET /v1/rounds/:id/payout-notices · POST /v1/rounds/:id/push-winnings | Legacy notices and operator push; see payout states. | 

| POST /v1/epochs/pools/prepare · POST /v1/epochs/pools | Unsigned pool prepare, then ownership registration. | 

| GET /v1/epochs/pools | Studio pool listing. | 

| POST /v1/epochs/series/prepare · GET /v1/epochs/series | Prepare unsigned series; read actual creation state. | 

| GET /v1/epochs/current · GET /v1/epochs/current/prize | Current window and prize, scoped by pool/series. | 

| POST /v1/epochs/enter · POST /v1/epochs/enter/confirm | SDK entry plan and mined-event confirmation. | 

| GET /v1/epochs/:epochId · GET /v1/epochs/:epochId/prize | Past/current window and player prize reads. | 

| POST /v1/epochs/:epochId/settle/signed | Server relay of studio operator-signed results; sk_ and configured relay required. | 

| GET /v1/epochs/:epochId/attestation | Read the available signed winner payload. | 

`epochs.executeSettlement`, `withdraw` and refund pulls in SDK 0.3.20 send through the client’s wallet. They do not call the similarly named service relay route. Preserve pool, series, epoch and chain context on every read and send.

## Economy, HTTP and payout API

| Method / path | Purpose | 

| POST /v1/agents/wallets · GET /v1/agents/wallets/:agentId | Server-owned agent wallet creation/read. | 

| POST /v1/agents/wallets/:agentId/fund | Sandbox test fixture funding. | 

| POST /v1/transfers · GET /v1/transfers/:id | Transfer and same-ID reconciliation. | 

| POST /v1/x402/challenges · POST /v1/x402/settle | Service-minted challenge and fulfillment. | 

| POST /v1/payouts/mode · POST /v1/payouts/onboarding_link | Payout configuration surfaces; fiat onboarding is a stub. |

## Read a payment

~~~
const result = await playmos.verify(paymentId);
console.log({
  id: result.id,
  status: result.status,
  verifiedVia: result.verifiedVia,
  txHash: result.txHash,
});
~~~

---

# Choose your language.

JavaScript handles browser wallets. Server packages and HTTP handle verification and backend work.

## Published packages

| Package | Version / public availability | 

| JavaScript / TypeScript | [@playmos/sdk](https://www.npmjs.com/package/@playmos/sdk) 0.3.20; browser and Node. Node 22 is supported. | 

| C# / .NET | [Playmos.Sdk](https://www.nuget.org/packages/Playmos.Sdk/0.1.1-preview) 0.1.1-preview; server REST, no browser wallet signing. | 

| Python | [playmos](https://pypi.org/project/playmos/0.1.0a1/) 0.1.0a1; server REST. | 

| Go | No public Playmos package; use direct server HTTP. | 

| Engine bridges | See the [engine support matrix](https://test.playmos.io/docs/engines); no public Unity UPM. | 

~~~
npm install @playmos/sdk@0.3.20
dotnet add package Playmos.Sdk --version 0.1.1-preview
pip install playmos==0.1.0a1
~~~

Registry versions were checked on October 7, 2026. Preview/alpha clients do not necessarily expose every field available through JavaScript or HTTP. Check the response before relying on a chain-provenance field or a feature beyond the package’s public methods.

## Verify over HTTP

On your server, read `GET /v1/payments/:id` with the secret belonging to the studio that created that purchase. Require a successful HTTP response and matching ID, player, SKU, canonical amount, `status === 'confirmed'` and `verifiedVia === 'chain'`.

~~~
curl --fail-with-body "https://api.sandbox.playmos.io/v1/payments/$PAYMENT_ID" \
  -H "Authorization: Bearer $PLAYMOS_SECRET"
~~~

## Python server verification

~~~
import json, os
from urllib.request import Request, urlopen
from urllib.parse import quote

def verify_purchase(payment_id, expected):
    req = Request(
        "https://api.sandbox.playmos.io/v1/payments/" + quote(payment_id, safe=""),
        headers={"Authorization": "Bearer " + os.environ["PLAYMOS_SECRET"]},
    )
    with urlopen(req, timeout=15) as response:
        receipt = json.load(response)
    ready = (receipt.get("id") == payment_id
        and receipt.get("status") == "confirmed"
        and receipt.get("verifiedVia") == "chain"
        and all(receipt.get(k) == expected[k]
                for k in ("playerId", "sku", "amount")))
    return ready, receipt
# Only grant once in your database transaction after ready is true.
~~~

## C# and Go

For .NET, `PlaymosClient.VerifyAsync(id)` is the published server method. Treat `IsConfirmed` as one status check, not complete purchase validation. If your package’s model omits provenance, use the HTTP response directly. For Go, build a request with a context/deadline, require a 2xx status, decode JSON and apply the same matching rules before a durable grant.

## Node server · equivalent verification

~~~
import { Playmos } from '@playmos/sdk';

export async function verifyOnYourServer(
  secretKey: string, paymentId: string,
  expected: { playerId: string; sku: string; amount: string },
) {
  if (!/^sk_(test|live)_\S+$/.test(secretKey)) {
    throw new Error('Use your server-only secret key.');
  }
  const server = new Playmos({ apiKey: secretKey }); // sk_… stays on your server.
  const result = await server.verify(paymentId);
  if (result.status !== 'confirmed' || result.verifiedVia !== 'chain'
    || result.id !== paymentId || result.playerId !== expected.playerId
    || result.sku !== expected.sku || result.amount !== expected.amount) {
    return { readyToGrant: false, result };
  }
  // Use canonical decimal strings for expected amounts.
  // Atomically record a single grant for this payment ID in your database.
  return { readyToGrant: true, result };
}
~~~

---

# Keys and environments.

Separate environments and capabilities at the code boundary, not just in the UI.

## Publishable and secret keys

| Key | Where it belongs | 

| pk_test_ / pk_live_ | Browser/client integration. Permissions remain limited by the service. | 

| sk_test_ / sk_live_ | Trusted backend only. Never paste into this playground or ship in a game bundle. | 

| Studio admin / operator private keys | Studio-controlled signing environment. Never upload them to Playmos. | 

## Test and live

Test keys resolve to Base Sepolia (84532); live keys resolve to Base (8453). Mock mode performs no network or chain transaction. Sandbox server settlement is test-only. A reachable live URL is not production readiness.

## Errors and recovery

| Condition | Response | 

| Invalid amount / missing field | Correct the typed input before sending. SDK amounts are decimal strings. | 

| 401 / 403 / studio mismatch | Check key type, environment and resource ownership on your backend. | 

| 409 / idempotency conflict | The same purchase key was reused with different terms. Recover the original purchase. | 

| 429 / sandbox rate limit | Respect backoff. Retry the same purchase key, not a new purchase. | 

| Pending / timeout | Keep the receipt/key. Retry verification; a timeout does not prove failure. | 

| Degraded chain read | Show stale/unverified state and retry later. Never invent confirmed totals. | 

## Production integration checklist

- Scope credentials and records to the verified studio and game.
- Verify payment provenance and expected purchase terms server-side.
- Deduplicate fulfillment and webhook events durably.
- Pin the SDK version and test payment, admission, score and payout recovery.
- Freeze and scan hosted releases before public eligibility.
- Keep operator/private service access behind your approved network controls.

## Mainnet availability

Live keys and funded mainnet operation remain gated by founder/compliance approval. A URL, key-shaped string or typed SDK method does not enable production. When that gate opens, key, chain and USDC change together; a dedicated chain-matched RPC is required for mainnet reads. Own-pool mainnet deployment is a new reviewed pool, not a migration of an existing Sepolia address.

The sandbox documents caps of 1.00 per server-settle request, 5 requests per minute and 20.00 per day. Honor actual service limits and response headers; these are test-service limits, not production pricing. Offline `mock: true` performs no network/chain transaction and carries mock provenance.

For diagnostics, typed error meanings and ambiguous settlement recovery, continue with [Troubleshooting](https://test.playmos.io/docs/troubleshooting).

## Server-owned verification

~~~
import { Playmos } from '@playmos/sdk';

export async function verifyOnYourServer(
  secretKey: string, paymentId: string,
  expected: { playerId: string; sku: string; amount: string },
) {
  if (!/^sk_(test|live)_\S+$/.test(secretKey)) {
    throw new Error('Use your server-only secret key.');
  }
  const server = new Playmos({ apiKey: secretKey }); // sk_… stays on your server.
  const result = await server.verify(paymentId);
  if (result.status !== 'confirmed' || result.verifiedVia !== 'chain'
    || result.id !== paymentId || result.playerId !== expected.playerId
    || result.sku !== expected.sku || result.amount !== expected.amount) {
    return { readyToGrant: false, result };
  }
  // Use canonical decimal strings for expected amounts.
  // Atomically record a single grant for this payment ID in your database.
  return { readyToGrant: true, result };
}
~~~

---

# Find the failing step.

Keep the payment ID and purchase key. Diagnose the operation that failed before attempting another charge or settlement.

## Payment recovery

| Symptom | Next action | 

| Server-settle timeout with paymentId | Save error.detail.paymentId and verify that ID. A created/cache result is unconfirmed. | 

| READY health but payment remains created | Read the actual payment. Health measures capabilities, not the success of a particular purchase. | 

| No ID after an interrupted create | Retry the same terms and saved idempotency key. | 

| 429 / rate limit | Honor Retry-After/backoff; do not mint new purchase keys. | 

| Own key says payment not found | Check studio/environment. The shared demo’s ID is not visible under your own secret. | 

| Own IAP says payoutAddress missing | Set the studio wallet using POST /v1/studio/payout. | 

| Degraded chain read | Mark the read unavailable; never treat cached confirmation as a fresh grant. | 

## Wallet and network checks

| Symptom | Next action | 

| USDC visible but test payment fails | Check Base Sepolia USDC first, then testnet ETH; mainnet balances do not fund testnet. | 

| Login shows 8453 | Align wallet list, signed message and server verification to 84532. | 

| Fallback send changes chain | Pin chain on every send path, including older buttons. | 

| Nothing to withdraw | Read the correct pool/chain and distinguish credited prizes from a completed withdrawal. | 

| Wallet says insufficient funds on entry | Also check RoundNotOpenError, series readiness and the actual entry window. | 

## Pool and operator checks

| Symptom | Next action | 

| pool_not_deployed | Deploy the prepare transaction, wait for its receipt, then register the mined address. | 

| ownership pending | Sign the returned proof with the bound studio admin, then re-register. | 

| Smart-wallet deploy or operator signature fails | Use the supported admin/operator EOA path. | 

| Series has created=false | Read the recorded create transaction and series. Do not repeatedly send a new create. | 

| rounds.open refuses an own pool | Use epochs.prepareSeries → epochs.enter → operator-signed settlement. | 

| Settlement times out | Read the same epoch/round before retrying. A send may already have landed. | 

| Local key file unreadable in Docker | Fix ownership/access for the process user; do not make it world-readable. | 

## Typed SDK errors

| Error | Interpretation | 

| InvalidAmountError / MissingFieldError | Correct amount or required fields before sending. | 

| ConfigError | Key/network/RPC/provider configuration mismatch. | 

| AuthError | Invalid key, forbidden capability or wrong studio. | 

| WalletConnectionError / WalletTimeoutError | Wallet interaction was cancelled or timed out. | 

| InsufficientGasError / PaymentFailedError | Inspect chain funds or transaction failure. | 

| RoundNotOpenError / AlreadyEnteredError | Admission state rejected the attempt; inspect the exact round identity. | 

| NothingToWithdrawError | No credited balance on the relevant withdrawal path. | 

| ApiError | Inspect code, detail, nextAction, requestId and any returned receipt ID. | 

## Settlement recovery

Legacy rounds can return `settling` after submission. Read the same round instead of making a blind second settle request. Service error hints such as `wait_then_check`, `retry_same_key` and `fix_payload` distinguish ambiguous sends from invalid inputs. For own pools, preserve the operator-signed payload and reconcile the epoch’s chain state before any resend.

**Latest sandbox observation · October 7, 2026**

A real 0.01 test IAP returned a payment ID, then timed out. Subsequent verification remained created/cache with no transaction hash. The playground preserves the ID for recovery. This observation does not establish that all sandbox requests fail; it means a green health response and a timing estimate are not payment proof.

Use [the playground’s connection check](https://test.playmos.io/docs/playground) to see current service capabilities. Do not send keys, private wallet material or full unredacted logs in a support report.

## Retain the purchase key on retry

~~~
import { Playmos } from '@playmos/sdk';

export async function sandboxPayment(idempotencyKey: string) {
  const playmos = new Playmos({
    apiKey: 'pk_test_playmos_sandbox',
    settle: 'server', // Base Sepolia only; no player wallet required.
  });
  const payment = await playmos.pay({
    gameId: 'game_sandbox_iap',
    amount: '0.01',
    sku: 'extra-life',
    playerId: 'sandbox-player',
    idempotencyKey, // Persist once per purchase. Reuse on retry.
  });
  const verified = await playmos.verify(payment.id);
  return { payment, verified };
}
~~~