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.