# Timbrale — API de facturación electrónica (SIFEN, Paraguay) > Una clave, un POST con JSON y el documento entra en el SIFEN con numeración > fiscal y CDC. El sandbox está abierto en todos los planes y no consume cuota. - Base URL: `https://api.timbrale.com.py` - Referencia en HTML: https://timbrale.com.py/docs - Colección Postman: https://timbrale.com.py/timbrale.postman_collection.json - Todos los nombres de campo son en español y se envían tal cual. ## Probar sin cuenta Clave demo pública: `sk_test_PTBJ4kRP.bI1MOdIBMdFJ2lUVhPbHbPHKpfMkr2kj5uM-lKb9WTs`. Emite en el Ambiente de Pruebas de la DNIT (establecimiento 001, puntos 001 a 003), sin validez fiscal. Es compartida: todos ven los documentos de todos, así que **no uses datos reales de clientes**. Para webhooks y tus propios datos, usá la clave de tu cuenta. ## Antes de la primera emisión Con la clave de tu cuenta hacen falta tres cosas cargadas en el panel: el timbrado con su establecimiento y punto de expedición, el certificado `.p12` y el CSC de la DNIT. Sin cualquiera de ellos la API responde 422 **antes** de gastar numeración: nunca queda un número asignado a un documento que no puede transmitirse. ## Autenticación Todas las rutas exigen `Authorization: Bearer sk_...`. Las claves se crean en Panel → Claves de API y el secreto se muestra una sola vez. | Prefijo | Ambiente | Qué hace | | --- | --- | --- | | `sk_test_` | sandbox | emite contra el ambiente de prueba del SIFEN; no consume cuota, disponible en todos los planes | | `sk_prod_` | producción | emite y consulta documentos con validez fiscal; exige un plan con API | El ambiente es el de la clave, no un parámetro: una clave de sandbox nunca ve ni emite documentos de producción. Sin un plan con API, toda llamada con clave de producción —emitir, listar, descargar XML o KuDE, contexto— responde 402; lo emitido se consulta en el panel. ## Endpoints | Método | Ruta | Qué hace | | --- | --- | --- | | POST | `/v1/documentos` | emite un documento. 202 si es nuevo (201 con Prefer: wait=N y veredicto a tiempo), 200 si la Idempotency-Key se repite | | GET | `/v1/documentos` | lista con filtros q, status, tipoDocumento, take (1-100, default 25) y skip | | GET | `/v1/documentos/opciones` | establecimientos y puntos de expedición habilitados | | GET | `/v1/documentos/:id` | un documento, con mensajesSifen | | POST | `/v1/documentos/:id/cancelar` | cancela un documento aprobado. Cuerpo: { motivo } de 5 a 500 caracteres. 202: el veredicto de la DNIT llega por webhook y el documento queda en cancelled | | GET | `/v1/documentos/:id/xml` | el XML del DE en application/xml | | GET | `/v1/documentos/:id/kude` | el KuDE en HTML; antes de la aprobación sale con la franja de provisorio | | GET | `/v1/documentos/:id/kude.pdf` | el KuDE en PDF, el definitivo — 404 hasta que el documento esté aprobado y el PDF generado | | GET | `/v1/contexto` | ambiente de la clave, empresa emisora, establecimientos y timbrados vigentes | La inutilización de numeración, los eventos del receptor y el cierre de lote **no** están en la API de clave: se hacen desde el panel, con sesión. ### Cancelación El plazo lo fija el Manual Técnico: **48 h** para la factura y **168 h** para los demás DTE, contadas desde la aprobación en el SIFEN, no desde la emisión. Fuera de plazo la respuesta es 422 `PLAZO_VENCIDO` y el camino es una nota de crédito sobre ese CDC. Otras respuestas: 422 `ESTADO_INVALIDO` (solo se cancela un documento aprobado), 422 `DOCUMENTO_CON_ASOCIADOS` (hay una nota sobre ese CDC: cancelala primero), 409 `CANCELACION_EN_CURSO` o `YA_CANCELADO` — pedir dos veces no abre dos eventos — y 409 `PERIODO_CERRADO`, que se confirma reenviando con `confirmarPeriodoCerrado: true`. ### El KuDE `GET /v1/documentos/:id/kude.pdf` devuelve el PDF que recibe el receptor — el que se imprime, se adjunta o se manda. Existe recién después de que el SIFEN aprueba **y** el worker lo genera: hasta ahí responde 404 diciendo cuál de las dos cosas falta. El polling es el mismo que ya se hace por `status`. `GET /v1/documentos/:id/kude` devuelve el mismo documento en HTML y sirve desde el primer segundo: antes de la aprobación sale con la franja «DOCUMENTO EN PROCESO DE APROBACIÓN — SIN VALIDEZ FISCAL». Las dos rutas respetan el ambiente de la clave: una `sk_test_` no baja el KuDE de un documento de producción. ## Emitir Headers: | Header | Obligatorio | Qué hace | | --- | --- | --- | | `Authorization` | sí | `Bearer sk_test_…` o `Bearer sk_prod_…` | | `Idempotency-Key` | sí | string único por documento — un UUID sirve. Sin él, 400 | | `Content-Type` | sí | `application/json` | | `Accept-Language` | no | `es`, `pt` o `en`; define el idioma de los mensajes de error | | `Prefer` | no | `wait=N` (hasta 30): espera hasta N segundos el veredicto del SIFEN | Reenviar la misma `Idempotency-Key` devuelve **200** con el documento anterior en lugar de emitir otro: un timeout de red no duplica una factura. Emisión nueva devuelve **202**. ```bash curl -X POST https://api.timbrale.com.py/v1/documentos \ -H "Authorization: Bearer sk_test_PTBJ4kRP.bI1MOdIBMdFJ2lUVhPbHbPHKpfMkr2kj5uM-lKb9WTs" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "tipoDocumento": "factura", "establecimiento": "001", "puntoExpedicion": "001", "moneda": "PYG", "tipoTransaccion": 2, "condicion": { "tipo": 1, "entregas": [ { "tipo": 1, "monto": "450000", "moneda": "PYG" } ] }, "factura": { "presencia": 1 }, "cliente": { "contribuyente": true, "ruc": "80069590-9", "razonSocial": "EMPRESA DE PRUEBA S.A.", "tipoOperacion": 1 }, "items": [ { "codigo": "SRV-01", "descripcion": "Servicio mensual", "cantidad": 1, "precioUnitario": "450000", "iva": 10 } ] }' ``` ``` 202 Accepted { "id": "0f3a1c2e-...", "cdc": "01800123450001001000000112026...", "tipoDocumento": "factura", "numeroCompleto": "001-001-0000001", "status": "queued", "fechaEmision": "2026-09-10T14:03:11.000Z", "moneda": "PYG", "total": "450000", "ambiente": "test", "qrLink": null, "ultimoError": null, "ultimoIntentoAt": null, "intentos": 0 } ``` 202 significa que el documento existe, tiene número y CDC, y quedó en cola para firma y transmisión. El veredicto del SIFEN llega después: se consulta con `GET /v1/documentos/:id` hasta que `status` sea `approved` o `rejected`, o llega solo por webhook (ver **Webhooks**). Con `Prefer: wait=10` la respuesta espera el veredicto: si el SIFEN responde dentro del plazo devuelve **201** con el documento completo (`approved` o `rejected`, igual que el `GET`) y el header `Preference-Applied`; si no, el mismo **202** de siempre. La integración tiene que tratar los dos. `modo: "lote"` no espera. `total` es la suma de `cantidad × precioUnitario − descuento × cantidad` de los items. El precio unitario en Paraguay ya incluye el IVA: el total no le suma nada encima. ## Campos del documento El cuerpo se valida entero antes de tocar la numeración. En la raíz, en `cliente` y en `items[]` los campos desconocidos se **rechazan** (un nombre mal escrito da 422 en vez de ser ignorado); dentro de `condicion` se descartan en silencio. ### Raíz | Campo | Tipo | Obligatorio | Nota | | --- | --- | --- | --- | | tipoDocumento | enum | sí | factura, autofactura, notaCredito, notaDebito, notaRemision | | establecimiento | string(3) | sí | código del establecimiento, 3 dígitos: "001" | | puntoExpedicion | string(3) | sí | punto de expedición, 3 dígitos | | fecha | date | no | omitida, vale el instante de la emisión. Sin zona horaria (`2026-09-30` o `2026-09-30T23:59:59`) es la hora de Asunción del emisor. Hasta 720 h (30 días) atrás y 120 h (5 días) adelante: fuera de eso el SIFEN rechaza (1150/1151) y la API responde 422 sin gastar numeración | | tipoEmision | int 1-2 | no | 1 normal (default), 2 contingencia | | tipoTransaccion | int 1-13 | no | omitido, cae en el configurado en la empresa; si tampoco está, 422 | | tipoImpuesto | int 1-5 | no | 1 IVA (default), 2 ISC, 3 renta, 4 ninguno, 5 IVA-renta | | moneda | enum | no | PYG (default), USD, BRL, ARS, EUR | | cambio | string | number | condicional | obligatorio cuando moneda ≠ PYG | | condicion | objeto | condicional | obligatorio salvo en notaRemision | | cliente | objeto | sí | ver abajo | | items | array | sí | al menos uno | | factura | { presencia } | condicional | obligatorio en factura: presencia 1-9 — 1 presencial, 2 electrónica, 3 telemarketing… | | autoFactura | objeto | condicional | obligatorio en autofactura: datos del vendedor no contribuyente | | remision | objeto | condicional | obligatorio en notaRemision: motivo, tipoResponsable, kms | | detalleTransporte | objeto | condicional | obligatorio en notaRemision, opcional en factura | | documentoAsociado | objeto | condicional | obligatorio en notaCredito, notaDebito y autofactura | | notaCreditoDebito | int 1-5 | condicional | motivo: 1 devolución, 2 descuento, 3 bonificación, 4 crédito incobrable… | | descripcion | string(1000) | no | — | | observacion | string(1000) | no | — | | modo | enum | no | sincrono (default) o lote — ver la nota más abajo | ### cliente | Campo | Tipo | Obligatorio | Nota | | --- | --- | --- | --- | | contribuyente | boolean | sí | true exige ruc con DV válido | | ruc | string | condicional | formato 80012345-0 (3 a 8 dígitos, guion, DV); el dígito verificador se valida antes de emitir | | razonSocial | string(255) | sí | — | | nombreFantasia | string(255) | no | — | | documentoTipo | int 1-9 | no | 1 CI, 2 pasaporte, 3 cédula extranjera, 9 otro (exige documentoTipoDescripcion) | | documentoNumero | string(20) | condicional | obligatorio cuando contribuyente = false | | documentoTipoDescripcion | string(50) | condicional | obligatorio cuando documentoTipo = 9 | | tipoOperacion | int 1-4 | sí | 1 B2B, 2 B2C, 3 B2G, 4 B2F (exterior, exige pais ≠ PRY) | | pais | string(3) | no | PRY por default | | paisDescripcion | string(50) | no | nombre del país en el B2F | | direccion | string(255) | condicional | obligatoria en notaRemision y cuando tipoOperacion = 4 (B2F): sin ella el SIFEN rechaza con 1318 | | numeroCasa | string(6) | no | número de puerta; acompaña a direccion | | ciudad / distrito / departamento | int | no | códigos del catálogo del SIFEN; ignorados cuando tipoOperacion = 4 (la localidad es extranjera) | | telefono / celular | string(20) | no | — | | email | string | no | destino del KuDE por correo | ### items[] | Campo | Tipo | Obligatorio | Nota | | --- | --- | --- | --- | | codigo | string(50) | sí | código interno del producto o servicio | | descripcion | string(500) | sí | — | | cantidad | number > 0 | sí | — | | precioUnitario | string | number | sí | IVA incluido; se devuelve siempre como string, sin pasar por float | | descuento | string | number | no | por unidad | | unidadMedida | int | no | 77 = unidad (default) | | ivaTipo | int 1-5 | no | 1 gravado (default), 2 exonerado, 3 exento, 4 gravado parcial | | iva | 0 | 5 | 10 | no | alícuota; 10 por default | | ivaBase | 0-100 | no | base gravada en porcentaje; 100 en el caso normal | | agricola | boolean | no | producto agrícola en estado natural; separa las casillas del Formulario 120 | | fleteExportacion | boolean | no | flete internacional para exportación de bienes; exige ivaTipo 2 o 3 y va a la casilla 153 del Formulario 120 | | observacion | string(500) | no | — | | productoId | string(40) | no | id de un producto del catálogo; no va al XML. Con el stock en ventas activado, la factura descuenta stock y la nota de crédito lo devuelve | ### condicion `tipo`: 1 contado, 2 crédito. Al contado, `entregas[]` es **obligatorio**: una entrada por forma de pago, con `tipo` y `monto` obligatorios, `moneda` (PYG por default) y `cambio` opcionales. La suma de los montos es el total del documento. Un contado sin `entregas` devuelve 422. Formas de pago en `entregas[].tipo`: 1 efectivo, 2 cheque, 3 tarjeta de crédito, 4 tarjeta de débito, 5 transferencia, 6 giro, 7 billetera electrónica, 8 tarjeta empresarial, 9 vale, 10 retención, 11 anticipo, 16 pago bancario, 17 pago móvil, 21 pago electrónico, 99 otro (exige `tipoDescripcion`). A crédito se agrega `credito` con `tipo` (1 plazo, 2 cuotas): el tipo 1 exige `plazo` (2 a 15 caracteres, «30 días»), el tipo 2 exige `cuotas`. `montoEntrega` es opcional. ### Por tipo de documento | tipoDocumento | Exige además | | --- | --- | | `factura` | `factura.presencia` y `condicion` | | `notaCredito` | `documentoAsociado` (formato 1 con el CDC, o formato 2 con timbrado, establecimiento, punto, número y fecha) y `notaCreditoDebito` | | `notaDebito` | lo mismo que la nota de crédito | | `autofactura` | `autoFactura` con los datos del vendedor no contribuyente, `cliente.tipoOperacion` = 2 (B2C) y `documentoAsociado` formato 3 con `constanciaTipo`, `constanciaControl` (8 caracteres) y `constanciaNumero` (11 dígitos) | | `notaRemision` | `remision` (motivo, tipoResponsable, kms), `detalleTransporte` completo y `cliente.direccion`; no lleva `condicion` | ### modo `modo: "lote"` deja el documento en `queued` hasta que se cierre el lote, y el cierre hoy sale del panel, no de la clave de API. Si se emite por lote y nadie lo cierra, el número queda asignado y el documento nunca llega al SIFEN. Con `sincrono` (el default) el documento sale solo, apenas se emite, y no hay nada que cerrar. En los dos casos la transmisión es por el WS asíncrono del SIFEN: el veredicto no viene en la respuesta, llega en segundos o minutos, por webhook o consultando el documento. ## Estados | status | Qué significa | | --- | --- | | `queued` | numeración asignada y CDC generado; esperando firma y envío | | `signed` | XML firmado con el certificado de la empresa | | `sent` | transmitido al SIFEN, sin veredicto todavía | | `approved` | aprobado por el SIFEN — el documento existe legalmente | | `approved_with_observations` | aprobado con observaciones; válido igual | | `rejected` | rechazado; el motivo viene en mensajesSifen | | `cancelled` | cancelado por un evento posterior | | `draft` | borrador — no lo produce la API | `qrLink` apunta a la consulta pública de la DNIT y solo resuelve después de que el SIFEN tiene el documento. `ultimoError`, `ultimoIntentoAt` e `intentos` distinguen un `queued` que está en la cola de uno que está parado con error. ## Consultar ``` GET https://api.timbrale.com.py/v1/documentos?status=approved&take=25&skip=0 Authorization: Bearer sk_prod_... { "items": [ { "id": "0f3a1c2e-...", "cdc": "01800123450001001000000112026...", "tipoDocumento": "factura", "numeroCompleto": "001-001-0000001", "status": "approved", "fechaEmision": "2026-09-10T14:03:11.000Z", "moneda": "PYG", "total": "450000", "ambiente": "prod", "qrLink": "https://ekuatia.set.gov.py/consultas/...", "ultimoError": null, "ultimoIntentoAt": "2026-09-10T14:03:14.000Z", "intentos": 1, "receptorDoc": "80069590-9", "receptorNombre": "EMPRESA DE PRUEBA S.A.", "protocolo": "1234567890", "mensajesSifen": [{ "codigo": "0260", "mensaje": "Autorizado el e-Kuatia" }] } ], "total": 1, "take": 25, "skip": 0 } ``` ## Webhooks En vez de consultar cada documento, tu sistema recibe un POST cuando el SIFEN decide. Se configuran en Panel → Webhooks, con sesión: URL `https://`, ambiente (un webhook de sandbox nunca recibe documentos de producción), eventos y, opcionalmente, establecimientos. Sin establecimientos marcados llegan los de todos; una empresa con un sistema por local crea un webhook por establecimiento. El secreto de firma (`whsec_…`) se muestra una sola vez. Hasta 10 webhooks por ambiente, contando los pausados; para cambiar uno, se elimina y se crea otro. | Evento | Cuándo | | --- | --- | | `documento.approved` | el SIFEN aprobó el documento | | `documento.approved_with_observations` | aprobado con observaciones; válido igual | | `documento.rejected` | rechazado; el motivo viene en datos.mensajesSifen | | `documento.cancelled` | la DNIT confirmó la cancelación | | `ping` | botón «Enviar prueba» del panel; datos trae solo webhookId | `datos` trae los mismos campos que `GET /v1/documentos/:id`. ``` POST https://tu-sistema.com/timbrale Content-Type: application/json Timbrale-Evento: documento.approved Timbrale-Entrega: cm1x8k2p40001... Timbrale-Signature: t=1757772194,v1=5f2c9a... { "id": "documento.approved:0f3a1c2e-...", "evento": "documento.approved", "creadoEn": "2026-09-10T14:03:14.000Z", "ambiente": "prod", "datos": { "id": "0f3a1c2e-...", "cdc": "01800123450001001000000112026...", "tipoDocumento": "factura", "establecimiento": "001", "puntoExpedicion": "001", "numeroCompleto": "001-001-0000001", "status": "approved", "fechaEmision": "2026-09-10T14:03:11.000Z", "moneda": "PYG", "total": "450000", "qrLink": "https://ekuatia.set.gov.py/consultas/...", "protocolo": "1234567890", "mensajesSifen": [{ "codigo": "0260", "mensaje": "Autorizado el e-Kuatia" }] } } ``` ### Verificar la firma `Timbrale-Signature` es `t=,v1=`: HMAC-SHA256 con el secreto sobre `.`. Rechazá un `t` de más de 5 minutos. ```js import { createHmac, timingSafeEqual } from 'node:crypto'; // body: el cuerpo crudo, tal como llegó — antes de JSON.parse. function firmaValida(body, header, secreto) { const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('='))); // Más de 5 minutos: una entrega vieja reenviada por un tercero. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const esperada = createHmac('sha256', secreto).update(`${t}.${body}`).digest('hex'); return v1?.length === esperada.length && timingSafeEqual(Buffer.from(v1), Buffer.from(esperada)); } ``` ### Entrega y reintentos - Respondé **2xx en menos de 10 segundos**. Cualquier otro código, un timeout o una redirección cuentan como falla: no seguimos redirecciones. - Tras una falla reintentamos después de 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h y 24 h (9 intentos, ~3 días). Si todos fallan, el webhook queda detenido y avisamos por correo; al reactivarlo en el panel se entregan los eventos acumulados. - El `id` del evento se repite en cada reintento: **deduplicá por él**. - El orden no está garantizado — un `documento.cancelled` puede llegar antes que un `documento.approved` reintentado. Usá `datos.status`, o `GET /v1/documentos/:id` ante la duda. - La URL no puede resolver a una red privada (localhost, 10.x, 192.168.x…). - El panel muestra las últimas entregas con el código de respuesta, y cada una se puede reenviar. ## Errores Los errores de validación devuelven la lista de campos en `fields`, nunca un código opaco. El idioma del mensaje sigue el `Accept-Language`. | Código | Cuándo | | --- | --- | | 400 | falta el header Idempotency-Key en POST /v1/documentos | | 401 | clave ausente, mal formada, revocada, o cuenta suspendida | | 402 | el plan no incluye el recurso pedido (API en producción, dossier de fiscalización, asistente de rectificativa: recurso dice cuál y upgrade el plan que lo incluye), o el plan gratis agotó la cuota del mes (el consumo va en uso) | | 404 | el documento no existe, o es de otro ambiente que el de la clave | | 409 | carrera de idempotencia que no se pudo resolver; el caso normal de reenvío devuelve 200 con el documento anterior | | 429 | más de 600 pedidos por minuto con la misma clave; Retry-After dice en cuántos segundos vuelve. Toda respuesta trae X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset | | 422 | validación del cuerpo (lista en fields) o regla de negocio: CSC no configurado, certificado vencido o ausente, timbrado vencido, numeración agotada o no configurada para el punto, establecimiento o punto no registrado, tipoTransaccion no configurado, fecha fuera del plazo del SIFEN (más de 720 h atrás o 120 h adelante) | | 503 | GET /v1/documentos/:id/xml cuando la firma o el QR fallan del lado de Timbrale (certificado, contraseña, CSC) — no es culpa del payload | ``` 422 Unprocessable Entity { "statusCode": 422, "error": "Unprocessable Entity", "fields": [ { "path": "cliente.ruc", "message": "dígito verificador incorrecto: para 80012345 el DV es 0" }, { "path": "items", "message": "documento precisa de pelo menos um item" } ] } ``` ## Conservación de los archivos Timbrale conserva por 10 años, contados desde el cierre del ejercicio fiscal de cada documento, el XML firmado tal como fue transmitido al SIFEN, la respuesta del SIFEN con su protocolo, los eventos asociados con sus XML firmados y el KuDE generado. De cada XML se guarda además su SHA-256 al momento de la emisión. La exportación del período (XML firmados, KuDE y un manifiesto que compara cada hash con el guardado al emitir) se pide desde el panel con sesión — `GET /v1/tenant/fiscal/periods/:periodo/export?format=zip`—, **no** con la clave de API. En un período grande la respuesta es 202 con un `jobId`, y el archivo se retira después en `GET /v1/tenant/fiscal/exports/:id/download`. Para una fiscalización de varios meses está el dossier: `POST /v1/tenant/fiscal/dossier` con `{ "desde": "AAAA-MM-DD", "hasta": "AAAA-MM-DD" }` responde siempre 202 con un `jobId` y arma los XML firmados del intervalo —también los de documentos anulados, sus eventos y las inutilizaciones—, con el control de integridad de cada uno y el estado del cierre de cada mes. Incluido en el Completo y Enterprise; en los demás planes, 402. La obligación legal de conservación (RG 05/18, art. 18) es del contribuyente. ## Errores frecuentes al integrar - Emitir sin `tipoTransaccion` y sin tenerlo configurado en la empresa: 422. - `condicion.tipo = 1` sin `entregas[]`: 422. - RUC con dígito verificador incorrecto: 422 con el DV correcto en el mensaje. - `tipoOperacion = 4` (B2F) sin `cliente.direccion`: 422 (el SIFEN rechaza con 1318). - Moneda ≠ PYG sin `cambio`: 422. - Esperar el veredicto en la respuesta del POST: llega por webhook o por polling de `GET /v1/documentos/:id`. - Procesar un webhook sin verificar `Timbrale-Signature`, o dos veces el mismo `id` de evento.