<!-- Copy of docs/partners.md served to partners at /sdk/partners.md. Edit docs/partners.md and re-copy. -->
# Embedding Playmez games in your site or app

Partners drop a single game (Ludo, Callbreak, Rummy, Tambola, Chess, Teen Patti, Carrom, Roulette, Crash or Trivia) into their own
website or app. The game runs on Playmez's servers inside an `<iframe>`; your side never hosts
game code, never decides outcomes and never holds balances. Players play **alone against the
computer** (turn games) or **solo at a private table** (round games). Points are virtual,
non-transferable and worth nothing. Lobbies and tournaments are not part of
the embed.

## How it works

Interactive version of this guide, with code for every game and platform: https://playmez.com/developers.

1. Playmez gives you an **API key** (`spk_…`). Keep it on your server only.
2. When one of your users taps "Play", your **server** calls `POST /partner/launch` with your
   own id for that user. You get back a one-time **launch URL**, valid for five minutes.
3. Your page (or app WebView) opens that URL in an iframe, with the SDK or by hand.
4. The game opens a 12-hour session for that player, seats them at their private table, and
   posts events (`ready`, `result`, `close`, `error`) to your page.

The same `externalUserId` always opens the same Playmez account, so the player keeps their
points, level and history between visits. Every account is private to your partner record.

## 1. Launch a game (server to server)

Ready-made functions: `sdk/server/launch.mjs` (Node), `sdk/server/launch.py` (Python), `sdk/server/launch.php` (PHP).

```
POST https://api.playmez.com/partner/launch
X-Api-Key: spk_…
Content-Type: application/json

{ "game": "ludo", "externalUserId": "user-123", "displayName": "Ravi", "avatar": "4" }
```

| Field | Required | Notes |
| --- | --- | --- |
| `game` | yes | `ludo`, `callbreak`, `rummy`, `tambola`, `chess`, `teen_patti`, `carrom`, `roulette`, `crash`, `trivia`, `wheel`, `scratch` |
| `externalUserId` | yes | Your id for the player, 1–128 chars. Stable per player. |
| `displayName` | no | 1–32 chars, shown at the table. Updates the account when it changes. |
| `avatar` | no | `"1"`–`"12"`, one of Playmez's avatars. |
| `tableCode` | no | Seat the player at a table you created with `POST /partner/tables` (below). Without it, a solo table. |
| `theme` | no | Appearance for this launch, merged over your partner theme. See Appearance below. |
| `lang` | no | Interface language for this launch: `en` or `hi`. See Languages below. |

Response (`200`):

```json
{
  "launchUrl": "https://playmez.com/embed/ludo?launch=…",
  "launchToken": "…",
  "expiresAt": "2026-10-22T10:05:00.000Z",
  "game": "LUDO",
  "player": { "id": "…", "externalUserId": "user-123", "displayName": "Ravi", "balance": 1000 }
}
```

Errors: `401 INVALID_API_KEY`, `400 INVALID_GAME`, `403 ACCOUNT_BANNED`. Rate limit: 600 launches
per minute per key.

`GET /partner/me` with the key returns your partner record (useful as a health check).

## 1b. Shared tables: your players at one table

Create a table from your server, then launch each player with its code. They sit together; set `computerFill` to let computer players take any empty seats when the first human sits (practice game, XP only). Table chat and voice inside your frames are switches on your partner record (off until you ask); when on, the embed shows the chat panel and the voice dock, and audio goes peer to peer between the players at the table, never through a server.

```
POST https://api.playmez.com/partner/tables
X-Api-Key: spk_…
Content-Type: application/json

{ "game": "callbreak", "hostExternalUserId": "user-123", "hostDisplayName": "Ravi", "name": "Friday Callbreak", "maxPlayers": 4, "computerFill": false }
```

Response (`201`): `{ "tableCode": "K7Q2MZ", "tableId": "…", "game": "CALLBREAK", "name": "Friday Callbreak", "maxPlayers": 4, "host": { "id": "…", "externalUserId": "user-123" } }`

Then `POST /partner/launch` with `{ "game": "callbreak", "externalUserId": "user-456", "tableCode": "K7Q2MZ" }` for every player, and mount each launch URL as usual. `GET /partner/tables/K7Q2MZ` returns `{ status, seated, connected, phase, maxPlayers }` while it runs. The host is one of your players (Tambola needs a host to press Start). Tables close themselves when idle; a launch to a closed table falls back to a solo table. Errors: `404 ROOM_NOT_FOUND`, `403 NOT_YOUR_TABLE`, `400 TABLE_GAME_MISMATCH`.

