> ## Documentation Index
> Fetch the complete documentation index at: https://vetta.sh/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Organization-scoped API keys and the Bearer scheme.

The Vetta API authenticates with **organization-scoped API keys**. Every key belongs to a single [organization](/docs/api/organizations) and can act only on that org's resources and credit balance.

## The Bearer scheme

Send your key in the `Authorization` header on every request:

```bash theme={"system"}
Authorization: Bearer sk_live_...
```

A missing or invalid key returns `401 unauthorized`. A valid key acting outside its scopes returns `403 forbidden`.

<Warning>
  Live keys are prefixed `sk_live_` and are shown **once** at creation. Store them in a secret manager — they cannot be retrieved again, only rotated.
</Warning>

## Scopes

Keys carry scopes that limit what they can do. Scopes follow a `resource:action` grammar (e.g. `agents:write`, `sessions:read`). Every key names at least one; grant `admin` for full access to the organization.

| Scope                              | Grants                                                                                   |
| ---------------------------------- | ---------------------------------------------------------------------------------------- |
| `agents:read` / `agents:write`     | Read or manage [agents](/docs/api/agents).                                                    |
| `sessions:read` / `sessions:write` | Read or run [sessions](/docs/api/sessions).                                                   |
| `computers:write`                  | Manage [computers](/docs/api/computers).                                                      |
| `proxy:write`                      | Call the [model proxy](/docs/api/proxy) and nothing else.                                     |
| `files:read` / `files:write`       | Read or upload [files](/docs/api/files).                                                      |
| `billing:read` / `billing:write`   | Read the [credits](/docs/api/credits) balance and ledger / charge a payment method to top up. |
| `webhooks:*`                       | Manage [webhook endpoints](/docs/api/webhooks), rotate secrets, and read delivery logs.       |
| `audit:read`                       | Read the [audit log](/docs/api/audit-logs).                                                   |
| `admin`                            | Manage members, keys, and organization settings.                                         |

<Note>
  `proxy:write` is deliberately narrow. The proxy routes accept it **or** `sessions:write`, so a key that reaches them today keeps working — but a credential that holds only `proxy:write` can make model calls and cannot start, cancel or read sessions. That is what makes it safe to hand to a sandbox: a harness that runs as a process inside a micro-VM needs a credential to reach the door, and one that leaked should not be able to run sessions for your organization.

  `webhooks:*` is one scope, spelled with the star: it is the only webhook scope, and it covers both reading and managing endpoints. `billing:write` gates the card-charging top-up endpoint and is deliberately separate from read-only `billing:read`.
</Note>

## Who is this credential?

`GET /v1/me` answers from the credential alone — no scope, no organization id in the request. It is
the first call any client should make, and it is how the CLI and the dashboard learn which
organization a key belongs to instead of asking you to paste an `org_` id you have no way to look
up.

```json Response theme={"system"}
{
  "object": "identity",
  "principal": { "type": "key", "id": "key_01H8AA..." },
  "organization": { "id": "org_01H8AA...", "name": "Acme", "mode": "live" },
  "scopes": ["admin"],
  "user": null
}
```

`organization` is `null` only for a signed-in person who does not belong to one yet; `user` is
`null` whenever the caller is an API key rather than a person.

`GET /v1/organizations` is the list form. An API key reaches exactly the one organization it is
bound to — a one-row page, never another tenant's row. A signed-in person reaches every
organization they are a member of. Cursor-paginated, see [Pagination](/docs/api/pagination).

## Signing a person in

A **person** authenticates with an email address and password and receives a short-lived bearer
session token prefixed `vt_`. It is accepted anywhere an `sk_` key is, on the same
`Authorization: Bearer` header, and its scopes are derived from the member's role.

```bash theme={"system"}
POST /v1/auth/session
```

