Skip to main content

Public API Posture

Event contribution intake​

User-owned personal tokens and user-delegated API-resource OAuth tokens can request events:contribute. This grants private intake and contribution access without verified email or community ownership. It does not grant events:write. Application-only credentials cannot use these routes.

RouteOperation
POST /api/v0/event-intakeSave a partial draft, optionally using draftId and expectedVersion to update it.
GET /api/v0/event-intake/{draftId}Read the actor's draft and published receipt ID.
PATCH /api/v0/event-intake/{draftId}Update a draft with expectedVersion and patch.
POST /api/v0/event-intake/{draftId}/extractPropose fields from supplied text or the draft's private poster.
POST /api/v0/event-intake/{draftId}/publishPublish with expectedVersion and idempotencyKey.
POST /api/v0/event-intake/{draftId}/poster-upload/beginReserve a private image upload with MIME type, byte count, and SHA-256.
POST /api/v0/event-intake/{draftId}/poster-upload/completeValidate and freeze the uploaded source for that draft.
POST /api/v0/event-intake/{draftId}/artworkExplicitly select and validate a separate public artwork derivative.
GET /api/v0/events/{slug}/contributionRead the actor's canonical editable fields and updatedAt revision.
PATCH /api/v0/events/{slug}/contributionCorrect the actor's contribution before staff takeover, using expectedUpdatedAt.
DELETE /api/v0/events/{slug}/contributionRetract the actor's contribution before staff takeover.
POST /api/v0/events/{slug}/reportSubmit a visitor report under the existing event/global abuse caps.

Schemas come from packages/api-contracts/src/event-intake.ts. Omission preserves a draft field, null clears it, and candidates remain tentative. Saving returns draftId and version; publishing returns eventId, eventPath, and receiptId. After a lost response, read the draft or replay publication with the exact same draft, version, and key. A changed request with the same key conflicts.

GET /api/v0/events/{eventId}/artwork/{artworkAssetId} returns only the separately selected WebP for that currently public event. It checks both IDs and current visibility on every request and sends private, no-store. The Next.js directory and OpenAPI parameter are named slug because event routes share one dynamic segment, but this artwork URL takes the receipt's internal eventId. Private source bytes are never served by this route.

Manual draft and publication routes work without model credentials. See source storage and extraction for opt-in model settings and private evidence retention.

Schedule and correction readback​

A date-only public event has scheduleKind: "date_only" and eventDate, with no startAt. Do not synthesize midnight or activate watch playback. Calendar export uses a date value with Time TBA; timed events retain exact instants. Lineup readback preserves ordered timed, untimed and unmatched entries.

Staff takeover closes contributor updates/retraction even with a fresh revision. Read the actor-scoped contribution endpoint before each correction and pass its updatedAt as expectedUpdatedAt. Contributor person matches remain visible in the event lineup but unconfirmed on the person's profile until staff review. A contributor can retry a successful retraction; the replay returns changed: false. A contributor can submit a correction suggestion through the event report flow; it does not edit the canonical event. Removal excludes the event from public lookup, search and feeds. Scope/version/actor parity is covered locally; hosted OAuth and connected browser proof remain outstanding in the checkpoint.

Status​

Current direction for #39.

#39 owns the first documented public API direction. Current Convex functions, Next.js route handlers, and E2E helper routes are implementation surfaces, not the stable public product API.

The full implementation-facing plan for API tokens, OAuth apps, rate limiting, Swagger/OpenAPI docs, and hosted/private MCP now lives in docs/planning/public-api-and-mcp-platform.md. This page remains the compact public API posture reference.

Current v0 Implementation Checkpoint​

/api/v0 is now backed by shared TypeScript contract schemas in packages/api-contracts. The checked-in OpenAPI artifacts are docs/api/openapi.json and docs/api/openapi.yaml; the web app serves the generated documents at GET /api/v0/openapi.json and GET /api/v0/openapi.yaml. The web app renders the generated API reference at /developers/api. Signed-in developers can manage personal API tokens at /developers/tokens and user-owned or community-owned OAuth client apps at /developers/apps; bearer-authorized /api/v0/developer/... routes also support developer credential listing, creation, and revocation, including owner-managed community OAuth apps via ownerCommunitySlug. OAuth metadata, JWKS, client-credentials token issuance, token revocation, constrained dynamic client registration for hosted MCP clients, Authorization Code with PKCE for public and confidential apps, and refresh-token rotation are also in place.

Implemented public reads are anonymous by default and accept optional scoped API bearer tokens or OAuth access tokens for authenticated public-read traffic:

