Desde

Reference

Viewer HTTP API

Every route under /api/v1, what it does, what credential it needs, and curl examples for the calls a self-hoster actually makes.

The Viewer exposes a JSON API under /api/v1. The dashboard uses it, the Editor uses part of it to sync comments, and you use it to create projects and publish builds. There is no project-creation screen in the UI, so the first thing anyone self-hosting does is a POST from a terminal.

Everything on this page is a route the server registers today. Base URL is your VIEWER_PUBLIC_URL; the examples assume http://localhost:3100.

Credentials#

Four kinds of caller appear in the tables below.

Session cookie: a browser signed in through GitHub. Set by the sign-in flow; you cannot mint one from a script.

dsv_ token: a personal access token you mint at /settings in the Viewer UI. Sent as Authorization: Bearer dsv_…. Each token carries read, write, or both. The format is dsv_<id>_<secret>; only a hash is stored, and the plaintext is shown once at creation.

Admin bearer: the static VIEWER_ADMIN_TOKEN, sent the same way. It reaches every project regardless of that project's access setting and cannot be revoked without a restart. It asserts no identity of its own.

Unauthenticated: no credential at all. Several routes accept this on purpose: anonymous review links are the product.

Warning A bearer header that matches neither the admin token nor a live dsv_ token is a 401, even if the same request also carries a valid session cookie. A bad machine credential never falls back to the cookie. This is deliberate: a revoked CI token that appears to keep working because the browser making the request happens to be logged in is the worst failure a machine credential can have.

How denials are shaped#

  • A project you may not read is a 404, byte-identical to a project that does not exist. A 403 there would confirm the project exists.
  • A valid credential that lacks the authority is a 403 with a reason.
  • Every project has one of three access settings, and none of them has a "zero members means world-readable" trap any more. all-members (the default) is readable by every signed-in instance member. invited is readable only by admins and by the people on that project's access list (an empty list means nobody but admins, never the world). public-link is anonymous-usable while the instance-wide allowPublicLinks setting is on. See members and tokens.

One guard that surprises people#

Every route under /api/v1 refuses a request the browser marks as a document fetch. The refusal is 403 with the body This endpoint may not be loaded as a document.

This stops a hostile prototype page from opening the API in a popup or an iframe and reading the response. In path mode the API is same-origin with a hosted prototype, so that read would otherwise work.

The guard checks the Sec-Fetch-Dest header. A browser sets this header on every request. Page JavaScript cannot change it. Curl and other non-browser clients never send it, so ordinary API calls pass straight through.

An earlier guard checked the Referer header instead. It is gone. Once a prototype gets its own origin, its referer names that origin, not the shell's, so the old check would have stopped working the moment that shipped. CSP and Host-scoping now do the job that guard used to do.

Health#

Method Path Purpose Auth
GET /api/v1/health { status, profile }. Liveness check. Unauthenticated
curl -s http://localhost:3100/api/v1/health

Projects#

Method Path Purpose Auth
GET /api/v1/projects List projects. Unreadable ones are filtered out, not 404'd. Any (result depends on caller)
GET /api/v1/projects/:id One project by id. Readable project
POST /api/v1/projects Create a project. Admin bearer, or a signed-in Editor/Admin's write token
PATCH /api/v1/projects/:id Update name, repoUrl, or access. Admin bearer, or a write token whose owner is an Editor/Admin who can already read this project
DELETE /api/v1/projects/:id Delete a project. Cascades its comments, deployments, members, and asset files. 409 while a build for it is running. Admin bearer, or a write token whose owner is an Editor/Admin who can already read this project
POST /api/v1/projects/resolve Reconcile a checkout against this Viewer's projects. Unauthenticated, on purpose (see below)

GET /api/v1/projects/:id also returns canComment, a boolean. It says whether this specific caller may write a comment here, folding in the allowAnonymousComments setting so a client does not have to re-derive the rule itself.

Note There is no GET /api/v1/projects/:slug. Lookup by id only. The Viewer's own review page fetches GET /api/v1/projects and finds the slug in the list, and any script you write has to do the same. Reaching for /api/v1/projects/my-slug returns 404 and looks like the project is missing.

curl -s http://localhost:3100/api/v1/projects | jq '.projects[] | {id, slug, name}'

Creating a project#

slug must be 2–63 characters, lowercase letters, digits and hyphens, starting with a letter or digit. name is required. access is all-members (default), invited, or public-link (the last only accepted while the instance-wide allowPublicLinks setting is on).

