© 2026 dots.id
TermsPrivacyApps
← developersopenapi.json
# 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 public PKCE or device-only registration; save the client ID and inspect `scope` / `dots_missing_scopes` |
| 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`. The published `@wrldbld/dots-cli@0.2.0-alpha.0` wraps this flow.
For your own agent, the core SDK's `createDotsAgentAuth(dots)` exposes
`signIn()` / `status()` / `cancel()` results while keeping tokens in the runtime.
See [the agent setup guide](/docs/webmcp) for package availability and examples.

### MCP

Connect to `https://www.dots.id/mcp` using streamable HTTP. Discovery, `setup`,
`register_app` and the `dots://docs/install` resource are public. Call `signin`
to start the MCP host's OAuth flow: the human signs in or creates an account
on Dots and approves access, then the host retries with its token. If the host
does not start OAuth on a tool's 401, use its Connect/Reconnect control.

Private calls require a dots Bearer token whose audience includes that exact
MCP URL. Tools: `signin`, `whoami`, `search`,
`recent`, `remember`, `notes`, `note`, `stumble`, and the `group.*` room tools. The
core SDK WebMCP adapter exposes the complete `tools/list` catalog to browser
agents; see [setup and tool descriptions](/docs/webmcp). Unauthenticated private calls return 401 with RFC 9728
`WWW-Authenticate: Bearer resource_metadata=…`. Server card:
`/.well-known/mcp/server-card.json`.

Dots is the resource server. Cursor, a bot, or another connector is the OAuth
client, with its own registration and user consent. There is no universal
"Dots of Dots" client ID that grants access to everyone or every app. A
Dots-managed connector can have a registration owned by a Dots operator;
ownership does not relax callback matching or consent. The current management
UI is `/dev/clients`, backed by `/api/dashboard/clients`; DCR-created clients
use the `dcr:public` sentinel owner. `admin.dots.id` currently manages account
operations, not this OAuth registry. Do not mark external connectors internal
just to skip sign-in consent.

#### Cursor desktop and web

Merge this entry into the connector's `mcp.json` and let it register through DCR:

```json
{
  "mcpServers": {
    "dots": { "url": "https://www.dots.id/mcp" }
  }
}
```

