Quickstart
De una cuenta nueva a una boleta emitida en el sandbox, con el ciclo queued → accepted completo y sin tocar el SII.
Todo lo de esta página ocurre en modo test: no hay tráfico al SII, no hay certificado, no hay
CAF y no hay costo. Cuando cambies sk_test_ por sk_live_, la misma llamada golpea el SII real.
1. Consigue tu primera llave
Una cuenta recién creada no tiene ninguna llave todavía. El camino más corto es una sola llamada: registrarte y recibir la llave de sandbox en la misma respuesta.
curl -sS https://api.facturia.cl/account/register \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "una-contraseña-larga",
"bootstrap": {
"sandbox_key": { "name": "primera llave" },
"empresa": { "rut": "76000002-7", "legal_name": "Ejemplo SpA" }
}
}'{
"status": 201,
"data": {
"token": "eyJhbGciOi…",
"api_key": { "object": "api_key", "id": "key_01K2R7…", "secret": "sk_test_9lQpV3fA2mKcRt7wZxYb1Nde" },
"empresa": { "object": "empresa", "id": "emp_01K2R7…", "rut": "76000002-7", "mode": "test" },
"next": {
"action": "emit_sandbox_document",
"method": "POST",
"url": "/v1/documents",
"headers": { "Authorization": "Bearer <data.api_key.secret>", "Facturia-Empresa": "76000002-7" }
}
}
}data.api_key.secret es la llave y se devuelve exactamente una vez. Siempre es sk_test_: no
hay variante live de este bloque y no la va a haber, porque mintear una llave live es justamente el
punto donde la verificación de correo tiene que apretar. data.next nombra la llamada que sigue,
con headers y todo; un valor entre <…> es un campo de esta misma respuesta que hay que sustituir.
Con bootstrap.empresa te saltas el paso 2. Si el RUT ya está tomado en otra cuenta, la cuenta y la
llave se crean igual y data.empresa_error dice por qué no hubo empresa — reportado, no lanzado.
export FACTURIA_KEY=sk_test_9lQpV3fA2mKcRt7wZxYb1Nde
export EMPRESA=76000002-7Registrarte no exige confirmar el correo
La verificación cierra exactamente una puerta: mintear una llave live. El modo test, y todo lo
que hay dentro, está abierto de inmediato — un agente puede llegar desde esta llamada hasta un DTE de
sandbox emitido sin que nadie abra un buzón. Cuando llegue el momento de la llave live, un
403 email_verification_required te dice qué falta.
La forma en dos llamadas
Sigue existiendo, y es la que necesitas si la cuenta ya existe o si quieres elegir los scopes. La
salida es la sesión de cuenta: el mismo JWT de una hora que devuelve POST /account/login,
presentado a la superficie del panel.
# Iniciar sesión (o registrarte sin `bootstrap`). No necesitas un navegador.
TOKEN=$(curl -sS https://api.facturia.cl/account/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"..."}' | jq -r .data.token)
# Mintear la 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":["*"]}'Estos seis scopes son el set de arranque, y son el mínimo que recorre esta página entera. Son
también el valor por defecto de bootstrap.sandbox_key.scopes, así que la forma de una llamada
mintea exactamente esto. El paso 2 crea una empresa y POST /v1/empresas pide empresas:write; el
paso 6 suscribe un webhook y POST /v1/webhook_endpoints pide webhooks:write. Una llave minteada
solo con los dos de documents: emite documentos perfectamente y responde 403 insufficient_scope a
la primera llamada de este quickstart. El build de este sitio cruza los scopes de esta llamada contra
el requireScope de cada endpoint que el quickstart invoca, así que no pueden volver a separarse.
Lo que no entra: keys:write —el bootstrap lo rechaza con 400 y errors: ["invalid_bootstrap"],
porque mintear es un poder de llave root y una llave de sandbox no debe ser una autoridad de
minteo— ni los scopes nuevos de la puesta en marcha (mandato:write, mandato:attest,
onboarding:write, onboarding:attest), que se piden por separado y a propósito. Todo eso está en
Scopes.
/dashboard/* es la superficie de sesión de cuenta. Sirve los mismos recursos que /v1 — los
mismos serializadores, los mismos filtros, el mismo sobre de error — autenticada con un JWT de
cuenta en vez de una llave sk_, porque un navegador nunca debe sostener una llave sk_.
El modo viaja en un header en la sesión
Una sesión no tiene prefijo que cargue su modo, así que lo hace Facturia-Mode: test|live, con
live por defecto. Un valor no reconocido es un 400, nunca una caída silenciosa a live.
Guarda el secreto: se devuelve exactamente una vez. Y ojo con los límites del arranque en frío:
/account/register y /account/login están topados en 10 por minuto y por IP y 5 por minuto
por correo, con 429 y errors: ["auth_throttled"]. Están en
Límites.
2. Crea una empresa de sandbox
Si usaste bootstrap.empresa en el paso 1, esto ya está hecho — salta al paso 3. Si no, es una
llamada. En modo test la empresa nace lista para emitir: sin certificado, sin mandato y sin
certificación.
curl -sS https://api.facturia.cl/v1/empresas \
-H "Authorization: Bearer $FACTURIA_KEY" \
-H "Content-Type: application/json" \
-d '{"rut":"76000002-7","legal_name":"Ejemplo SpA"}'Solo rut y legal_name son obligatorios. El resto lo leemos del registro de contribuyentes del
SII una vez cargado el certificado, y puedes sobrescribir cualquier campo después.
3. Emite una boleta electrónica
Dos campos.
curl -sS https://api.facturia.cl/v1/documents \
-H "Authorization: Bearer $FACTURIA_KEY" \
-H "Facturia-Empresa: $EMPRESA" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"tipo_dte": 39,
"items": [{ "description": "Servicio de mantención mensual", "quantity": 1, "unit_price": 25000 }]
}'Si prefieres un cliente tipado en vez de requests o fetch, el contrato se genera con una línea:
Generar un cliente tipado.
La respuesta es 201 y llega de inmediato:
{
"id": "doc_01K2R7Q4XW9M3B8ZC5YHTVJD6N",
"object": "document",
"mode": "test",
"status": "queued",
"tipo_dte": 39,
"folio": null,
"issue_date": "2026-08-14",
"totals": { "net": 21008, "iva": 3992, "exempt": 0, "total": 25000 },
"pdf_url": "https://api.facturia.cl/v1/documents/doc_01K2R7.../pdf",
"xml_url": "https://api.facturia.cl/v1/documents/doc_01K2R7.../xml",
"sii": { "environment": "sandbox", "track_id": null, "estado": null },
"created_at": "2026-08-14T13:04:11Z"
}Fíjate en tres cosas, y la primera es la que sorprende: el folio viene null. El POST no
asigna folios. Escribe la fila, encola el trabajo y responde; un par de segundos después el worker
saca el folio del contador — en modo test parten en 1 por cada (empresa, tipo_dte) — y recién ahí
el documento tiene número. Lo segundo, los totales sí vienen calculados de inmediato, porque son
aritmética nuestra y no dependen del SII: una boleta trae los precios brutos, así que los 25.000 se
descomponen en neto e IVA. Y lo tercero, el status es queued, no accepted.
Trampa
No guardes el folio desde la respuesta del POST
Siempre va a ser null ahí. El folio aparece en el evento document.folio_allocated, en la
siguiente lectura de GET /v1/documents/{document_id} y en el objeto que viaja dentro de los
webhooks del ciclo de vida — nunca en la respuesta que creó el documento.
4. El ciclo de vida honesto: queued → sent → accepted
El POST es síncrono. La ida y vuelta al SII no lo es, y no fingimos lo contrario.
┌───────────► rejected (terminal)
│
queued ──► sent ──────┼───────────► reparo (terminal-ish; válido pero observado)
│ │
│ └───────────► accepted ──► annulled (una NC lo anuló)
│
└──► failed ──(retry)──► queuedEn el sandbox el documento se asienta en accepted en aproximadamente un segundo, disparando cada
webhook que dispararía un documento real. En producción el trayecto típico a un estado terminal es
de 30 segundos a unos pocos minutos, y el SII puede tardar horas en sus peaks o en mantención.
curl https://api.facturia.cl/v1/documents/doc_01K2R7Q4XW9M3B8ZC5YHTVJD6N \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Facturia-Empresa: $EMPRESA"# Los eventos del documento, del más antiguo al más nuevo
curl https://api.facturia.cl/v1/documents/doc_01K2R7.../events \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Facturia-Empresa: $EMPRESA"{
"object": "list",
"data": [
{ "type": "document.folio_allocated", "at": "2026-08-14T13:04:13Z", "detail": { "folio": 1, "sandbox": true } },
{ "type": "document.signed", "at": "2026-08-14T13:04:14Z", "detail": { "status": "signed" } },
{ "type": "document.submitted", "at": "2026-08-14T13:04:19Z", "detail": { "status": "submitted", "track_id": "9000000001" } },
{ "type": "document.accepted", "at": "2026-08-14T13:04:20Z", "detail": { "status": "accepted", "sii_estado": "DOK" } }
]
}Este listado es el diario del motor, y su vocabulario es más fino que el de los cuatro estados
públicos: folio_allocated y signed son los dos pasos que desde afuera se leen como queued, y
submitted es el paso que se lee como sent. Ahí está, en un solo lugar y con hora, el resto de la
secuencia que el 201 no te podía contar: folio asignado → firmado → transmitido → aceptado. El
diario empieza en el folio y no en la creación porque la creación ya la tienes — es la respuesta que
acabas de leer, con su created_at.
Es un vocabulario distinto del de los webhooks, y conviene no confundirlos. Un endpoint suscrito
recibe document.created, document.sent y document.accepted — el catálogo de
Webhooks — porque un webhook anuncia un cambio sobre el que se puede actuar, y
«ya le puse folio» y «ya lo firmé» no cambian nada que el suscriptor pueda observar: los dos siguen
siendo queued. El diario los guarda igual, para cuando estás depurando y sí importa saber en cuál
de los dos se quedó.
No construyas sobre polling apretado
Los webhooks son la integración prevista. Si igual necesitas hacer polling: no consultes
GET /v1/documents/{document_id} más seguido que cada 10 segundos durante los primeros 2
minutos, y luego cada 60 segundos. Mejor aún, consulta el listado con
status=sent&created_at[gte]=… una vez por minuto y reconcilia en bloque. El header
Facturia-Poll-After en la respuesta del documento te dice el próximo momento útil, calculado por
nuestro propio scheduler.
Ese header desaparece cuando el documento llega a un estado terminal (accepted, reparo,
rejected, annulled): ya no queda nada que observar, y decirte «vuelve en 60 segundos» sería
mandarte a consultar algo que no va a cambiar nunca más. Trata su ausencia como la señal de que
terminaste, no como un header perdido.
En el sandbox eso significa que casi nunca lo vas a ver: el documento se asienta en ~1 segundo, así
que para cuando lo lees ya es terminal y la respuesta viene sin Facturia-Poll-After. Es el
comportamiento correcto y es exactamente el mismo código que corre en producción — no lo tomes como
que el header no existe. Ojo también con que solo viaja en GET /v1/documents/{document_id}: el
listado no lo lleva, porque una página de cien documentos no tiene un único próximo momento útil.
5. Baja el PDF
curl -L https://api.facturia.cl/v1/documents/doc_01K2R7.../pdf \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Facturia-Empresa: $EMPRESA" -o boleta.pdfEn modo test el PDF trae el layout real con una marca de agua SIN VALOR TRIBUTARIO.
6. Recibe el webhook en vez de preguntar
curl -sS https://api.facturia.cl/v1/webhook_endpoints \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://api.miempresa.cl/hooks/facturia",
"enabled_events": ["document.accepted","document.rejected","document.reparo"]
}'El secret (whsec_…) se devuelve una sola vez. Cómo verificar la firma está en
Webhooks · Firma.
Y ahora, en serio
Cambiar sk_test_ por sk_live_ cambia lo que pasa en el cable, no tu código. Lo que sí cambia es
que la empresa necesita recorrer una puesta en marcha real: mandato, certificado o clave
centralizada, los pasos que hace una persona en el SII, la aprobación del mes y los folios. El camino
completo, numerado y con los dos carriles lado a lado, está en
De sandbox a producción; lo maneja un solo endpoint que puedes dibujar
como asistente: GET /v1/empresas/{empresa_id}/onboarding.