R.E.C.R.E.C. rec.farm
Valtr docs 3 pages

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 is secret.read of KEY@n; secret.restored is KEY@n, the version brought back.
  • secrets.exported: dotenv or json.
  • 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.

From rec-farm/valtr/docs/API.md · master@3d56e62 · 2026-10-10