Voice in a frame needs `allow="microphone; autoplay"` on the iframe (the SDK sets it) and, in native wrappers, the WebView microphone permission: React Native WebView `mediaCapturePermissionGrantType="grant"`, Android `WebChromeClient.onPermissionRequest` granting `RESOURCE_AUDIO_CAPTURE`, iOS `WKUIDelegate` media capture permission, Flutter `onPermissionRequest` on the InAppWebView.

## 2. Show the game

### With the SDK (recommended)

```html
<script src="https://playmez.com/sdk/playmez.js"></script>
<div id="game"></div>
<script>
  // launchUrl comes from YOUR server, which called /partner/launch.
  const game = Playmez.mount(document.getElementById("game"), {
    launchUrl,
    height: 640,                 // px, or "100%" to fill the container
    onEvent: (e) => {
      if (e.type === "result") console.log(e.game, e.won ? "won" : "lost", e.net);
      if (e.type === "close") showYourOwnScreenAgain();
    },
  });
  // game.unmount() removes the frame.
</script>
```

Options: `height` (default `640px`), `title`, `background`, `radius`, `autoHeight` (grow with
`resize` events), `closeOnExit` (default `true`: the frame is removed on `close`).

Sizes: games adapt to the frame. Give them at least 300 px of height; under 640 px they hide
subtitles and side information, under 440 px only the playing surface and controls remain.
Portrait phones, landscape phones and half-screen panels all work.

### By hand

```html
<iframe src="LAUNCH_URL" allow="autoplay; fullscreen" allowfullscreen
        style="width:100%;height:640px;border:0"></iframe>
```

Listen for `message` events whose `data.source === "spinline"` and whose `event.origin` is the
Playmez web origin.

### Packages

One version for every platform (`sdk/VERSION`, changelog in the repository):

| Platform | Install | Import |
| --- | --- | --- |
| Website | `npm install playmez-embed` (or the script tag above) | `import { mount } from "playmez-embed"` |
| React / Next.js | `npm install playmez-embed` | `import { PlaymezGame } from "playmez-embed/react"` |
| React Native / Expo | `npm install playmez-embed-react-native react-native-webview` | `import { PlaymezGame } from "playmez-embed-react-native"` |
| Flutter | `flutter pub add playmez_embed` | `import 'package:playmez_embed/playmez_embed.dart';` |
| Android | `implementation("com.github.playmez:playmez-android:2.0.0")` with `maven("https://jitpack.io")` | `import com.playmez.embed.PlaymezGameView` |
| iOS | Swift Package `https://github.com/playmez/playmez-swift` from `2.0.0` | `import PlaymezEmbed` |

Every package relays the same events, trusts only the launch URL's origin, and grants the
microphone to the game page alone (for voice at shared tables; pass `allowMicrophone: false` to
refuse). The drop-in files below are generated from the same sources for projects that cannot add
a dependency.

### Drop-in files

Every file below is served from `https://playmez.com/sdk/…` and is meant to be copied
into your project as is (index: `sdk/README.md`, API reference: `sdk/openapi.yaml`).

| Platform | File |
| --- | --- |
| Any website | `sdk/playmez.js` (+ `sdk/playmez.d.ts`) |
| React / Next.js / Vite | `sdk/react/PlaymezGame.tsx` |
| React Native / Expo | `sdk/react-native/PlaymezGame.tsx` |
| Flutter (iOS + Android) | `sdk/flutter/playmez_game.dart` |
| Android (Kotlin) | `sdk/android/PlaymezGameView.kt` |
| iOS (UIKit + SwiftUI) | `sdk/ios/PlaymezGameView.swift` |
| Your backend | `sdk/server/launch.mjs`, `sdk/server/launch.py`, `sdk/server/launch.php` |

### React

`sdk/react/PlaymezGame.tsx` renders the iframe and listens for its messages; no script tag.

```jsx
import { PlaymezGame } from "./PlaymezGame";

<PlaymezGame launchUrl={launchUrl} height={640} onEvent={(e) => console.log(e)} onClose={() => setOpen(false)} />
```

### React Native, Flutter, Android, iOS

Native apps open the launch URL in a WebView with JavaScript enabled. The page finds the native
bridge and posts the same events to it as JSON (`{"source":"spinline","type":"ready",...}`):