curl -s -X POST http://localhost:3100/api/v1/projects \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"checkout-redesign","name":"Checkout redesign"}'

Creating and managing a project needs Editor or Admin instance authority. There is no per-project ownership any more. An admin bearer works alone; a dsv_ token needs its owning user to hold the Editor or Admin role, plus write scope. A signed-in Viewer, or a read-only token, gets 403.

Note Creating a project with access: "invited" as a non-admin editor would otherwise lock the creator out of the project they just made. An invited project starts with an empty access list. The server adds the creator to that list automatically in this one case (addCreatorIfLockedOut in projects-routes.ts). An admin creator is never added: admin authority does not depend on the access list. The default access: "all-members" does not need this at all, since every instance member (including the creator) can already read it.

POST /projects/resolve is deliberately unauthenticated#

This is the Editor's reconcile call. Before the Editor links a local checkout to a Viewer project, it asks: is there already a project for this repository, or for the project id written in this repository's .desde/config.json?

It takes { embeddedId?, remoteUrl?, name? } (at least one of the first two) and answers with one of three decisions:

  • { decision: "adopt", project }: an existing project matches; link to it.
  • { decision: "mint", suggestedSlug }: nothing matches; create a new one.
  • { decision: "conflict", conflictWith, reason }: two things claim each other. The Editor asks the user rather than guessing.

It is open because gating it would defeat its purpose: the Editor could not avoid creating a duplicate project without signing in first, and a duplicate silently splits a review's comments across two projects. What it discloses is narrow (whether a project exists for an id or a repository the caller already knows), and it grants nothing.

curl -s -X POST http://localhost:3100/api/v1/projects/resolve \
  -H "Content-Type: application/json" \
  -d '{"remoteUrl":"git@github.com:acme/checkout.git","name":"Checkout"}'

Repository connection#

Method Path Purpose Auth
PUT /api/v1/projects/:id/repo Connect a GitHub repository and record its build settings. Admin bearer, or an Editor/Admin who can read this project
DELETE /api/v1/projects/:id/repo Disconnect. Leaves existing deployments alone. Same

The body carries installationId, owner, name, branch, installCommand, buildCommand, outputDir and autoDeploy. The installationId you send is not trusted as an authorization boundary: the Viewer checks it against your own installation set and then checks that the repository is actually in that installation, and takes owner/name from GitHub's answer rather than echoing yours back.

branch and outputDir are validated against a strict character allowlist and must not start with - or contain ... Those values end up as arguments to git, so a rejection here is a rejection you want.

GitHub discovery#

Method Path Purpose Auth
GET /api/v1/github/installations Installations of the App this caller can see. Session cookie or any dsv_ token
GET /api/v1/github/installations/:id/repos Repositories in one installation. Same
GET /api/v1/github/installations/:id/repos/:owner/:name/branches Branch names for one repo, for the connect form's branch picker. Same

Both answer { configured: false, … } with a 200 when no GitHub App is configured on the deployment, rather than 404, so a signed-out visitor learns "not configured" instead of "sign in first". An installation you cannot see is a 404, identical to one that does not exist.

The bare admin bearer does not count as signed in here: it asserts no identity, and there is no "installations for the admin" to answer.

Deployments and builds#

Method Path Purpose Auth
GET /api/v1/projects/:id/deployments List deployments. Readable project
POST /api/v1/projects/:id/deployments Upload a gzipped tar of a build and make it live. Admin bearer, or a write token owned by an Editor/Admin who can read this project
POST /api/v1/projects/:id/deployments/build Trigger a build of the connected repository. Admin bearer, or an Editor/Admin who can read this project, with write authority
GET /api/v1/deployments/:id/log/stream Server-sent stream of the build log. Admin bearer, or an Editor/Admin who can read this project

Uploading a build directly#

This is the escape hatch that needs no GitHub App: build locally, upload the output. The body is the raw gzipped tar, and the archive must have index.html at its root.

tar czf bundle.tar.gz -C dist .
curl -s -X POST "http://localhost:3100/api/v1/projects/$PROJECT_ID/deployments?commitSha=$(git rev-parse HEAD)" \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H "Content-Type: application/gzip" \
  --data-binary @bundle.tar.gz

On success the deployment becomes the project's active one and the prototype is live at /p/{slug}/. Uploads are capped at 200 MB compressed, 200 MB decompressed, and 20,000 entries in the archive.

