# Root keys and rotation (/en/docs/autenticacion/llaves-root)

If you are embedding Facturia in your own product — a vertical SaaS, an ERP, an accounting firm —
you will want to mint one key per customer empresa without a human opening a dashboard. That is a
first-class flow in v1.

## The root key [#the-root-key]

The key-management endpoints authenticate with a **root key** (`sk_live_root_…` /
`sk_test_root_…`), carrying the `keys:write` scope. It is minted by an **account session** — the
only credential that can — with `POST /dashboard/api_keys {"root": true}`; neither the dashboard nor
a human opening a screen is a requirement, it is the same call your own backend would make at cold
start:

```bash
curl -sS https://api.facturia.cl/dashboard/api_keys \
  -H "Authorization: Bearer $TOKEN" \
  -H "Facturia-Mode: live" \
  -H "Content-Type: application/json" \
  -d '{"name": "root — arranque en frío", "root": true}'
```

No `scopes`, no `empresas`: a root key carries them fixed (`["keys:write"]`, no empresas), and
sending `["keys:write"]` explicitly is not an error — sending anything else is,
`400 invalid_field`. A key — root or ordinary — that asks for `{"root": true}` gets
`403 privilege_escalation`: only an account session is above delegation.

A root key cannot emit, cannot read documents and cannot touch tax data. Its only power is minting
and revoking other keys. Keep exactly one, store it where you store your database credentials, and
never deploy it to an edge runtime.

## Minting [#minting]

```bash
curl -sS https://api.facturia.cl/v1/api_keys \
  -H "Authorization: Bearer $FACTURIA_ROOT_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "cliente — Comercial Andina",
    "mode": "live",
    "scopes": ["documents:read", "documents:write", "received:read"],
    "empresas": ["76543212-K"]
  }'
```

```json
{
  "id": "key_01K2QWB7T4N9",
  "object": "api_key",
  "name": "cliente — Comercial Andina",
  "mode": "live",
  "secret": "sk_live_qT8vN2wLmR5xKpJ7hYdCbA3s",
  "last4": "bA3s",
  "scopes": ["documents:read", "documents:write", "received:read"],
  "empresas": ["76543212-K"],
  "created_at": "2026-08-14T13:12:40Z"
}
```

`secret` is returned **exactly once**. Because this key names a single empresa, it is an empresa
token: hand it to that customer's system and it cannot address anything else.

## The three guardrails [#the-three-guardrails]

They are enforced on the server, not in your code:

