Valtr docs
API.
Everything is JSON under /api. Errors are {"error": "message"} with a matching status code.
Authentication
There are two ways to call the API:
- A session cookie, from signing in with Handstamp. Sessions last 7 days.
- An API key:
Authorization: Bearer vltr_…. A key belongs to one project and one person, has a scope, and may expire.
People see the projects they own or are a member of. Each project has one owner; members have the
role read or write. A key acts as the person who holds it, limited to its project and scope:
it never does more than they may.
| Action | Owner | write member |
read member |
write key |
read key |
|---|---|---|---|---|---|
| Read projects, environments, secrets, logs | yes | yes | yes | yes¹ | yes¹ |
| Set or delete secrets, add/remove envs | yes | yes | 403 | yes¹ ² | 403 |
| See who's in a project | yes | yes | yes | 403 | 403 |
| Invite, change roles, remove people | yes | 403 | 403 | 403 | 403 |
| Delete the project | yes | 403 | 403 | 403 | 403 |
| Create projects; manage API keys | yes | yes | yes | 403 | 403 |
¹ Its own project only. ² Only while its holder may write.
Projects outside someone's (or a key's) reach answer 404, as if they didn't exist.
| Method | Path | Body / notes |
|---|---|---|
| POST | /api/auth/dev-login |
Development only (VALTR_ENV=development): {user} and the session cookie. |
| POST | /api/auth/logout |
Ends the session. |
| GET | /api/auth/me |
{user: {id, email}} |
| GET | /api/auth/oidc/start?returnTo=/path |
Browser redirect to Handstamp. 404 when it isn't configured. |
| GET | /api/auth/oauth2/callback/oidc |
Handstamp's redirect URI. Sets the session and sends you to returnTo. |
| POST | /api/auth/backchannel-logout |
Handstamp's back-channel logout URI: form field logout_token. 200 or 400. |
A person's first sign-in makes their account. People are matched by Handstamp issuer and subject,
never by email: if an older account already has their email, the callback answers 409 until an
operator links the two with valtr link (see Deploying).
GET /api/instance describes the instance: {name, version, signIn: {oidc, development}}.
oidc is {name, url} for the sign-in button. Without the web app, GET / answers the same.
GET /healthz answers ok while the process runs. GET /readyz answers ok when the database
does too, and 503 otherwise; the image's HEALTHCHECK uses it.
With the web app, a failed Handstamp sign-in goes back to /sign-in?error=<reason> instead of
answering JSON. The reason is one of expired, rejected, refused, email, linking, off or
unavailable.
Projects
| Method | Path | Response |
|---|---|---|
| GET | /api/projects |
{projects: [{id, name, slug, ownerId, createdAt, role}]}. role is yours: owner, write or read. |
| POST | /api/projects |
{name} → 201 {project}. The slug comes from the name; 409 if taken. Creates development, staging and production. |
| GET | /api/projects/:slug |
{project, environments} |
| DELETE | /api/projects/:slug |
{ok: true}. Owner only. Deletes its environments, secrets, members and API keys. |
People
Session only. Under /api/projects/:slug:
| Method | Path | Response |
|---|---|---|
| GET | /members |
{members: [{userId, email, role, createdAt}]}, the owner first. Anyone in the project. |
| PATCH | /members/:userId |
{role: "read" | "write"} → {ok: true}. Owner only. |
| DELETE | /members/:userId |
{ok: true}. The owner removes someone, or a member removes themselves. Their API keys for it are deleted. |
| GET | /invites |
{invites: [{id, role, createdAt, expiresAt}]}: pending ones. Owner only. |
| POST | /invites |
{role?: "read" | "write"} → 201 {invite, token, url}. Owner only. Default read. |
| DELETE | /invites/:id |
{ok: true}. Owner only. The link stops working. |
POST /api/invites/accept with {token} adds whoever is signed in as a member with the invite's
role and returns {project}. An invite works once, for 7 days: 404 once it's used, withdrawn or
expired, 409 (without using it up) if you're already in the project.
The token and url (<VALTR_PUBLIC_ORIGIN>/invite#<token>) are returned only when the invite is
created; Valtr stores the token's SHA-256. The token travels in the URL fragment, so it never
reaches a server log. People are never looked up or added by email.
Environments
| Method | Path | Response |
|---|---|---|
| GET | /api/projects/:slug/environments |
{environments: [{id, projectId, name, createdAt}]} |
| POST | /api/projects/:slug/environments |
{name} → 201 {environment}; 409 if it exists. |
| DELETE | /api/projects/:slug/environments/:env |
{ok: true}. Deletes its secrets. |
Environment names use letters, digits, _, - and . (64 at most).
Secrets
Under /api/projects/:slug/environments/:env/secrets:
| Method | Path | Response |
|---|---|---|
| GET | / |
{secrets: [{id, key, version, createdAt, updatedAt}]}, sorted by key. Never values. |
| POST | / |
{key, value}. New: 201 {ok, action: "created", version: 1}. Existing: 200 {ok, action: "updated", version}. |
| GET | /:key |
{secret: {id, key, value, version, createdAt, updatedAt}} |
| DELETE | /:key |
{ok: true}. Its history goes with it. |
| GET | /.env |
text/plain attachment <env>.env, one KEY=value per line. |
| GET | /json |
{"KEY": "value", …} |
Secret keys use letters, digits, _, - and . (256 at most). Values are any string, the empty
string included.
The .env output reads the same in dotenv libraries, docker compose and a shell that sources it
(set -a; . ./production.env). Values made only of letters, digits and _ . / : @ % + , = - are
written bare. Everything else is single-quoted, line breaks included, and read literally: nothing is
expanded or run. Values that contain ' are double-quoted, with \, ", $ and `
escaped by shell rules; some dotenv parsers keep those backslashes, so use the JSON export when such
a value must be exact. Keys that aren't valid names (possible only in data from the TypeScript
version) are left out.
Reading one secret and each export are recorded in the audit log.
Secret history
Every value a secret has had is kept, encrypted like the current one. Under
/api/projects/:slug/environments/:env/secrets/:key:
| Method | Path | Response |
|---|---|---|
| GET | /versions |
{versions: [{version, createdAt, userId, userEmail}]}, newest first. Never values. |
| GET | /versions/:n |
{secret: {key, value, version, createdAt}}. Recorded as secret.read of KEY@n. |
| POST | /versions/:n/restore |
{ok, action: "restored", version}: the old value becomes a new version. secret.restored KEY@n. |
userId is null for versions written before Valtr kept history.
API keys
Session only. You see your own keys, and every key of the projects you own.
| Method | Path | Response |
|---|---|---|
| GET | /api/api-keys?project=:slug |
{apiKeys: [{id, name, keyPrefix, scope, projectId, userId, userEmail, lastUsedAt, expiresAt, createdAt}]}. project is optional. |
| POST | /api/api-keys |
{name, projectSlug, scope?: "read" | "write", expiresInDays?: 1–3650} → 201 {key, prefix, name, scope, expiresAt}. Default read, no expiry. |
| POST | /api/api-keys/:id/rotate |
{expiresInDays?} → {key, prefix, name, scope, expiresAt}. Your own keys. Same lifetime as before unless expiresInDays says otherwise. |
| DELETE | /api/api-keys/:id |
{ok: true}. Your keys, or any key of a project you own. It stops working at once. |
The raw key is returned only when it's created or rotated. Valtr stores its SHA-256. A write
key needs its holder to have write access; read members make read keys. An expired key answers
401 with This API key has expired.
From a shell, valtr pull and valtr run use a key for you; see the README.
Audit log
GET /api/projects/:slug/audit-log?limit=50&offset=0, newest first. limit is 1–500.
{
"auditLog": [
{
"id": 7,
"userId": 1,
"userEmail": "me@example.com",
"projectId": 1,
"environmentId": 3,
"action": "secret.updated",
"targetKey": "DATABASE_URL",
"ipAddress": "203.0.113.9",
"createdAt": "2026-10-08T12:00:00.000Z"
}
]
}Actions and their targetKey:
project.created,project.deleted: the slug.environment.created,environment.deleted: the environment's name.secret.created,secret.updated,secret.read,secret.deleted: the key. Reading an old version issecret.readofKEY@n;secret.restoredisKEY@n, the version brought back.secrets.exported:dotenvorjson.api_key.created,api_key.rotated,api_key.deleted: the key's name.invite.created,invite.deleted: the invite's role.member.joined,member.updated:email:role.member.removed: the email.