This is the exhaustive contract surface for memo. Every route below is implemented in apps/memo/src/pages/api/.
/api/owner/*#All owner endpoints are Clerk-gated. The handler calls requireOwner(context) which resolves the Clerk session, looks up the matching memo.users row via clerk_user_id, and returns 401 if either fails. Subsequent ownership checks (e.g., projects.owner_id) are explicit per-route.
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/owner/projects | { name } (JSON) or name= (form) | 201 { project } |
| POST | /api/owner/projects/<id>/publish | — | 201 { status, versionId, versionNumber, fileCount, totalBytes, durationMs } or 422 { errors, warnings, durationMs } |
| POST | /api/owner/projects/<id>/upload | multipart form, files[] (loose or .zip) | 200 { written, totalBytes, skipped } or 413 |
| GET | /api/owner/projects/<id>/files | — | 200 { files: string[] } |
| GET | /api/owner/projects/<id>/files/<path> | — | 200 <file body> |
| PUT | /api/owner/projects/<id>/files/<path> | text or binary | 200 { ok, path } |
| DELETE | /api/owner/projects/<id>/files/<path> | — | 204 |
All file routes path-validate via normalizeProjectPath (segments match [A-Za-z0-9._-]+, no .. / . / empty).
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/owner/links | LinkCreateInput (JSON) | 201 { link } or 409 { error: "slug_taken" } |
| PATCH | /api/owner/links/<id> | { active?, name?, watermark?, allowDownloads? } | 200 { ok } |
| DELETE | /api/owner/links/<id> | — | 204 |
LinkCreateInput schema (zod, in src/lib/validation/link.ts):
{
projectId: uuid;
versionId: uuid;
slug: string; // /^[a-z0-9][a-z0-9-_]{1,63}$/
name: string; // 1..120
requireEmail?: boolean;
verifyEmail?: boolean; // requires OTP
password?: string; // 4..200, will be argon2id hashed
allowEmails?: string[]; // lowercase emails
blockEmails?: string[];
allowDomains?: string[];
blockDomains?: string[];
requireAgreementId?: uuid;
allowDownloads?: boolean;
expiresAt?: ISO8601;
watermark?: boolean; // default true
welcomeMessage?: string;
welcomeDisplayName?: string;
customButtonLabel?: string;
customButtonUrl?: url;
branding?: object;
}
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/owner/agreements | { title, contentHtml } | 201 { agreement } |
| DELETE | /api/owner/agreements/<id> | — | 204; 409 { error: "agreement_in_use" } if attached to any active link |
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/owner/tokens | — | 200 { tokens } (no raw values) |
| POST | /api/owner/tokens | { name, scopes[], expiresInDays? } | 201 { id, tokenPrefix, rawToken } (raw shown once) |
| DELETE | /api/owner/tokens/<id> | — | 204 |
| Method | Path | Returns |
|---|---|---|
| DELETE | /api/owner/connections/<id> | 204 — revokes the access token + every refresh token for the same (client_id, user_id) pair |
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/owner/ai/generate | { projectId, path, currentSource, prompt } | Streaming text/plain of file contents. 503 anthropic_api_key_unset if env unset |
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/owner/projects/<id> | — | 200 { project, currentVersionId, currentVersionNumber } — used by the editor toolbar for the "Download v{N}" button |
| PATCH | /api/owner/projects/<id>/rename | { name } | 200 { ok } |
| PATCH | /api/owner/projects/<id>/notes | { notesMd } (nullable) | 200 { ok } |
| GET | /api/owner/projects/<id>/versions | — | 200 { versions } |
| POST | /api/owner/projects/<id>/versions/rollback | { versionId } | 200 { ok }; fires version.rollback webhook |
| GET | /api/owner/projects/<id>/export/visits.csv | — | text/csv attachment |
| GET | /api/owner/projects/<id>/export/staged.zip | — | application/zip — current editor buffer + __memo_staged__.json manifest |
| GET | /api/owner/projects/<id>/export/version/<versionId>.zip | — | application/zip — immutable published version's R2 prefix + __memo_version__.json manifest with full file sha256 list |
| GET | /api/owner/recordings/<visitId> | — | 200 { events } — rrweb event stream, ownership-gated |
| GET | /api/owner/search?q=… | — | 200 { projects, links, agreements } — command-palette source |
These are the endpoints the IDE shell, Changes panel, find-in-files, section analytics, people timeline, and live activity feed consume.
GET /api/owner/projects/<id>/changesGit-status-style diff: staged files vs the project's current published version. Modified detection compares stored sha256 (from memo.files) against on-the-fly sha256 of staged bytes. Capped at 1000 files per side (413 with detail if exceeded).
// 200
{
"fromVersion": { "id": "<uuid>", "versionNumber": 3 },
"added": ["src/new-file.ts"],
"removed": ["docs/old.md"],
"modified": ["index.html"],
"unchanged": 12
}
// when no version is current yet:
{
"fromVersion": null,
"added": ["index.html", "style.css", ...],
"removed": [],
"modified": [],
"unchanged": 0
}
GET /api/owner/projects/<id>/versions/<versionId>/files/<...path>Streams a single file from a published version. Used by the editor's Monaco DiffEditor as the "original" (left) side of a diff. Ownership verified by a join across files → versions → projects → owner. Cache: private, max-age=300.
200 OK
content-type: <mime from memo.files>
<file bytes>
POST /api/owner/projects/<id>/searchFind-in-files. Owner-gated, rate-limited 30/min per (user, project).
// request
{
"query": "TODO",
"regex": false,
"caseSensitive": false
}
// 200
{
"matches": [
{ "path": "src/app.ts", "line": 42, "col": 8, "snippet": "// TODO: handle errors" }
],
"truncated": false,
"fileCount": 1,
"matchCount": 1
}
// 400 on bad regex
{ "error": "invalid_regex" }
Per-file cap: 50 matches. Global cap: 500 matches (truncated: true when hit). Skips binary image files (isBinaryImageMime). Audit-logged with action project.search.
GET /api/owner/projects/<id>/analytics/sectionsPer-section dwell aggregation from the Mongo time-series (type: "section_exit" events). Sorted by avg dwell descending. Cache: private, max-age=30.
// 200
{
"sections": [
{
"id": "hero",
"totalViews": 42,
"uniqueVisitors": 18,
"totalMs": 123456,
"avgMs": 2939,
"p50Ms": 2500,
"p90Ms": 9800,
"firstViewMs": 3200
}
],
"totalVisits": 47,
"ms": 18
}
firstViewMs is the average dwell on the first time a visitor saw the section (per visit_id), useful for separating "first impression" attention from "they came back to re-read".
GET /api/owner/projects/<id>/activity/since?ts=<iso>Polled by the Activity tab every 8 seconds. Returns visits with started_at > ts, oldest-first. Capped at 50 per poll. Joins visit_signals so badge chips arrive with the visit.
// 200
{
"visits": [
{
"id": "<uuid>",
"startedAt": "ISO-8601",
"endedAt": "ISO-8601 | null",
"visitorEmail": "string | null",
"emailHash": "<16-char base64url> | null",
"linkId": "<uuid>",
"linkName": "string",
"linkSlug": "string",
"country": "string | null",
"city": "string | null",
"durationMs": "number | null",
"completionPct": "number | null",
"engagementScore":"number | null",
"signals": ["skim" | "deep_read" | "return" | "share_detected"]
}
],
"serverTime": "ISO-8601"
}
When ts is missing/invalid, defaults to now - 60s. The server-computed emailHash saves the client from re-implementing sha-256 just to link to /u/<username>/people/<emailHash>.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/owner/webhooks | — | 200 { webhooks } |
| POST | /api/owner/webhooks | { url, events[], description? } | 201 { id, url, events, rawSecret } (raw shown once) |
| PATCH | /api/owner/webhooks/<id> | { active } | 200 { ok } |
| DELETE | /api/owner/webhooks/<id> | — | 204 |
| Method | Path | Behavior |
|---|---|---|
| POST | /api/recording/rrweb/events | rrweb ingest. Visitor JWT-gated. Batch of events, optional final: true. |
versionHash = sha256(contentHtml) is computed server-side and frozen into every subsequent signature.
All behind withOwner; every [id] route re-confirms spaces.owner_id = caller. See Spaces.
| Method + path | Purpose |
|---|---|
GET/POST /api/owner/spaces | List / create (slug-clash 409, argon2 password, space.created webhook) |
GET/PATCH/DELETE /api/owner/spaces/<id> | Read / update / delete one space |
GET/POST/PUT/DELETE /api/owner/spaces/<id>/projects | List / attach / reorder (full ordered set, validated against existing membership) / detach (?projectId=) |
GET /api/owner/spaces/<id>/search?q= | ilike over attached project file paths |
GET /api/owner/analytics/space?spaceId=<id> | Per-project engagement from space-native visits |
GET /api/owner/analytics/campaigns | UTM campaign rollup |
GET /api/owner/space-templates | Static room templates (fundraising / M&A / board / sales) |
| Method + path | Purpose |
|---|---|
GET/POST /api/owner/teams | List / create ({ name, memberEmails[] }) |
GET/PATCH/DELETE /api/owner/teams/<id> | Read / update / delete |
GET/POST /api/owner/diligence | List items for a space / create item |
GET/PATCH/DELETE /api/owner/diligence/<id> | Read / update (status, assignee…) / delete |
GET/POST /api/owner/diligence/comments | List / add a comment |
| Method + path | Purpose |
|---|---|
GET /api/owner/qa?linkId=<id> | List questions for a link the caller owns (walks qa_item → link → project) |
POST /api/owner/qa/<id> | Write the answer (sets answered_at) |
GET /api/owner/nda-templates | Static NDA boilerplate (owner-auth required) |
<provider> ∈ {google, dropbox, box} (allowlisted). See Integrations.
| Method + path | Auth | Purpose |
|---|---|---|
GET /api/oauth/<provider>/connect | withOwner | Begin connect — mint nonce + PKCE S256, 302 to provider |
DELETE /api/oauth/<provider>/connect | withOwner | Disconnect (delete encrypted token) — kill switch |
GET /api/oauth/<provider>/callback | nonce-bound | Exchange code → store encrypted tokens → 302 to settings |
All behind withOwner and gated by requireProjectAccess(projectId, userId) (owner or project_members row). Members API mutates project_members / project_invites; the realtime routes mint scoped transport tokens for live co-editing. See Collaboration.
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /api/owner/projects/<id>/members | { email } | 201 — adds a project_members row instantly if the email has an account, else a pending project_invites row |
| DELETE | /api/owner/projects/<id>/members | { userId } or { inviteId } | 204 — remove a member or revoke a pending invite |
| POST | /api/owner/realtime/liveblocks-auth | — | Mints a Liveblocks token scoped to project:<id> (Liveblocks transport) |
| GET | /api/owner/realtime/durable-token | — | Mints a short-lived HS256 JWT (signed with MEMO_REALTIME_SECRET) for the Durable Objects realtime Worker |
/api/v/<slug>/*#All visitor endpoints are JWT-gated. The handler calls loadLink(slug) first; if the link isn't ok (not_found / inactive / expired), the response is 404 / 403 / 410 respectively.
| Method | Path | Body | Behavior |
|---|---|---|---|
| POST | /api/v/<slug>/email | email= (form) | Issues OTP if verifyEmail, re-signs JWT, 303 to next gate |
| POST | /api/v/<slug>/otp | code= (form, 6 digits) | Verifies OTP atomically, re-signs JWT |
| POST | /api/v/<slug>/password | password= (form) | argon2id-verifies, re-signs JWT |
| POST | /api/v/<slug>/sign | agreementId=, signatureText= (form) | Writes signatures row, fires QStash render-pdf, re-signs JWT |
Each returns 303 See Other to nextStep(link, claims). Ratelimits documented in Security.
/api/s/<spaceSlug>/*#Same four gate steps as links, built from the shared factories but validated against the space JWT.
| Method | Path | Behavior |
|---|---|---|
| POST | /api/s/<spaceSlug>/email | Email gate (+ optional inline password) |
| POST | /api/s/<spaceSlug>/otp | OTP verify (strict \d{6}) |
| POST | /api/s/<spaceSlug>/password | Password verify |
| POST | /api/s/<spaceSlug>/sign | NDA sign — writes signatures.space_id (not link_id) |
| GET | /api/s/<spaceSlug>/project/<projectId> | 303 → native viewer /s/<spaceSlug>/p/<projectId>/ (gate + membership enforced) |
/api/<slug>/qa#| Method | Path | Behavior |
|---|---|---|
| GET | /api/<slug>/qa | The asking visitor's own thread (visit_id = claims.sub AND link_id), limit 100 |
| POST | /api/<slug>/qa | Submit a question — rate-limited 10/min per link:visit:ip, zod question 1–2000 + pagePath ≤500 |
POST /api/recording/rrweb/events now accepts scope: "link" | "space". The space path validates the space gate and stores visit_recordings.space_id (with link_id = NULL); the R2 blob stays keyed by project_id+visit_id. See Replay.
POST /api/beacon#Body (sent via navigator.sendBeacon):
{
slug: string;
visit: string; // visitor JWT sub
scope?: "link" | "space"; // default "link"; "space" for data-room views
events: Array<{ // .max(50) — at most 50 events per batch
type: "page_view" | "page_leave" | "scroll_depth" | "click_zone"
| "focus_change" | "gate_passed" | "download"
| "section_enter" | "section_exit" | "media_progress";
payload?: object; // type-specific
t?: number; // unix ms; defaults to server time
}>;
}
The handler:
body.visit.nextStep(link, claims) === "view").Always returns 204 No Content. Failures are silently dropped (telemetry must not retry-storm).
POST /api/recording/logrocket#Body:
{ visit: string; url: string } // url must start with https://app.logrocket.com/
Round-trips LogRocket's getSessionURL() callback onto visits.logrocket_session_url. See Recording.
/api/mcp#| Method | Behavior |
|---|---|
OPTIONS | CORS preflight |
GET | Returns server name, protocol version, docs URL (helpful for discovery) |
POST | JSON-RPC 2.0 envelope. Requires Authorization: Bearer <PAT or OAuth access token>. Rate-limited 60/min per (bearerId, IP). |
POST errors:
401 missing_bearer_token — no Authorization header. Response includes WWW-Authenticate: Bearer realm="memo", resource_metadata="…/.well-known/oauth-protected-resource" so clients can pivot into the OAuth flow.401 invalid_token — bearer didn't resolve (PAT revoked / OAuth token expired / unknown).429 rate_limited — sliding-window cap hit.See /memo/mcp for the JSON-RPC method table and tool surface.
/oauth/*#| Method | Path | RFC | Body / Behavior |
|---|---|---|---|
| POST | /oauth/register | 7591 | Dynamic Client Registration. Public. Returns { client_id }. |
| GET | /oauth/authorize | 6749 + 7636 | Renders consent UI. Clerk-gated. Validates response_type=code, code_challenge_method=S256, redirect_uri, scopes. |
| POST | /oauth/authorize/grant | — | Form submit from the consent button. Mints a 10 min one-shot authorization code, 303s to redirect_uri. |
| POST | /oauth/token | 6749 | Exchanges code → access+refresh, or refreshes a refresh token (single-use rotation). |
| POST | /oauth/revoke | 7009 | Revokes either token type. Always returns 200 (per spec). |
| GET | /.well-known/oauth-authorization-server | 8414 | AS metadata. |
| GET | /.well-known/oauth-protected-resource | 9728 | Points clients at the AS that mints tokens for /api/mcp. |
/api/cron/*#All cron endpoints accept either:
GET with Authorization: Bearer ${CRON_SECRET} (Vercel native), orPOST with Upstash-Signature header (QStash).| Path | Trigger | Action |
|---|---|---|
/api/cron/rotate-jwt-key | Vercel cron 03:17 UTC daily | Rotate memo:jwt:key:current → :previous; 48 h TTL on previous |
/api/cron/expire-links | Vercel cron every 15 min | UPDATE links SET active=false WHERE expires_at < now() |
/api/cron/render-signed-pdf | QStash, fired from sign.ts | Render signed-NDA PDF, upload to R2, back-fill signatures.signed_pdf_r2_key |
/api/cron/dispatch-webhooks | Vercel cron */2 * * * * | Pick pending webhook_deliveries rows and POST them with HMAC signature |
/api/cron/derive-cross-visit-signals | Vercel cron */10 * * * * | Scan visits in trailing 72h; classify return (≥3 visits per visitor_persistent_id) and share_detected (≥2 emails same domain). Upsert into memo.visit_signals with ON CONFLICT (visit_id, kind) DO UPDATE. See Observability → Cross-visit cron. |
These aren't "API" but documenting the contract:
| Path | Behavior |
|---|---|
/ | Landing |
/health | 200 OK liveness |
/sign-in, /sign-up | Clerk components |
/u/<username> | Project grid |
/u/<username>/projects/new | Create form (POSTs to /api/owner/projects) |
/u/<username>/projects/<id> | Editor (Monaco island) |
/u/<username>/projects/<id>/{activity,performance,utilization,links} | Analytics tabs |
/u/<username>/agreements | List |
/u/<username>/agreements/new | Tiptap editor (POSTs to /api/owner/agreements) |
/u/<username>/agreements/<id>/edit | Read-only view |
/u/<username>/agreements/<id>/signatures | Signature ledger |
/u/<username>/people/<emailHash> | Cross-link timeline for one email. emailHash = sha256(email.lower().trim()).base64url.slice(0,16). Resolved server-side by re-hashing every distinct visitor_email the owner has. 404 on no match. See Observability → People page. |
/u/<username>/settings | Profile + recording-provider doc |
| Path | Behavior |
|---|---|
/<slug> | Router — redirects to nextStep(link, claims) |
/<slug>/email, /otp, /password, /nda | Gate forms |
/<slug>/ | Streamed HTML entry with watermark + beacon + recording injected. Canonical trailing-slash URL — relative <img>/<link> paths in the served HTML resolve against this directory. /<slug>/view is a 308 redirect to here for legacy bookmarks. |
/<slug>/raw/<path> | Per-asset proxy; re-checks gate before streaming from R2 |
Inline deck annotations — click-to-place pins on the project preview with threaded comments. Any project member may create and view; delete is restricted to the annotation author or a project manager.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/owner/projects/<id>/annotations | — | 200 { annotations: Annotation[] } |
| POST | /api/owner/projects/<id>/annotations | { xPct, yPct, body } | 201 { annotation } |
| PATCH | /api/owner/projects/<id>/annotations | { annotationId, body?, resolvedAt? } | 200 { ok } |
| DELETE | /api/owner/projects/<id>/annotations | { annotationId } | 204 |
Create body schema (Zod):
z.object({
xPct: z.number().int().min(0).max(100),
yPct: z.number().int().min(0).max(100),
body: z.string().min(1).max(4000),
})
xPct / yPct are integer percentages (0–100) relative to the top-left corner of the preview iframe. The client computes them from getBoundingClientRect() at click time. Stored as integers to avoid float precision drift.
Annotation object:
{
id: string,
projectId: string,
authorUserId: string,
authorEmail: string,
xPct: number,
yPct: number,
body: string,
resolvedAt: string | null,
createdAt: string,
}
All mutations are audit-logged via the audit() helper.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/owner/notifications/preferences | — | 200 { alertHotLead, alertDeepRead, slackWebhookUrl } |
| PATCH | /api/owner/notifications/preferences | { alertHotLead?, alertDeepRead?, slackWebhookUrl? } | 200 { alertHotLead, alertDeepRead, slackWebhookUrl } |
Auth: Clerk. PATCH is additive — omitted fields are left unchanged. Setting slackWebhookUrl to null clears it.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /api/owner/projects/<id>/analytics/cohort-retention | — | 200 { cohorts } |
Auth: Clerk + project ownership. See Observability → Cohort retention grid for the full response schema.
All API endpoints return JSON for non-2xx responses where reasonable:
{ error: "invalid_input" | "slug_taken" | "version_not_in_project" | ...,
issues?: ZodIssue[] }
Some endpoints (e.g., the publish route on compile failure) return the full esbuild error array:
{ status: "error", errors: Message[], warnings: Message[], durationMs: number }