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.invitedis 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-linkis anonymous-usable while the instance-wideallowPublicLinkssetting 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/healthProjects#
| 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 fetchesGET /api/v1/projectsand finds the slug in the list, and any script you write has to do the same. Reaching for/api/v1/projects/my-slugreturns 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. Aninvitedproject starts with an empty access list. The server adds the creator to that list automatically in this one case (addCreatorIfLockedOutinprojects-routes.ts). An admin creator is never added: admin authority does not depend on the access list. The defaultaccess: "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.gzOn 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) |