K KyrFact · API
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.

i

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:

  1. Authentication — get your API key
  2. Quick start — issue your first FE in 90 seconds
  3. Payload structure — field-by-field reference
  4. Endpoints — every URL you can call
  5. 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:

PrefixEnvironmentUse for
kf_live_…ProductionReal invoices submitted to Hacienda
kf_test_…SandboxHacienda'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.

i

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

FieldTypeRequiredDescription
typestringYesDocument type. One of FE · TE · NC · ND · FEC · FEE.
fechaISO-8601 stringYesDatetime with timezone offset. e.g. "2026-04-28T10:00:00-06:00". Hacienda requires the offset.
condicionVentastringYesSale-condition code. "01" cash, "02" credit, "99" other.
condicionVentaOtrosstringWhen condicionVenta = "99"Free-text condition description.
mediosPagostring[]YesPayment-method codes. Min. 1. "01" cash, "02" card, "03" check, "04" transfer, "99" other.
codMonedastringYesISO-4217 currency code. "CRC", "USD", "EUR".
tipoCambionumberYesExchange rate applied. 1 when currency = CRC.
omitirReceptor"true" · "false"NoValid only on TE and FE. "true" for anonymous tickets. Default: "false".
otrosstringNoFree-text printed on the PDF.
sucursalstringNoBranch identifier (3 digits). Default: tenant's value.
terminalstringNoTerminal identifier (5 digits). Default: "00001".
proveedorSistemastringNoIdentifier 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.

FieldTypeRequiredDescription
totalVentasnumberYesSum of subtotals before discounts.
totalVentasNetanumberYestotalVentas − totalDescuentos.
totalComprobantenumberYesFinal amount the buyer pays (includes taxes, discounts, other charges).
totalImpuestosnumberWhen lines have taxesSum of taxes for the document.
totalDescuentosnumberNoSum of discounts. Default: 0.
totalServGravados · totalServExentos · totalServExonerados · totalServNoSujetonumberNoService subtotals by IVA category.
totalMercGravada · totalMercExenta · totalMercExonerada · totalMercNoSujetanumberNoGoods subtotals by IVA category.
totalGravados · totalExentos · totalExonerado · totalNoSujetonumberNoAggregate subtotals (services + goods) by IVA category.
totalImpuestosAsumidosFabricanumberNoTax collected at factory (special regime).
totalIVADevueltonumberNoIVA refunded to the customer (basic-basket items, etc.).
totalOtrosCargosnumberNoOther 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.

FieldTypeDescription
codigoActividadEmisorstringHacienda economic-activity code. e.g. "6201.0".
nombrestringIssuer's legal name.
tipoIdstringID type. "01" physical, "02" legal, "03" dimex, "04" nite.
idstringIssuer's cedula (no dashes).
nombreComercialstringCommercial name.
provincia · canton · distritostringHacienda location codes. e.g. "1" · "1" · "01".
senasstringOther address details (street, avenue, building).
codigoPaisTelstringCountry phone code (e.g. "506").
telstringPhone number.
emailstringIssuer's email.
registroFiscal8707stringOptional fiscal-registry number for special regimes.
logoUrlURLFull URL of the logo image (PNG or JPG).
logoAnchonumber 20-200Logo width in points. Default: 80.
estiloFactura"C" · "G" · "Y" · "L"PDF color theme. C light blue (default), G green, Y yellow, L sky blue.
pieFacturastringFooter text on the PDF.
paginaWebstringIssuer'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).

