# Developer platform

> Developer platform design: keys, limits, webhooks, OpenAPI and MCP.

Source: https://docs.tune.toys/contributors/developer-platform/

How do scripts, publisher automation and local agents use Tune.Toys without a browser session, and how does the API describe itself? Developer apps hold scoped API keys; the dispatcher authenticates keys, rate-limits and meters them with database-backed buckets; pro apps receive signed webhooks; and an OpenAPI 3.1 document, JSON Schemas at their `$id` URLs, `llms.txt` and an MCP server are generated from the same route declarations that the server dispatches.

**Status:** Implemented locally (WP-12). Numbers marked _proposed_ are defaults pending the owner decision on advertised API limits and terms ([scope decision 5](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/sprints/v0.0.2/00-scope-and-decisions.md)). Third-party OAuth, a hosted MCP endpoint, paid usage packs and CORS for browser-side keys are planned, not implemented. The plan is [12 Developer platform](https://github.com/joshstovall/TuneToys/blob/main/docs/delivery/sprints/v0.0.2/12-developer-platform.md); the wire contract is [the HTTP API](https://github.com/joshstovall/TuneToys/blob/main/docs/specs/http-api.md#developer-api-keys).

## Quickstart

1. Sign in, open **Settings → Developer**, create an app and a key. New keys get `read:public` only; tick each extra scope. The full key (`tt_<prefix>_<secret>`) is shown once.
2. Call the API with the key as a bearer token. Never send cookies with it.

```sh
curl -H "Authorization: Bearer $TUNETOYS_API_KEY" https://tune.toys/v1/catalog?q=delay
curl -H "Authorization: Bearer $TUNETOYS_API_KEY" https://tune.toys/v1/developer/key
```

3. From TypeScript, `new PlatformClient('https://tune.toys/v1', fetch, { apiKey })` sends the bearer, omits credentials and skips CSRF. It refuses to run in a browser page unless `allowBrowserKey` is passed.
4. For agents, run the [MCP server](https://github.com/joshstovall/TuneToys/blob/main/packages/mcp/README.md) with `TUNETOYS_API_KEY`.

The machine-readable reference is `GET /v1/openapi.json`, browsable at `/developers` (Swagger UI from the pinned `swagger-ui-dist`, no CDN; "Try it out" sends `credentials: 'omit'`).

## Authentication and keys

| Property     | Behavior                                                                                                                                                                                                                     |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Wire format  | `tt_<prefix>_<secret>`: 8 lowercase base32 characters, then 43 base64url characters of 32 random bytes and a 6-character CRC32 checksum (`^tt_[a-z2-7]{8}_[A-Za-z0-9_-]{49}$`). The checksum rejects typos without a lookup. |
| Presentation | `Authorization: Bearer tt_…` or `X-TuneToys-Key: tt_…`. A key with a cookie, a second key header or any other Authorization value is `400 MIXED_AUTH`.                                                                       |
| Storage      | Only `HMAC-SHA256(pepper, key)`. The pepper is `TUNETOYS_API_KEY_PEPPER` (64 hex) or, when unset, derived from the native authorization key all API tasks share. Rotating either invalidates every key and webhook secret.   |
| Lifetime     | 30, 90 (default) or 365 days; no non-expiring keys. Rotation issues a replacement with the same scopes and keeps the old key for an overlap (default 24 h, at most 7 days).                                                  |
| Revocation   | Immediate on this instance; other instances drop cached key rows within 30 s. `POST /v1/developer/key/revoke` lets a leaked key revoke itself. Deleting an app revokes its keys and disables its webhooks.                   |
| Network      | Optional IP allowlist of up to 16 CIDR entries, checked against the client address (`403 KEY_NETWORK`).                                                                                                                      |
| Acting user  | The key's creator. Handlers run their normal owner and visibility checks; scopes never widen them.                                                                                                                           |
| Last used    | Written at most once per minute per key.                                                                                                                                                                                     |
| Audit        | App, key, webhook and tier changes with actor, key prefix and a hashed address; visible to the app owner.                                                                                                                    |

| Scope                | Allows                                                                                                                                |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `read:public`        | Catalog, listings, releases and release bytes                                                                                         |
| `read:library`       | Library, bookmarks and sync                                                                                                           |
| `write:library`      | Bookmark, annotate and remove library items                                                                                           |
| `read:projects`      | Own draft projects                                                                                                                    |
| `write:projects`     | Create drafts, save, apply revision-checked operation batches and fork. Never publishes.                                              |
| `read:products`      | Own publishers' products and listings; needed for product webhooks                                                                    |
| `write:listings`     | Listing draft fields, assets, documents and translations. Never publishes or changes visibility                                       |
| `read:usage`         | The key's own app usage                                                                                                               |
| `read:notifications` | Reserved for notification inbox reads                                                                                                 |
| `publish:releases`   | Create versions, upload artifacts and publish releases for your publisher; the only way to register `signed` or `notarized` artifacts |

Keys never reach billing, native device authorization, account authentication, developer app/key/webhook management, moderation, release/listing/storefront publication, visibility changes or server agent proposals. Those route declarations omit `key` from `auth`, so the denial is structural (`403 AUTH_MODE`), and `services/api/test/openapi.test.mjs` fails if one gains it.

Key requests are exempt from Origin, `X-TuneToys-Request` and CSRF checks because they carry no ambient credential. Bodies must still be `application/json` (`415` otherwise) and routes that declare `idempotency: 'required'` need an `Idempotency-Key`. No CORS headers are sent for `/v1`.

## Rate limits and quotas

Keys and anonymous reads use GCRA token buckets stored in `api_rate_buckets`, so limits hold across API instances without Redis. A process-local bucket with the same parameters rejects floods before the database; after each database decision it adopts the shared arrival time. Signed-in browser sessions keep the existing 600/minute fixed window and named limits (auth, proposals, checkout, native grants, listing writes).

| Bucket (per minute, burst) _proposed_ | Anonymous (per address) | Free key | Pro key   |
| ------------------------------------- | ----------------------- | -------- | --------- |
| read                                  | 120, 60                 | 300, 100 | 3000, 600 |
| write                                 | not limited here¹       | 30, 10   | 300, 60   |
| expensive                             | 10, 5                   | 20, 10   | 240, 60   |
| Rolling 24 h request quota            | none                    | 10 000   | 500 000   |

¹ Anonymous writes (registration, login) keep the 600/minute window and their named limits.

Weights come from each route's `cost`: list and sync pages 2, release package/engine/asset bytes 5, webhook test sends 25 in `expensive`, and `0` (no bucket) for `/v1/developer/key`, `/v1/developer/limits`, `/v1/developer/scopes` and `/v1/openapi.json`. Daily byte quotas are recorded in usage but not enforced yet.

Every limited response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds) for the most constrained bucket. A rejection is `429 RATE_LIMIT` with `Retry-After` and `details: {bucket, limit, remaining, resetSeconds, retryAfterSeconds, quota}`; `bucket` is `daily` for the quota. The first quota rejection per key per day notifies the owner (`api_limit_reached`).

Anonymous limits key on the client address from the router's trusted-proxy rule: the socket peer, or the right-most untrusted `X-Forwarded-For` hop when the peer is a configured trusted proxy (`TUNETOYS_TRUSTED_PROXIES`). Without that configuration a load balancer makes every caller share one address; configure it before relying on per-address limits.

## Tiers and usage

`developer_apps.tier` records the last observed effective tier. The effective tier is `pro` while the app owner holds an active `feature:api.pro` grant (the `TuneToys Developer API` billing offer) and `free` otherwise, cached 60 s. A change writes a `tier_changed` audit event and an `api_tier_changed` notification; a downgrade pauses active webhooks without deleting them and an upgrade resumes them. Keys never purchase anything; upgrades go through Settings → Billing.

Usage accumulates in process per key and UTC hour and flushes every 30 s (and before usage reads and on shutdown) as additive upserts into `api_usage`: accepted requests, `429` rejections and response bytes. A crash may lose up to 30 s of counts; usage is informational until a paid pack needs an exact ledger. Usage is kept 90 days; `GET /v1/developer/apps/{appId}/usage` returns zero-filled hourly or daily series with per-key totals and `usage.csv` exports `window_start,key_prefix,requests,rejected,bytes_out`. Keys expiring within 7 days and 1 day notify the owner (`api_key_expiring`) from the hourly sweep.

## Webhooks (pro)

| Topic        | Behavior                                                                                                                                                                                                                                               |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Events       | `version_published`, `listing_updated`, `review_created`, `purchase_completed`, `update_feed_changed` for products of the app's publisher (or the owner's personal publisher), when the app has a live key with `read:products`                        |
| Payload      | Thin: `{id, type, createdAt, apiVersion, data}` where `data` holds ids and minimal fields (at most 1 KiB). Fetch details through the API.                                                                                                              |
| Signature    | `TuneToys-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>` plus `TuneToys-Webhook-Id` and `TuneToys-Event-Id`. Receivers reject `t` older than five minutes and deduplicate on the event ID; the SDK exports `verifyWebhook`. |
| Secret       | `whsec_…`, 32 random bytes, shown once, stored AES-256-GCM encrypted under a key derived from the pepper                                                                                                                                               |
| Endpoint     | Public HTTPS only. The resolved address is checked at connect time against private, loopback, link-local and other reserved ranges; no redirects; 10 s timeout; 64 KiB read limit                                                                      |
| Verification | Creation (and URL changes or re-enabling) sends `webhook.verify` with `data.challenge`; a 2xx JSON response echoing `{"challenge": …}` makes the webhook `active`                                                                                      |
| Retries      | Eight attempts: immediately, then after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. Each attempt is a row; after the last failure a webhook with no success in three days is `disabled`                                                             |
| Dispatcher   | Leased (`lease_until`) runner in the API process every 5 s; HTTP runs outside transactions. Test sends and manual redelivery queue rows and wake it                                                                                                    |

