Fac-360

Quickstart

De cero al CDR de SUNAT en beta, sin emitir nada por accidente.

Esta guía recorre el camino completo de una integración: conseguir una credencial, ensayar el cuerpo, crear el comprobante, enviarlo a SUNAT y recoger el CDR. Todo contra el edge de staging, que emite contra SUNAT beta:

https://apifact-staging.fac-360.com

Nada de lo que se envíe por ahí es fiscalmente válido y nada cuesta. Es donde se construye una integración, y también donde la prueba de activación del certificado es gratis.

1. La credencial y sus scopes

La API para máquinas autentica con una credencial opaca en Authorization: Bearer. Está ligada a un tenant, expira y lleva scopes; cada operación declara en x-required-scope el único scope que exige, y ningún scope implica otro.

Para el camino de esta guía hacen falta seis:

ScopeHabilita
documents:validatePOST /api/v2/documents/validations
documents:createPOST /api/v2/documents
documents:submitPOST /api/v2/documents/{id}/submissions
documents:readGET /api/v2/documents/{id}
operations:readGET /api/v2/operations/{id}
artifacts:readGET /api/v2/documents/{id}/artifacts y su descarga

documents:create no implica documents:validate, igual que no implica documents:submit. Es deliberado: separa "puede ensayar" de "puede emitir" y de "puede enviar a SUNAT", así que una credencial de desarrollo puede tener la primera y ninguna de las otras dos. Si el scope falta, la respuesta es 403, no 401.

Hay dos formas de acuñar una credencial de empresa, y son la misma función de base de datos con dos puertas:

El token va en el 201 y en ningún otro sitio: solo se guarda su SHA-256, no hay ruta que lo vuelva a leer, y un token perdido se reemplaza acuñando otro y revocando este. companies:manage no está en el vocabulario que una credencial de empresa puede llevar y no puede estarlo — una credencial que emite comprobantes y además acuña credenciales es la escalada de privilegios que el modelo confina al lado de la organización.

Una credencial pertenece a un edge. La de staging presentada a producción es desconocida y responde 401. El entorno no se puede equivocar en silencio.

2. Ensaya antes de emitir

POST /api/v2/documents/validations corre exactamente las mismas comprobaciones que la creación y tira el resultado: no hay comprobante, ni correlativo, ni operación, ni evento de outbox, ni consumo de cuota. No es un flag de dry-run sobre la ruta de creación — eso se rechazó por la razón obvia: el día que alguien lo deje puesto, existe un comprobante real. Es una ruta cuyo repositorio no tiene un solo método de escritura y que corre dentro de una transacción PostgreSQL READ ONLY.

No lleva Idempotency-Key, porque no crea nada:

curl -sS https://apifact-staging.fac-360.com/api/v2/documents/validations \
  -H "Authorization: Bearer $APIFACT_TOKEN" \
  -H "Content-Type: application/json" \
  -d @factura.json

Con factura.json, que es el ejemplo publicado facturaGravada tal cual:

{
  "schemaVersion": "1.0",
  "documentType": "01",
  "series": "F001",
  "number": "1024",
  "issueDate": "2026-08-12",
  "issueTime": "10:15:00",
  "currency": "PEN",
  "supplier": {
    "documentType": "6",
    "documentNumber": "20601030405",
    "legalName": "MI EMPRESA EMISORA S.A.C.",
    "address": "AV. JAVIER PRADO ESTE 1234",
    "ubigeo": "150131",
    "district": "SAN ISIDRO",
    "province": "LIMA",
    "department": "LIMA"
  },
  "customer": {
    "documentType": "6",
    "documentNumber": "20512345678",
    "legalName": "COMERCIAL LOS ANDES S.A.C.",
    "address": "AV. AREQUIPA 4321",
    "ubigeo": "150122",
    "district": "MIRAFLORES",
    "province": "LIMA",
    "department": "LIMA"
  },
  "lines": [
    {
      "productCode": "SERV-001",
      "description": "Consultoria de implementacion - plan mensual",
      "unitCode": "ZZ",
      "quantity": "1",
      "unitValue": "1000.00",
      "affectationCode": "10"
    }
  ]
}