RoutePurpose
GET /api/v0/search?q=Search public profiles, worlds, and events.
GET /api/v0/profiles/:slugRead a public person or community profile.
GET /api/v0/profiles/:slug/assetsRead public profile media-kit assets.
GET /api/v0/profiles/:slug/assets/:assetId/fileDownload a public profile media-kit asset.
GET /api/v0/profiles/:slug/logosRead public profile logo assets.
GET /api/v0/profiles/:slug/logos.zipDownload public profile logos as a ZIP.
GET /api/v0/people/:slugRead a public person profile.
GET /api/v0/people/:slug/eventsRead public upcoming events for a person profile.
GET /api/v0/communities/:slugRead a public community profile.
GET /api/v0/communities/:slug/eventsRead public upcoming hosted events for a community profile.
GET /api/v0/events/:slugRead a public event.
GET /api/v0/events/upcomingList upcoming public events from discovery data.
GET /api/v0/worlds/:slugRead a public world.
GET /api/v0/worlds/:slug/eventsRead recent and upcoming public events linked to a world.
GET /api/v0/worlds/activeList worlds with upcoming or live public events.
GET /api/v0/claims/:slug/statusRead public claim and trust state.
GET /api/v0/usage/rate-limitRead route-class quota policies and the caller's current public API window.

All public read routes reject bearer tokens in URL query parameters. Send API tokens and future OAuth access tokens through the Authorization header only.

All /api/v0 routes support cross-origin browser clients. Responses and automatic OPTIONS preflight responses allow public origins, the documented HTTP methods, bearer authorization, JSON request bodies, conditional asset downloads, and the one-time x-vrdex-upload-token header. Rate-limit, authentication-challenge, redirect, entity-tag, and download-disposition headers are exposed to browser code. The API does not enable credentialed cookies; authenticated cross-origin clients send bearer credentials through Authorization.

Implemented authenticated reads require a valid bearer credential:

RoutePurpose
GET /api/v0/meRead metadata for the current API token or API-resource OAuth caller.
GET /api/v0/me/profilesList current user's owned profile summaries.
GET /api/v0/me/communitiesList current user's owned community profile summaries.
GET /api/v0/me/eventsList current user's community-managed event summaries.
POST /api/v0/profilesSubmit a community-sourced profile for a person or community that has none. Needs profile:contribute.
PATCH /api/v0/profiles/:slugUpdate public metadata and outbound links for a profile the current user owns, or for an unclaimed profile as a community correction.
POST /api/v0/profiles/:slug/assets/upload-intentCreate a one-time media-kit upload intent for a claimed profile the current user owns.
POST /api/v0/eventsCreate a public event attached to a community profile the current user owns.
PATCH /api/v0/events/:slugUpdate a public event attached to a community profile the current user owns.
GET /api/v0/developer/tokensList current user's personal API token metadata.
POST /api/v0/developer/tokensCreate a current user's personal API token and return its value once.
GET /api/v0/developer/oauth-appsList OAuth application metadata for the current user and owned communities.
POST /api/v0/developer/oauth-appsCreate a user-owned or community-owned OAuth application and return any confidential client secret once.
PATCH /api/v0/developer/oauth-apps/:clientIdUpdate metadata, redirects, grants, and scopes for a user-owned or community-owned OAuth application.
POST /api/v0/developer/oauth-apps/:clientId/secretsCreate an additional confidential OAuth client secret and return it once.
DELETE /api/v0/developer/tokens/:tokenIdRevoke a current user's personal API token.
DELETE /api/v0/developer/oauth-apps/:clientIdRevoke a user-owned or community-owned OAuth application and active secrets.

/api/v0/me/profiles requires profile:read; /api/v0/me/communities requires community:read; /api/v0/me/events requires events:read. The current event inventory covers events attached to community profiles the user owns. Submitter-only event inventory can be added after there is an efficient user/event index and authorization shape.

PATCH /api/v0/profiles/:slug requires profile:write and user authority, plus profile:contribute when the target is not the caller's own profile. It writes display name, aliases, tags, headline, bio, region, timezone, outbound links, person pronouns and role tags, and community subtype and category tags. It does not change profile slugs, claims, publication state, field visibility, media-kit assets, or page-builder settings.

Ownership is not required, but the wider grant is. A caller who owns the profile edits it as its owner under profile:write alone, which is what that scope's consent line promises: "Edit your profiles". A caller who does not own it edits an unclaimed profile as a community contributor, the same authority the signed-in web editor grants, and that needs profile:contribute as well. A credential without it answers 403, as does any caller against a profile somebody else has claimed. Community writers cannot change slug, and their links are recorded as community-submitted rather than owner-authored, which the public page renders as a trust signal.

