On this page
KyrFact Developer Guide
Issue, retrieve, and notify Costa Rica electronic invoices over a single REST API. Six document types — FE, TE, NC, ND, FEC, FEE — one consistent request shape.
This guide is everything you need to integrate KyrFact. Hit your API URL with any HTTP client; we handle the rest.
For security, the examples in this guide use $KYRFACT_API_BASE as a placeholder for your API URL. The real URL is delivered when your API key is activated (alongside the plaintext key). Set $KYRFACT_API_BASE in your environment before running the examples.
Read these in order if you're starting from scratch:
- Authentication — get your API key
- Quick start — issue your first FE in 90 seconds
- Payload structure — field-by-field reference
- Endpoints — every URL you can call
- Document types — pick the right one and copy its payload
Authentication
Every endpoint requires a Bearer token in the Authorization header:
Authorization: Bearer kf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Two environments — pick one per key:
| Prefix | Environment | Use for |
|---|---|---|
kf_live_… | Production | Real invoices submitted to Hacienda |
kf_test_… | Sandbox | Hacienda's stag environment for testing |
The plaintext key is shown once at mint time. Save it immediately; subsequent reads only show the prefix.
Quick start
Issue an FE for 100 CRC + 13% IVA → 113 CRC total. Copy, paste, set $KYRFACT_KEY:
curl -X POST $KYRFACT_API_BASE/v1/invoices \
-H "Authorization: Bearer $KYRFACT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-order-12345" \
-d '{
"type": "FE",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC",
"tipoCambio": 1,
"emisor": {
"codigoActividadEmisor": "6201.0",
"nombre": "Acme Comercializadora S.A.",
"tipoId": "02", "id": "3101234567",
"provincia": "1", "canton": "1", "distrito": "01",
"senas": "Calle 5, Avenida 2",
"email": "facturas@acme.cr"
},
"receptor": {
"nombre": "Cliente Ejemplo",
"tipoId": "01", "id": "100100100",
"provincia": "1", "canton": "1", "distrito": "01",
"email": "cliente@example.com"
},
"totalServGravados": 100, "totalGravados": 100,
"totalVentas": 100, "totalVentasNeta": 100,
"totalImpuestos": 13, "totalComprobante": 113,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Sp",
"detalle": "Servicio profesional",
"precioUnitario": 100, "montoTotal": 100, "subTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "8314100000200",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 13 } }
}
},
"options": { "email": { "onAccept": true } }
}'
You get back, immediately:
{
"id": "6500000000000000dd000001",
"type": "FE",
"env": "live",
"status": "enviada",
"clave": "50601012600031012345670010000101000000000412345678",
"consecutivo": "00100001010000000042",
"totals": { "totalComprobante": 113, "totalImpuestos": 13, "codMoneda": "CRC" },
"mensajeHacienda": null
}
Store the id and clave. Status flips to completa within 1-3 minutes — see Status & polling.
Tip: if your business profile is set on the tenant, you can omit the emisor block entirely from the payload — see Emisor defaults.
Payload structure
Every POST /v1/invoices sends a single JSON object. This section documents every field. Required fields produce a 400 if missing; optional ones have safe defaults.
The shape is shared across all document types. For type-specific fields (NC, ND, FEC, FEE), see the per-type pages further down.
Header
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Document type. One of FE · TE · NC · ND · FEC · FEE. |
fecha | ISO-8601 string | Yes | Datetime with timezone offset. e.g. "2026-04-28T10:00:00-06:00". Hacienda requires the offset. |
condicionVenta | string | Yes | Sale-condition code. "01" cash, "02" credit, "99" other. |
condicionVentaOtros | string | When condicionVenta = "99" | Free-text condition description. |
mediosPago | string[] | Yes | Payment-method codes. Min. 1. "01" cash, "02" card, "03" check, "04" transfer, "99" other. |
codMoneda | string | Yes | ISO-4217 currency code. "CRC", "USD", "EUR". |
tipoCambio | number | Yes | Exchange rate applied. 1 when currency = CRC. |
omitirReceptor | "true" · "false" | No | Valid only on TE and FE. "true" for anonymous tickets. Default: "false". |
otros | string | No | Free-text printed on the PDF. |
sucursal | string | No | Branch identifier (3 digits). Default: tenant's value. |
terminal | string | No | Terminal identifier (5 digits). Default: "00001". |
proveedorSistema | string | No | Identifier of your software, embedded in the XML. |
Pre-computed totals
v1 expects pre-computed totals — the caller is responsible for the arithmetic before calling. Totals validation checks invariants with ±0.01 tolerance and returns 400 with the detail.
| Field | Type | Required | Description |
|---|---|---|---|
totalVentas | number | Yes | Sum of subtotals before discounts. |
totalVentasNeta | number | Yes | totalVentas − totalDescuentos. |
totalComprobante | number | Yes | Final amount the buyer pays (includes taxes, discounts, other charges). |
totalImpuestos | number | When lines have taxes | Sum of taxes for the document. |
totalDescuentos | number | No | Sum of discounts. Default: 0. |
totalServGravados · totalServExentos · totalServExonerados · totalServNoSujeto | number | No | Service subtotals by IVA category. |
totalMercGravada · totalMercExenta · totalMercExonerada · totalMercNoSujeta | number | No | Goods subtotals by IVA category. |
totalGravados · totalExentos · totalExonerado · totalNoSujeto | number | No | Aggregate subtotals (services + goods) by IVA category. |
totalImpuestosAsumidosFabrica | number | No | Tax collected at factory (special regime). |
totalIVADevuelto | number | No | IVA refunded to the customer (basic-basket items, etc.). |
totalOtrosCargos | number | No | Other non-tax charges. |
emisor block
The party issuing the invoice. All fields are optional in the payload — anything missing is filled from the tenant's stored profile. See Emisor defaults for the merge rules.
| Field | Type | Description |
|---|---|---|
codigoActividadEmisor | string | Hacienda economic-activity code. e.g. "6201.0". |
nombre | string | Issuer's legal name. |
tipoId | string | ID type. "01" physical, "02" legal, "03" dimex, "04" nite. |
id | string | Issuer's cedula (no dashes). |
nombreComercial | string | Commercial name. |
provincia · canton · distrito | string | Hacienda location codes. e.g. "1" · "1" · "01". |
senas | string | Other address details (street, avenue, building). |
codigoPaisTel | string | Country phone code (e.g. "506"). |
tel | string | Phone number. |
email | string | Issuer's email. |
registroFiscal8707 | string | Optional fiscal-registry number for special regimes. |
logoUrl | URL | Full URL of the logo image (PNG or JPG). |
logoAncho | number 20-200 | Logo width in points. Default: 80. |
estiloFactura | "C" · "G" · "Y" · "L" | PDF color theme. C light blue (default), G green, Y yellow, L sky blue. |
pieFactura | string | Footer text on the PDF. |
paginaWeb | string | Issuer's website URL. |
Hacienda's seven required fields are: codigoActividadEmisor, nombre, tipoId, id, provincia, canton, distrito. If any are missing after the tenant merge, the API returns 400 with the exact list.
receptor block
The buyer. Not filled from the tenant — must come in the payload (or use omitirReceptor: "true" for anonymous TEs).
| Field | Type | Description |
|---|---|---|
codigoActividadReceptor | string | Buyer's economic-activity code (required for FEC). |
nombre | string | Buyer's name. |
tipoId | string | "01" physical · "02" legal · "03" dimex · "04" nite · "05" foreign (FEE). |
id | string | Buyer's cedula. Omit when tipoId = "05". |
identifExtranjero | string | Foreign ID (passport, DNI). Required when tipoId = "05". |
nombreComercial | string | Commercial name. |
provincia · canton · distrito | string | CR location codes. Omit for foreign buyer. |
senas | string | Other address details. Required for FEC. |
senasExtranjero | string | Foreign address (FEE). |
codigoPaisTel · tel · email | string | Buyer's contact info. |
detalles block (line items)
Nested object with numeric string keys ("1", "2", …). Each value is a line on the invoice.
"detalles": {
"1": {
"cantidad": 1,
"unidadMedida": "Sp",
"detalle": "Professional service",
"precioUnitario": 100,
"subTotal": 100,
"montoTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "8314100000200",
"impuesto": {
"1": {
"codigo": "01", "codigoTarifa": "08",
"tarifa": 13, "monto": 13
}
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
cantidad | number | Yes | Quantity of units. |
unidadMedida | string | Yes | Hacienda unit of measure. "Sp" professional services, "Unid" unit, "kg" kilo, "L" liter, "m" meter. |
detalle | string | Yes | Line description. |
precioUnitario | number | Yes | Price per unit before discounts and taxes. |
subTotal | number | Yes | cantidad × precioUnitario. |
montoTotal | number | Yes | Same as subTotal before discounts. |
montoTotalLinea | number | Yes | Line total with taxes: subTotal − montoDescuento + tax. |
codigoCABYS | string | Yes | 13-digit CABYS code. KyrFact validates length, not the registry (Hacienda updates the catalogue monthly). |
baseImponible | number | Auto | Tax base. Auto-filled to subTotal − montoDescuento when tarifa > 0 and type is not FEE. |
montoDescuento | number | No | Per-line discount. |
naturalezaDescuento | string | When discount applied | Discount description. Required when montoDescuento > 0. |
impuesto | object | When IVA applies | Sub-object with numeric string keys. Each entry describes one tax. |
Sub-block impuesto["1"]:
| Field | Type | Description |
|---|---|---|
codigo | string | Tax code. "01" IVA, "02" ISC, etc. |
codigoTarifa | string | Tariff code. "08" 13% general, "04" 4% institutional, "02" 1%, "01" exempt. |
tarifa | number | Tariff percentage. e.g. 13. |
monto | number | Tax amount: baseImponible × tarifa / 100. |
exoneracion | object | Exoneration block when codigoTarifa = "01". See per-type sections for examples. |
options block
Per-call behavior. All defaults are safe.
| Field | Type | Default | Description |
|---|---|---|---|
persist | "full" · "minimal" | "full" | "full" stores request, items, emisor, receptor, and XMLs in MongoDB. "minimal" stores only what's needed for reports (clave, status, totals). |
email.onAccept | boolean | false | Send PDF + XML to the receiver when Hacienda accepts. |
email.onReject | boolean | false | Notify the issuer when Hacienda rejects the invoice. |
email.replyTo | — | Reply-To override. Falls back to emisor.email → tenant.correo. The From header is fixed to facturas@kyrapps.com. |
Type-specific fields
A few fields apply only to certain document types. See the per-type pages below for full examples.
| Field | Applies to | Description |
|---|---|---|
omitirReceptor: "true" | TE; NC against an anonymous TE | Document with no identified buyer. |
informacionReferencia | NC, ND, FEC | Reference to the original document. Array or object with tipoDoc, numero (50-digit clave), fechaEmision, codigo (reason) and razon. |
identifExtranjero · senasExtranjero | FEE | Buyer's foreign details (with tipoId = "05"). |
Emisor defaults from your tenant
Every invoice request takes an emisor block describing the issuer (your business). Repeating it on every call gets old, so KyrFact merges the payload with the issuer profile stored on your tenant — payload wins per-field, and any field you omit is filled from the tenant.
Set the issuer profile once via POST or PATCH /v1/admin/tenants. The following tenant fields are used as fallbacks:
| Tenant field | Emisor field | Notes |
|---|---|---|
nombre | emisor.nombre | Legal name |
cedula | emisor.id | |
tipoCedula | emisor.tipoId | Mapped: fisico→01, juridico→02, dimex→03, nite→04 |
nombreComercial | emisor.nombreComercial | |
provincia / canton / distrito | same names | Hacienda location codes |
senas | emisor.senas | |
telefono | emisor.tel | |
correo | emisor.email | |
codigoActividad | emisor.codigoActividadEmisor | Hacienda economic activity code |
logoName | emisor.logoUrl | Resolved to https://facturas-storage.s3.us-east-1.amazonaws.com/logos/<logoName> |
Three calling patterns
1. Full payload — every emisor field provided. Works as before; payload values win:
POST /v1/invoices
{
"type": "FE",
"emisor": {
"codigoActividadEmisor": "6201.0",
"nombre": "Acme S.A.",
"tipoId": "02", "id": "3101234567",
"provincia": "1", "canton": "1", "distrito": "01",
"senas": "Calle 5, Avenida 2",
"email": "facturas@acme.cr"
},
...
}
2. Emisor omitted — every required field is read from the tenant:
POST /v1/invoices
{
"type": "FE",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC", "tipoCambio": 1,
"totalComprobante": 113, "totalImpuestos": 13,
"totalVentas": 100, "totalVentasNeta": 100,
"detalles": { "1": { ... } }
}
3. Mixed override — override only specific fields. Useful when a business has multiple registered activities:
POST /v1/invoices
{
"type": "FE",
"emisor": { "codigoActividadEmisor": "8314.0" },
...
}
What stays required
- The seven Hacienda-required emisor fields (
codigoActividadEmisor,nombre,tipoId,id,provincia,canton,distrito) must be set somewhere — either in the payload or on the tenant. Missing fields surface as a 400 listing them. - The
receptorblock is not merged from the tenant — receptors are buyers, not issuers. Use the standard FE/TE/NC patterns to provide it (oromitirReceptor: "true"for anonymous TE).
Validation error shape
If neither the payload nor the tenant has all the required fields, you get:
HTTP 400
{
"statusCode": 400,
"error": "Bad Request",
"message": "emisor is incomplete after merging with tenant defaults — missing [codigoActividadEmisor, provincia, canton, distrito]. Either include these fields in the request body or set them on the tenant via PATCH /v1/admin/tenants/:id.",
"missingEmisorFields": ["codigoActividadEmisor", "provincia", "canton", "distrito"]
}
The merge runs before consecutivo allocation, so an incomplete-emisor request never burns a Hacienda number.
All endpoints
Every URL the API exposes. All are scoped to your tenant by the API key.
Issuing & reading invoices
| Method | Path | Purpose |
|---|---|---|
POST | /v1/invoices | Issue a new invoice (any type) |
GET | /v1/invoices | List invoices with filters (paginated) |
GET | /v1/invoices/:id | Fetch one invoice's public projection |
Documents (PDF + XML)
| Method | Path | Returns |
|---|---|---|
GET | /v1/invoices/:id/pdf | The rendered PDF (binary, application/pdf) |
GET | /v1/invoices/:id/xml | The signed FE/TE/NC/ND XML (application/xml) |
GET | /v1/invoices/:id/respuesta-xml | Hacienda's signed MensajeHacienda response XML |
POST | /v1/invoices/:id/resend-email | Re-fire the customer email with the 3 docs attached |
Counters
| Method | Path | Purpose |
|---|---|---|
GET | /v1/consecutivos/next?type=FE&sucursal=1 | Preview the next consecutivo for a (type, sucursal) — read-only, doesn't consume |
Filters on GET /v1/invoices
GET /v1/invoices?type=FE&estado=completa&limit=20&offset=0
&receptorCedula=100100100
&q=widget
&from=2026-01-01&to=2026-12-31
&kyrfactOnly=true
| Param | Description |
|---|---|
type | FE / TE / NC / ND / FEC / FEE — narrows by document type |
estado | pendiente / enviada / completa / rechazada / cancelada |
receptorCedula | Exact match on the receptor's cedula |
q | Substring search on receptor's name |
from / to | Date range (ISO date) on the documento's fecha |
kyrfactOnly | true to exclude legacy non-KyrFact invoices |
limit / offset | Pagination (default 20 / 0) |
Usage PDF report
Generates a PDF listing every invoice your business issued in a date range, spanning the three storage collections (FE/TE/NC/ND, FEC, FEE), with per-tarifa IVA totals plus per-type and per-estado breakdowns at the bottom. Authenticate with your own tenant API key — the endpoint only ever returns your own data.
Request
GET /v1/reports/usage.pdf?from=YYYY-MM-DD&to=YYYY-MM-DD[&estado=…][&type=FE,TE,…]
Authorization: Bearer kf_live_… # or kf_test_… for sandbox
Tenant identity is derived from the API key automatically — no tenantId parameter is sent. You'll only ever receive invoices issued by the tenant bound to that key.
Query parameters
| Param | Required | Description |
|---|---|---|
from | yes | Inclusive start date in YYYY-MM-DD (UTC, start of day). |
to | yes | Inclusive end date in YYYY-MM-DD (UTC, end of day). Must be ≥ from. |
estado | no | Document status filter — e.g. completa to include only Hacienda-accepted invoices. Typical values: completa, rechazada, enviada, pendiente, cancelada. |
type | no | Comma-separated list — any subset of FE,TE,NC,ND,FEC,FEE. Omit the param to include all six. Invalid codes return 400 listing the offenders. |
Response
On success: 200 OK with Content-Type: application/pdf and a Content-Disposition header carrying the filename:
Content-Type: application/pdf
Content-Disposition: attachment; filename="Reporte-de-Uso-<slug>[-<types>]-<from>_<to>.pdf"
Cache-Control: no-store
The <slug> is built from tenant.nombreComercial (or nombre, falling back to cedula) — lowercased, accents stripped, non-alphanumeric characters replaced with -, capped at 60 chars. When the type filter is set, the filename gets a -FE-TE (etc.) segment before the date range.
PDF layout
- Centered header: title (with estado and/or types subtitle when filtered),
nombreComercial,cedula, "Rango de Fechas" + the range. Page counter top-right. - Detail table (40 rows per page):
Tipo · Consecutivo · Cliente · Fecha · Total Imp - Exo · Total Fact. Alternating white/light-blue rows like fs-fe. - Currency convention: total columns show the CRC equivalent using each documento's
tipocambio. Rows whosetipocambio ≠ 1are suffixed with*. - Footer on every page: small grey legend explaining the
*("amount converted to CRC at the document's exchange rate"). - Totals block (last page, bottom-right):
Total Sin Impuestos·Total Con Impuestos·Total Impuestos al 13%·al 4%·al 2%·al 1%. - Extra breakdown (last page, bottom-left): per doc type and per estado.
How totals are computed
The report only sums invoices whose original event was accepted by Hacienda (documentos[0].estado === 'completa'). Credit notes (NC) are subtracted from the totals, not added — their amount and IVA contribute with a negative sign to Total Con Impuestos, Total Sin Impuestos, the per-tarifa breakdown, and the NC row of the "By type" block. Debit notes (ND), FE, TE, FEC, and FEE add normally. This mirrors real fiscal effect: an NC cancels or reduces the IVA charged on a previous invoice.
About duplicate cancellations: Hacienda does not enforce 1:1 uniqueness between an NC and its source invoice — if you issue several accepted NCs against the same reference numero, the report will subtract each one. This can drive the net below zero. The report is faithful to Hacienda's records; the fix is to avoid issuing duplicate NCs.
Examples
Full month, all types and statuses:
curl -H "Authorization: Bearer $KYRFACT_KEY" \
"$KYRFACT_API_BASE/v1/reports/usage.pdf?from=2026-04-01&to=2026-04-30" \
-o report.pdf
FE and TE only, accepted by Hacienda:
curl -H "Authorization: Bearer $KYRFACT_KEY" \
"$KYRFACT_API_BASE/v1/reports/usage.pdf?from=2026-04-01&to=2026-04-30&type=FE,TE&estado=completa" \
-o fe-te-completed.pdf
Credit and debit notes only:
curl -H "Authorization: Bearer $KYRFACT_KEY" \
"$KYRFACT_API_BASE/v1/reports/usage.pdf?from=2026-04-01&to=2026-04-30&type=NC,ND" \
-o credit-debit-notes.pdf
Errors
| Status | When |
|---|---|
400 | from or to missing or malformed · from > to · any type outside {FE,TE,NC,ND,FEC,FEE}. |
401 | Missing Authorization header or invalid key. |
403 | Key revoked or wrong env (e.g. kf_test_… against a live operation). |
For programmatic integrations, prefer hitting this endpoint directly rather than re-implementing the layout locally — the layout may evolve in future releases.
Compare document types
All six types share the same request shape — the differences are which fields are required and how the parties are arranged.
| Type | Use when… | Receptor required | Reference required |
|---|---|---|---|
FEFactura Electrónica |
Standard sale to a known buyer (B2B or B2C with cédula) | Yes | No |
TETiquete Electrónico |
POS receipt to an unidentified consumer | Optional | No |
NCNota de Crédito |
Cancel or reduce a prior FE/TE | Yes — must match the original | Yes — the prior FE/TE clave |
NDNota de Débito |
Add a charge on top of a prior FE/TE | Yes — must match the original | Yes — the prior FE/TE clave |
FECFactura Electrónica de Compra |
Record a purchase from a supplier without electronic invoicing | Yes — the supplier | Yes — the paper doc |
FEEFactura Electrónica de Exportación |
Export sale to a foreign buyer | Yes | No |
Quick decision tree
- Selling something?
- Buyer has a CR cédula →
FE - Anonymous walk-in customer →
TE - Foreign buyer (export) →
FEE
- Buyer has a CR cédula →
- Adjusting a previous invoice?
- Reducing or canceling →
NC - Adding a charge →
ND
- Reducing or canceling →
- Recording a purchase you made? →
FEC
FE — Factura Electrónica
Standard B2B / B2C sale to an identified buyer. Most common doc type.
Payload
{
"type": "FE",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC",
"tipoCambio": 1,
"emisor": {
"codigoActividadEmisor": "6201.0",
"nombre": "Acme Comercializadora S.A.",
"tipoId": "02", "id": "3101234567",
"provincia": "1", "canton": "1", "distrito": "01",
"senas": "Calle 5, Avenida 2",
"email": "facturas@acme.cr"
},
"receptor": {
"nombre": "Cliente Ejemplo S.A.",
"tipoId": "02", "id": "3101987654",
"provincia": "1", "canton": "1", "distrito": "01",
"email": "cliente@example.com"
},
"totalServGravados": 100, "totalGravados": 100,
"totalVentas": 100, "totalVentasNeta": 100,
"totalImpuestos": 13, "totalComprobante": 113,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Sp",
"detalle": "Servicio profesional",
"precioUnitario": 100, "montoTotal": 100, "subTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "8314100000200",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 13 } }
}
},
"options": { "email": { "onAccept": true } }
}
Endpoint: POST /v1/invoices
TE — Tiquete Electrónico
Counter / POS receipt to an unidentified consumer. Same shape as FE, but the receptor block is optional and typically minimal.
Payload (anonymous receptor)
{
"type": "TE",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC",
"tipoCambio": 1,
"omitirReceptor": "true",
"emisor": { /* …same as FE */ },
"totalMercGravada": 100, "totalGravados": 100,
"totalVentas": 100, "totalVentasNeta": 100,
"totalImpuestos": 13, "totalComprobante": 113,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Unid",
"detalle": "Sandwich + bebida",
"precioUnitario": 100, "montoTotal": 100, "subTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "5611901100100",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 13 } }
}
}
}
Endpoint: POST /v1/invoices
If the customer asks for a receipt with their cedula included, set omitirReceptor: "false" and supply the receptor block — same shape as FE.
NC — Nota de Crédito
Cancels or reduces a prior FE/TE. The receptor must be the same party as the original document's receptor.
Payload
{
"type": "NC",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC",
"tipoCambio": 1,
"emisor": { /* …same as FE */ },
"receptor": { /* …MUST match the receptor of the referenced FE/TE */ },
"totalServGravados": 100, "totalGravados": 100,
"totalVentas": 100, "totalVentasNeta": 100,
"totalImpuestos": 13, "totalComprobante": 113,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Sp",
"detalle": "Anulación de servicio facturado en error",
"precioUnitario": 100, "montoTotal": 100, "subTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "8314100000200",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 13 } }
}
},
"informacionReferencia": [{
"tipoDoc": "01",
"numero": "50601012600031012345670010000101000000000412345678",
"fechaEmision": "2026-04-15T10:00:00-06:00",
"codigo": "01",
"razon": "Anulación por error de digitación"
}]
}
Endpoint: POST /v1/invoices
informacionReferencia codes
| Field | Common values |
|---|---|
tipoDoc | 01 referenced doc is FE, 04 TE |
numero | The 50-character clave of the original FE/TE |
codigo | 01 Anula documento de referencia, 02 Corrige texto, 04 Referencia a otro documento, 99 Otros |
razon | Free-text explanation, max 180 chars |
ND — Nota de Débito
Adds a charge on top of a prior FE/TE (e.g. late-payment surcharge, agreed price increase). Same structure as NC.
Payload
{
"type": "ND",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC",
"tipoCambio": 1,
"emisor": { /* …same as FE */ },
"receptor": { /* …MUST match the receptor of the referenced FE/TE */ },
"totalServGravados": 50, "totalGravados": 50,
"totalVentas": 50, "totalVentasNeta": 50,
"totalImpuestos": 6.5, "totalComprobante": 56.5,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Sp",
"detalle": "Cargo adicional por mora",
"precioUnitario": 50, "montoTotal": 50, "subTotal": 50,
"montoTotalLinea": 56.5,
"codigoCABYS": "8314100000200",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 6.5 } }
}
},
"informacionReferencia": [{
"tipoDoc": "01",
"numero": "50601012600031012345670010000101000000000412345678",
"fechaEmision": "2026-04-15T10:00:00-06:00",
"codigo": "04",
"razon": "Cargo adicional sobre factura original"
}]
}
Endpoint: POST /v1/invoices
FEC — Factura Electrónica de Compra
Records a purchase you made from a supplier who can't issue electronic invoices (paper-only vendors, foreign suppliers, etc.). You — the buyer — are the one signing.
Use the same convention as the other types: emisor is your tenant, receptor is the supplier. KyrFact arranges the wire payload correctly.
Payload
{
"type": "FEC",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "CRC",
"tipoCambio": 1,
"emisor": { /* …your tenant — same as FE */ },
"receptor": {
"nombre": "Proveedor Servicios Test",
"tipoId": "02", "id": "3101234500",
"provincia": "1", "canton": "1", "distrito": "01",
"senas": "Oficina San José centro",
"email": "proveedor@example.cr"
},
"totalServGravados": 100, "totalGravados": 100,
"totalVentas": 100, "totalVentasNeta": 100,
"totalImpuestos": 13, "totalComprobante": 113,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Sp",
"detalle": "Servicio comprado al proveedor",
"precioUnitario": 100, "montoTotal": 100, "subTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "8314100000200",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 13 } }
}
},
"informacionReferencia": [{
"tipoDoc": "08",
"numero": "PAPER-INV-12345",
"fechaEmision": "2026-04-28T10:00:00-06:00",
"codigo": "04",
"razon": "Compra a proveedor sin facturación electrónica"
}]
}
Endpoint: POST /v1/invoices
FEE — Factura Electrónica de Exportación
Export sale to a foreign buyer. Currency is typically USD; the receptor often uses tipoId: "05" with a foreign tax ID.
Payload (CR-resident receptor)
{
"type": "FEE",
"fecha": "2026-04-28T10:00:00-06:00",
"condicionVenta": "01",
"mediosPago": ["01"],
"codMoneda": "USD",
"tipoCambio": 510,
"emisor": { /* …your tenant — same as FE */ },
"receptor": {
"nombre": "Foreign Buyer LLC",
"tipoId": "02", "id": "3101000000",
"provincia": "1", "canton": "1", "distrito": "01",
"senas": "Foreign address",
"email": "buyer@example.com"
},
"totalServGravados": 100, "totalGravados": 100,
"totalVentas": 100, "totalVentasNeta": 100,
"totalImpuestos": 13, "totalComprobante": 113,
"detalles": {
"1": {
"cantidad": 1, "unidadMedida": "Sp",
"detalle": "Servicio exportado",
"precioUnitario": 100, "montoTotal": 100, "subTotal": 100,
"montoTotalLinea": 113,
"codigoCABYS": "8314100000200",
"impuesto": { "1": { "codigo": "01", "codigoTarifa": "08", "tarifa": 13, "monto": 13 } }
}
}
}
Endpoint: POST /v1/invoices
Truly foreign receptors (tipoId: "05" with identifExtranjero) are not supported in v1 yet. Contact us if you need this — it's on the short-term roadmap.
Status & polling
Every POST /v1/invoices returns immediately with status: "enviada". The status field then transitions to one of:
| Status | Meaning |
|---|---|
enviada | Submitted; waiting for Hacienda's verdict. |
completa | Hacienda accepted. PDF and response XML available, customer email sent (if configured). |
rechazada | Hacienda rejected. mensajeHacienda contains the reason; the response XML has full detail. |
To detect the transition, poll:
GET /v1/invoices/:id
while true; do
STATUS=$(curl -s -H "Authorization: Bearer $KEY" \
$KYRFACT_API_BASE/v1/invoices/$ID | jq -r '.status')
case "$STATUS" in
completa|rechazada) break ;;
*) sleep 30 ;;
esac
done
Median time-to-completa is ~90 seconds. Worst case ~5 minutes. Don't poll faster than every 30s.
Alternatively, set options.email.onAccept: true and the customer email arrives the moment status flips to completa.
Idempotency
An idempotency key is a way to tell us "if you've already seen this exact request, return the same answer instead of doing the work again." It exists because networks fail.
The problem it solves
Without it, this scenario breaks billing:
- Your app sends
POST /v1/invoices. - KyrFact issues the invoice, signs it, submits to Hacienda — and starts replying with a 200.
- The response packet drops on the way back to your app. Your code times out.
- Your code retries → a second invoice gets issued. Two consecutivos burned, two emails sent, customer charged twice.
How to use it
Pick a string that uniquely identifies the operation (not the HTTP call) — typically your internal order or transaction ID — and send it in the Idempotency-Key header:
POST /v1/invoices
Authorization: Bearer kf_live_…
Idempotency-Key: order-12345
Content-Type: application/json
{ ...your invoice payload... }
If your code retries the same operation (network glitch, app crash, queue redelivery), send the same key. KyrFact recognizes it within a 24-hour window and returns the original response without issuing a new invoice.
Picking a good key
| Example | Why | |
|---|---|---|
| Good | order-12345 |
Stable across retries — same order, same key. |
| Good | checkout-2026-04-28-12345 |
Date-namespaced order ID. Stable across retries. |
| Bad | crypto.randomUUID() |
New random value per call → every retry looks like a new request → duplicates happen. |
| Bad | Date.now() |
Different on every retry. Same problem. |
Rules
- Any string up to 256 characters.
- Cached for 24 hours. After that the same key is treated as a new request.
- Scoped per tenant — Tenant A and Tenant B can use the same key without interfering.
- Optional but strongly recommended. Without it, retries cost you real consecutivos.
Also works on POST /v1/invoices/:id/resend-email if you want to make resend-on-button-click safe against double-clicks.
Pre-flight validation
KyrFact runs three layers of validation before allocating a consecutivo or talking to php-api / Hacienda. If anything is wrong, you get a single 400 Bad Request with every problem in errors[], so you fix the whole payload in one round-trip.
Error response shape
HTTP 400
{
"statusCode": 400,
"error": "Bad Request",
"message": "<reason>",
"errors": [
"<every problem found>",
...
]
}
Layer order
- Emisor merge + required-field check. The payload's
emisorblock is merged with the tenant's stored issuer profile (see Emisor defaults). Then we verify the seven Hacienda-required fields are present (codigoActividadEmisor,nombre,tipoId,id,provincia,canton,distrito). - Structural payload check. Receptor, references, header, and per-line — see the breakdown below.
- Math check. Per-line invariants and document totals (±0.01 rounding tolerance).
What gets checked
Header
| Field | Rule |
|---|---|
fecha | ISO 8601 with offset (e.g. 2026-04-29T10:00:00-06:00). |
condicionVenta | Hacienda code in 01..15 or 99. If 99, also requires condicionVentaOtros. |
mediosPago[] | Non-empty array; each item in 01..07 or 99. |
codMoneda | ISO 4217 — 3 uppercase letters (CRC, USD, EUR…). |
tipoCambio | Greater than 0. |
totalComprobante | Greater than 0 — Hacienda rejects zero-value invoices. |
Emisor
| Field | Rule |
|---|---|
tipoId | 01 física, 02 jurídica, 03 DIMEX, 04 NITE. Issuer cannot be foreign (05). |
id (cedula) | Format per tipoId: 9 digits for física, 10 for jurídica, 11–12 for DIMEX, 10 for NITE. |
email | Basic email format check (when provided). |
Receptor
| Field / rule | Detail |
|---|---|
| Receptor block presence | Required for FE / NC / ND / FEC / FEE. Only TE allows anonymous. |
nombre | Non-empty. |
tipoId | In 01..05. 05 is for foreign buyers. |
Foreign receptor (tipoId=05) | Requires identifExtranjero. |
| CR-resident receptor | Requires provincia, canton, and distrito. |
id (cedula) | Format per tipoId (same as emisor). |
email | Basic email format check (when provided). |
Type-specific
| Type | Additional rules |
|---|---|
TE / FE | omitirReceptor: "true" allowed (anonymous). Other types reject this flag. |
NC | Requires informacionReferencia. numero must be a 50-digit Hacienda clave. codigo in 01..05 or 99. |
ND | Same as NC. |
FEC | Requires informacionReferencia pointing at the paper invoice. receptor.senas must be non-empty — php-api silently rejects FEC with empty supplier senas and the request never reaches Hacienda. |
Per-line
| Field / rule | Detail |
|---|---|
detalle | Non-empty description text. |
unidadMedida | Non-empty Hacienda code (Sp, Unid, kg, etc.). |
codigoCABYS | 13 digits. |
impuesto.1.codigo + codigoTarifa | Both required when an impuesto is present. |
naturalezaDescuento | Required when montoDescuento > 0. |
| Exoneration support | If tarifaExoneracion > 0: required tipoDocumento, numeroDocumento, nombreInstitucion, fechaEmision. |
| Math invariants | montoTotal = cantidad × precioUnitario; subTotal = montoTotal − montoDescuento; impuesto.monto = subTotal × tarifa/100; montoTotalLinea = subTotal + impuesto.monto. |
tarifa | In {0, 0.5, 1, 2, 4, 13}. baseImponible auto-fills as subTotal − montoDescuento when tarifa > 0 and not provided. |
Document totals
Every total you provide must match the line-derived sum, within ±0.01. Optional totals are skipped if omitted; values you send are validated.
totalVentas,totalDescuentos,totalVentasNeta,totalImpuestos,totalExonerado,totalComprobante— all checked against the lines.
Example: multi-error response
POST /v1/invoices
{
"type": "FEC",
"fecha": "yesterday",
"codMoneda": "crc",
"tipoCambio": -1,
"mediosPago": ["77"],
"receptor": { "nombre": "Supplier X", "tipoId": "02", "id": "3101234567" },
"informacionReferencia": [{
"tipoDoc": "08",
"numero": "PAPER-INV-1",
"fechaEmision": "2026-04-29",
"codigo": "04"
}],
...
}
HTTP 400
{
"statusCode": 400,
"error": "Bad Request",
"message": "invoice payload validation failed",
"errors": [
"fecha must be ISO 8601 with offset (got \"yesterday\", expected e.g. \"2026-04-29T10:00:00-06:00\").",
"mediosPago[0] \"77\" is not a Hacienda payment-method code (valid: 01-07, 99).",
"codMoneda must be a 3-letter ISO 4217 code in uppercase (got \"crc\").",
"tipoCambio must be > 0 (got -1).",
"informacionReferencia[0].razon is required (free-text reason, max 180 chars).",
"FEC.receptor.senas is required and must be non-empty (php-api gen_xml_fec rejects FEC with empty supplier senas)."
]
}
Because validation runs before consecutivo allocation, a request with any error never burns a Hacienda number.
What we deliberately don't check
- CABYS code registry — we only validate format (13 digits). The CABYS catalog is Hacienda-managed and updates monthly.
- Economic-activity registry — your activity code must be registered to your cedula at Hacienda; we can't verify that here.
- Full
impuesto.codigoandcodigoTarifaenums — Hacienda has been adding codes in recent revisions; we'd rather pass a valid new code through than false-reject it locally. - "Fecha too old" policy — Hacienda's window may change; let them surface it.
Errors
All errors follow:
{
"statusCode": 400,
"error": "Bad Request",
"message": "human readable explanation"
}
Pre-flight validation 400s also include an errors[] field listing every problem found in one response — see Validation.
| Status | When |
|---|---|
400 | Validation error — totals math, missing required field, unsupported doc type, missing informacionReferencia on NC/ND/FEC. |
401 | Missing or invalid Authorization header. |
403 | Key revoked, or wrong env (e.g. kf_test_… against a live operation). |
404 | Invoice not found, or belongs to a different tenant. |
409 | Idempotency-Key collision with a concurrent request still in-flight. |
Totals validation errors
When totals don't match what we recompute from the line items, you get a structured error pointing at the mismatched fields:
{
"statusCode": 400,
"message": "invoice totals failed validation",
"errors": [
{ "path": "totalComprobante", "expected": 113, "actual": 100, "diff": -13 },
{ "path": "detalles.1.impuesto.1.monto", "expected": 13, "actual": 12.5, "diff": -0.5 }
]
}
Fix and retry — no consecutivo is consumed for invalid payloads.
Hacienda rejections
These are not HTTP errors. The invoice was issued and submitted, but Hacienda found a problem with the data. You'll see:
HTTP 200 OK
{
"id": "...",
"status": "rechazada",
"mensajeHacienda": "El código del tipo de identificación no corresponde…",
...
}
Inspect mensajeHacienda for the reason or fetch GET /v1/invoices/:id/respuesta-xml for full detail. Most rejections are 1-line schema fixes — correct, re-submit with a new Idempotency-Key.