Los once ejemplos restantes — boleta con DNI, boleta anónima, notas 07 y 08, detracción, venta al crédito, exportación, ICBPER, transferencia gratuita — están publicados en la página de createDocument. Ninguno está escrito de memoria, así que copiar uno y cambiarle los datos es más rápido que deducir la forma del oneOf.

La ruta responde 200 aunque el documento sea inválido. El veredicto está en el cuerpo, en valid, y el status dice solo si el servicio pudo contestar. Es lo contrario del resto de la API y es a propósito: esta es la ruta que se llama en bucle mientras se programa, donde "inválido" es el resultado esperado, y la mayoría de clientes HTTP convierten un 4xx en una excepción que habría que atrapar y desenvolver para leer la lista.

{
  "schemaVersion": "2.0",
  "requestId": "9f2f2a4c-8e3a-4a1a-9d1e-0d1a2b3c4d5e",
  "valid": true,
  "findings": [],
  "totals": {
    "currency": "PEN",
    "lineExtensionAmount": "1000.00",
    "igvAmount": "180.00",
    "taxAmount": "180.00",
    "payableAmount": "1180.00"
  }
}

totals va abreviado arriba: el objeto completo tiene quince campos obligatorios, más detraction y credit cuando la operación los tiene, y está descrito entero en validateDocument. Aparece siempre que la aritmética cierra, incluso en un documento inválido por otra razón, porque ver el número es la mitad del trabajo de armar un mapeo.

Cuando valid es false, findings trae los problemas en el orden en que la ruta de creación los evalúa: findings[0] es aquel con el que POST /api/v2/documents contestaría, con su code literal — INVALID_DOCUMENT, DOCUMENT_ARITHMETIC_INVALID, SUPPLIER_RUC_MISMATCH, AFFECTED_DOCUMENT_NOT_FOUND, AFFECTED_FACTURA_CREDIT_EXCEEDED… — y el status HTTP que devolvería.

Un límite que conviene conocer antes de montar el bucle: 60 peticiones por 60 segundos y por credencial. Al pasarse, la respuesta es el sobre de error normal con code VALIDATION_RATE_LIMITED y una cabecera Retry-After, no el cuerpo rate_limit_exceeded del edge que publican las rutas de escritura. Así se distingue "estás preguntando demasiado seguido" de "tu comprobante es inválido" sin parsear un mensaje.

3. Crea el comprobante

POST /api/v2/documents toma el mismo cuerpo y exige la cabecera Idempotency-Key (obligatoria, de 1 a 200 caracteres):

curl -sS https://apifact-staging.fac-360.com/api/v2/documents \
  -H "Authorization: Bearer $APIFACT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: f001-1024-primera-emision" \
  -d @factura.json
{
  "schemaVersion": "2.0",
  "requestId": "1c0f4a9b-3d21-4f5e-8a77-2b6c9e0d1f34",
  "operationId": "3a5b7c9d-1e2f-4a6b-8c0d-5e7f9a1b3c5d",
  "resourceId": "7d9e1f3a-5b7c-4d9e-8f1a-3b5c7d9e1f3a",
  "state": "VALIDATED",
  "replay": false
}

El 201 crea el documento en estado VALIDATED; resourceId es el documentId. Repetir la misma Idempotency-Key con el mismo cuerpo devuelve 200 con replay: true y el mismo resourceId; repetirla con un cuerpo distinto es 409 IDEMPOTENCY_CONFLICT.

Crear no es enviar. El comprobante existe, tiene número y está validado, pero SUNAT todavía no sabe nada de él. Ese es el paso siguiente.

