POST /v1/invoices
Emite una factura legal contra el rango CAI de tu empresa.
Lee esto antes de escribir tu lógica de reintento
Este endpoint exige Idempotency-Key. Genera la key por factura lógica,
persístela antes de la primera llamada y manda la misma key con el mismo
body en cada reintento. Detalles en Idempotencia.
Para probar, usa POST /v1/invoices/preview
Mismo body, mismos cálculos, mismo PDF — pero no consume CAI ni correlativo y
no necesita Idempotency-Key. Cada llamada a este endpoint, en cambio, emite
una factura legal.
Solicitud
POST /v1/invoices
Authorization: Bearer ctd_sk_...
Content-Type: application/json
Idempotency-Key: order-2026-0917
Headers
| Header | Req. | Notas |
|---|---|---|
Authorization | Sí | Bearer <tu api key>. |
Idempotency-Key | Sí | Único por factura lógica. Tu propio id de orden sirve; no tiene que ser un UUID. Se conserva ~24 h. |
Body
{
"clientId": 1042,
"invoiceDate": "2026-08-10T15:04:05.000Z",
"isCredit": false,
"items": [
{ "externalProductId": "SKU-WEB-HOSTING-01", "quantity": 2 },
{ "externalProductId": "SKU-SOPORTE-MES", "quantity": 1 }
]
}
| Campo | Tipo | Req. | Notas |
|---|---|---|---|
items | array | Sí | Al menos una línea. |
clientId | number | No | Id devuelto por POST /v1/clients. Omítelo para una venta sin cliente registrado. Obligatorio si la venta es exonerada. |
invoiceDate | string | No | ISO 8601. Por defecto, la fecha y hora actuales. Facturar con fecha retroactiva está fuera del alcance de v1. |
isCredit | boolean | No | Reservado para v2. true se rechaza con 422. Por defecto false. |
purchaseOrderNumber no es una referencia de compra del cliente
Aunque su nombre lo sugiere, este campo no guarda el número de compra interno de tu cliente. Es la Orden de Compra Exenta — un documento legal de las ventas exoneradas. Enviar uno (con cualquier contenido) exonera toda la factura del ISV y exige que el comprador tenga RTN válido. Es un campo de uso reservado para ventas exoneradas; en una venta ordinaria no lo mandes. Se explica en Conceptos de facturación.
items[]
| Campo | Tipo | Req. | Notas |
|---|---|---|---|
externalProductId | string | Sí | Tu propio identificador de producto. El personal de Contadito lo mapea una sola vez a un producto interno. Un id sin mapear se rechaza con PRODUCT_NOT_FOUND. |
quantity | number | Sí | Mayor que cero. |
price | number | No | Precio unitario negociado para esta línea. Omítelo para facturar al precio del catálogo. Mayor que cero. Ver abajo. |
Fíjate en lo que no mandas: impuesto, descuento, correlativo, CAI. Todo eso se resuelve con el catálogo y la configuración de tu empresa. Ver Conceptos de facturación.
Precio negociado (price)
Por defecto el unitPrice sale de la lista de precios de tu empresa. Si esta
venta se cerró a otro precio, mándalo en la línea:
{
"items": [
{ "externalProductId": "SKU-WEB-HOSTING-01", "quantity": 2, "price": 1500 },
{ "externalProductId": "SKU-SOPORTE-MES", "quantity": 1 }
]
}
La primera línea se factura a 1500 por unidad; la segunda, al precio del catálogo. Reglas:
- Va en la moneda del producto, no necesariamente en lempiras. Un producto en dólares recibe un precio en dólares y se convierte con la misma tasa de cambio que se le habría aplicado al precio de catálogo.
- Debe ser mayor que cero. No sirve para regalar una línea.
- No crea ni actualiza nada en el catálogo. Aplica solo a esta factura.
- El ISV lo sigue poniendo el producto: mandar
pricecambia la base gravable, no la tasa ni la condición de exento.
Productos parcialmente gravados
En un producto marcado como parcialmente gravado, el monto exento por unidad
sigue viniendo del catálogo: no se reescala con tu precio. Si bajas el precio
de una línea así, la porción exenta se mantiene y la gravada absorbe toda la
diferencia. Confírmalo con
POST /v1/invoices/preview antes de emitir.
Respuesta 201
{
"id": 8801,
"invoiceNumber": 1573,
"cai": "A1B2C3-D4E5F6-A1B2C3-D4E5F6-A1B2C3-12",
"invoiceDate": "2026-08-10T15:04:05.000Z",
"clientId": 1042,
"importe": 2000,
"discountTotal": 0,
"exemptedAmount": 0,
"exoneratedAmount": 0,
"totalTaxes": 300,
"total": 2300,
"totalInLetters": "DOS MIL TRESCIENTOS LEMPIRAS EXACTOS",
"isPaid": true,
"isAnnulled": false,
"taxBreakdown": [{ "rate": 15, "taxAmount": 300, "taxedAmount": 2000 }],
"lines": [
{
"externalProductId": "SKU-WEB-HOSTING-01",
"quantity": 2,
"unitPrice": 1000,
"importe": 2000,
"discountAmount": 0,
"subTotal": 2000,
"exemptAmount": null
}
],
"replayed": false,
"pdfBase64": "JVBERi0xLjQK..."
}
| Campo | Tipo | Descripción |
|---|---|---|
id | number | Úsalo en GET /v1/invoices/{id}. Guárdalo. |
invoiceNumber | number | Correlativo del rango CAI. |
cai | string | null | CAI del SAR bajo el cual se emitió. |
invoiceDate | string | ISO 8601. |
clientId | number | null | |
importe | number | Suma del importe de las líneas, antes de descuentos. |
discountTotal | number | Descuentos aplicados. |
exemptedAmount | number | Monto no sujeto a ISV por naturaleza del bien. |
exoneratedAmount | number | Monto cubierto por una exoneración del cliente. |
totalTaxes | number | ISV total. |
total | number | Lo que paga el cliente. |
totalInLetters | string | Total en letras, como exige el comprobante. |
isPaid | boolean | Siempre true en v1 (solo contado). |
isAnnulled | boolean | |
taxBreakdown | array | Una entrada por tasa de ISV. |
lines | array | Las líneas facturadas. |
replayed | boolean | true si esta respuesta es la repetición de una solicitud anterior con la misma Idempotency-Key. |
pdfBase64 | string | null | El PDF A4 de la factura en base64. null si la factura se emitió pero el PDF no pudo generarse: la factura es válida de todos modos y el PDF sigue disponible en GET /v1/invoices/{id}/pdf. |
taxBreakdown[]
| Campo | Tipo | Descripción |
|---|---|---|
rate | number | Tasa de ISV en porcentaje (p. ej. 15). |
taxAmount | number | Impuesto cobrado a esa tasa. |
taxedAmount | number | Base gravable a la que se aplicó. |
lines[]
| Campo | Tipo | Descripción |
|---|---|---|
externalProductId | string | null | Tu id, tal como lo mandaste. |
quantity | number | |
unitPrice | number | Precio unitario facturado, sin impuestos. |
importe | number | unitPrice × quantity, antes de descuento. |
discountAmount | number | |
subTotal | number | Total de la línea tras el descuento, antes de impuestos. |
exemptAmount | number | null | Siempre null en lecturas: no se persiste el exento por línea. Usa exemptedAmount del encabezado. |
replayed: true no es un error
Significa que tu reintento no emitió una segunda factura y que estás viendo la original. Es un éxito.
Errores
| HTTP | code | Causa |
|---|---|---|
| 400 | MISSING_IDEMPOTENCY_KEY | Falta el header. |
| 400 | VALIDATION_FAILED | Body inválido o con campos desconocidos. |
| 400 | PRODUCT_NOT_FOUND | Un externalProductId no está mapeado. |
| 400 | EXTERNAL_INVOICE_NO_ITEMS | items vacío. |
| 400 | EXTERNAL_INVOICE_CLIENT_NOT_FOUND | El clientId no existe en tu empresa. |
| 400 | EXTERNAL_INVOICE_EXONERATION_REQUIRES_RTN | El cliente exonerado no tiene RTN. |
| 401 | MISSING_API_KEY / INVALID_API_KEY | Credencial. |
| 403 | INTEGRATION_NOT_ENABLED | Integración no habilitada. |
| 409 | IDEMPOTENCY_KEY_REQUEST_MISMATCH | Misma key, body distinto. |
| 409 | INTEGRATION_MISSING_*, PRICE_NOT_FOUND | Configuración o catálogo incompletos. |
| 422 | EXTERNAL_INVOICE_CREDIT_NOT_SUPPORTED | isCredit: true. |
| 500 | INTERNAL_ERROR | Falla nuestra: reintenta con la misma key. |
Ver el catálogo completo de errores.