Facturiadocs
Authentication

Root keys and rotation

Mint one key per customer empresa without a human opening a dashboard, rotate secrets with no downtime, and the cold start of a brand new account.

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 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:

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

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"]
  }'
{
  "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

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

# 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

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.

# 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":"[email protected]","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 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.

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

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

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

# 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":"[email protected]"}'
# → 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.

On this page