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