FieldTypeDescription
codigoActividadReceptorstringBuyer's economic-activity code (required for FEC).
nombrestringBuyer's name.
tipoIdstring"01" physical · "02" legal · "03" dimex · "04" nite · "05" foreign (FEE).
idstringBuyer's cedula. Omit when tipoId = "05".
identifExtranjerostringForeign ID (passport, DNI). Required when tipoId = "05".
nombreComercialstringCommercial name.
provincia · canton · distritostringCR location codes. Omit for foreign buyer.
senasstringOther address details. Required for FEC.
senasExtranjerostringForeign address (FEE).
codigoPaisTel · tel · emailstringBuyer'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
      }
    }
  }
}
FieldTypeRequiredDescription
cantidadnumberYesQuantity of units.
unidadMedidastringYesHacienda unit of measure. "Sp" professional services, "Unid" unit, "kg" kilo, "L" liter, "m" meter.
detallestringYesLine description.
precioUnitarionumberYesPrice per unit before discounts and taxes.
subTotalnumberYescantidad × precioUnitario.
montoTotalnumberYesSame as subTotal before discounts.
montoTotalLineanumberYesLine total with taxes: subTotal − montoDescuento + tax.
codigoCABYSstringYes13-digit CABYS code. KyrFact validates length, not the registry (Hacienda updates the catalogue monthly).
baseImponiblenumberAutoTax base. Auto-filled to subTotal − montoDescuento when tarifa > 0 and type is not FEE.
montoDescuentonumberNoPer-line discount.
naturalezaDescuentostringWhen discount appliedDiscount description. Required when montoDescuento > 0.
impuestoobjectWhen IVA appliesSub-object with numeric string keys. Each entry describes one tax.

Sub-block impuesto["1"]:

FieldTypeDescription
codigostringTax code. "01" IVA, "02" ISC, etc.
codigoTarifastringTariff code. "08" 13% general, "04" 4% institutional, "02" 1%, "01" exempt.
tarifanumberTariff percentage. e.g. 13.
montonumberTax amount: baseImponible × tarifa / 100.
exoneracionobjectExoneration block when codigoTarifa = "01". See per-type sections for examples.

options block

Per-call behavior. All defaults are safe.

FieldTypeDefaultDescription
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.onAcceptbooleanfalseSend PDF + XML to the receiver when Hacienda accepts.
email.onRejectbooleanfalseNotify the issuer when Hacienda rejects the invoice.
email.replyToemailReply-To override. Falls back to emisor.emailtenant.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.

FieldApplies toDescription
omitirReceptor: "true"TE; NC against an anonymous TEDocument with no identified buyer.
informacionReferenciaNC, ND, FECReference to the original document. Array or object with tipoDoc, numero (50-digit clave), fechaEmision, codigo (reason) and razon.
identifExtranjero · senasExtranjeroFEEBuyer'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 fieldEmisor fieldNotes
nombreemisor.nombreLegal name
cedulaemisor.id
tipoCedulaemisor.tipoIdMapped: fisico01, juridico02, dimex03, nite04
nombreComercialemisor.nombreComercial
provincia / canton / distritosame namesHacienda location codes
senasemisor.senas
telefonoemisor.tel
correoemisor.email
codigoActividademisor.codigoActividadEmisorHacienda economic activity code
logoNameemisor.logoUrlResolved 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 receptor block is not merged from the tenant — receptors are buyers, not issuers. Use the standard FE/TE/NC patterns to provide it (or omitirReceptor: "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"]
}
i

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

MethodPathPurpose
POST/v1/invoicesIssue a new invoice (any type)
GET/v1/invoicesList invoices with filters (paginated)
GET/v1/invoices/:idFetch one invoice's public projection

Documents (PDF + XML)

MethodPathReturns
GET/v1/invoices/:id/pdfThe rendered PDF (binary, application/pdf)
GET/v1/invoices/:id/xmlThe signed FE/TE/NC/ND XML (application/xml)
GET/v1/invoices/:id/respuesta-xmlHacienda's signed MensajeHacienda response XML
POST/v1/invoices/:id/resend-emailRe-fire the customer email with the 3 docs attached

Counters

