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#

  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.
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
  1. 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.
  2. 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#

PropertyBehavior
Wire formattt_<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.
PresentationAuthorization: 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.
StorageOnly 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.
Lifetime30, 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).
RevocationImmediate 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.
NetworkOptional IP allowlist of up to 16 CIDR entries, checked against the client address (403 KEY_NETWORK).
Acting userThe key's creator. Handlers run their normal owner and visibility checks; scopes never widen them.
Last usedWritten at most once per minute per key.
AuditApp, key, webhook and tier changes with actor, key prefix and a hashed address; visible to the app owner.
ScopeAllows
read:publicCatalog, listings, releases and release bytes
read:libraryLibrary, bookmarks and sync
write:libraryBookmark, annotate and remove library items
read:projectsOwn draft projects
write:projectsCreate drafts, save, apply revision-checked operation batches and fork. Never publishes.
read:productsOwn publishers' products and listings; needed for product webhooks
write:listingsListing draft fields, assets, documents and translations. Never publishes or changes visibility
read:usageThe key's own app usage
read:notificationsReserved for notification inbox reads
publish:releasesCreate 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) proposedAnonymous (per address)Free keyPro key
read120, 60300, 1003000, 600
writenot limited here¹30, 10300, 60
expensive10, 520, 10240, 60
Rolling 24 h request quotanone10 000500 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)#

TopicBehavior
Eventsversion_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
PayloadThin: {id, type, createdAt, apiVersion, data} where data holds ids and minimal fields (at most 1 KiB). Fetch details through the API.
SignatureTuneToys-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.
Secretwhsec_…, 32 random bytes, shown once, stored AES-256-GCM encrypted under a key derived from the pepper
EndpointPublic 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
VerificationCreation (and URL changes or re-enabling) sends webhook.verify with data.challenge; a 2xx JSON response echoing {"challenge": …} makes the webhook active
RetriesEight 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
DispatcherLeased (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#

PathSource
GET /v1/openapi.jsonpackages/platform-contracts/openapi.json from npm run generate:openapi (strong ETag, 5-minute cache)
GET /developersSwagger 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.txtpackages/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 itemImplemented
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 quotaRecorded 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 storageThe 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 webhooksNot 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 excerptOnly status and state are stored (final schema)
Publisher-role intersection for publisher appsKeys 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 deletionNot yet wired; app deletion revokes its keys
x-tunetoys-cost object, TuneToys-API-Version header, Deprecation/Sunset, api-changelog.mdCost 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/v1Servers are origins (https://tune.toys, http://127.0.0.1:8787) and paths keep /v1, matching the route declarations
Swagger UI at /developers/apiServed at /developers on the API origin
Immutable versioned schema file namesCurrent $ids are unversioned for platform contracts; cached for an hour because pre-1.0 contracts change in place
MCP propose_operation_batch / apply_operation_batchNamed propose_operations / apply_operations; plus get_listing and validate_toy_json
health and config for keysNot key-accessible yet (deny by default; they live in server.ts)

Edit this page on GitHub

esc

↑ ↓ move↩ open⌘↩ new tabesc close