- **React Native** (`react-native-webview`): `sdk/react-native/PlaymezGame.tsx`, a `<PlaymezGame launchUrl onEvent onClose />` component.
- **Flutter** (`webview_flutter`): `sdk/flutter/playmez_game.dart`, a `PlaymezGame(launchUrl:, onEvent:, onClose:)` widget. Registers the `PlaymezBridge` JavaScript channel and enables inline media playback on iOS and autoplay on Android.
- **Android** (`WebView`): `sdk/android/PlaymezGameView.kt`, a `WebView` subclass with `onEvent` / `onClose` and `open(launchUrl)`. Adds the `PlaymezBridge` JavaScript interface, enables JavaScript and DOM storage, and keeps navigation on the Playmez host.
- **iOS** (`WKWebView`): `sdk/ios/PlaymezGameView.swift`, a `UIView` with `onEvent` / `onClose` and `open(launchUrl:)`, plus a SwiftUI `PlaymezGame` wrapper. Registers the `spinline` script message handler and allows inline media playback.

Doing it by hand instead: React Native `onMessage={(e) => handle(JSON.parse(e.nativeEvent.data))}`;
iOS `configuration.userContentController.add(self, name: "spinline")` (`message.body` is the event object);
Flutter `controller.addJavaScriptChannel("PlaymezBridge", onMessageReceived: (m) => handle(jsonDecode(m.message)))`;
Android `webView.addJavascriptInterface(bridge, "PlaymezBridge")` with a `@JavascriptInterface fun postMessage(json: String)`.

Fetch the launch URL from your backend, never with the API key in the app. Allow inline media
playback / autoplay for sounds.

## Daily rewards: `wheel` and `scratch`

Two loyalty mechanics that are not tables: **Spin the Wheel** and **Scratch Card**. The player
gets a fixed number of free plays per UTC day (set by Playmez under Admin → Rewards, default
one each). Prizes are virtual points credited to the player's Playmez balance; a `result` event
carries the points (`net`, `won: net > 0`). The frame posts `close` when the player taps Done.
Each play publishes `sha256(seed)` before the spin or scratch and reveals the seed afterwards.

Embed them at a fixed height of about 560 px, or let the host scroll.

## Events

Every message is `{ source: "spinline", type, ...fields }`:

| `type` | Fields | When |
| --- | --- | --- |
| `ready` | `game` | The player is seated. |
| `result` | `game`, `won`, `net` | A match (turn games) or round (round games) settled for this player. `net` is in virtual points. |
| `close` | | The player tapped Leave or Close. Remove or hide the frame. |
| `error` | `code`, `message` | The game could not open (`LAUNCH_EXPIRED`, `LAUNCH_USED`, `PARTNER_DISABLED`, …). Fetch a new launch URL. |
| `resize` | `height` | Reserved for pages that grow with their content. |

## Languages

Pass `lang` on a launch, or set a default for your partner in the portal (Settings → Language) or
with `PATCH /partner/…` through Playmez. The embed renders in that language from its first paint,
numbers group the local way, and the `lang` attribute of the frame's document follows it.

| `lang` | Language |
| --- | --- |
| `en` (default) | English |
| `hi` | Hindi (हिन्दी). Every game and every surface an embedded player sees. Tambola's traditional English call names ("Two little ducks") show as plain numbers in Hindi. |

BCP 47 tags are accepted (`hi-IN` → `hi`). An unsupported value is refused with `400 INVALID_LANG`.
A launch's `lang` wins over the partner default; the launch response echoes the resolved `lang`.
Ask us for other languages: a catalog is a single typed file, so a new one ships without code changes.

## Appearance: make the frame yours

The embed takes your colours, corners and font on its first paint, so nothing flashes Playmez's
look inside your product. Set it once in the portal (Appearance tab) or with the API, and override
any field for one launch with `theme` on `POST /partner/launch`.

```json
{ "accent": "#1e90ff", "background": "#101418", "text": "#ffffff", "radius": "large", "font": "system", "googleFont": "Poppins" }
```

| Field | Values | What it changes |
| --- | --- | --- |
| `accent` | hex | buttons, highlights, focus rings; the text on them flips light or dark by itself |
| `background` | hex, must be dark (relative luminance ≤ 0.25) | the page; cards, panels and borders are derived from it |
| `text` | hex, must be light | body text; muted tones are derived |
| `radius` | `none`, `small`, `medium`, `large` | every corner, from chips to cards |
| `font` | `spinline`, `system`, `rounded`, `serif`, `mono` | a named font stack |
| `googleFont` | a family from fonts.google.com, e.g. `Poppins` | loaded by the embed and used for all text; wins over `font` |

Every field is optional; empty ones keep Playmez's value. Unknown fields are dropped. A bad value
is refused with `400 INVALID_THEME` and a `problems` list naming each field. `PATCH` with
`"theme": null` restores Playmez's look. The game boards themselves (cards, dice, carrom coins)
keep their own colours so play stays readable; the partner portal shows a live preview.

