HTTP API

Authentication

Pass a personal access key as a bearer token. That is the whole auth model.

Every request must include an Authorization header carrying a personal access key (ev_sk_...).

bash
Authorization: Bearer ev_sk_xxxxxxxxxxxx

The Bearer prefix is stripped when present and is otherwise optional, so a bare Authorization: ev_sk_... is accepted too. What is not optional is the ev_sk_ prefix: a value that does not start with it is rejected before any database lookup happens.

Generate keys at envless.cloud/dashboard/keys. The full key is shown once. Copy it into your secret store immediately. See Keys for naming, rotation, and revocation.

A CLI session token (env_cli_...) is a different credential and is refused on these endpoints. The two families never overlap: the REST API rejects anything that is not ev_sk_, and the dashboard and /cli/* endpoints reject an ev_sk_ key with 401, error.code auth.unauthorized and the message auth.api_key_not_accepted: API keys are not accepted on this endpoint; use the public API.

The same routes are served twice, once at the root and once under /v1, so https://api.envless.cloud/workspace and https://api.envless.cloud/v1/workspace are the same endpoint.

The key is the whole check

A key never presents a code, of either kind. Neither the two factor sign-in challenge nor step-up verification is ever put to this surface. A key that has the scope performs the operation, including the ones the dashboard guards: deleting another key or rewriting its scopes, removing a member, deleting a project, rolling a version back, creating a webhook endpoint and rotating its signing secret. The owner turning two factor on for their own account adds no challenge here and revokes nothing.

What can stop a live key is a workspace that requires two factor. A workspace owner or admin can require the second factor of every member. While that requirement is on, a key whose owner has not enabled two factor on their account is refused on every route, in the auth middleware, before scopes are read: 403 with auth.workspace_two_factor_required. That is enforceable on a credential that cannot answer a challenge because the question is asked of the owner's account record rather than of the request. Nothing is deleted, deactivated or re-scoped, and the key answers normally again the moment its owner enrolls or the requirement is turned off. See Requiring it across a workspace.

That is structural rather than an omission. Both of those checks are answered by a human at a keyboard: one reads a code out of an inbox, the other reads it off a phone. An ev_sk_ key is a pre-authorized machine credential running unattended in CI, so there is nobody to ask, and gating the surface would break every consumer that uses it. The gates sit on minting instead: creating a key in the dashboard, rotating one, deleting one, and changing its scopes or allowlist all require step-up, and the account that does the minting can be second-factor protected. The workspace requirement above is the one gate that reaches a key already in use, and it manages that by asking a question about the owner's account rather than about the request.

What follows from that is the control model. A key's blast radius is its scopes and its project allowlist, nothing else, and the only revocation is deletion or rotation from the dashboard. Scope keys to the minimum, keep one key per consumer so a single revocation is targeted, and treat a leaked key as fully live until the moment somebody deletes it.

What a key grants

Every key belongs to a single workspace and carries a scope per resource. Each resource is granted one of three levels (none, read, or write), and write implies read. There are eleven resources:

Resourcereadwrite
workspaceGET /workspace, GET /workspaces, GET /workspace/domainupdate / delete workspace, create workspace, claim / verify / remove custom domain
productslist / get products, list a product's projectscreate / update / delete / duplicate
product-accesslist a product's access grantsgrant / revoke member access
projectslist / getcreate / update / delete / duplicate
environmentslist / getcreate / update / delete / duplicate / expose
variableslist / get (ciphertext), historycreate / update / delete (single, bulk, apply)
versionslist / get, list entriescreate / rollback / re-encrypt entries
memberslist members, list invitesinvite / update role / remove / leave, resend / revoke invite
roleslist / get roles, list permissionscreate / update / delete roles
keyslist / getcreate / update / delete
webhookslist / get, event types, deliveriescreate / update / delete, rotate secret, redeliver

A resource left at none returns 403 on every route that names it. Some routes require two scopes at once, and both are checked before the handler runs: every duplicate route also needs variables:write (a product duplicate additionally needs projects:write), version rollback and entry re-encryption need variables:write, reading a version or its entries needs variables:read, and GET /products/:slug/projects needs projects:read.

A key can additionally be pinned to an allowlist of projects. Leave it empty for the whole workspace, or list specific project IDs (not slugs) to fence the key in.

What the fence narrows is environments, variables, versions and the project reads: GET /projects and GET /products/{slug}/projects return only the fenced projects, and any route resolving a project outside the fence is 403 auth.key_project_restricted.

The workspace-wide surfaces are not narrowed, they are closed. Each of these refuses a fenced key outright with 403 auth.key_project_restricted, whatever scope the key holds:

Refused for a fenced keyWhy
GET /keys, GET /keys/{id}, PATCH /keys/{id}, DELETE /keys/{id}Keys are workspace-wide, so there is no project subset of the key list to hand back
Every webhook route, including the reads and GET /webhooks/event-typesEndpoints subscribe to workspace-wide events and have no project dimension
POST /products, PATCH and DELETE /products/{slug}, POST /products/{slug}/duplicate, and PUT and DELETE /products/{slug}/access/{memberId}A product spans projects the fence may not cover
POST /projects and POST /projects/{slug}/duplicateThe new project could not be covered by the allowlist

POST /keys is the one key route a fenced key keeps. There projectIds becomes mandatory and must be a non-empty subset of the caller's own fence, so a fenced key can mint narrower keys but can never read back the list it just added to.

Everything else at workspace level is untouched by the fence, because none of it has a project dimension to narrow: GET /me, the workspace and domain routes, GET /workspaces, all of members, invites and roles, and the product reads GET /products, GET /products/{slug} and GET /products/{slug}/access.

Scope is checked in middleware and the fence inside the handler, so the two errors are ordered. A fenced key without keys:read gets 403 auth.scope_missing on GET /keys; add the scope and the same key gets 403 auth.key_project_restricted. Granting the scope never opens a closed route.

Grant the narrowest scopes that get the job done. A CI key that only pulls ciphertext needs nothing beyond variables:read: the variables routes take the project and environment slugs in the path and resolve them internally, so no projects or environments scope is involved. Add workspace:read only if the client has to resolve the workspace ID itself. @goenvless/env/edge is the common case: its load({ token, project, environment, passphrase }) calls GET /workspace first whenever no workspaceId and no key were passed, because the passphrase is derived against the workspace ID. Pass workspaceId or a raw key and that call disappears, leaving variables:read as the only scope the edge loader needs.

A key created without a scopes object gets every scope set to none. It can authenticate but not touch any resource until you assign scopes. Set them when you create the key.

Examples

GET /me is the only endpoint that requires no scope at all, which makes it the way to prove a key is live before you debug anything else. It returns the owning user, the bound workspaceId and the key's own id, name and createdAt.

curl https://api.envless.cloud/me \
    -H "Authorization: Bearer ev_sk_xxxxxxxxxxxx"

The TypeScript, Python, Ruby and PHP SDKs send the header for you, and this whole call is envless.me.get() in the first two, Envless.me.get in Ruby and $envless->me->get() in PHP.

Every response is the same envelope: success, code, message, error, data and _req. On a failure error.code carries the machine-readable string from the tables below, and data is null.

Failure modes

Statuserror.codeMeaningFix
401auth.api_key_requiredNo Authorization header, or the value does not start with ev_sk_.Add or fix the header.
401auth.api_key_not_foundKey was deleted, rotated, deactivated, or never existed.Generate a new one and update your secret store.
401auth.api_key_user_missingThe key is valid but its owner account no longer exists.Create the key again under a live account.
401auth.api_key_owner_not_memberThe key's owner has left or been removed from the workspace.Re-add the owner, or issue the key from a current member.
403auth.workspace_two_factor_requiredThe key's workspace requires two factor authentication and the key's owner has not enabled it on their account. Refused before scopes are read, on every route.The owner turns two factor on from Account settings, or someone with workspace:update turns the requirement off.
403auth.scope_missingThe key is valid but lacks the scope the endpoint needs. The message and error.resource name it as resource:level.Add the scope to the key in the dashboard, or use a key that has it.
403auth.key_project_restrictedThe key is fenced to specific projects and the request touched something outside them, or touched a closed workspace-wide surface: any key route other than POST /keys, any webhook route, a product or product-access write, or project creation and duplication.Use a key with no project fence. On POST /keys, send a non-empty projectIds subset of the caller's own fence.
403auth.product_restrictedThe key owner has no grant on the private product the target project belongs to.Grant the owner access to the product.
403auth.scope_ceiling_exceededPOST /keys asked for a resource:level above what the calling key holds. Nothing is created.Request scopes at or below the calling key's own.
402plan.upgrade_requiredThe target project is locked because it is over the plan's project limit.Upgrade the plan, or delete projects until the target is unlocked.

Never put a key in client-side code, public repos, build logs, or chat. Scope every key to the minimum it needs: a leaked key can do exactly what its scopes allow until you revoke it, so a read-only key limits the blast radius.

Last used

Each key records a single lastUsedAt timestamp, shown in the dashboard, and nothing else. The write is throttled: a key that was already touched within the last 60 seconds is not written again, and the update is fire-and-forget so a failed write never fails the request. There is no per-key request counter and no per-request log to read back, so treat lastUsedAt as a liveness signal for finding dormant keys to rotate, not as usage accounting.

Keys change underneath you

Two things change what a key can do without anyone touching the key. A plan change is not one of them: keys work on every plan, Free included, so a downgrade never deactivates a key, though a project the downgrade locks still answers 402 to it (see Plans and billing).

A role change re-clamps the scopes of every key its owner holds in that workspace. Scopes are clamped to the owner's workspace permissions at creation time and again whenever the owner's role, or that role's permission set, changes. A key can silently drop from write to read, or to none, so read scopes back rather than assuming the key still holds what you asked for.

A workspace two factor requirement stops every key whose owner has not enrolled. Turning the requirement on is an admin action taken in the dashboard, and it lands on the keys immediately: every request returns 403 auth.workspace_two_factor_required until that owner enables two factor or the requirement comes back off. The key's record is not changed, so nothing in GET /keys looks different. If a pipeline stops the same day someone tightened workspace settings, this is the first thing to check.

members:write escalates past the key's own scopes. An invited role is checked against the owner's workspace permissions, not against the calling key's scopes, so a key that holds nothing but members:write can invite an address its holder controls into any role the owner could grant. Two limits apply and only two: the owner system role is never assignable through an invite (409 role.owner_not_assignable), and a role granting permissions the owner does not hold is refused with 403 auth.permission_denied. Do not put members:write on a CI key that only needs to read variables.

keys:write can mint further keys, but cannot escalate. A requested scope above the calling key's own aborts the whole request with 403 auth.scope_ceiling_exceeded, and whatever survives is then clamped silently to the owner's role, so a requested write can land as read or none. A fenced key must pass a non-empty projectIds subset of its own fence. Scope CI keys to variables:read and versions:read unless they genuinely need more.