If your build bakes root-absolute URLs into its JavaScript or CSS, the response's build log carries a Note: saying so. Those still resolve, through a fallback that depends on the Referer header. Building with base: './' avoids the whole class.

Triggering a build#

curl -s -X POST "http://localhost:3100/api/v1/projects/$PROJECT_ID/deployments/build" \
  -H "Authorization: Bearer $VIEWER_TOKEN"

Answers 202 with { deploymentId, status: "building" }. A build already running for that project answers 409 and includes the in-flight deploymentId, which is usually the one you wanted to watch anyway. With no GitHub App configured there is no build queue and the route answers 503.

Watching the log#

This route needs more than read access to the project. It needs manage authority: an Admin, or an Editor who can already read the project. A Viewer-role member, and an anonymous visitor on a public-link project, both get 403. The build log can carry the operator's install and build commands, plus the full output of a private repo's toolchain, so it is gated higher than an ordinary read.

curl -N "http://localhost:3100/api/v1/deployments/$DEPLOYMENT_ID/log/stream" \
  -H "Authorization: Bearer $VIEWER_TOKEN"

The stream sends event: log frames carrying only what is new since the last frame, a : ping comment every 25 seconds so intermediaries do not drop an idle connection, and a final event: done with the finished status. Then it closes.

Comments#

Method Path Purpose Auth
GET /api/v1/projects/:id/comments All comments on the project. Readable project
POST /api/v1/projects/:id/comments Create a comment. Readable project; a dsv_ token must have write
PATCH /api/v1/projects/:id/comments/:commentId Edit the body, resolve, or change mentions. Same
DELETE /api/v1/projects/:id/comments/:commentId Delete a comment. Same
POST /api/v1/projects/:id/comments/:commentId/replies Add a reply. Same
GET /api/v1/projects/:id/comments/stream Change notifications over SSE. Readable project

Anonymous writes are allowed on any project the caller can read, as long as the instance setting allowAnonymousComments is on. It defaults to on. When an Admin turns it off, an anonymous caller gets 403 Sign in to comment on this project on all four write routes above. That is the anonymous-review product, with an opt-out for a deployment that does not want it. Separately, and regardless of that setting, the write gate always stops a read-scoped dsv_ token from writing. A caller who genuinely wants anonymous-level access can just omit the header.

Authorship is decided server-side. If the request carries an identity (a session or a dsv_ token), the author field in your body is ignored entirely and the real identity is used, so an authenticated client may omit author altogether. An anonymous caller supplies its own author, but cannot claim a user:-prefixed uid.

Editing or deleting a comment's content has one more rule, checked only by PATCH and DELETE. If the comment's author is a verified, signed-in user (their uid starts with user:), only that same user may change its body, change its mentions, or delete it. An Admin may too, and so can anyone already on the project's access list (see Members below). Everyone else gets 404 Comment not found, the same body a missing comment returns. A resolved-only PATCH skips this check entirely: any caller who can write on the project may toggle it, since resolving does not change what the comment says. The rule does not apply to a self-declared anonymous author (their uid starts with viewer:), since there is no verified identity behind it to protect.

mentions is an array of participant ids, never email addresses. Ids that do not belong to a participant of this project are dropped silently; the comment still posts.

curl -s -X POST "http://localhost:3100/api/v1/projects/$PROJECT_ID/comments" \
  -H "Authorization: Bearer $VIEWER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Button label is truncated at 320px","position":{"anchorSelector":"[data-testid=\"submit\"]","page":"/checkout"}}'

The comment stream#

The SSE stream is a change bell, not a feed. It sends {"type":"connected"} on open and {"type":"changed"} whenever anything about the project's comments changes; you then re-fetch the list. Readability is checked once, when the connection opens. Revoking someone's membership takes effect on their next reconnect, not mid-stream.

Each client may hold at most 20 of these streams open at once. A 21st stream gets 429 Too many open connections from this client, with a Retry-After: 5 header telling the client when to try again.

curl -N "http://localhost:3100/api/v1/projects/$PROJECT_ID/comments/stream"

Participants#

Participants are the mention directory: people who can be @-mentioned on a project. Comment authors are added automatically as active; invited addresses start as pending.

Method Path Purpose Auth
GET /api/v1/projects/:id/participants List the directory. Readable project
POST /api/v1/projects/:id/participants Invite by email: { email, displayName? }. Readable project, signed in; a dsv_ token must have write