outboundLinks replaces the entire list rather than appending. Read the profile first and send back the full set, or an edit meant to add one link silently drops the rest. Branded link types are checked against their provider's hosts, so a discord link must point at a Discord domain.

POST /api/v0/profiles requires profile:contribute and user authority, and creates a profile that publishes immediately as unclaimed, credited to the submitter. Whoever it describes can claim it later. Search first: a duplicate submission creates a second profile under a suffixed slug, and nothing merges them.

POST /api/v0/profiles/:slug/assets/upload-intent requires assets:write, user authority, active ownership of the target profile, and a claimed profile. It returns an upload URL plus the x-vrdex-upload-token header value to use when completing the file or source import through POST /api/v0/profile-assets/upload-intents/:intentId. When direct upload is enabled for a file intent, the response also includes directUploadUrl. POST to that URL with the one-time header to receive an exact-size/type-bound S3 form target, upload the source to the returned private quarantine target, then POST the empty completion request to uploadUrl. The upload transport does not accept bearer credentials; the one-time upload token is the credential for those operations. When completion succeeds, the intent is consumed into an active public profile asset and any supplied media-kit placement metadata. Supported placement keys include gallery and singleton featured alongside the existing profile-image, banner, and logo placements. Upload intent metadata may include label, caption, altText, credit, and an HTTP(S) creditUrl without embedded credentials.

Upload completion validates image content independently from the declared MIME type. Raster uploads retain the exact source privately, publish a full-resolution metadata-sanitized artifact in the uploaded format for downloads, and use a bounded WebP derivative for display. SVG uploads retain a private exact source and publish only the restricted sanitized SVG. Existing assets created before source preservation continue to download their available display artifact and report sourcePreserved=false.

POST /api/v0/events and PATCH /api/v0/events/:slug require events:write, user authority, and ownership of the target communitySlug. Event updates also require ownership of the event's current community. The first public write version only creates and updates events attached to owned community profiles; it does not create standalone submitter-only events. A move to another owned community is refused while the event has confirmed group-instance associations, so its existing recap remains attached to the original group. Event updates preserve fields and relations that are omitted from the PATCH body. Set an optional scalar or worldSlug to null to clear it. Supply an empty array to clear mediaLinks; schedule and lineup replacements supply slotLinks and participantLinks together, using two empty arrays to clear both.

Developer list routes require developer:read plus user authority. Developer creation and revocation routes require developer:write plus user authority. User-owned personal API tokens qualify. User-delegated API-resource OAuth access tokens qualify. App-only client-credentials tokens and anonymous callers do not act on a user's developer resources. The Next.js gateway validates the bearer credential first, then calls internal Convex developer credential functions with server-side admin auth so arbitrary owner ids are not exposed through public Convex functions.

When a public read request has no bearer token, it is treated as anonymous traffic. When it has an opaque API bearer token, the Next.js route handler parses the vrdx_... token, hashes it with VRDEX_API_TOKEN_PEPPER, and asks Convex to validate the token prefix, hash, status, expiry, and required scopes. When it has an OAuth JWT access token, the route handler validates the issuer, audience/resource, signature, expiry, and scope claims with VRDEX_OAUTH_ACCESS_TOKEN_SIGNING_KEY, then asks Convex to confirm the stored access-token id, client id, resource, status, expiry, and scopes. Convex stores token prefixes, hashes, OAuth access-token ids, ownership, scopes, lifecycle metadata, and audit events, but never raw personal token or OAuth client-secret values.

Current personal API token backend primitives:

  • apiTokens.createPersonalToken
  • apiTokens.createDeveloperTokenForApiOwner
  • apiTokens.listDeveloperTokensForApiOwner
  • apiTokens.listPersonalTokens
  • apiTokens.revokeDeveloperTokenForApiOwner
  • apiTokens.revokePersonalToken

apiTokens.validateBearerTokenHash is an internal server-only function. The public API handler invokes it with Convex admin authentication after the HTTP security boundary has validated the request.

Current owner inventory backend primitives:

  • profiles.listProfilesForApiOwner
  • profiles.updateProfileForApiOwner
  • events.createCommunityEventForApiOwner
  • events.listCommunityManagedEventsForApiOwner
  • events.updateCommunityEventForApiOwner

Current profile media backend primitives:

  • profileAssets.createUploadIntentForApiProfileOwner
  • profileAssets.claimUploadIntentForStorage
  • profileAssets.releaseUploadIntentStorageClaim
  • profileAssets.markUploadIntentUploaded