Other packages emit events with `emitWebhookEvent(db, {type, publisherId, data})` from `services/api/src/webhooks.ts`. It only inserts delivery rows, so it is safe inside the caller's transaction, and returns the number queued. `version_published` is emitted from version publication in `versions.ts` (deterministic event ID per version). `listing_updated` (listing publish in `listings.ts`), `review_created` (review creation in `posts-store.ts`/`posts-routes.ts`), `purchase_completed` (grant creation in `billing.ts`) and `update_feed_changed` (`appendUpdateFeeds` in `distribution.ts`) are not wired yet; each owner adds one `emitWebhookEvent` call with thin ids in its existing transaction.

## Agent-facing documents

| Path                                      | Source                                                                                                                                                                                       |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /v1/openapi.json`                    | `packages/platform-contracts/openapi.json` from `npm run generate:openapi` (strong ETag, 5-minute cache)                                                                                     |
| `GET /developers`                         | Swagger UI over the document                                                                                                                                                                 |
| `GET /schemas/index.json`, `/schemas/...` | Every platform contract at `https://tune.toys/schemas/platform/<name>.schema.json` and the Toy schema at `/schemas/toy-0.1.0.schema.json`                                                    |
| `GET /llms.txt`, `/llms-full.txt`         | `packages/platform-contracts/llms/` from `npm run generate:llms`: summary and links; full reference with this guide, MCP tools, operations, schemas, Toy format and node summaries (≤ 1 MiB) |

