Developer platform
Developer platform design: keys, limits, webhooks, OpenAPI and MCP.
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). 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; the wire contract is the HTTP API.
Quickstart#
- Sign in, open Settings → Developer, create an app and a key. New keys get
read:publiconly; tick each extra scope. The full key (tt_<prefix>_<secret>) is shown once. - Call the API with the key as a bearer token. Never send cookies with it.
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- 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 unlessallowBrowserKeyis passed. - For agents, run the MCP server 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 $ids, 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 $ids 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 — wire contract and error codes
- Human and agent authoring — the revision rules every agent write follows
- Security
- MCP server and SDK