Current OAuth app registry primitives:

  • oauthApps.createDeveloperApplicationForApiOwner
  • oauthApps.createDeveloperApplicationSecretForApiOwner
  • oauthApps.createPersonalApplication
  • oauthApps.listDeveloperApplicationsForApiOwner
  • oauthApps.listPersonalApplications
  • oauthApps.revokeDeveloperApplicationForApiOwner
  • oauthApps.revokePersonalApplication
  • oauthApps.updateDeveloperApplicationForApiOwner

OAuth consent completion and authorization-code issuance run atomically through the internal oauthApps.completeAuthorizationConsent mutation. It verifies the authenticated user against the hashed, single-use transaction and revalidates the stored client before either approval or denial. Dynamic Client Registration, Client ID Metadata Document materialization, authorization-client resolution, token exchange and rotation, revocation, and durable access-token validation are also internal server-only functions invoked with Convex admin authentication after the HTTP security boundary has validated the request.

Current OAuth issuer routes:

  • GET /.well-known/oauth-authorization-server
  • GET /.well-known/oauth-protected-resource
  • GET /.well-known/oauth-protected-resource/mcp
  • GET /oauth/authorize, for Authorization Code with PKCE consent
  • GET /oauth/jwks.json
  • POST /oauth/register, for constrained hosted MCP Dynamic Client Registration
  • POST /oauth/token, for authorization_code, refresh_token, and confidential client_credentials
  • POST /oauth/revoke, for JWT access-token and opaque refresh-token revocation

POST /oauth/register is not the normal developer-app creation path. It creates separate public dynamic MCP clients with exact redirect URIs, authorization_code grant metadata, code response type metadata, token_endpoint_auth_method=none, and the MCP resource. These clients are for hosted MCP OAuth compatibility.

A dynamic client may request mcp:read and optional public:read for reads, and mcp:write paired with at least one of assets:write, assets:contribute, events:write, profile:write, or profile:contribute for writes. Either half of a write pair on its own is rejected: mcp:write reaches no tool without a resource scope, and a resource scope opens no hosted write session without mcp:write.

Hosted MCP OAuth also supports Client ID Metadata Documents for public clients that use an HTTPS URL as client_id. Accepted CIMD metadata is fetched during authorization, constrained to the same public/no-secret MCP client shape, and stored as a dynamic MCP client.

GET /oauth/authorize currently requires code_challenge_method=S256. Approval creates a short-lived single-use authorization code, and POST /oauth/token exchanges that code for a resource-bound JWT access token plus an opaque refresh token. Public apps and dynamic MCP clients exchange and refresh without a client secret. Confidential apps must authenticate with an active client secret when exchanging authorization codes and rotating refresh tokens.

Current hosted MCP route:

  • GET|POST|DELETE /mcp, Streamable HTTP MCP with anonymous public read tools

Current local MCP package:

  • @basicbit/vrdex-mcp in packages/vrdex-mcp, stdio transport backed by /api/v0 routes
  • accepts VRDEX_API_BASE_URL for hosted or self-hosted deployments
  • accepts VRDEX_API_TOKEN, VRDEX_OAUTH_ACCESS_TOKEN, or VRDEX_OAUTH_TOKEN_FILE for optional authenticated public-read requests
  • OAuth access tokens used by the local package must be issued for the API resource because the package calls /api/v0

Current token validation behavior:

  • malformed, unknown, revoked, or expired bearer tokens return 401
  • scope-insufficient bearer tokens return 403
  • public read routes currently require public:read
  • OAuth access tokens must be issued for the API resource to count as authenticated API traffic
  • OAuth access tokens issued for the MCP resource and carrying mcp:read count as authenticated MCP traffic
  • anonymous public reads still work without credentials

Locked Direction​

  • Public API behavior and limits should be documented before outside consumers depend on them.
  • Public API routes should be versioned from the start.
  • The first unstable public surface should use /api/v0/... so pre-launch breaking changes are honest and easy to isolate.
  • Public API responses must preserve trust, provenance, claim, visibility, and opt-out semantics.
  • First-party web usage and public consumer usage may share business logic while still using different transport, auth, and rate-limit layers.
  • Structured integrations should prefer public API or MCP tools over website scraping.
  • API docs should be usable by humans and agents, including compact examples and machine-readable schema docs once endpoints stabilize.

First Public Read Surface​