<ParamField body="email" type="string">The account's email address. Required with `password` when signing in fresh; omitted when re-scoping an existing `vt_` token.</ParamField>
<ParamField body="password" type="string">The account's password. Paired with `email`.</ParamField>
<ParamField body="provider_token" type="string">The access token an external provider sign-in came back with. Verified against the provider on every call; never decoded here. Used instead of `email`/`password`.</ParamField>
<ParamField body="organization_id" type="string">The organization to scope the session to. With an existing `vt_` token this re-scopes the session — the org switcher. Must be an organization the person belongs to.</ParamField>

```json Request theme={"system"}
{ "email": "ada@acme.test", "password": "..." }
```

```json Response theme={"system"}
{
  "object": "auth_session",
  "token": "vt_...",
  "expires_at": "2026-08-22T09:00:00Z",
  "organization": { "id": "org_01H8AA...", "name": "Acme", "mode": "live" }
}
```

Presenting an existing `vt_` token to the same route with an `organization_id` re-scopes the session
to another organization you belong to — that is what an organization switcher is. Passing an
`organization_id` you are not a member of returns `403 forbidden`.

### Signing in with Google

The dashboard offers "Continue with Google" wherever the deployment has the provider turned on. It
is the same route and the same session at the end of it: the provider hands back an access token,
and that token is exchanged for a `vt_`.

```json Request theme={"system"}
{ "provider_token": "..." }
```

The token is **verified against the provider on every call** — it is never decoded or taken at face
value, so a forged, expired or revoked one is `401 unauthorized`, phrased exactly as a wrong
password is.

Two things are worth knowing before you build against it:

* **The address must be one your provider has verified.** You are matched to your organizations by
  email address, so an unverified address is refused rather than signed in. Google verifies the
  addresses it asserts, so this is invisible in practice.
* **It signs you in; it does not enrol you.** Signing in with Google as somebody who belongs to no
  organization gives you the same session a password sign-in would: `organization_id: null`, no
  scopes, and the choice of joining one or creating one. Your role and scopes always come from your
  membership — never from how you signed in, and never from anything the caller asks for.

`POST /v1/auth/users` registers a person:

<ParamField body="email" type="string" required>The address to register. Must be confirmed by mail before sign-in succeeds.</ParamField>
<ParamField body="password" type="string" required>At least 8 characters.</ParamField>

```json Request theme={"system"}
{ "email": "ada@acme.test", "password": "..." }
```

```json Response theme={"system"}
{ "object": "auth_user", "email": "ada@acme.test", "confirmation_required": true }
```

`confirmation_required: true` means the address must be confirmed by mail before
`POST /v1/auth/session` will succeed — the response never pretends the person is signed in.

<Note>
  Session tokens are for interactive clients. Machine-to-machine callers should hold a scoped API key:
  a token expires and a key does not.
</Note>

## Password reset and confirmation

`POST /v1/auth/recover` starts a password reset:

<ParamField body="email" type="string" required>The address to send the reset link to.</ParamField>

`POST /v1/auth/confirmations` re-sends the
confirmation for an address that has not been proved yet:

<ParamField body="email" type="string" required>The address to re-send the confirmation to.</ParamField>

Both answer `202` with the same body **whether or not that address has an account** — an endpoint that
answered differently would be a way to discover who your users are. Do not branch on it; there is
nothing to branch on.

```json Response theme={"system"}
{ "object": "auth_recovery", "delivery": "sent" }
```

`delivery` is `sent` when the message was handed to a mailer and `undeliverable` when the deployment
has none attached. It describes the *deployment*, never the address, which is why it is safe to show
a caller: it lets a sign-in screen say "check your inbox" or "nothing will arrive, ask an operator"
truthfully, without either state being hard-coded.

`POST /v1/auth/password` finishes a reset with the token the emailed link carried:

<ParamField body="token" type="string" required>The single-use token from the emailed link.</ParamField>
<ParamField body="password" type="string" required>The new password. At least 8 characters.</ParamField>