The OpenAPI document embeds the platform-contract and Toy schemas whole in `components.schemas` with their `$id`s, so relative references between contract files keep resolving, and adds shared error responses, `RateLimit-*` headers, security schemes (`sessionCookie`, `apiKey`, `apiKeyHeader`, `nativeBearer`), per-operation scopes and the extensions `x-tunetoys-stability`, `x-tunetoys-auth`, `x-tunetoys-scopes`, `x-tunetoys-rate-bucket` and `x-tunetoys-cost`. Internal routes (OAuth callbacks, signed webhooks, public HTML) are served but omitted. Pre-1.0 schemas still change in place, so they are cached for an hour rather than marked immutable.

## Differences from the plan

| Plan item                                                                                           | Implemented                                                                                                                                                                         |
| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session GCRA buckets (600/min read for sessions)                                                    | Not enabled: browser sessions keep the existing fixed window so signed-in behavior is unchanged until the owner sets advertised limits                                              |
| Daily bytes-out quota                                                                               | Recorded in usage; not enforced                                                                                                                                                     |
| Pepper file for SQLite (`<database>.api-key-pepper`)                                                | Derived from the native authorization key (already a stable per-deployment secret, with its own file for SQLite) unless `TUNETOYS_API_KEY_PEPPER` is set                            |
| Webhook payload storage                                                                             | The final schema has no event table, so `api_webhook_deliveries.event_id` stores a self-describing `evt.<base64url>` token with the thin payload; every attempt signs the same body |
| Webhook secret rotation with two signatures, `DELETE` webhooks                                      | Not implemented (one secret column; deliveries reference the row). Disable a webhook with `PATCH {state: 'disabled'}`; receivers already accept several `v1=` values                |
| Delivery log latency, error class and response excerpt                                              | Only status and state are stored (final schema)                                                                                                                                     |
| Publisher-role intersection for publisher apps                                                      | Keys act as their creator through each handler's own publisher-role checks; no separate 60 s role cache                                                                             |
| Keys revoked on password reset or account deletion                                                  | Not yet wired; app deletion revokes its keys                                                                                                                                        |
| `x-tunetoys-cost` object, `TuneToys-API-Version` header, `Deprecation`/`Sunset`, `api-changelog.md` | Cost is split into `x-tunetoys-rate-bucket` and `x-tunetoys-cost`; version headers, deprecation headers and the changelog check are planned                                         |
| Servers `https://tune.toys/v1`                                                                      | Servers are origins (`https://tune.toys`, `http://127.0.0.1:8787`) and paths keep `/v1`, matching the route declarations                                                            |
| Swagger UI at `/developers/api`                                                                     | Served at `/developers` on the API origin                                                                                                                                           |
| Immutable versioned schema file names                                                               | Current `$id`s are unversioned for platform contracts; cached for an hour because pre-1.0 contracts change in place                                                                 |
| MCP `propose_operation_batch` / `apply_operation_batch`                                             | Named `propose_operations` / `apply_operations`; plus `get_listing` and `validate_toy_json`                                                                                         |
| `health` and `config` for keys                                                                      | Not key-accessible yet (deny by default; they live in `server.ts`)                                                                                                                  |

## Related

- [HTTP API](https://github.com/joshstovall/TuneToys/blob/main/docs/specs/http-api.md#developer-api-keys) — wire contract and error codes
- [Human and agent authoring](https://docs.tune.toys/contributors/authoring-agents/) — the revision rules every agent write follows
- [Security](https://docs.tune.toys/contributors/security/)
- [MCP server](https://github.com/joshstovall/TuneToys/blob/main/packages/mcp/README.md) and [SDK](https://github.com/joshstovall/TuneToys/blob/main/packages/sdk/README.md)