If using a pre-registered client, its `redirect_uris` must include the callbacks
for the surfaces in use. Cursor currently documents
`http://localhost:8787/callback` for desktop and
`https://www.cursor.com/agents/mcp/oauth/callback` for web/agents.
Use the callback reported by the actual connector; other hosts need their own
registration. See [Cursor's MCP documentation](https://prod.cursor.com/docs/mcp#static-redirect-url).

An example registration body for those two Cursor surfaces, sent to
`POST /api/oauth/register`, is:

```json
{
  "client_name": "Dots context in Cursor",
  "token_endpoint_auth_method": "none",
  "redirect_uris": [
    "http://localhost:8787/callback",
    "https://www.cursor.com/agents/mcp/oauth/callback"
  ],
  "scope": "openid profile context:read offline_access"
}
```

Save the returned `client_id` in the connector's static OAuth config. Cursor's
entry can include `"auth": {"CLIENT_ID": "RETURNED_CLIENT_ID", "scopes":
["openid", "profile", "context:read", "offline_access"]}`. Public clients have
no client secret. To read the user's context across apps, explicitly enable
and request `context:read:all` in addition to `context:read`, then obtain user
consent. Add `context:write` for remembering or drafting and `notes:share` for
invitations only when needed. A DCR allowlist is not a user grant.

#### Grok and other bots

For Grok Build, the documented remote OAuth setup becomes:

```sh
grok mcp add --transport http dots https://www.dots.id/mcp
grok mcp doctor dots --json
```

Grok Build performs browser OAuth on first use. See its [MCP setup](https://docs.x.ai/build/features/mcp-servers).
For a hosted Grok bot, use that host's actual HTTPS callback. For a headless
bot using the xAI API, the bot must first obtain a Dots MCP resource token and
pass it as the remote MCP tool's `authorization`; the xAI API connection alone
does not sign the user into Dots. See [remote MCP authorization](https://docs.x.ai/developers/tools/remote-mcp).
Use Dots device authorization when the bot cannot receive a browser callback.

#### Callback and token rules

- `GET /api/oauth/authorize` and `POST /api/oauth/consent` share one validator.
  Public clients require an explicit S256 challenge (43 base64url characters).
- A registered HTTP loopback callback may use a different port for a public
  PKCE client. The literal host, path and query remain identical. `localhost`,
  `127.0.0.1`, and `[::1]` are separate registrations, not interchangeable aliases.
  IP literals are preferred; `localhost` is supported for connector compatibility.
  HTTPS, custom native schemes and confidential-client callbacks match exactly.
- Token exchange must send the **exact concrete redirect_uri used at authorize**,
  including its selected port, and the original verifier. The port exception
  applies to registration matching only. This follows [RFC 8252 §8.4](https://www.rfc-editor.org/rfc/rfc8252.html#section-8.4).
- Send `resource=https://www.dots.id/mcp` at authorization and exchange. If the
  bot also calls REST, authorize both `/mcp` and `/api` resources. Token exchange
  cannot expand the grant, and an ID token is not an MCP access token.
- `/oauth/device` is where the person approves a device code. Registering that
  page does not authorize `http://localhost:8787/callback`. Repair the selected
  client's registration through its owner, or re-register and replace the
  connector's cached client ID. Then restart authorization with fresh PKCE/state.

The original `invalid_request` / `Invalid redirect_uri` report is a registration
mismatch: the client had the device page, while the request used the connector's
loopback callback. Do not fix it by accepting unregistered loopback URLs for
every public client. Invalid callbacks now return a local 400 with registration
guidance, without redirecting or issuing a code.

`whoami` exposes the scoped identity snapshot, permitted context previews and,
with `listen`, the existing listening graph. Search/recent/notes obey context
policy; note sharing uses accepted, revocable grants. This native MCP does not
automatically proxy every connected provider's API or expose their credentials.
Social handles are available through scoped OIDC userinfo (`social:twitter`,
`social:instagram`, `social:google`); captured social content is searchable when
it has been ingested into permitted Dots context. `mcp:use` belongs to the separate
third-party MCP connection feature and is not required for this native server.

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.

## Resource audiences and note invitations

Access tokens target `https://www.dots.id/api` or `https://www.dots.id/mcp`; ID tokens target the OAuth client. Send repeated `resource` parameters during authorization (or an array in device JSON). Omission defaults to API-only. Refresh preserves the original resource ceiling and cannot add scopes. Reconnect old MCP sessions after this release.

`context:read` is app-specific through every interface. Additionally consenting to `context:read:all` enables cross-app reads. Retrieval without an index searches the most recent 100 authorized records. Per-record shares use their own acceptance and permission checks.

| Method | Path | OAuth scope | Behavior |
| --- | --- | --- | --- |
| GET/POST | `/api/identity/grants` | read: `context:read`; invite: `notes:share` | List invitations or create a pending invitation |
| GET/PATCH/DELETE | `/api/identity/grants/{grantId}` | `context:read`; owner revocation also `notes:share` | Inspect, accept/decline, or revoke |
| GET/PUT | `/api/identity/notes/{noteId}` | `context:read` / `context:write` | Read or edit using current resource permission |
| GET/POST | `/api/identity/notes/{noteId}/drafts` | `context:read` / `context:write` | List or submit separate drafts; POST needs accepted `note:draft` and idempotent `requestId` |

Example invite body: `{ "noteId": "NOTE_ID", "recipientUsername": "alex", "permissions": ["note:read", "note:draft"], "clientId": "AGENT_CLIENT_ID", "maxWrites": 3, "expiresAt": "2026-09-18T23:59:00Z" }`.

Defaults are `note:read`, seven days, no write cap. Recipients must already have a dot; optional client binding restricts acceptance/use to that client. `PATCH` body is `{ "accept": true }` or `{ "accept": false }`. No bearer credentials appear in the share URL. Revoke prevents subsequent authorized reads/writes/drafts through REST, MCP and CLI; previously downloaded copies cannot be recalled. Shared notes must use these APIs, since direct Jazz share rows bypass per-operation expiry and revocation checks and are disabled by the accompanying policy release.

Machine schemas, operation IDs and errors are in `/openapi.json`. Error bodies contain `error` and optional `error_description`: 400 input, 401 authentication/audience, 403 scope/policy, 404 hidden resource, 409 invitation/idempotency conflict, 413 content size, 503 service failure.

## 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`; POST/PUT: `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` | `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`; POST: `notes:share` | POST uses the new grant invitation input below; GET `?shareId=GRANT_ID` | Legacy Jazz invite inputs return `share_api_upgrade_required` (409) |
| 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`. Stale streaming libraries refresh in the background for any `listen` token (OAuth apps included). |
| POST | `/api/identity/listen/sync` | Owner or `listen` | — | re-read Meshcast Turso rows and pull libraries from connected sources into Jazz |
| PATCH | `/api/identity/listen` | First-party owner | `{ sharingEnabled: boolean }` | Save explicit sharing opt-in; return owner snapshot with `privacy` |
| DELETE | `/api/identity/listen` | First-party owner | `{ id, source }` or `{ all: true }` | Delete a song or all stored history across Turso, streaming caches and Jazz; return owner snapshot |
| 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` and OAuth apps with `listen` can call `POST /api/identity/listen/sync` so pages like 01.the.fans pick up Meshcast + streaming plays. The OAuth client must include `listen` in `allowed_scopes` or authorize strips it.

Listening is private by default, including for existing accounts without a saved preference. OAuth GET, sync, userinfo and bootstrap responses omit history, now playing and artists until the owner enables sharing on `/listen`. The scope alone does not enable sharing. Direct Jazz history reads require a first-party Jazz session; apps must use the HTTP API so privacy changes apply on each request.

Deletion keeps only source/track identifiers as exclusions so deleted songs stay excluded from future imports, including later plays of the same provider song ID. Clear history covers all stored rows, turns sharing off, and rejects older plays on re-import. Send `x-timezone` with an IANA timezone when clearing so Meshcast wall-clock timestamps are interpreted correctly. Provider artist summaries are withheld after deletion because they can reveal removed listening. Deletion does not erase the provider's history or copies an app has already saved.

Deployment requires `20260921120000_listen_privacy.sql` and the updated Jazz permissions. Apply the database migration and Jazz policy before rolling out the app. Existing Jazz owner sessions need a refreshed token containing `dots_listen_owner`; privacy-store failures deny history reads and mutations.

The server must have working Jazz credentials to complete deletion. `JAZZ_BACKEND_SECRET` is preferred; the hosted alpha also supports `JAZZ_ADMIN_SECRET` when no backend secret is configured. Keep either credential server-only. Mirror deletion waits for the global query and global delete acknowledgements; an unavailable mirror returns a retryable error instead of reporting an empty successful deletion.

## 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)

Optional World ID and Reclaim verification can award Pute credits under explicit server-side policies. See [verification and crediting](../VERIFICATION_CREDITS.md) for the first-party challenge, proof submission, credit-status, and existing-attestation claim endpoints. Proof recording and eligible credit issuance are transactional; repeat claims never pay twice. No payout amounts are enabled by default.

| 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) |
| GET | `/api/identity/purchases` | User or OAuth `purchases:read` | Current verified-email purchase evidence for app access; no email addresses returned. Require `complete` before granting access. |

See [Purchase emails and app access](../PURCHASE_EMAILS.md) for verification, shared-email behavior, reward deduplication, and the current product-detail limitations.

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`).