Dos rechazos que sorprenden aquí. Las series F000 y B000 están reservadas para la plataforma y se rechazan con 422 INVALID_DOCUMENT en todo tipo de documento, notas incluidas: F000 es la serie en la que emite la prueba de activación de certificado y SUNAT la registra bajo el RUC de este contribuyente, así que un F000 propio chocaría con un número que SUNAT ya tiene. Y 422 DOCUMENT_ARITHMETIC_INVALID rechaza las cuatro condiciones aritméticas que ningún esquema puede expresar — descuentos de línea que superan el valor de la línea, un descuento global que supera el total, un ajuste sobre base cero, y un cronograma al crédito cuyas cuotas no liquidan lo pendiente.

4. Envía a SUNAT y sigue la operación

curl -sS -X POST \
  https://apifact-staging.fac-360.com/api/v2/documents/$DOCUMENT_ID/submissions \
  -H "Authorization: Bearer $APIFACT_TOKEN" \
  -H "Idempotency-Key: f001-1024-primer-envio"

También exige Idempotency-Key. Responde 202 con el mismo cuerpo que la creación — operationId, resourceId, state, replay — o 200 si es una repetición idéntica. El 202 significa que el envío quedó encolado para el Workflow privado de SUNAT y llegará a un estado terminal; no significa que SUNAT lo haya aceptado.

Un 409 TENANT_CAPABILITY_DISABLED aquí no es un error del cuerpo: es una capacidad apagada en la empresa, y error.details nombra cada requisito por su columna exacta de PostgreSQL — submission_publication_enabled para cualquier tipo, más boleta_submission_enabled, credit_note_submission_enabled o debit_note_submission_enabled para 03, 07 y 08. Un administrador la enciende y la misma petición funciona; la petición no se queda esperando a que alguien la encienda.

Detrás del 202, el Workflow hace en orden: construye el XML UBL, lo firma, prepara el ZIP de envío, ejecuta sendBill una sola vez y finaliza el CDR. Si acaba aceptado u observado, genera además el QR y las representaciones A4 y ticket.

Para saber en qué va, GET /api/v2/operations/{id} con el operationId que devolvió el envío:

curl -sS https://apifact-staging.fac-360.com/api/v2/operations/$OPERATION_ID \
  -H "Authorization: Bearer $APIFACT_TOKEN"
{
  "schemaVersion": "2.0",
  "requestId": "5e7f9a1b-3c5d-4e7f-9a1b-3c5d7e9f1a3b",
  "operationId": "3a5b7c9d-1e2f-4a6b-8c0d-5e7f9a1b3c5d",
  "documentId": "7d9e1f3a-5b7c-4d9e-8f1a-3b5c7d9e1f3a",
  "resourceKind": "document",
  "resourceId": "7d9e1f3a-5b7c-4d9e-8f1a-3b5c7d9e1f3a",
  "operationType": "SUBMIT_DOCUMENT_V2",
  "status": "SUCCEEDED",
  "startedAt": "2026-08-12T15:16:02.114Z",
  "finishedAt": "2026-08-12T15:16:09.887Z"
}

status recorre PENDING y RUNNING y termina en SUCCEEDED o FAILED. Un operationId de otro tenant es indistinguible de uno que nunca existió: ambos responden 404 OPERATION_NOT_FOUND.

SUCCEEDED describe el despacho, no el veredicto fiscal. Ese está en el documento, con GET /api/v2/documents/{id}:

{
  "schemaVersion": "2.0",
  "requestId": "8c0d5e7f-9a1b-4c3d-8e5f-7a9b1c3d5e7f",
  "documentId": "7d9e1f3a-5b7c-4d9e-8f1a-3b5c7d9e1f3a",
  "environment": "BETA",
  "documentType": "01",
  "series": "F001",
  "number": "1024",
  "state": "ACCEPTED",
  "fiscalState": "ISSUED",
  "stateVersion": 6,
  "createdAt": "2026-08-12T15:15:41.002Z",
  "updatedAt": "2026-08-12T15:16:09.887Z",
  "responseCode": "0",
  "responseMessage": "La Factura numero F001-1024, ha sido aceptada",
  "sunat": {
    "severity": "ACCEPTED",
    "standing": "REGISTERED",
    "action": "NONE",
    "guidance": "SUNAT accepted and registered the document. No action is required.",
    "catalogVersion": "sunat-response-catalog-v2"
  }
}

