Appearance
Route inventory
GET /api/health (unauthenticated, api/index.js) answers { status, timestamp, version, commit }. commit is RAILWAY_GIT_COMMIT_SHA (null locally); .github/workflows/deploy-check.yml polls it after every push to staging/main until it equals the pushed sha (see Deploys).
Registry-mounted module routes (/api/modules/…)
Mounted by mountModuleRoutes at /<routeAlias ?? id> + each routeAliases entry.
| Mount | Module id | Notes |
|---|---|---|
/api/modules/collections | 1a-blog-publisher | routeAlias collections overrides the frozen id |
/api/modules/pages | pages | Site pages + discovery/refresh |
/api/modules/gsc | gsc | + legacy alias /api/modules/seo-analytics. /dashboard (live KPI aggregate), /series (stored daily series + tile totals, compare=), /breakdown?dimension= (the six non-query splits), /queries (every query for the window, rows carrying attributed pages), /queries/series?query= (one query's trajectory) — the Performance page reads the last four, which are the agent tools' own computations |
/api/modules/orbit-pixel | orbit-pixel | Authenticated dashboards (public ingestion is separate) |
/api/modules/backlinks | backlinks | |
/api/modules/health | health | |
/api/modules/ai-visibility | ai-visibility | |
/api/modules/lead-audit | lead-audit | Ops-only routes; public router mounted separately |
/api/modules/mcp | mcp | Remote MCP server — OAuth-only (bearer tokens minted by the PKCE exchange; manual keys + tokenized URL removed 2026-08-02) + /tokens connection list/disconnect (auth) + /oauth/* (DCR/approve/token) — see Orbit MCP. Vanity hosts mcp.orbit.luniq.io / mcp-staging.orbit.luniq.io rewrite here (createApp, pixel pattern); OAuth discovery metadata mounts at the origin root /.well-known/oauth-* |
/api/modules/market | market | Competitive field: /overview (scorecard + competitor set + SoV trend), /keywords (head-to-head, enveloped), /moves (move log + atlas sections + provenance), POST /competitors (track/watch/remove curation, 409 on cap) — see Market |
/api/modules/social · /sales | shells | 501 until built |
web (agent tools only) and agent-autopilot (crons only) mount nothing. Also under /api/modules/ but not registry modules: /api/modules/settings (settings CRUD — its PATCH is the one legitimate writer of the '1a-blog-publisher' row, and it enforces the CMS lock: assertCmsTypeChangeAllowed refuses to change cmsType — or to clear it to null, the "No CMS yet" tile (2026-08-27, a CMS is optional) — once the workspace has published articles, 409 cms_locked_by_published_content. Coded refusals keep their own status + code on the response so the UI can explain the rule instead of showing a generic save failure) and /api/modules/enablement — despite the (legacy) path, the workspace UI context: { readiness, setup, automation, surfaces, viewer, branding } in one call — readiness is workspaceReadyById verbatim (the automation gate's own verdict), setup is that list plus the Orbit pixel rung, and automation is { runs, ready, paused, reason }, the shape the switcher and agency list carry per workspace (all three from modules/setup-state.js#getSetupState, the one composition the chat context block and MCP list_workspaces read too — the pixel is module data the spine may not import, so the join lives in the wiring ring), so the app's yellow "finish setup" banner, the workspace status pill and the chat's pause control render a server-side verdict and never recompute the rule (modules — the per-workspace switches — was removed 2026-08-14; every module is available everywhere). One request on purpose — separate calls would let board visibility and branding disagree mid-render. A rendering HINT only; every surface it hides is refused server-side too. branding resolves through the WORKSPACE, not the caller's own agency — a guest of a white-labelled agency must see the agency's logo, and has no agency of their own to resolve.
Top-level authenticated routes (api/index.js)
/api/health, /api/modules, /api/invite, /api/notifications, /api/onboarding (POST /:ws/prefill also PERSISTS the detected publish languages, fill-only — persistDetectedLanguages in modules/onboarding/setup.js writes them the moment detection produced them and never over a saved value, so the detection survives a wizard reload instead of living only in client state), /api/documents, /api/workspaces (POST /create-workspace — normalizes websiteUrl through normalizeWebsiteUrl and persists THAT canonical https origin (a bare acme.com used to be validated as https://acme.com but stored raw, so the setup scrape's fetch() threw and prefill silently returned nothing); rejects a non-public hostname before the SSRF check. Accepts an optional IANA timezone, validated via Intl and seeded from the creator's browser so crons and daily windows are local from day one; invalid/absent → null → UTC. agencySite: true (2026-08-29) claims the INCLUDED agency-site slot: the create gate's agencySite sub-verdict applies (same checks minus no_seats, refused agency_site_claimed once taken) and the route stamps accounts.agency_workspace_id atomically, only-while-unclaimed; PATCH /:workspaceId — name/timezone for members; a timezone change also drops the reporting cache and hot-reloads the workspace's crons via reloadWorkspace so "overnight" moves immediately — the reason this is a route and not an RLS row write), /api/collections (collections CRUD; since 2026-08-27 the list serves getCollectionsWithPublishing so every row carries publishing { publishes, target, reason } — the card badge and the article page's Publish gate render the server's rule), /api/agency (the /api/accounts alias was removed 2026-08-10 — the join page was its last caller), /api/legal, /api/support, /api/agent, /api/voice.
Voice dictation (POST /api/voice/:workspaceId/transcribe, requireAuth + requireWorkspaceMember, expensiveLimiter) — the mic in the chat composers. Multipart (audio + the browser's own mimeType), returns { success, text } and persists NOTHING: the transcript goes into the composer the user is looking at and is theirs to edit or discard. Its own thin route file (api/routes/voice.js) rather than another block in agent.js — one endpoint, one service call, no shared state. The guarded vendor client is modules/agent/voice/transcribe.js; it sits in the platform ring because the spend spine needs ensureBudget + track in the same file and check-rings will not let a NEW core/ file import modules/observability. Billed per minute of audio (not tokens), so it books cost directly instead of through the token table in pricing.js; caps are 2 minutes / 8 MB, enforced server-side as well as in the composer. A cap hit returns 429.
One /api/agent route is public: POST /api/agent/docs-chat — the docs assistant on the unauthenticated /docs pages (SSE; the client resends the whole conversation each turn, nothing persisted). It is the platform's only public LLM endpoint, so it's belt-and-braces limited: strictLimiter per IP on the route (on top of /agent's expensiveLimiter) plus a process-wide daily spend ceiling inside modules/agent/docs-chat.js (DOCS_CHAT_DAILY_CAP_USD, default $10). Single-segment path, so it can't collide with the /:workspaceId/* routes.
POST /api/agent/:workspaceId/skills/:skillName/preflight is THE pre-queue gate (runs-and-actions PR A, 2026-09-02): validates body.params against the skill's own schema with validateSkillParams — the SAME check POST /jobs runs, so a preflight pass guarantees a queue accept (the old module-owned brief-preview route checked only title.length >= 5 and could bless a payload the queue then 400'd) — then runs the skill's declared preflight() with the workspace's standing directives (fetchWorkspaceDirectives) threaded and the caller's declared origin (manual/signal/chat, stamped on the brief). A skill without a preflight passes on schema validation alone. 400 = a schema sentence; a gate refusal is a 200 with data.rejection set — strategy, not an error. Since pre-writing P2 the response also carries data.locale_plan (write_article on multilingual workspaces): the per-language keyword rows the approval form renders editable, confirmed back as params.locale_keywords. The write dialog and the signal board's Write with Orbit both gate here; /api/modules/collections/articles/:ws/brief-preview was deleted with this (its body duplicated write_article's preflight minus the directives, which made the directives gate unreachable from every live door). Since PR D, POST /jobs/:jobId/approve accepts an optional { params } body — the chat approval card's edited proposal, schema-validated by approveJob and written in the same pending-guarded update — and the direct-queue cost-cap 429 says "Orbit's daily work budget for this site is used up" with no dollar amounts (law 2: no pricing to users).
GET /api/agent/:workspaceId/activity returns BOTH halves of the runs-and-actions vocabulary (PR B, 2026-09-02): running = the in-flight skill jobs (queued + running, the ACTIONS — unchanged shape) and runs = the routine pipelines open right now (job_runs status 'running', the RUNS), each labeled through runLabelMap() — the modules' own operatorCrons declarations, so an undeclared job (fleet sweeps, alarms) never surfaces. The runs read is best-effort (.catch(() => [])): the actions half must never fail because the ledger read did, and on a database that predates the running-status migration the list is simply empty.
POST /api/agent/:workspaceId/status is WRITE-ONLY (2026-08-18): it flips the one agent.paused bit. There is no GET beside it — the app reads the bit as automation.paused on the workspace context above, next to the readiness verdict, so the status pill, the banners and the toggle are one answer. The chat's own GET-on-mount was the third reader of a state that has exactly one definition, and it let a paused workspace look healthy everywhere but inside its own chat.
Agent files (/api/agent/:workspaceId/files*, all requireAuth + requireWorkspaceMember) — the deliverables the agent produces are documents the user works in, not just downloads (2026-08-13):
| Endpoint | What it does |
|---|---|
GET /files/:fileId?name= | the original download — streams bytes from the private agent-files bucket |
GET /files/:fileId/content | the same file as TEXT for the in-app editor, plus kind + edited. 415 on a non-editable type. kind falls back to the stored content type when the chunk index doesn't know the file yet (mid-turn open — the index only exists once the turn persists) |
PUT /files/:fileId {content} | saves the user's edit over the SAME id (so every card that references it serves the new version). The whitelist (csv/md/txt/json) is enforced here as well as at creation and in the panel (same mid-turn content-type fallback as the viewer); json must parse |
GET /files?threadId= | the workspace's files, newest first — derived from the chat chunks that delivered them (file-index.js), since files carry no index table |
/api/agency (api/routes/agency/, four thin files) is the AGENCY surface — the org. "Account" now means the person; the accounts TABLE name stays frozen.
| Endpoint | Gate |
|---|---|
GET /me → { agency, account, createWorkspace, billing } (onboardingCompletedAt null routes the admin into /agency-setup; createWorkspace = {allowed, reason} is the create-gate verdict the UI renders from; billing = {enabled, trialDays} flips the wizard's step-4 slot and AgencyBilling's portal button; agency.plan is the billingAccess verdict + trial/assigned dates, agency.pricing the catalog plan {id, name, currency, agencyMonthly, workspaceMonthly} the agency is on — no page carries a price) · POST /register (wizard step 1; creates the pending agency, no operator alert) | auth |
PATCH / (rename / note / billing currency while no subscription exists — the wizard edits all three) · POST /complete-setup (wizard step 4: stamps onboarding_completed_at, sends the operator heads-up (new-agency-alert.js) WITH the full profile — brand set up, teammates invited, currency; idempotent, never re-alerts; nothing waits on it — the agency is active from /register, its plan is the gate). There is deliberately NO agency-facing endpoint that changes the workspace count (2026-08-18): paid workspaces are the Stripe subscription quantity (checkout/portal → webhook), assigned ones are set only from the internal dashboard | requireAgencyAdmin |
GET /members | requireAgencyMember |
PATCH /members/:userId {role} · DELETE /members/:userId | requireAgencyAdmin |
GET/POST /invites · DELETE /invites/:id | requireAgencyAdmin |
POST /invite/:token/accept | auth (verifies signed-in email vs invited) |
POST /billing/checkout {quantity, returnTo} → Stripe Checkout url (the agency plan + quantity workspaces on ONE subscription; returnTo setup|billing picks the return page) · POST /billing/portal → customer-portal url · POST /billing/seats {quantity} (paid workspaces on the live subscription, prorated; never below live minus assigned) · POST /billing/sync (pull the subscription from Stripe — the return from Checkout, so the page is right before the webhook lands). All honest 400s while BILLING_ENABLED is off; Orbit never renders an invoice | requireAgencyAdmin |
GET /branding | requireAgencyMember |
PATCH /branding · POST/DELETE /branding/logo/:slot/:theme (slot wordmark|mark, theme light|dark; a light upload also stores the measured tone/mono (dark-mode treatment), aspect (render height) and lowRes (crispness hint)) | requireAgencyAdmin |
GET /workspaces — each LIVE workspace carries signals (2026-08-27: the cards PRESENT on its board — open + claimed — counted per severity {critical, warning, opportunity, info}, modules/agent/signals.js#countPresentSignals, composed in the route because the agency spine may not read module data; null when the count failed, so the list never fails on a board read and the tile says "Signals not read" instead of a false zero). Each row also carries isAgencySite (2026-08-29: the ONE workspace holding the included agency-site slot — the hub pins its tile first). The agency home's tiles render exactly this, in the board's own colours | requireAgencyMember |
GET /workspaces/:workspaceId/people → staff / guest. The Luniq support identity is dropped on the wire (2026-09-02): this one response feeds Settings → People, the signal assignee picker and the board's assignee filter, so excluding it here makes every present and future consumer correct by default instead of correct-if-remembered. listWorkspacePeople still returns it stamped platform for the access/operator callers | requireWorkspaceMember |
GET /workspace-statuses | auth |
GET /for-workspace/:workspaceId | requireWorkspaceMember |
POST /workspaces/:workspaceId/agency-site (2026-08-29: make this live workspace the agency's OWN site — stamps accounts.agency_workspace_id, the included seat-free slot; archiving/deleting the site releases it) | + requireWorkspaceAgencyAdmin |
POST /workspaces/:workspaceId/status (archive/reactivate) | + requireWorkspaceAgencyStaff |
DELETE /workspaces/:workspaceId | + requireWorkspaceAgencyAdmin |
Destructive workspace actions are agency-ADMIN only (Leon, 2026-07-26) and live only here and in the internal dashboard — archive/reactivate, delete, and POST /api/onboarding/:workspaceId/reset. They were removed from the workspace's own settings page. requireVisibleSignalBoard guards every signals/storylines endpoint under /api/agent/:workspaceId/ (mode × viewer; fails OPEN on a settings-read error so an agency is never blacked out). The signal endpoints there: GET /signals (?statuses= accepts the five board stages plus gone, Orbit's hidden close — off by default (the two client stages retired with the client approval workflow, signal plan step 0, 2026-09-01); the envelope carries runs: { sweep, research } — the last two runs' own return values, 2026-08-16 — and seeding: boolean, true while the first dashboard is being built), POST /signals/seed (2026-08-23 — build the first dashboard: modules/agent/seed.js runs the sweep → bootstrap research → daily research through the SAME operator triggers as the internal Run-now, in the background; 202 { started }; refuses 422 not_ready/agent_paused (the automation gate) and 409 not_empty (any row in a board status — closed included) / already_running (one seed per workspace at a time, process-local)), POST /signals { title, body_md?, severity?, effort?, goal_lens?, status? (never a closed one), assignee?, linked_pages? } (2026-08-19 — a person's own card: source/category manual, born owned; 201 + the row), PATCH /signals/:id { status?, assignee?, title?, body_md?, severity?, effort?, goal_lens?, linked_pages? } (a person edits the card — assignee (2026-09-01, a member's user id or null, resolved via resolveAssignee against listWorkspacePeople) is the ONE edit that does not claim: it lands through setSignalAssignee beside the EDIT = CLAIM writes, refused 409 on a closed card — status is the stage move through the human closer; every other field is EDIT = CLAIM through saveSignalEdits: an open card moves to planned first and the response carries status + body_edited_at; title 1–300 chars, body ≤ 40k, linked_pages ≤ 100 normalized pages (normalizeLinkedPage) — the WHOLE list the person wants kept; since 2026-08-25 also linked_signals ≤ 20 ({ id, relation? }, self-link refused) and linked_keywords ≤ 30 strings (since 2026-08-28 also linked_prompts ≤ 30 strings — the AI-search questions the card tracks), same whole-list rule, applied through board/linked-lists.js#applyPersonLinks (an Orbit entry left out becomes an excluded tombstone Orbit never refills); POST /signals takes all three too; 2026-08-19 — this absorbed the former PATCH /signals/:id/body; 2026-08-20 — a CLOSED card is read-only: edits refuse 409 ("move it back onto the board"), and the stage move applies BEFORE the edits so one call may reopen-and-edit), (POST /signals/:id/proposal — 2026-08-16 to 2026-08-22, REMOVED: a claimed card is claimed, Orbit no longer proposes changes to it), GET /signals/:id/outcomes (2026-08-20 — the card's outcome ledger: { rows: [{ kpi, reading_date, value }], total, truncated }, oldest first, the results card's chart read), GET /signals/:id/jobs, GET /storylines, POST /storylines (a person makes one), PATCH /storylines/:id (edit — headline / diagnosis / play / goal / severity, or body_md for the storyline document; marks it theirs), POST /storylines/:id/members { signal_ids } / DELETE /storylines/:id/members/:signalId (regroup), POST /storylines/:id/disband (ungroup + delete, signals stay), POST /storylines/:id/dismiss (close members + delete + never-rebuild memory), GET /storylines/:id/jobs.
The last two are the fix for the pre-R0 hole where requireWorkspaceMember alone let an invited guest archive or permanently delete the agency's workspace. Reversible → agency staff; irreversible → agency admin. It is an OWNERSHIP check ("staff of the agency that owns this workspace"), not a within-workspace role.
/api/notifications: GET /preferences (every workspace the caller belongs to, each with its switchable email types — emailed and not always — and their current state; the account page's one read) · PUT /preferences/:workspaceId (set the caller's switches there; unknown/unswitchable types ignored). GET /types and apply-to-all were removed 2026-08-23 with the per-workspace settings page. Per-type prefs stay per-workspace rows; the cross-workspace master mute is profiles.email_notifications_enabled. Both are applied in core/notifications/index.js — and both are skipped for an always type (invites), which is always emailed.
/api/invite (strict-limited; resolve, check-email + signup are pre-auth): GET /resolve/:token is THE pre-auth resolver for BOTH invite kinds — it checks account_invitations then workspace_invitations and returns the invited email, entity name, inviter display name, and the sending agency's branding (logos + colour), so the unified join page (frontend/src/pages/Join.tsx, serving /join/:token with /join-agency/:token as an email-compat alias) renders branded before the visitor has an account; status codes carry the UI states (404 unknown, 409 already accepted, 410 expired). POST /signup creates the invited user born email-confirmed (admin.createUser with email_confirm: true) — the invite token proved mailbox ownership, so no Supabase confirmation round trip; the email always comes from the invitation row (workspace_invitations or account_invitations by kind), never the request body.
/api/legal (api/routes/legal.js) is the contract surface — see Legal.
| Endpoint | Gate |
|---|---|
GET /documents (published catalog) · GET /documents/:id (the text) | public — terms have to be readable before anyone signs up |
GET /pending — what this caller still owes | auth · fails open on error |
POST /accept — accepts everything pending | auth · takes no document list from the client; the server re-resolves what was owed |
/api/share + /api/shares (api/routes/shares.js) — branded public pages. See Shares.
| Endpoint | Gate |
|---|---|
GET /api/share/:token — the whole public page payload (envelope + the kind's data) | public, strict-limited. Unknown/revoked → 404, expired → 410 |
POST /api/share/:token/:action {itemId} — a verb the reader may perform (e.g. approve) | public, strict-limited. 404 unless the kind declares that action; the action re-checks the item is on this page |
GET /api/shares/:workspaceId — the workspace's links + the shareable kinds | auth · workspace member |
POST /api/shares/:workspaceId {kind, resourceId?, label?, expiresInDays?} — idempotent per subject | auth · workspace member · agency staff |
DELETE /api/shares/:workspaceId/:shareId | auth · workspace member · agency staff |
The public read sits INSIDE /api (the /legal pattern) rather than pre-CORS: the only browser that calls it is our own SPA serving /s/:token. Minting is gated by requireWorkspaceAgencyStaff — publishing outward on the client's behalf is the agency's act, so a workspace guest cannot do it.
Internal (requireInternalUser, @luniq.io): /api/internal/observability (GET /errors?since=&level= — each event carries kind: bot/client/provider/agent/api/system, see modules/observability/error-kind.js; the events list + detail also return the three DISJOINT input buckets — inputTokens (uncached only), cachedInputTokens, cacheCreationInputTokens — and totalInputTokens, since 2026-08-17: a $2.99 agent turn had read as "32 tokens in" with only the uncached bucket shown), /api/internal/client-errors (any authed user), /api/internal/app-events (any authed user — engagement ingest, 410 under DISABLE_APP_EVENTS), /api/internal/engagement (GET /summary·/timeseries off the app_engagement_daily rollup; GET /accounts·/workspaces·/users add the churn dimensions — per-agency via app_engagement_by_account, per-user with real identity + agency + role via app_engagement_users (auth.users + account_members join), every ACTIVE account and LIVE workspace listed even at zero usage, and each row carrying priorSeconds from the equal-length window before from so the dashboard can flag "quiet"/"live, unused", plus userSplit (app_engagement_user_splits — the identified users behind the row's count with their active seconds, feeding the hover panels); GET /paths·/clicks off raw app_events; GET /sessions/:id — one session's events + client errors sharing its sessionId; GET /users/:id?from=&to= — the per-user drill (engagement-user.js, helpers in engagement-lib.js): one composite response of identity (app_user_profile), per-clock-hour active seconds (app_user_hours, timestamptz so the dashboard buckets viewer-local), time per page (app_user_paths), clicked controls (app_user_clicks), the session list (app_user_sessions, entry→exit path + exact start/end) and priorSeconds — raw-backed, so live including today but bounded by the 90-day retention), /api/internal/system (kill-switch + Run-now), /api/internal/smoke (POST / — runs the READ-ONLY live-smoke suite (scripts/smoke-live.mjs) in a child process and reports { pass, failed, probes }; NOT user-authed: gated by SMOKE_TRIGGER_TOKEN bearer compare inside the route because the caller is CI, which holds no session and never holds DB keys; 404 while the token is unset, one run in flight at a time — testing-v1 T0), /api/internal/accounts (accounts + POST /:id/status suspend/reactivate (no approve/reject since 2026-08-27 — there is no approval step) + POST /:id/plan {comped, until} the ASSIGNED agency plan + POST /:id/seats {comped, until?} — the ASSIGNED workspaces, the only writer of seats_comped; 0 allowed, capacity never below live (the module enablement toggles were removed 2026-08-14) + GET/POST /workspaces/:workspaceId/experience — the surface mode (agency/content) + GET/POST /workspaces/:workspaceId/agent — the agent level ladder { level: full/light/off, schedule: {mon..sun: full/light/off} } + GET/POST /:id/contract — customer type (agency/direct) + the commercial Order blob), /api/internal/legal (GET / catalog + acceptance counts · GET /:id/versions · GET /:id/current the version in force with its text — seeds the dashboard's editor · GET /:id/source the repo markdown, the seed for a never-published document · POST /:id/publish {version, body?, summary?, material?, effectiveAt?} — body is the dashboard-authored markdown (2026-08-12; falls back to the repo file when absent), HTML comments are stripped and an implausibly short body is refused · GET /acceptances/:accountId). Versions stay immutable snapshots — authoring always creates a NEW version.
Public routes (pre-CORS, no auth)
| Path | Purpose |
|---|---|
/pixel/* | Orbit Pixel ingestion. Script: GET /orbit.js and /js/o.js. Beacons: POST /v1/pageview · /v1/engagement · /v1/events · /v1/lead, each also mounted at a neutral alias /o/p · /o/e · /o/v · /o/l (one handler per beacon, so the two mounts cannot drift). Host rewrite (PIXEL_HOSTS in api/index.js): orbspan.com (CDN-fronted ingest host, 2026-08-07) and pixel.orbit.luniq.io → /pixel prefix. ⚠️ orbit.js posts to INGEST_BASE + an unprefixed path, so a beacon host missing from PIXEL_HOSTS 404s every beacon silently — sendBeacon() reports queueing, not status. Asserted by scripts/test-pixel-edge-trust.mjs. Beacons are rate-limited on the trusted hop × workspace (600/min) under a 3000/min per-workspace ceiling; on Cloudflare-verified requests the key upgrades to the real per-visitor cf-connecting-ip — never on an unverified header; see Statistics § Edge trust |
/lead-audit/* | Public lead-magnet audit (POST /start, GET /:id/stream SSE, GET /:id/events, POST /:id/claim, GET /:id/report + /:id/report.pdf hosted report for finished audits) |
POST /stripe/webhook | Stripe subscription mirror (server-to-server; raw-body + signature-verified via core/stripe/; 404 while BILLING_ENABLED is off). Handles checkout.session.completed (attach customer id, only if none yet) + customer.subscription.* → subscriptionMirrorFromStripe (core, pure: seats = the WORKSPACE Price item's quantity) → applySubscriptionMirror (status + seats_paid + trial end; order-safe via subscription_synced_at) + invoice.payment_failed (operator alert — the subscription's own past_due update is what stops the agency). applySubscriptionMirror also ACTIVATES a pending agency on an active/trialing subscription (paying replaces waiting, the manual review gate is for the assigned door) — in the mirror, not the handler, so the Checkout-return POST /billing/sync activates too |
Legacy api/routes/ files
Thin transport only (no new files here): agency/* (index · members · invites · workspaces), agent.js, legal.js (spine surface, like agency/*), collections.js, documents.js, invite.js, notifications.js, onboarding.js, pixel.js, stripe-webhook.js, support.js, workspaces.js, internal/*, modules/*. (reports.js was deleted 2026-07-27; the generator behind it was cut 2026-08-16 — the monthly report has no surface anywhere.)
onboarding.js also carries the CMS-connection surface the wizard and Settings share — GET /:ws/cms/types, GET /:ws/cms/credential-fields, POST /:ws/cms/test, POST /:ws/cms/discover-collections|discover-fields — plus two Drupal-only routes (both requireAuth + requireWorkspaceMember):
| Endpoint | What |
|---|---|
GET /:ws/cms/drupal/translations | Live probe of the workspace's Drupal: { applicable, configured, installed, ready, version, languages, defaultLangcode, workspaceLanguages, message }. Never 500s — the panel is informational and an unreachable Drupal must not break Settings. |
GET /:ws/cms/drupal/module | The orbit_translations module as .tar.gz (built in memory, X-Module-Sha256 on the response). |
Drupal-only because Drupal is the only platform where publishing a second language needs something installed on the customer's side — see CMS platform internals.