**A minted key can never reach further than the key that minted it.** Its `empresas` must be a
subset of the minting key's allowlist, and its `scopes` a subset of that key's scopes. A root key
with `["*"]` can mint anything in the account; one restricted to three RUT (Chile's taxpayer ids)
can only produce keys inside those three. Violations return `403 privilege_escalation`, naming the
offending RUT or scope.

**Mode is inherited.** An `sk_test_root_` key mints test keys only. There is no path from a test
credential to a live one.

**A root key cannot mint root keys.** Delegation is one level deep, by design. If you need a second
root key, mint it with an account session (`POST /dashboard/api_keys {"root": true}`), as above.

## List, rotate, revoke [#list-rotate-revoke]

```bash
# Listar (solo metadatos — los secretos no son legibles, ni siquiera por una llave root)
curl https://api.facturia.cl/v1/api_keys -H "Authorization: Bearer $FACTURIA_ROOT_KEY"

# Rotar: emite un secreto de reemplazo; el anterior sigue funcionando 24 h,
# así que puedes desplegar sin corte
curl -X POST https://api.facturia.cl/v1/api_keys/key_01K2QWB7T4N9/roll \
  -H "Authorization: Bearer $FACTURIA_ROOT_KEY"

# Revocar de inmediato
curl -X DELETE https://api.facturia.cl/v1/api_keys/key_01K2QWB7T4N9 \
  -H "Authorization: Bearer $FACTURIA_ROOT_KEY"
```

An ordinary (non-root) key calling `GET /v1/api_keys` sees only itself — useful as a health check,
useless as an inventory.

All of it is audited: `api_key.created`, `api_key.rolled` and `api_key.revoked` land in the event
ledger with the id of the key that minted them.

> **Rotating is not revoking**
>
> `POST /v1/api_keys/{key_id}/roll` leaves the previous secret alive for 24 hours so you can deploy
> without a window of downtime. If you are rotating **because a key leaked**, rotate and then revoke —
> or revoke outright and take the outage. The 24 hours are a deployment convenience, not an incident
> response.

## The cold start [#the-cold-start]

Everything above needs a root key, and a new account has none. The way out is the **account
session**: the same one-hour JWT that `POST /account/login` returns.

```bash
# 1. Registrarte (o iniciar sesión). No se necesita navegador: es una llamada normal.
TOKEN=$(curl -sS https://api.facturia.cl/account/register \
  -H "Content-Type: application/json" \
  -d '{"email":"tu@empresa.cl","password":"..."}' | jq -r .data.token)

# 2. Mintear la primera llave con la sesión, no con una llave que todavía no tienes.
curl -sS https://api.facturia.cl/dashboard/api_keys \
  -H "Authorization: Bearer $TOKEN" \
  -H "Facturia-Mode: test" \
  -H "Content-Type: application/json" \
  -d '{"name":"primera llave","scopes":["documents:read","documents:write","empresas:read","empresas:write","webhooks:read","webhooks:write"],"empresas":["*"]}'
```

Those six are the **starter set**, the same across the three surfaces that publish it (this page,
the [quickstart](/en/docs/empezar/quickstart) and the cold start's own contract). It is the minimum
that gets you from here to an emitted document: creating the empresa needs `empresas:write` and
subscribing a webhook needs `webhooks:write`, so a key holding only the two `documents:` scopes dies
at the next step with `403 insufficient_scope`. See [Scopes](/en/docs/autenticacion/scopes).

To stop holding a human's password, the session mints a **root key** at the same door:

```bash
curl -sS https://api.facturia.cl/dashboard/api_keys \
  -H "Authorization: Bearer $TOKEN" \
  -H "Facturia-Mode: test" \
  -H "Content-Type: application/json" \
  -d '{"name":"root de integracion","root":true}'
# → {"id":"key_…","root":true,"scopes":["keys:write"],"empresas":["*"],"secret":"sk_test_root_…"}
```

The root key **inherits the session's reach** (`["*"]`: every empresa on the account, present and
future). That reach is what makes it usable: the subset guardrail compares a minted key's `empresas`
against the minter's, so a root key that reached no empresa could mint no key that reaches one. It
cannot use the reach itself — its only scope is `keys:write`; the keys it mints do. `empresas` on a
root mint may be absent, `[]` or `["*"]`; a narrower list is `400 invalid_field` (narrow the minted
key, not the root).

`/dashboard/*` is a deliberately **narrow** surface: empresas, documents, received documents, usage,
API keys and certificates, and nothing else. In particular, it cannot emit.

### If the password is lost [#if-the-password-is-lost]

The session is the only door to the first key, so losing the password left a person with none.
There is a recovery path:

```bash
# 1. Pedir el enlace. SIEMPRE responde 202, exista o no la cuenta: una respuesta
#    distinta para una dirección desconocida sería una lista de clientes pública.
curl -sS https://api.facturia.cl/account/password/reset_request \
  -H "Content-Type: application/json" -d '{"email":"tu@empresa.cl"}'
# → 202 {"object":"password_reset_request","status":"accepted"}

# 2. Con el token del correo (vive 60 minutos, un solo uso):
curl -sS https://api.facturia.cl/account/password/reset \
  -H "Content-Type: application/json" -d '{"token":"...","password":"..."}'
# → 200 {"object":"account","id":"...","email":"..."}
```

Asking for a new link invalidates the earlier ones, and changing the password invalidates whatever
is left. A token that does not work answers `400 password_reset_token_invalid` without saying which
of the four reasons it was: distinguishing them is a probe oracle over a bearer credential, and the
remedy is the same — ask for another link.

`POST /account/login` and `POST /account/register` both report `emailVerified`, so a client can show
the state without a second call. Minting an `sk_live_` key from an unverified account returns
`403 email_verification_required`; test mode is unaffected.