## 3. Webhooks: results pushed to your server

Polling is optional. Point us at a URL and Playmez posts events to your server, signed, and
retries until you answer.

```http
PUT https://api.playmez.com/partner/webhook
X-Api-Key: spk_…
Content-Type: application/json

{ "url": "https://example.com/playmez/webhook" }
```

The answer is `{ "url", "secret" }`. **The secret (`whsec_…`) is shown only this once**; store it
next to your API key. Setting the URL again returns `"secret": null`. `POST /partner/webhook/rotate-secret`
mints a new one (old signatures stop verifying at once); `DELETE /partner/webhook` switches webhooks off.
URLs must be `https` and reachable from the internet (plain `http` and local hosts work against a
local Playmez API while you develop).

### What arrives

Every delivery is a `POST` with `Content-Type: application/json` and these headers:

| Header | Value |
| --- | --- |
| `Playmez-Event` | the event type, e.g. `player.result` |
| `Playmez-Delivery` | the delivery id, the same on every retry (use it as an idempotency key) |
| `Playmez-Timestamp` | unix seconds when this attempt was sent |
| `Playmez-Signature` | `v1=<hex>`: HMAC-SHA256 of `"<Playmez-Timestamp>.<raw body>"` with your secret |
| `Playmez-Attempt` | 1 for the first attempt, then 2, 3, … |

The body is `{ "id", "type", "createdAt", "partner": { "id", "slug" }, "data" }`:

| `type` | `data` | When |
| --- | --- | --- |
| `player.created` | `player { id, externalUserId, displayName }` | One of your user ids opened a game for the first time and got a Playmez account. |
| `session.opened` | `player`, `game`, `tableCode` | A player opened a launch URL and is in the game. |
| `player.result` | `player`, `game`, `won`, `net`, `withBots`, `matchId`, `tableCode` | A match (turn games) or round (round games) settled for this player. `net` is in virtual points; `withBots` means computer players were at the table (XP only). |
| `table.closed` | `tableCode`, `tableId`, `game` | A table you created with `POST /partner/tables` closed. |
| `webhook.test` | `{}` | You called `POST /partner/webhook/test`. |

`player` never carries the Playmez username, balance or anything another partner could use.
Events for one player can arrive out of order; use `createdAt` when order matters.

### Verify, then answer 2xx

