# dots.id REST API
Base URL = your dots issuer (dev: `https://dots.localhost`, prod: `https://www.dots.id`).
Auth types:
- **Owner** — first-party Privy session/token, for the dots UI only.
- **App** — dots OAuth access token. Every app-facing route enforces its documented scope and binds writes to the token's `client_id`.
- **Admin** — `Authorization: Bearer <DOTS_ADMIN_SECRET>` (server-to-server only).
- **OAuth** — standard OAuth2/OIDC endpoints under `/api/oauth/*`.
## OAuth (dots as provider)
| Method | Path | Notes |
|---|---|---|
| GET | `/api/oauth/authorize` | `client_id, redirect_uri, scope, code_challenge, code_challenge_method=S256, state, nonce?` |
| POST | `/api/oauth/device` | RFC 8628 device authorization: `{ client_id, scope? }` → `{ device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }` |
| GET | `/oauth/device` | hosted user-code entry + approval screen (`?user_code=` pre-fills) |
| POST | `/api/oauth/token` | `grant_type=authorization_code\|refresh_token\|urn:ietf:params:oauth:grant-type:device_code`; returns `access_token`, `refresh_token?`, `id_token?`, `expires_in`. Device polling returns `authorization_pending` / `slow_down` / `access_denied` / `expired_token` until approved |
| GET | `/api/oauth/userinfo` | OIDC userinfo (Bearer): scoped claims + `identity_stage`, `dots_id_status`, `wallet_confirmed`, `mcp`, `bootstrap`; `listen` graph with the `listen` scope. Portable context is **not** here — use `/api/identity/bootstrap` |
| POST | `/api/oauth/register` | RFC 7591 dynamic client registration (public PKCE clients; narrower scope allowlist) |
| GET | `/api/oauth/client-info` | `?client_id=` public client metadata for consent screens (cacheable) |
| POST | `/api/oauth/introspect`, `/api/oauth/revoke` | token introspection / revocation |
| `.well-known` | `/.well-known/openid-configuration`, `/oauth-authorization-server`, `/oauth-protected-resource`, `/jwks.json` | discovery + keys (`service_documentation` → `/developers`) |
| Machine index | `/openapi.json`, `/.well-known/api-catalog`, `/.well-known/agent-skills/index.json`, `/llms.txt` | OpenAPI 3.1 with scopes, API catalog, agent skills, agent instructions |
### Device sign-in (bots, CLIs, chat agents)
Use this when the client cannot open a browser to a redirect URI. Register a
public client (or use dynamic registration), then:
```sh
curl -X POST https://www.dots.id/api/oauth/device \
-H 'content-type: application/json' \
-d '{"client_id":"<client_id>","scope":"openid profile context:read"}'
# show user_code / verification_uri_complete to the user, then poll:
curl -X POST https://www.dots.id/api/oauth/token \
-d 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \
-d 'device_code=<device_code>' -d 'client_id=<client_id>'
```
Respect `interval` (seconds) between polls; `slow_down` means add 5s. Codes
expire after `expires_in`. There is no published `dots` CLI yet — this flow is
what one would wrap.
### MCP
`POST /mcp` (streamable HTTP) with a dots Bearer token. Tools: `whoami` (any
valid token), `search` and `recent` (`context:read`), `remember`
(`context:write`). Unauthenticated calls return 401 with RFC 9728
`WWW-Authenticate: Bearer resource_metadata=…`. Server card:
`/.well-known/mcp/server-card.json`.
The OIDC `sub` is an immutable dots-owned identifier. Store users under a unique
`(iss, sub)` key. Never merge or upsert accounts by email, wallet, or username.
Tokens also expose progressive assurance through `identity_stage`,
`dots_id_status`, and `wallet_confirmed`.
Better Auth applications should use its Generic OAuth plugin with discovery and
PKCE enabled. See [`docs/BETTER_AUTH.md`](../BETTER_AUTH.md). Better Auth owns
the app's local session; dots access tokens authorize calls to dots APIs.
## Identity / dots.id creation
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET | `/api/identity/bootstrap` | `openid` App (`context:read` for context) | — | one-call login snapshot: identity, color, `identity_stage`, and — with `context:read` — a compact `context` snapshot (counts + recent previews scoped to this client). Hydrate your app from `sub` + this; the same shape backs MCP `whoami` |
| GET | `/api/identity/status` | User | `?lite=1` skips Stripe/loyalty/connection extras | full creation status (wallet, username, vercel, email, subscription, color, baseColor, loyalty, dotSuggestions, peopleHexes, connections.listen); first fetch auto-assigns a random color verified unminted via base.colors.bio |
| GET | `/api/identity/color` | User | — | the dots' color: `{ hex, source: minted\|assigned, canReroll, rerollCost, claimCost, attestationsEnabled, previous }` (minted NFT wins over assigned; `previous` = open 48h window) |
| POST | `/api/identity/color` | `pute:spend` (revert: User) | `{}` \| `{ hex }` \| `{ revert: true }` + `requestId?` | `{}` rolls a random color (rerollCost), `{hex}` claims a specific one (claimCost), `{revert}` restores the previous color free inside the 48h window; idempotent debit, auto-refund on failure; `409 color_minted` once permanent |
| GET | `/api/identity/color/check` | User | `?hex=` | `{ available, reason: held\|minted\|null, cost }` — dots holders (incl. 48h-held previous colors) + base.colors.bio catalog |
| GET | `/api/identity/username` | `username:create` App | `?username=` | global availability across dots.id people/org handles, reserved product routes, and just.is Firebase reservations |
| POST | `/api/identity/username` | `username:create` App | `{ username }` | creates the authenticated user's permanent dots.id username; the OAuth client must enable username creation |
| POST | `/api/identity/confirm` | User | `{ signature, message, walletAddress, timestamp }` | `{ success }` — sets `dots_id_status=complete`, provisions Turso |
| GET | `/api/identity/activity` | Owner or `context:read` | `?limit&category&before` | `{ activity: [...] }`; apps see only their own records |
| GET | `/api/identity/live` | Owner | — | `{ worlds, slots, feed, liveCount }` — finite catalog of possible worlds (listen sources, vercel, signed-in apps) with Jazz routines (`realtime` \| cron `15m/1h/6h/1d` \| `off`) plus a session feed |
| GET | `/api/identity/vercel` | Owner | — | connected Sign in with Vercel account plus `{ teams, defaultTeam }` for the default deploy team |
| PATCH | `/api/identity/vercel` | Owner | `{ teamId }` | persist the default deploy team for this identity |
| PATCH | `/api/identity/live` | Owner | `{ worldId, choice }` or `{ worldId, cadence, every? }` | saves the routine to `identity_live_routines` and mirrors Jazz `live_routines` |
| GET/POST | `/api/identity/context` | Owner or `context:read/write` | POST `{ content, kind?, role?, metadata? }` | app-scoped context history |
| GET/PUT/DELETE | `/api/identity/context-policy` | Owner (OAuth can GET its own) | PUT `{ appClientId, ingestEnabled?, readEnabled?, indexEnabled?, realtimeEnabled?, durableEnabled?, actionsEnabled?, allowedKinds?, maxContentBytes?, retentionDays? }` | owner-controlled per-app handling rules; DELETE resets defaults |
| GET/POST | `/api/identity/jazz` | GET: Owner or `context:read`; POST: Owner or `context:write` | POST idempotently provisions the caller's Jazz v2 profile row (no invites) | `{ configured, jazzVersion: 2, appId, serverUrl, syncMode }` — mount `JazzProvider` with the dots JWT |
| GET/PUT | `/api/identity/sync-mode` | User | PUT `{ mode }` | Jazz sync preference: `realtime` \| `lazy` \| `off` (default `lazy`) |
| GET/POST/PUT | `/api/identity/notes` | GET: `context:read` or `delegate`; POST/PUT: `delegate` or `context:write` | GET lists owned + shared Jazz notes (OAuth scoped to `client_id`); POST `{ title?, content?, appFileId? }` creates a row; PUT `{ noteId, title?, content? }` updates when the caller is owner or writer | First-party console `/notes` (txt-fil.es UI). `appClientId` comes from OAuth `client_id`, or `dots` for first-party creates |
| POST/PUT | `/api/identity/notes/session` | `delegate` or `context:write` | `{ appFileId, title?, content? }` (`appClientId` from OAuth `client_id`) | POST provisions/reuses a per-doc Jazz note row; PUT write-through title/content into an existing session |
| GET/POST | `/api/identity/notes/share` | GET: `context:read` or `delegate`; POST: `delegate` or `context:write` | POST `{ appFileId, title?, content?, granteeDotsSub\|granteeUsername, role? }`; GET `?shareId=` | POST grant + Jazz invite for a collaborator; GET mints a role-scoped invite for an existing share |
| GET/POST | `/api/identity/files` | Owner | — | personal, directly shared, and org files; POST retries pending Jazz manifests |
| GET/PUT | `/api/identity/files/:fileId/content` | `files:read/write` | raw bytes | private download or writer replacement |
| POST/DELETE | `/api/identity/files/:fileId/share` | `files:share` | `{ dotsSub, role }` | grant or revoke direct person-to-person access |
| GET/POST | `/api/identity/files/:fileId/jazz` | `files:read` | — | isolated shared-file Jazz root and role-scoped invite |
| POST | `/api/identity/sync` | Owner/App | — | mirrors the durable aggregation copy to Turso |
| GET/POST | `/api/identity/ur/sync-health` | Owner | — | GET reports UR layer readiness; POST runs an ephemeral Supabase → Jazz Cloud → Turso write/read/cleanup round-trip |
| GET | `/api/identity/emails` | User | — | verified-email attestations |
| POST | `/api/identity/backup-email/send` | User | `{ email }` | sends a verification link |
| GET | `/api/identity/dots` | User | — | `{ suggested, connected, file }` — connected is connections.txt; suggestions are overlaps not yet in the file |
| GET | `/api/identity/connections` | User | — | `{ file: "connections.txt", text, hexes }` — people are a string of color hexes; no graph/activity write |
| PUT | `/api/identity/connections` | User | `{ text }` \| `{ hexes }` \| `{ add }` \| `{ remove }` | rewrite or patch the hex file; 1px presence follows |
| POST | `/api/identity/dots/connect` | User | `{ suggestionId, action }` | `connect` appends their color to connections.txt (does not write a graph row); `dismiss` hides the suggestion |
Username creation is disabled for every OAuth client by default. Enable it in
the client dashboard; this also enables the `username:create` consent scope.
First-time users then hit a hosted claim surface at `/oauth/claim` during
authorize: colors shuffle until a free hex is assigned, the suggested name is
checked, and they can claim it or type a different one. Apps can also call
`GET/POST /api/identity/username` after consent. Reservations remain unavailable
except to a caller whose verified profile email matches the migrated just.is
reservation.
### Live just.is transition
While the legacy just.is Firebase database still accepts username writes, set
`JUST_IS_FIREBASE_LIVE_ENABLED=true` and configure
`FIREBASE_SERVICE_ACCOUNT_JSON`. Availability and creation then query Firebase
Realtime Database live and mirror successful lookups into `reserved_usernames`.
Firebase is authoritative in this mode: lookup failures fail closed, while
ordinary sign-in continues without automatically assigning a cached name.
## Calls (shared endpoints + JSON viewer)
Personal console under `/hooks` (saved calls tab). Save API endpoints from users or apps, run them through a server proxy, and inspect responses in a visual JSON tree. Public calls share a snapshot at `/calls/s/:code`. `/calls` redirects to `/hooks?tab=calls`.
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET/POST | `/api/identity/calls` | Owner | POST `{ name, url, method?, headers?, body?, notes?, visibility?, source?, appClientId? }` | `{ calls }` / `{ call }` |
| GET/PATCH/DELETE | `/api/identity/calls/:id` | Owner | PATCH fields above | `{ call }` / `{ ok }` |
| POST | `/api/identity/calls/:id/run` | Owner | — | fetch endpoint (SSRF-guarded), store `lastBody` / status, return `{ call }` |
| GET | `/api/identity/calls/share/:code` | Public | — | public call snapshot (headers redacted) |
| POST | `/api/identity/calls/clone` | Owner | `{ code }` | clone a public shared call into your list |
## Verification (attestations)
First-party personal console under `/sports`. Fan picks are multi-sport rows (not a second bio). Overlap is among people in your connections.txt hex file plus remaining signal suggestions. Portals bridge fan + pickup with private/public invite links.
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET/POST/DELETE | `/api/identity/sports/picks` | Owner | POST `{ sport, entityKind, label, externalRef?, priority? }`; DELETE `?id=` | `{ picks }` / `{ pick }` / `{ ok }` — `entityKind`: country\|club\|team\|athlete\|competition\|other |
| GET | `/api/identity/sports/overlap` | Owner | — | `{ overlap: [{ privyUserId, username, displayName, baseColorHex, connectionStatus, sharedPicks }] }` |
| GET/POST | `/api/identity/sports/portals` | Owner | POST `{ name, description?, visibility?, locationLabel? }` | `{ portals }` / `{ portal }` with `joinUrl` |
| GET/PATCH | `/api/identity/sports/portals/:id` | Owner (member) | PATCH `{ name?, description?, visibility?, locationLabel? }` | `{ portal }` — PATCH owner-only |
| POST | `/api/identity/sports/portals/:id/invite` | Owner | — | rotate invite code |
| POST | `/api/identity/sports/portals/join` | Owner | `{ code }` | join portal by invite/public code |
| GET/POST | `/api/identity/sports/sessions` | Owner | POST `{ portalId, title, locationLabel, locationDetail?, startsAt, visibility? }`; GET `?portalId` | `{ sessions }` / `{ session }` |
| POST | `/api/identity/sports/sessions/:id/rsvp` | Owner | `{ status: going\|maybe\|out }` | `{ ok }` |
| GET | `/api/identity/sports/public/:code` | Public | — | resolve portal or session join payload for `/sports/p/:code` |
Fan/session writes also mirror into `identity_context` as kinds `sports.fanhood` / `sports.pickup`.
## Listen (aux auth)
First-party `/listen` is the streaming sign-in shim. Meshcast history still arrives through a-x.to. Spotify, Apple Music, YouTube Music, Tidal, and SoundCloud appear only when this dots has admin credentials (`SPOTIFY_*`, `APPLE_MUSIC_*`, `YOUTUBE_*` or `GOOGLE_*`, `TIDAL_*`, `SOUNDCLOUD_*`). Tokens stay encrypted on the identity. Apps request the `listen` scope and read sources, now playing, favorite artists, and recent plays — they never receive provider tokens. `GET /api/identity/status` also lists connections under `connections.listen`.
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET | `/api/identity/listen` | Owner or `listen` | `?limit` | Snapshot: `sources[]`, `artists[]`, `nowPlaying`, `history`, `connectUrl` |
| POST | `/api/identity/listen/sync` | Owner | — | re-read Meshcast Turso rows and pull libraries from connected sources into Jazz |
| GET | `/api/identity/listen/connect/:provider` | Owner | — | Spotify/YouTube Music/Tidal/SoundCloud: OAuth redirect. Apple Music: `{ developerToken }` for MusicKit JS |
| POST | `/api/identity/listen/connect/apple_music` | Owner | `{ userToken }` | store the MusicKit music-user token |
| GET | `/api/identity/listen/connect/:provider/callback` | — | OAuth `code` + `state` | redirects to `/listen?connected=` or `?listen_error=` |
| DELETE | `/api/identity/listen/connect/:provider` | Owner | — | drop stored tokens + `identity_connections` listen row |
Redirect URIs: `{APP_URL}/api/identity/listen/connect/{spotify\|youtube_music\|tidal\|soundcloud\|apple_music}/callback`. Env: `SPOTIFY_CLIENT_ID`/`SECRET`, `TIDAL_CLIENT_ID`/`SECRET`, `SOUNDCLOUD_CLIENT_ID`/`SECRET`, `YOUTUBE_CLIENT_ID`/`SECRET` (or `GOOGLE_CLIENT_ID`/`SECRET`), `APPLE_MUSIC_TEAM_ID`/`KEY_ID`/`PRIVATE_KEY`.
With `listen` on an access token, `GET /api/oauth/userinfo` includes a compact `listen` claim (`connect`, `connected`, `sources` including meshcast, `now_playing`, recent `history`, `artists`). Full snapshot stays on `GET /api/identity/listen`. First-party `/listen` **sync to apps** calls `POST /api/identity/listen/sync` so pages like 01.the.fans pick up Meshcast + streaming plays.
## Vercel (deploy connector)
First-party `/vercel` is Sign in with Vercel for this identity. After the account is verified, the owner picks a **default deploy team**. Tokens stay encrypted on `vercel_oauth_tokens`. Team listing uses the Sign in with Vercel access token (`GET /v2/teams`); API permissions for team resources are still in Vercel private beta, so the surface falls back to Vercel's own default team id and any previously saved choice.
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|
| GET | `/api/identity/vercel/authorize` | Owner | `?next=/vercel\|/create/identity\|/profile` | redirect to Vercel IdP |
| GET | `/api/identity/vercel/callback` | — | OAuth `code` + `state` | redirects to `next` (`/vercel?connected=1` or `/create/identity?success=vercel`) |
| GET | `/api/identity/vercel` | Owner | — | `{ connected, username, email, teams[], defaultTeam, suggestedTeamId, teamsError }` |
| PATCH | `/api/identity/vercel` | Owner | `{ teamId }` | persist default deploy team; `400 invalid_team` if the id is not on this account |
Redirect URI: `{APP_URL}/api/identity/vercel/callback`. Env: `VERCEL_CLIENT_ID` / `VERCEL_CLIENT_SECRET`. OIDC scopes: `openid email profile offline_access`.
## Verification (attestations)
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| GET | `/api/identity/attestations` | User | — | `{ attestations: [{ kind, identifier, issuer, verified, verifiedAt }] }` |
| POST | `/api/identity/attestations` | User | `{ proof }` | `{ ok, attestationId, method }` — `422` if the proof doesn't verify |
Action attestations (hyperhooks, both `eip712-wallet` and `jws-issuer` modes)
require the actor to be a **verified dot** — a color minted to their wallet.
An assigned (rented) color identifies a dot but can't anchor an attestation;
issuer receipts carry the anchor as `anchorColor` / `anchorTokenId` JWS claims
so counterparties confirm interactions against the on-chain color.
## Credits (pute)
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| GET | `/api/pute/balance` | `pute:read` | — | `{ balance, currency }` |
| POST | `/api/pute/debit` | `pute:spend` | `{ amount, requestId }` | `{ ok, balance, idempotent, attestationId, attestationGate? }` — `requestId` is required; `402` if insufficient; `attestationGate: "verified_color_required"` when the debit succeeded but the actor has no verified (minted) color to anchor an attestation |
| POST | `/api/pute/topup` | `pute:topup` | `{ credits?, requestId? }` | `{ url, credits, amountUsd, requestId }` (Stripe Checkout) |
| POST | `/api/pute/topup/crypto` | `pute:topup` | `{ usd? \| credits?, requestId? }` | Quote Base USDC→pute: `{ treasuryAddress, amountRaw, credits, transfer: { to, data, chainId } }` |
| POST | `/api/pute/topup/crypto/confirm` | `pute:topup` \| `pute:read` | `{ txHash }` | Verify on-chain USDC→treasury, mint credits. Idempotent on `crypto:base:<txHash>`. |
| POST | `/api/admin/pute/grant` | Admin | `{ privyUserId, amount, reason?, requestId? }` | `{ ok, balance }` |
| GET | `/api/admin/pute/grant` | Admin | `?privyUserId` | `{ privyUserId, balance }` |
Crypto top-ups use the same credit rate as Stripe (`PUTE_CREDITS_PER_USD`, default 100/$1) and the same min/max. Privy `wallet.funds_deposited` to the treasury also credits as a webhook backup.
## Loyal (global rewards)
Loyal is the cross-app rewards layer. dots pulls eligibility, receives webhooks, and reports checkouts Loyal does not already see.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | `/api/webhooks/loyal` | Loyal HMAC (`X-Loyal-Signature`) | Events: `cohort.matched`, `loyalty.points_awarded`, `reward.redeemed`. Dedupe via `X-Loyal-Delivery-Id`. |
| GET | `/api/identity/loyalty` | User | Pull eligibility / rewards (existing) |
Register once (save returned `whsec_...` as `LOYAL_WEBHOOK_SECRET`):
```bash
LOYAL_API_KEY=... bun scripts/register-loyal-webhook.ts
```
Pute top-ups report to `POST /v1/actions/payment` with stable `externalId=dots_pute_<stripe_session_id>`.
## Data (per-user storage, app-scoped)
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET | `/api/oauth/data` | User + `data:read` | `?collection&key` | `{ data: [{ collection, key, value, created_at, updated_at }] }` |
| POST | `/api/oauth/data` | User + `data:write` | `{ collection, key, value }` | `{ success, client_id, collection, key }` |
Data lives in the user's own Turso database, namespaced by your `client_id`.
The server ignores client-supplied attribution and derives the namespace from
the validated access token.
## Files (dots-blob, Files SDK compatible)
`GET/POST/PUT /api/files` is a Files SDK HTTP gateway backed by private Vercel
Blob. Use `files:read`, `files:write`, and `files:share`. Personal and org
objects are isolated by dots subject/org and then by the validated OAuth
`client_id`. Send `X-Dots-Org-Id` with `org:act` for org storage. See
[`docs/FILES.md`](../FILES.md).
## Orgs
Turning an account into an org keeps the person profile. The org is a separate shell (`from_account`) with org-only lanes: affiliate → clientele → team → admin → exec. Groups skip lanes, chart, passages, and decisions.
| Method | Path | Auth | Body / Query | Returns |
|---|---|---|---|---|
| GET/POST | `/api/orgs` | Owner | POST `{ name, kind?, groupPurpose?, username?, description? }` | collectives the caller belongs to / created org |
| POST | `/api/orgs/from-account` | Owner | — | one account-org; `409 account_org_exists` if already promoted |
| GET | `/api/orgs/:orgId` | Owner (member) | — | org/group detail + membership |
| GET/POST/PATCH | `/api/orgs/:orgId/people` | Owner (member; POST/PATCH leadership) | POST `{ username\|privyUserId, relation }`; PATCH `{ username\|privyUserId, reportsToUsername?, reportsTo?, relation? }` | lanes + chart (`reportsTo`); org-only writes |
| GET/POST | `/api/orgs/:orgId/passages` | Owner (member) | POST `{ toRelation?, note? }` | right of passage queue; POST requests a higher lane |
| POST | `/api/orgs/:orgId/passages/:passageId` | Owner (admin/exec) | `{ action: approve\|deny }` | grant or deny; cannot grant your own |
| GET/POST | `/api/orgs/:orgId/decisions` | Owner (member; POST team+) | POST `{ title, body?, requiredRelation? }` | decision process; `requiredRelation` is `admin` or `exec` |
| POST | `/api/orgs/:orgId/decisions/:decisionId` | Owner | `{ action: pass\|reject\|withdraw }` | close (admin/exec bar) or withdraw your own |
| GET/POST | `/api/orgs/:orgId/jazz` | `files:read org:act` | — | org/group Jazz root and role-scoped invite |
## Co-sign _(alpha preview)_
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
| GET | `/api/identity/cosign` | User | — | `{ cosigns: [...] }` (issued + received) |
| POST | `/api/identity/cosign` | User | `{ message, signature, secondFactor, statement? }` | `{ ok, cosignId }` — requires confirmed dots.id + ≥1 connected dot + wallet match + zkTLS second factor |
| POST | `/api/identity/cosign/accept` | User | `{ cosignId }` | `{ ok }` |
| POST | `/api/identity/cosign/revoke` | User | `{ cosignId }` | `{ ok }` |
## Dashboard (manage your apps / API keys)
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET/POST | `/api/dashboard/clients` | User (owner) | list / create OAuth clients (API keys) |
| GET/PATCH/DELETE | `/api/dashboard/clients/[id]` | User (owner) | manage a client |
| POST | `/api/dashboard/clients/[id]/regenerate-secret` | User (owner) | rotate the client secret |
Error shape is consistent: `{ error, error_description? }` with the matching HTTP status (`400/401/402/403/404/422/500`).