MethodPathPurpose
GET/v1/consecutivos/next?type=FE&sucursal=1Preview 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
ParamDescription
typeFE / TE / NC / ND / FEC / FEE — narrows by document type
estadopendiente / enviada / completa / rechazada / cancelada
receptorCedulaExact match on the receptor's cedula
qSubstring search on receptor's name
from / toDate range (ISO date) on the documento's fecha
kyrfactOnlytrue to exclude legacy non-KyrFact invoices
limit / offsetPagination (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

ParamRequiredDescription
fromyesInclusive start date in YYYY-MM-DD (UTC, start of day).
toyesInclusive end date in YYYY-MM-DD (UTC, end of day). Must be ≥ from.
estadonoDocument status filter — e.g. completa to include only Hacienda-accepted invoices. Typical values: completa, rechazada, enviada, pendiente, cancelada.
typenoComma-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 whose tipocambio ≠ 1 are 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

StatusWhen
400from or to missing or malformed · from > to · any type outside {FE,TE,NC,ND,FEC,FEE}.
401Missing Authorization header or invalid key.
403Key revoked or wrong env (e.g. kf_test_… against a live operation).
i

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.

TypeUse when…Receptor requiredReference required
FE
Factura Electrónica
Standard sale to a known buyer (B2B or B2C with cédula) Yes No
TE
Tiquete Electrónico
POS receipt to an unidentified consumer Optional No
NC
Nota de Crédito
Cancel or reduce a prior FE/TE Yes — must match the original Yes — the prior FE/TE clave
ND
Nota de Débito
Add a charge on top of a prior FE/TE Yes — must match the original Yes — the prior FE/TE clave
FEC
Factura Electrónica de Compra
Record a purchase from a supplier without electronic invoicing Yes — the supplier Yes — the paper doc
FEE
Factura 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
  • Adjusting a previous invoice?
    • Reducing or canceling → NC
    • Adding a charge → ND
  • 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

i

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

FieldCommon values
tipoDoc01 referenced doc is FE, 04 TE
numeroThe 50-character clave of the original FE/TE
codigo01 Anula documento de referencia, 02 Corrige texto, 04 Referencia a otro documento, 99 Otros
razonFree-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:

StatusMeaning
enviadaSubmitted; waiting for Hacienda's verdict.
completaHacienda accepted. PDF and response XML available, customer email sent (if configured).
rechazadaHacienda 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
i

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:

  1. Your app sends POST /v1/invoices.
  2. KyrFact issues the invoice, signs it, submits to Hacienda — and starts replying with a 200.
  3. The response packet drops on the way back to your app. Your code times out.
  4. 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

ExampleWhy
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.
i

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

  1. Emisor merge + required-field check. The payload's emisor block 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).
  2. Structural payload check. Receptor, references, header, and per-line — see the breakdown below.
  3. Math check. Per-line invariants and document totals (±0.01 rounding tolerance).

What gets checked

Header

FieldRule
fechaISO 8601 with offset (e.g. 2026-04-29T10:00:00-06:00).
condicionVentaHacienda code in 01..15 or 99. If 99, also requires condicionVentaOtros.
mediosPago[]Non-empty array; each item in 01..07 or 99.
codMonedaISO 4217 — 3 uppercase letters (CRC, USD, EUR…).
tipoCambioGreater than 0.
totalComprobanteGreater than 0 — Hacienda rejects zero-value invoices.

Emisor

FieldRule
tipoId01 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.
emailBasic email format check (when provided).

Receptor

Field / ruleDetail
Receptor block presenceRequired for FE / NC / ND / FEC / FEE. Only TE allows anonymous.
nombreNon-empty.
tipoIdIn 01..05. 05 is for foreign buyers.
Foreign receptor (tipoId=05)Requires identifExtranjero.
CR-resident receptorRequires provincia, canton, and distrito.
id (cedula)Format per tipoId (same as emisor).
emailBasic email format check (when provided).

Type-specific

TypeAdditional rules
TE / FEomitirReceptor: "true" allowed (anonymous). Other types reject this flag.
NCRequires informacionReferencia. numero must be a 50-digit Hacienda clave. codigo in 01..05 or 99.
NDSame as NC.
FECRequires 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 / ruleDetail
detalleNon-empty description text.
unidadMedidaNon-empty Hacienda code (Sp, Unid, kg, etc.).
codigoCABYS13 digits.
impuesto.1.codigo + codigoTarifaBoth required when an impuesto is present.
naturalezaDescuentoRequired when montoDescuento > 0.
Exoneration supportIf tarifaExoneracion > 0: required tipoDocumento, numeroDocumento, nombreInstitucion, fechaEmision.
Math invariantsmontoTotal = cantidad × precioUnitario; subTotal = montoTotal − montoDescuento; impuesto.monto = subTotal × tarifa/100; montoTotalLinea = subTotal + impuesto.monto.
tarifaIn {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.codigo and codigoTarifa enums — 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.

StatusWhen
400Validation error — totals math, missing required field, unsupported doc type, missing informacionReferencia on NC/ND/FEC.
401Missing or invalid Authorization header.
403Key revoked, or wrong env (e.g. kf_test_… against a live operation).
404Invoice not found, or belongs to a different tenant.
409Idempotency-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.