Verify against the **raw request bytes** (a re-serialised JSON object will not match), reject
timestamps older than five minutes, compare in constant time, and answer any `2xx` within ten
seconds. Do the work after answering if it is slow. Drop-in verifiers:
[`server/verify-webhook.mjs`](https://playmez.com/sdk/server/verify-webhook.mjs) (Node, Express example) and
[`server/verify_webhook.py`](https://playmez.com/sdk/server/verify_webhook.py) (Python, Flask example).

```js
import { createHmac, timingSafeEqual } from "node:crypto";
const expected = createHmac("sha256", process.env.PLAYMEZ_WEBHOOK_SECRET).update(`${req.header("Playmez-Timestamp")}.${rawBody}`).digest("hex");
const given = req.header("Playmez-Signature").split(",").map((s) => s.trim()).filter((s) => s.startsWith("v1=")).map((s) => s.slice(3));
const ok = given.some((g) => g.length === expected.length && timingSafeEqual(Buffer.from(g), Buffer.from(expected)));
```

### Retries

A delivery that gets no `2xx` (a non-2xx status, a redirect, a timeout, a connection error) is
tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours: seven attempts
over about 35 hours, then it is marked **failed**. Redirects are not followed. Deliveries to one
partner go one at a time.

`GET /partner/webhook/deliveries?limit=50` lists the last deliveries (`id`, `event`, `status`
`PENDING | DELIVERED | FAILED`, `attempts`, `nextAttemptAt`, `lastStatusCode`, `lastError`).
`POST /partner/webhook/deliveries/{id}/redeliver` queues one again at once. `POST /partner/webhook/test`
sends a `webhook.test`. Deliveries are kept for 30 days. Errors: `400 INVALID_WEBHOOK_URL`,
`400 WEBHOOK_NOT_CONFIGURED`, `404 DELIVERY_NOT_FOUND`.

## Your portal

Everything above is self-service at **https://playmez.com/partners**. Playmez adds the
first owner when it creates your partner record (a registered Playmez account, by username);
owners add more people. Two roles:

| Role | Can |
| --- | --- |
| **Owner** | rotate the API key, edit allowed origins and the name, switch table chat and voice on or off, set the webhook URL and rotate its secret, add and remove members |
| **Developer** | read usage and settings, send test webhooks, redeliver failed ones |

The portal shows launches, opened games, players and new players per day (UTC) for the last 7,
30 or 90 days, launches by game, the live key's hint (`spk_…x7Qk`), webhook deliveries, and an
activity log of every change with who made it. Switching a partner off entirely stays with Playmez.

**Try it.** The portal's Try it tab makes a real launch against your record (same code path as
`POST /partner/launch`), mounts it with the `playmez-embed` package at phone, tablet or full
width, and shows the events as they arrive, the exact request your server would send with the
response, and the webhook deliveries the launch produced. You can create a shared table there and
open it from two windows. Sandbox launches are flagged and never count in your usage; their
`session.opened` webhook carries `sandbox: true`.

## 4. Tournaments for your players

Run a scored event across your own players without building a leaderboard. Every match or round
they finish in the window counts (net virtual points, then wins, then who joined first). Players
see a strip at the top of the embed with their rank, the time left and the top ten; you get the
standings in the portal, over the API and by webhook when it ends.

```http
POST https://api.playmez.com/partner/tournaments
X-Api-Key: spk_…
Content-Type: application/json

{ "name": "Friday Carrom Cup", "game": "carrom", "startsAt": "2026-10-30T14:00:00Z", "endsAt": "2026-10-30T18:00:00Z" }
```

| Field | Default | Meaning |
| --- | --- | --- |
| `game` | every game | One game (its embed ruleset) or omit to score all of them. |
| `startsAt`, `endsAt` | required | ISO timestamps; at most 31 days apart. A window that already started begins at once. |
| `autoEnrol` | `true` | Every one of your players who plays is in. `false`: only players you add with `POST /partner/tournaments/{id}/entries { "externalUserIds": [...] }`. |
| `prizeFirst`, `prizeSecond`, `prizeThird` | `0` | Playmez virtual points credited to the top three at the end. Real rewards are yours to give from the standings. |
| `description` | | Shown to players in the strip. |

`GET /partner/tournaments` lists yours; `GET /partner/tournaments/{id}` returns it with `standings`
(`rank`, `player { id, externalUserId, displayName }`, `netPoints`, `wins`, `roundsPlayed`, `prize`),
live while it runs and final afterwards; `POST /partner/tournaments/{id}/cancel` stops one. Webhooks:
`tournament.started` and `tournament.finished` (with the top 50). Your tournaments never appear in
Playmez's public Events list, and only your players score in them. Practice matches against the
computer count for XP only and do not move points, so they do not change standings.

## Security

- The API key is a server secret. Never put it in a page, an app bundle or a public repo.
- A launch URL works **once**, within five minutes. Fetch a fresh one every time a player opens
  a game. A reload inside the frame keeps the session it already opened.
- Playmez only lets the **origins you registered** frame the embed pages
  (`Content-Security-Policy: frame-ancestors`). Ask us to add every domain, including staging.
  `*` is for integration only.
- The game runs on Playmez's origin with its own bearer session; your site never sees the
  session token, and the game never sees your users' data beyond what you send to `/partner/launch`.
- Outcomes, balances and timers are decided on Playmez's servers. Fairness: every match or
  round publishes `sha256(seed)` first and reveals the seed afterwards.

## Points and limits

- Points are virtual entertainment points: they cannot be bought, sold, transferred or redeemed,
  and you must not map them to money, prizes or anything of value. This is a condition of use.
- Practice matches against the computer are free and award a little XP only. Round games
  (roulette, crash, trivia) stake points from the player's virtual wallet: a welcome bonus on
  first launch and a daily free allowance, both set by Playmez.
- One game per frame. Each player can hold one seat per turn-based game at a time. Shared tables, chat and voice: see 1b. Results on your server: webhooks, see 3.

## Running it locally (for Playmez developers)

1. Seed (`pnpm db:seed`) creates the partner `demo` with API key `SEED_PARTNER_API_KEY`
   (default `spk_demo_partner_key_change_me`) and origins `*`.
2. Open http://localhost:3000/sdk/demo.html, pick a game, "Open game".
3. Create real partners under **Admin → Partners**: name, allowed origins, portal owner username, then copy the key
   shown once. "Rotate key" invalidates the old one immediately; "Switch off" blocks launches
   and new sessions.

Env (`apps/api/.env`): `EMBED_BASE_URL` (web origin used in launch URLs, defaults to the first
`WEB_ORIGINS`), `EMBED_SESSION_HOURS` (default 12), `SEED_PARTNER_API_KEY`.
