Facturiadocs
Autenticación

Llaves root y rotación

Mintear una llave por empresa cliente sin que un humano abra un panel, rotar secretos sin corte y el arranque en frío de una cuenta nueva.

Si estás incrustando Facturia en tu propio producto — un SaaS vertical, un ERP, un estudio contable — vas a querer mintear una llave por empresa cliente sin que un humano abra un panel. Eso es un flujo de primera clase en la v1.

La llave root

Los endpoints de gestión de llaves se autentican con una llave root (sk_live_root_… / sk_test_root_…), portadora del scope keys:write. La mintea una sesión de cuenta —la única credencial que puede— con POST /dashboard/api_keys {"root": true}; ni el panel ni un humano abriendo una pantalla son un requisito, es la misma llamada que haría tu propio backend en el arranque en frío:

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}'

Sin scopes ni empresas: una llave root los lleva fijos (["keys:write"], sin empresas) y mandarlos igual a ["keys:write"] no es error, mandar cualquier otra cosa sí lo es — 400 invalid_field. Una llave —root u ordinaria— que pide {"root": true} recibe 403 privilege_escalation: solo una sesión de cuenta está por encima de la delegación.

Una llave root no puede emitir, no puede leer documentos y no puede tocar datos tributarios. Su único poder es mintear y revocar otras llaves. Ten exactamente una, guárdala donde guardas las credenciales de tu base de datos, y nunca la despliegues a un runtime de edge.

Mintear

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 se devuelve exactamente una vez. Como esta llave nombra una sola empresa, es un token de empresa: entrégasela al sistema de ese cliente y no podrá direccionar nada más.

Las tres barreras

Se aplican en el servidor, no en tu código:

Una llave minteada nunca puede alcanzar más que quien la mintea. Sus empresas deben ser un subconjunto de la lista blanca de la llave minteadora, y sus scopes un subconjunto de los suyos. Una llave root con ["*"] puede mintear cualquier cosa de la cuenta; una restringida a tres RUT solo puede producir llaves dentro de esos tres. Las violaciones devuelven 403 privilege_escalation nombrando el RUT o el scope ofensor.

El modo se hereda. Una llave sk_test_root_ solo mintea llaves de test. No hay camino de una credencial de test a una live.

Una llave root no puede mintear llaves root. La delegación es de un nivel de profundidad, por diseño. Si necesitas una segunda llave root, mintéala con una sesión de cuenta (POST /dashboard/api_keys {"root": true}), como arriba.

Listar, rotar, revocar

# 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"

Una llave ordinaria (no root) que llame a GET /v1/api_keys se ve solo a sí misma — útil como health check, inútil como inventario.

Todo esto queda auditado: api_key.created, api_key.rolled y api_key.revoked caen en el libro de eventos con el id de la llave que las minteó.

Rotar no es revocar

POST /v1/api_keys/{key_id}/roll deja el secreto anterior vivo 24 horas para que puedas desplegar sin ventana de caída. Si estás rotando porque una llave se filtró, rota y después revoca — o revoca directamente y asume el corte. Las 24 horas son una comodidad de despliegue, no una respuesta a incidentes.

El arranque en frío

Todo lo anterior necesita una llave root, y una cuenta nueva no tiene ninguna. La salida es la sesión de cuenta: el mismo JWT de una hora que devuelve POST /account/login.

# 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":["*"]}'

Esos seis son el set de arranque, el mismo en las tres superficies que lo publican (esta página, el quickstart y el contrato del arranque en frío). Es el mínimo con el que se llega desde acá hasta un documento emitido: crear la empresa pide empresas:write y suscribir un webhook pide webhooks:write, así que una llave con solo los dos de documents: muere en el paso siguiente con 403 insufficient_scope. Ver Scopes.

Para dejar de sostener la contraseña de un humano, la sesión mintea una llave root en la misma puerta:

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_…"}

La root hereda el alcance de la sesión (["*"]: toda empresa de la cuenta, presentes y futuras). Ese alcance es lo que la hace útil: la barrera de subconjunto compara las empresas de la llave minteada contra las de quien mintea, así que una root que no alcanzara ninguna empresa no podría producir ninguna llave que alcance alguna. Ella misma no puede usarlo — su único scope es keys:write —; lo usan las llaves que mintea. empresas en un mint root puede ir ausente, [] o ["*"]; una lista más estrecha es 400 invalid_field (se acota la llave minteada, no la root).

/dashboard/* es una superficie deliberadamente estrecha: empresas, documentos, documentos recibidos, consumo, llaves de API y certificados, y nada más. En particular, no puede emitir.

Si se perdió la contraseña

La sesión es la única puerta a la primera llave, así que perder la contraseña dejaba a una persona sin ninguna. Hay recuperación:

# 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":"..."}

Pedir un enlace nuevo invalida los anteriores, y cambiar la contraseña invalida los que queden. Un token que no sirve responde 400 password_reset_token_invalid sin decir cuál de las cuatro razones fue: distinguirlas sería un oráculo para adivinar una credencial, y el remedio es el mismo — pedir otro enlace.

POST /account/login y POST /account/register reportan ambos emailVerified, así que un cliente puede mostrar el estado sin una segunda llamada. Mintear una llave sk_live_ desde una cuenta sin verificar devuelve 403 email_verification_required; el modo test no se ve afectado.

En esta página