The POST route additionally requires an identified caller. A fully anonymous request, no session and no bearer at all, gets 401 Sign in to invite a participant by email. That is stricter than the comment write routes above, which allow anonymous entirely: inviting a real person by email is not part of the anonymous-review product the way commenting is.

This is separate from a project's access list. A participant can be mentioned; someone on the access list of an invited project can read it.

Members (a project's access list)#

A project's "members" are its access list: the people who can read an invited project, nothing more. There is no per-project role any more: whether someone can manage the project (these routes included) comes entirely from their instance role, via requireProjectManage: admin authority, or an Editor who can already read the project.

Method Path Purpose Auth
GET /api/v1/projects/:id/members List the access list. Readable project
POST /api/v1/projects/:id/members Add by email: { email }. Admin bearer, or an Editor/Admin who can read this project
DELETE /api/v1/projects/:id/members/:userId Remove an entry. Same

email is only returned to callers who are themselves on the list, or an Editor/Admin who can read the project. On a public-link project the list renders for anonymous visitors too, and an entry's email is a verified identity, so it is omitted entirely rather than blanked.

Adding someone resolves to an existing, active account. If nobody has an account with that address yet, the call is a 404 telling you to invite them to the instance first (see members and tokens), then retry.

DELETE has no last-member guard. Removing the final entry from an invited project's access list is allowed, for any caller requireProjectManage already admits. A guard here refused that until 2026-08-29: an Editor who emptied their own project's roster locked themselves out, since their read access came from that same list. It was removed on purpose. Losing read access this way is recoverable in one step by any Admin, and removal never changes the project's access setting, so an emptied roster cannot expose the project to anyone new.

curl -s -X POST "http://localhost:3100/api/v1/projects/$PROJECT_ID/members" \
  -H "Authorization: Bearer $VIEWER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"jo@example.com"}'

Instance (members, invites, domain rules, settings)#

Every route under /api/v1/instance/** is gated by requireInstanceAdmin: the admin bearer, or a signed-in Admin's session or write-scoped token. A non-admin caller, including an Editor, gets 403. This is the surface /settings → Members/Domain rules/Instance settings calls.

Method Path Purpose
GET /api/v1/instance/members List every account, including removed ones.
PATCH /api/v1/instance/members/:userId Change role: { role }. Refused (409) if it would leave zero active admins.
DELETE /api/v1/instance/members/:userId Remove (soft-delete). Kills that user's sessions and dsv_ tokens immediately. Refused (409) on the same last-admin guard.
POST /api/v1/instance/members/:userId/restore Reactivate a removed account.
POST /api/v1/instance/members/:userId/signin-link Mint a one-time sign-in link for an existing, active member (24-hour expiry). 409 if the member is removed.
POST /api/v1/instance/invites Create an invite: { email, role }. Always returns a copyable url; emailed: true if SMTP sent it too. 7-day expiry. 409 if an unexpired invite for that email already exists.
GET /api/v1/instance/invites List invites, with derived state: pending, used, revoked, or expired.
POST /api/v1/instance/invites/:id/regenerate Mint a fresh link for the same invite, invalidating the old one.
DELETE /api/v1/instance/invites/:id Revoke.
GET /api/v1/instance/domain-rules List domain rules.
PUT /api/v1/instance/domain-rules/:domain Create or update a rule: { role }. :domain must be lowercase, contain no @, and include a ..
DELETE /api/v1/instance/domain-rules/:domain Remove a rule.
GET /api/v1/instance/settings { allowPublicLinks, allowAnonymousComments, emailFrom, email }. email is a status object (configured, source, host, port, user, from, hasPassword); it never carries the password itself.
PATCH /api/v1/instance/settings Body: { allowPublicLinks?, allowAnonymousComments? }, both optional booleans. Response is the same shape as the GET. Each field takes effect immediately.
PUT /api/v1/instance/email Set the SMTP server: { host, port, user, pass, from }. Write-scoped, same as every mutation here. 409 if this deployment sets its mail server through environment variables instead.
DELETE /api/v1/instance/email Turn mail off and forget the stored password. Same write-scope rule, same 409 for an env-configured deployment.
curl -s -X POST http://localhost:3100/api/v1/instance/invites \
  -H "Authorization: Bearer $VIEWER_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"jo@example.com","role":"editor"}'

See members and tokens for the full sign-in and role model these routes manage.

Tokens#

Method Path Purpose Auth
POST /api/v1/tokens Mint a token: { name, scopes, expiresInDays? }. Returns the plaintext once. Session cookie only
GET /api/v1/tokens List your own tokens. Never the hash or the plaintext. Session cookie only
DELETE /api/v1/tokens/:id Revoke. Another user's token id is a 404, never a 403. Session cookie only
POST /api/v1/admin/users/:userId/tokens/revoke-all Operator-side revocation: deletes every token belonging to one user. For retiring a leaked credential, or a person who has left, without that user's cooperation. Admin bearer, or a signed-in Admin with write scope. Anyone else gets 404, not 401 or 403: there is no oracle for whether a user id exists.

Session only: a dsv_ token presented here is a 403 saying Personal access tokens cannot manage tokens, and the admin bearer does not work either. A leaked token must never be able to mint its replacement before anyone notices the original.

In practice you mint tokens in the Viewer UI at /settings, which is the only place the plaintext is ever shown. name must be 1-64 characters after trimming. expiresInDays is 1–365; 50 live tokens per user is the cap.

Sign-in#

Method Path Purpose Auth
GET /api/v1/me { user, authEnabled, scopes }. Any
GET /api/v1/auth/github Start the GitHub sign-in flow. Unauthenticated
GET /api/v1/auth/github/callback Finish it; sets the session cookie. Unauthenticated
POST /api/v1/auth/logout Revoke the current session server-side. Session cookie

/auth/github and /auth/github/callback only work when GitHub sign-in is configured on this deployment. When it is not, they answer the same 404 an unregistered path would, so a caller cannot tell "not configured" from "no such route."

/me and /auth/logout always exist, GitHub configured or not. Neither one depends on GitHub: a session is a capability of the Viewer itself. /me is how you tell "signed out" from "this deployment has no sign-in configured", and it is the cheapest way to check whether a dsv_ token is alive and what it may do:

curl -s http://localhost:3100/api/v1/me -H "Authorization: Bearer $VIEWER_TOKEN"

scopes is null for a browser session and an array for a dsv_ token. [] and null mean different things, so do not conflate them.

Browser sign-in pages#

A few more routes live under /api/v1/auth/**: GET/POST /auth/invite/:token, POST /auth/magic-link, GET/POST /auth/signin/:token, and GET /auth/local. These back the browser sign-in pages, not API clients, so this page does not document their request or response bodies.

For each token-link pair, the GET only renders a confirmation page. It touches no storage and spends nothing. The POST that page's button submits is what actually redeems the link and signs the caller in. A second click, or a bot pre-fetching the link from an email, cannot burn the token on its own.

Webhooks and unsubscribe#

Method Path Purpose Auth
POST /api/v1/webhooks/github GitHub push events; triggers auto-deploy for matching projects. GitHub's X-Hub-Signature-256
GET /api/v1/unsubscribe?token=… Unsubscribe link target from a mention email. Renders a confirmation page. The signed token in the query string
POST /api/v1/unsubscribe?token=… One-click unsubscribe (RFC 8058), used by mail clients. Same

The webhook is the only route authenticated by a signature rather than an identity. With VIEWER_GITHUB_APP_WEBHOOK_SECRET unset it answers 503 rather than processing unverified input. A bad signature is a 401. Events other than push are acknowledged and ignored, because a non-2xx makes GitHub mark the delivery failed and retry it forever.

A push builds every project whose connected repository and branch match and which has autoDeploy on. Nothing matching is still a 200: "no project is wired to this repository" is not an error you should have to chase in GitHub's deliveries tab.

The unsubscribe routes 404 when VIEWER_UNSUBSCRIBE_SECRET is unset. The signed token is the entire authentication: it proves the caller is the intended recipient, and adding a bearer gate would break clicking the link from an email client.

Error shapes#

Every error is { "error": "…" }. An unknown path under /api/v1 is a JSON 404, not the dashboard's HTML 404 page, so a client parsing responses does not choke on markup.

Status Means
400 Malformed body, failed validation, or a semantic refusal such as a comment reaching its 500-reply limit
401 No credential, or a bearer matching neither the admin token nor a live dsv_ token
403 Valid credential, insufficient authority: wrong scope, wrong instance role, an anonymous comment write refused by allowAnonymousComments, or a request the browser sent as a document fetch (see above)
404 Does not exist, or exists and you may not read it
409 A build already running, public links disabled, the last-admin guard, or 50 slug-suffix attempts exhausted (a merely taken slug is auto-suffixed, not refused)
503 The feature is not configured on this deployment (builds, webhooks)