Candidate first public API endpoints:

  • GET /api/v0/profiles/:slug
  • GET /api/v0/profiles/:slug/assets
  • GET /api/v0/profiles/:slug/logos
  • GET /api/v0/profiles/:slug/logos.zip
  • GET /api/v0/people/:slug
  • GET /api/v0/communities/:slug
  • GET /api/v0/search?q=
  • GET /api/v0/cards/:slug
  • GET /api/v0/worlds/:slug
  • GET /api/v0/worlds/active
  • GET /api/v0/people/:slug/events
  • GET /api/v0/communities/:slug/events

GET /api/v0/cards/:slug remains deferred until its compact schema, visibility behavior, and relationship to the existing profile response have a shared contract. Consumers should use the shipped profile endpoints in the meantime.

The first public API should be read-only unless a specific write flow has an auth, rate-limit, audit, and abuse-handling design. v0 can be replaced or deprecated before public launch if the implementation reveals a better shape.

Client Classes​

Use client classes instead of one global rate-limit model:

  • first-party web app: normal product traffic, protected by app/session behavior and platform controls
  • anonymous public clients: conservative unauthenticated read limits and cache-friendly responses
  • trusted partners: explicit credentials, higher or specialized limits, and revocable access
  • self-hosted local clients: operator-controlled limits documented by deployment configuration

Partner limits are a product and operations decision, not an excuse to bypass visibility, provenance, moderation, or opt-out rules.

Rate-Limiting Intent​

The first implementation should document:

  • request identity basis, such as IP, token, partner key, or app session
  • limit window and burst behavior
  • cache headers where public data can be safely cached
  • not-found behavior that does not leak private or suppressed records
  • escalation path for trusted partner access

Current recommendation: use Redis-compatible TTL counters for hosted high-volume anonymous public API and hosted MCP traffic. Keep Convex as the durable source for token/app ownership, quota policy, trusted-partner overrides, coarse usage summaries, and audit events. Local development can use an in-memory adapter; production fails closed unless VRDEX_RATE_LIMIT_STORE selects the Redis REST adapter.

Current implementation:

  • VRDEX_RATE_LIMIT_STORE=memory uses a process-local fixed-window counter.

  • VRDEX_RATE_LIMIT_STORE=redis-rest or upstash uses a Redis-compatible REST pipeline with VRDEX_RATE_LIMIT_REDIS_REST_URL and VRDEX_RATE_LIMIT_REDIS_REST_TOKEN.

  • VRDEX_RATE_LIMIT_REDIS_PREFIX isolates keys when shared infrastructure is used.

  • VRDEX_RATE_LIMIT_STORE=disabled is only for local diagnostics.

  • trusted_partner personal tokens and OAuth apps get higher effective quotas on authenticated API/MCP traffic after operator review.

  • OAuth traffic is isolated by access-token id and also checked against a secondary client-wide abuse cap without double-counting route observability.

  • Client Credentials traffic has an additional hashed application-owner cap, bounding aggregate traffic across multiple apps without putting owner ids in rate-limit keys or durable event rows.

  • Dynamic Client Registration is limited by requesting network, hashed software identity, and hashed redirect hostname. Metadata and host buckets use broader aggregate limits than the per-network registration limit. The dedicated rate-limit guide lives in docs/developers/api-rate-limits.md.

Response Safety Rules​

Public API responses must:

  • hide private fields
  • exclude unlisted fields from search, cards, and discovery-style projections
  • honor profile-level opt-out and moderation suppression
  • label unclaimed, community-submitted, imported, partner-provided, reviewed, and owner-confirmed data honestly
  • avoid private auth identifiers, raw provider tokens, unreviewed contact exports, and moderation-only notes
  • include stable IDs or slugs for follow-up calls where useful
  • return compact not-found responses without hinting whether a private/suppressed object exists
  • expose profile media-kit assets from VRDex-managed storage rather than hotlinking external source URLs as canonical downloads
  • include primary logo plus additional public logos where logo lookup is requested
  • include bounded avatar appearance metadata only as presentation hints, including border color/thickness/softness and roundedness, never as arbitrary CSS
  • include bounded profile section ordering only as known public section keys, never as arbitrary page-builder blocks

Documentation Shape​

The first implementation issue for the public API should add:

  • endpoint reference
  • auth and rate-limit behavior
  • OpenAPI artifacts generated from shared API contract schemas
  • task-oriented examples for profile lookup, search, event lookup, profile cards, and partner-safe seed validation
  • clear guidance to use API or MCP for structured reads instead of scraping public pages

Non-goals for the original posture pass​

  • implementing the public API now
  • finalizing every endpoint
  • replacing the full platform plan in docs/planning/public-api-and-mcp-platform.md
  • partner contracts beyond the auth/rate-limit hooks needed for future implementation