# RingStack publishing API (v1)

Base URL: `https://ringstack.co`. Everything `ringstack publish` does is one of
these calls; any HTTP client can do the same.

**Auth**: `Authorization: Bearer rsp_…`, a publisher API token from `ringstack login` or the
publisher dashboard (Publish → API tokens). Tokens are stored hashed, can be revoked, and
work on `/api/v1/publish/*` only: they cannot spend, read wallets or touch player data.
Errors are JSON `{ "error": "<code>", "message": "…" }` with a 4xx/5xx status.

| Method & path | Body → response |
| --- | --- |
| `GET /api/v1/publish/whoami` | → `{ user, publisher, token, limits, fee }` |
| `POST /api/v1/publish/cli-login` (no auth) | `{ clientName }` → `{ deviceCode, userCode, verificationUrl, interval, expiresIn }` |
| `POST /api/v1/publish/cli-login/poll` (no auth) | `{ deviceCode }` → `{ status: "pending" }` or `{ status: "approved", token }` (once) |
| `DELETE /api/v1/publish/token` | revokes the token used for the request |
| `GET /api/v1/publish/games` | → `{ games: [status…] }` |
| `POST /api/v1/publish/games` | manifest (`slug`, `title`, `tagline`, `description`, `tags`, `aspect`, `achievements[]`, `leaderboards[]`, `items[]`) → game status. Creates the game (unlisted draft) or updates it; achievements/leaderboards/items are upserted. Items: `sku`, `name`, `description`, `price` (Rings), `kind` (skin, effect, trail, outfit, emote, title, bundle, other), `slot`, `rarity` (common … legendary), `preview` (path in the build or https URL on the game's origin), `consumable`, `bundle: [sku]`, `available: { from, until }`, `featured: { from, until }`, `active`. |
| `GET /api/v1/publish/games/{slug}` | → `{ slug, status, fee: { paid }, liveVersion, url, listingUrl, dashboardUrl, deploys[] }` |
| `POST /api/v1/publish/games/{slug}/checkout` | → `{ paid: true }` or `{ paid: false, url, paymentId, amount }`. The **owner** opens `url` in a signed-in browser to pay the one-time $5 fee. |
| `POST /api/v1/publish/games/{slug}/thumbnail` | raw PNG/JPEG/WebP/GIF body (≤ 2 MB, 1280×720 recommended) |
| `POST /api/v1/publish/games/{slug}/uploads` | `{ version, changelog, spa?, files: [{ path, size, sha256 }] }` → `{ uploadId, version, build, missing: [sha256…] }` |
| `PUT /api/v1/publish/uploads/{uploadId}/files/{sha256}` | raw file bytes, for each hash in `missing` (≤ 25 MB each) |
| `POST /api/v1/publish/uploads/{uploadId}/deploy` | → `202 { deployId, version, state: "queued", url }` |
| `GET /api/v1/publish/deploys/{deployId}?after={logId}` | → `{ state, version, changelog, error, logs: [{ id, at, level, msg }] }` |
| `GET /api/v1/publish/games/{slug}/deploys` | → `{ deploys: […] }` |
| `GET /api/v1/publish/games/{slug}/versions` | → `{ live, versions: [{ version, build, changelog, files, bytes, live, createdAt }] }` |
| `POST /api/v1/publish/games/{slug}/rollback` | `{ version }` → `202 { deployId, … }` (earlier version that was live before) |

Rules:

- **Version + changelog on every upload**, including the first: `version` is semver
  (`1.2.0`, `2.0.0-beta.1`) and must be greater than every version ever uploaded for the game
  (failed and unfinished uploads count; numbers are never reused). `changelog` is non-empty
  text for players (≤ 5000 chars). Codes: `version_required`, `invalid_version`,
  `version_not_greater` (409, with `latest`), `changelog_required`.
- **Fee**: uploads and deploys return `402 payment_required` until the fee is paid.
- **Files**: ≤ 1000 files, ≤ 25 MB each, ≤ 100 MB total, `index.html` at the root, no hidden
  files, no `_headers`/`_redirects`/`_worker.js`/`_routes.json`. `spa: true` serves
  `index.html` for unknown paths (otherwise `404.html` if present).
- **Deploy states**: `queued` → `building` → `live` | `failed` (a newer queued deploy
  supersedes an older one; the previous live one becomes `superseded`).
- **Review**: the first live build sends the game to admin review (`status: "pending"`);
  later versions of an approved game go live in the catalog immediately.

## A full publish with curl

```bash
P=https://ringstack.co/api/v1/publish
H="Authorization: Bearer $RINGSTACK_TOKEN"
SLUG=my-game

# 1. Register (or update) the game and its achievements/leaderboards
curl -sf -H "$H" -H 'Content-Type: application/json' "$P/games" -d '{
  "slug": "'$SLUG'", "title": "My Game", "aspect": "16:9",
  "leaderboards": [{ "key": "score", "name": "High score" }],
  "achievements": [{ "key": "first_win", "name": "First win", "points": 10 }]
}'

# 2. First time only: pay the one-time fee (open the URL in a signed-in browser)
curl -sf -H "$H" -X POST "$P/games/$SLUG/checkout"

# 3. Describe the build: path, size and sha256 of every file under public/
FILES=$(cd public && find . -type f ! -name '.*' | sed 's|^\./||' | while read -r f; do
  printf '{"path":"%s","size":%s,"sha256":"%s"},' "$f" "$(wc -c < "$f" | tr -d ' ')" "$(sha256sum "$f" | cut -d' ' -f1)"
done | sed 's/,$//')
UP=$(curl -sf -H "$H" -H 'Content-Type: application/json' "$P/games/$SLUG/uploads" \
  -d '{"version":"1.0.0","changelog":"First release.","files":['"$FILES"']}')
UPLOAD_ID=$(echo "$UP" | jq -r .uploadId)

# 4. Upload the files RingStack doesn't have yet
for sha in $(echo "$UP" | jq -r '.missing[]'); do
  f=$(cd public && find . -type f ! -name '.*' -exec sha256sum {} + | awk -v s="$sha" '$1==s{print $2; exit}')
  curl -sf -H "$H" -X PUT --data-binary "@public/${f#./}" "$P/uploads/$UPLOAD_ID/files/$sha" > /dev/null
done

# 5. Deploy and follow it
DEP=$(curl -sf -H "$H" -X POST "$P/uploads/$UPLOAD_ID/deploy" | jq -r .deployId)
until curl -sf -H "$H" "$P/deploys/$DEP" | jq -e '.state == "live" or .state == "failed"' > /dev/null; do sleep 3; done
curl -sf -H "$H" "$P/deploys/$DEP" | jq '{state, version, url, logs: [.logs[].msg]}'
```

## Cosmetics at runtime (SDK HTTP API)

These are what the SDK calls (`/api/v1/games/{slug}/…`, bearer token in token mode, or through
the portal when embedded):

| Method & path | |
| --- | --- |
| `GET items` | items on sale, with `kind`, `rarity`, `slot`, `preview` |
| `GET inventory` | `{ items, loadout }` for the player |
| `GET loadout` · `PUT loadout/{slot} { sku }` · `DELETE loadout/{slot}` | the player's equipped items (must own them) |
| `GET loadouts?users=u_1,u_2` | other players' loadouts (up to 100, no sign-in) |
| `POST cosmetics/attest` | `{ token, exp, loadout, owned }`: Ed25519 JWT for your game server |

Verify an attestation token on your server: it's a JWT with `alg: EdDSA` and a `kid`; fetch
the public keys from `https://ringstack.co/api/v1/jwks.json`, check the signature,
`aud` (your game slug), `iss` (`https://ringstack.co`) and `exp`. Claims: `sub` (player
id), `name` (username), `loadout` (`{ slot: sku }`), `owned` (skus in the loadout, owned at
signing time), `iat`, `exp` (10 minutes), `jti`.