```json Request theme={"system"}
{ "token": "...", "password": "..." }
```

It answers `200` with `{ "object": "auth_password", "updated": true }`, or `401` when the token is
expired or already spent. This one *is* allowed to refuse out loud — the caller already holds the
credential, so saying it is dead discloses nothing.

Both mailed routes are rate limited per address.

## Create an API key

`POST /v1/api_keys` → `201 Created`. Creates a new key for the caller's organization. The plaintext `secret` is returned only in this response.

<ParamField body="name" type="string" required>
  Human-readable label for the key.
</ParamField>

<ParamField body="scopes" type="string[]" required>
  Scopes to grant. At least one; `admin` for full org access.
</ParamField>

<CodeGroup>
  ```bash cURL theme={"system"}
  curl -fsSL https://api.vetta.sh/v1/api_keys \
    -H "authorization: Bearer sk_live_..." \
    -H "content-type: application/json" \
    -H "idempotency-key: $(uuidgen)" \
    -d '{ "name": "ci", "scopes": ["agents:write", "sessions:write"] }'
  ```
</CodeGroup>

<ResponseField name="id" type="string">The key id (e.g. `key_01H...`).</ResponseField>
<ResponseField name="name" type="string">The label you supplied.</ResponseField>
<ResponseField name="secret" type="string">The plaintext key. Returned only once.</ResponseField>
<ResponseField name="scopes" type="string[]">Granted scopes.</ResponseField>
<ResponseField name="prefix" type="string">The non-secret prefix, shown in listings for identification.</ResponseField>
<ResponseField name="created_by" type="object">The principal that created this key — `{ "type": "user" | "key" | "system", "id": "..." }`, the same shape the [audit log](/docs/api/audit-logs) records.</ResponseField>
<ResponseField name="last_used_at" type="string | null">When the key was last used to authenticate a request; `null` until first use.</ResponseField>
<ResponseField name="created_at" type="string">Creation timestamp.</ResponseField>
<ResponseField name="org_id" type="string">The organization this key opens. Returned only at create and rotate.</ResponseField>
<ResponseField name="mode" type="string">`live` or `test` — the tier the key spends on. Returned only at create and rotate.</ResponseField>

<ResponseExample>
  ```json Response theme={"system"}
  {
    "id": "key_01H8XK2M...",
    "object": "api_key",
    "name": "ci",
    "secret": "sk_live_9f2c...redacted",
    "prefix": "sk_live_9f2c",
    "scopes": ["agents:write", "sessions:write"],
    "created_by": { "type": "user", "id": "usr_01H8AA..." },
    "last_used_at": null,
    "created_at": "2026-08-20T17:00:00Z",
    "org_id": "org_01H8A0...",
    "mode": "live"
  }
  ```
</ResponseExample>

## List API keys

`GET /v1/api_keys` returns key metadata (never the secret). Cursor-paginated — see [Pagination](/docs/api/pagination).

```json Response theme={"system"}
{
  "data": [
    { "id": "key_01H...", "name": "ci", "prefix": "sk_live_9f2c", "scopes": ["agents:write"], "created_by": "usr_01H8AA...", "last_used_at": "2026-08-20T17:22:10Z", "created_at": "2026-08-20T17:00:00Z" }
  ],
  "has_more": false,
  "next_cursor": null
}
```

## Rotate & revoke

Rotation issues a new secret and invalidates the old one. Revocation deletes the key immediately.

```bash theme={"system"}
POST   /v1/api_keys/{id}/rotate   # returns a new secret
DELETE /v1/api_keys/{id}          # revoke
```

<Info>
  Rotate on a schedule and on any suspected exposure. Because keys are org-scoped, revoking one never affects another org's access.
</Info>

<Note>
  Key creation, rotation, and revocation are recorded in the [audit log](/docs/api/audit-logs) as `api_key.created`, `api_key.rotated`, and `api_key.revoked`, attributed to the acting principal.
</Note>