responseCode 0 es la aceptación; cualquier otro valor es el código de rechazo. El bloque sunat clasifica ese código contra el catálogo versionado de respuestas: standing dice si el comprobante existe fiscalmente en SUNAT y action qué hacer a continuación. Dos casos que importan más que el resto: standing MAY_BE_REGISTERED con action RECONCILE_DO_NOT_RESEND significa que reenviar duplicaría un documento fiscal, y hay que reconciliar antes de tocar nada. Y un comprobante observado reporta severity ACCEPTED aquí, porque SUNAT contesta las observaciones con ResponseCode 0 y las lleva como notas; quien dice que hubo observaciones es state, con el valor OBSERVED.

fiscalState va en un eje aparte y responde otra pregunta: si el comprobante todavía existe. ISSUED es lo normal; VOIDED es un comprobante que fue aceptado y después retirado por una comunicación de baja.

5. Recoge el CDR

GET /api/v2/documents/{id}/artifacts lista los artefactos con metadatos seguros — kind, status, mediaType, byteSize, sha256, readyAt — y nunca claves de objeto internas ni XML sin firmar. Los kind posibles son signed-xml, submission-zip, cdr-zip, cdr-xml, pdf-a4, pdf-ticket y qr-png.

La descarga es por kind, y pasa por un servicio privado que verifica la integridad de los metadatos antes de transmitir los bytes:

curl -sS -o cdr-F001-1024.xml \
  https://apifact-staging.fac-360.com/api/v2/documents/$DOCUMENT_ID/artifacts/cdr-xml \
  -H "Authorization: Bearer $APIFACT_TOKEN"

No soporta peticiones Range. Un artefacto que todavía no está READY responde 409, no un fichero a medias.

6. La firma del CDR en beta

Este es el detalle que cuesta una madrugada si nadie lo escribió: el CDR que devuelve SUNAT beta no viene firmado de verdad. Trae marcadores literales en lugar de la firma y del certificado (*Private key 'BetaPublicCert' not up* y *Named certificate 'BetaPrivateKey' not up*). La plataforma reconoce esa forma exacta como BETA_PLACEHOLDER y la acepta solo en beta.

Ese mismo CDR se rechaza en producción, y no es una comprobación que se pueda relajar. Si llega un placeholder de beta mientras creemos estar en producción, el endpoint está mal apuntado y todo lo "emitido" es ficción; aceptarlo por ser menos estricto convertiría una mala configuración ruidosa en una silenciosa. Los detalles de cada entorno están en Entornos.

7. El paso a producción

Lo que cambia de verdad, y poco más:

  1. Otra credencial. Una credencial pertenece a un edge, así que hay que acuñar una en https://apifact.fac-360.com. La de staging responde 401 allí.
  2. Un certificado digital real y credenciales SOL del contribuyente. Su activación no se puede pedir: un certificado llega a ACTIVE solo después de que la plataforma haya firmado con él en la serie reservada F000 y SUNAT beta lo haya aceptado. Está explicado en Entornos.
  3. La política de firma del CDR se endurece, como acaba de decirse.
  4. Las capacidades de la empresa siguen decidiendo qué tipos se pueden enviar, y son independientes por tipo.

Y sobre todo, lo que ahí sí es distinto: cada documento aceptado es un comprobante real, entra en el registro de ventas del contribuyente y solo se retira con una comunicación de baja aceptada.

Lo que no cambia: el cuerpo del comprobante, los scopes, la semántica de Idempotency-Key y el significado de cada estado. Y POST /api/v2/documents/validations sigue siendo gratis y sin escritura en los dos entornos — es la ruta con la que se verifica un mapeo nuevo antes de tocar nada en producción.

Referencia

On this page