Blog
Desarrolladores·17 de octubre de 2026 · 9 min

Factura electrónica con Python: cómo emitir por API en Costa Rica

Con Python emitís una factura electrónica v4.4 con un POST y requests: dos cabeceras, Idempotency-Key y el veredicto de Hacienda por webhook o consulta.

Factura electrónica con Python: cómo emitir por API en Costa Rica

Respuesta corta: para emitir una factura electrónica con Python hacés un POST con requests a https://api.facturaencr.com/v2/efactura/documents/factura, con las cabeceras X-API-Key, X-API-Secret e Idempotency-Key. La API responde 202 en 1 a 3 segundos con la clave de 50 dígitos y el consecutivo; el veredicto de Hacienda llega de 5 a 60 segundos después, por webhook o consultando el documento.

No necesitás generar el XML v4.4, firmarlo con XAdES ni hablar con TRIBU-CR: mandás JSON y la plataforma hace el resto. Esta guía cubre el ciclo completo en Python: autenticar, emitir, manejar errores, esperar el veredicto y verificar el webhook.

Lo que necesitás antes de escribir código

QuéDe dónde sale
Una llave de API (X-API-Key + X-API-Secret)Se crea en el portal. El secret se muestra una sola vez
Un emisor para firmarTu certificado .p12 subido, o el emisor de pruebas EMISORPRUEBA
Python 3 y requestspip install requests

El ambiente (sandbox o producción) no se elige en la URL: lo define la llave que usás. La misma petición con otra llave emite de verdad, así que guardá las credenciales en variables de entorno y nunca en el repositorio.

Para empezar sin certificado propio, poné "emisorLegalId": "EMISORPRUEBA": es un emisor de la plataforma que ya viene habilitado en tu cuenta y emite contra el sandbox real de Hacienda. Más detalle en Sandbox de Hacienda: probar sin emitir de verdad.

Paso 1: una sesión con las credenciales

import os
import time
import requests

BASE = "https://api.facturaencr.com/v2/efactura"

api = requests.Session()
api.headers.update({
    "X-API-Key": os.environ["EFACTURA_KEY"],
    "X-API-Secret": os.environ["EFACTURA_SECRET"],
})

# Verificá que la llave responde y en qué ambiente está
info = api.post(BASE + "/auth/verify", timeout=15).json()
print(info["apiKey"]["environment"])   # "sandbox" o "production"

No se usa Authorization: Bearer. Si falta cualquiera de las dos cabeceras, la respuesta es 401.

Paso 2: emitir la factura

def emitir_factura(pedido_id: str) -> dict:
    payload = {
        "emisorLegalId": "EMISORPRUEBA",      # en producción, la cédula del comercio
        "condicionVenta": "01",               # contado
        "medioPago": ["01"],                  # efectivo
        "receptor": {
            "tipoIdentificacion": "01",
            "numeroIdentificacion": "100000002",
            "nombre": "Juan Ejemplo",
            "correoElectronico": "juan@ejemplo.cr",
        },
        "detalle": [{
            "codigoCabys": "8999999999013",   # ficticio: buscá el real en /catalogs/cabys
            "cantidad": 1,
            "unidadMedida": "Sp",
            "detalle": "Honorarios contables",
            "precioUnitario": 85000,          # SIN IVA
            "impuesto": [{"codigo": "01", "codigoTarifa": "08", "tarifa": 13}],
        }],
    }
    r = api.post(
        BASE + "/documents/factura",
        json=payload,
        headers={"Idempotency-Key": "pedido-" + pedido_id},
        timeout=30,
    )
    if r.status_code == 202:
        return r.json()   # documentId, clave, consecutivo, status: "queued"
    raise ErrorDeEmision(r)

Tres reglas que evitan la mayoría de los tropiezos:

  • Toda línea lleva IVA, aunque sea exenta ("codigoTarifa": "10", "tarifa": 0). Una línea sin impuesto o con impuesto: [] la rechaza Hacienda con -1 y el consecutivo se pierde.
  • No mandés los totales del resumen. La plataforma los calcula desde las líneas; si los enviás, se ignoran.
  • El CABYS de los ejemplos es ficticio. Copiado tal cual, Hacienda lo rechaza con -400. Buscá el de cada producto con GET /catalogs/cabys.

Paso 3: entender los errores antes de que lleguen

Todos los errores tienen la misma forma: un error estable, un message para humanos y un requestId. Programá contra error, nunca contra el texto.

class ErrorDeEmision(Exception):
    def __init__(self, r):
        self.status = r.status_code
        cuerpo = r.json() if r.content else {}
        self.codigo = cuerpo.get("error")
        self.detalles = cuerpo.get("details", [])   # puede no venir
        self.request_id = cuerpo.get("requestId")
        super().__init__(self.status, self.codigo, cuerpo.get("message"))
Código HTTPQué hacer
400Corregí el payload. No consumió consecutivo
401 / 403Revisá cabeceras y scopes de la llave
409 idempotency_in_progressYa hay una emisión en curso con esa clave: esperá retryAfterSeconds y repetí con la misma clave
409 idempotency_conflictReusaste la clave con otro cuerpo
422Regla fiscal incumplible, por ejemplo un certificado vencido
429Respetá Retry-After y reintentá con la misma clave
500 / 503Reintentá con backoff, con la misma clave

Las validaciones de esquema devuelven validation_error con details; las reglas de negocio traen un código propio en mayúscula y sin details. Tu manejador tiene que aceptar las dos formas.

Paso 4: esperar el veredicto

El 202 significa «lo recibimos y lo firmamos», no «Hacienda lo aceptó». Solo accepted y rejected son estados finales. Si todavía no tenés webhooks, consultá con backoff y nunca en un bucle cerrado:

ESPERAS = [10, 20, 40, 60] + [300] * 6   # +10 s, +20 s, +40 s, +60 s y cada 5 min hasta ~30 min

def esperar_veredicto(document_id: str) -> dict:
    for espera in ESPERAS:
        time.sleep(espera)
        doc = api.get(BASE + "/documents/" + document_id, timeout=15).json()
        if doc["status"] in ("accepted", "rejected"):
            return doc
    # Pasados 30 minutos sin veredicto, forzá una reconsulta a Hacienda
    api.post(BASE + "/documents/" + document_id + "/refresh", timeout=15)
    return api.get(BASE + "/documents/" + document_id, timeout=15).json()

Si el resultado es rejected, haciendaMessage trae los códigos de Hacienda y el objeto rechazo los explica. Un rechazado no se corrige: se emite uno nuevo que lo sustituye. Lo contamos en qué hacer si Hacienda rechaza una factura.

Paso 5: el webhook, que es lo recomendado

Registrás una URL HTTPS con POST /webhooks y te avisamos con document.accepted o document.rejected. Cada entrega viene firmada con HMAC-SHA256 en la cabecera X-Efactura-Signature (t=…,v1=…), calculada sobre t + . + el cuerpo crudo. Un ejemplo con Flask:

import hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["EFACTURA_WEBHOOK_SECRET"]

def firma_valida(cuerpo: bytes, cabecera: str, tolerancia: int = 300) -> bool:
    partes = dict(kv.strip().split("=", 1) for kv in cabecera.split(","))
    t = int(partes["t"])
    if abs(time.time() - t) > tolerancia:          # anti-replay
        return False
    esperado = hmac.new(SECRET.encode(), str(t).encode() + b"." + cuerpo,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperado, partes["v1"])

@app.post("/hooks/efactura")
def hook():
    cuerpo = request.get_data()                     # crudo, antes de parsear
    if not firma_valida(cuerpo, request.headers.get("X-Efactura-Signature", "")):
        abort(401)
    encolar(request.get_json())                     # procesá después
    return "", 200                                  # respondé ya

Respondé 2xx de inmediato y procesá en segundo plano; descartá repetidos con X-Efactura-Delivery-Id o con la clave, porque una entrega puede llegar más de una vez. La comparación entre los dos modelos está en webhooks vs polling.

Qué guardar de cada emisión

Guardá documentId, clave y consecutivo junto a tu pedido. Con el documento aceptado podés bajar el XML firmado (GET /documents/{id}/xml), la respuesta de Hacienda (/xml-respuesta) y el PDF (/pdf). El XML es el comprobante legal; el PDF es una representación de cortesía. Con el emisor de pruebas compartido la descarga del XML no está disponible (403 shared_test_emisor_no_download); con tu propio certificado, sí.

Preguntas frecuentes

¿Hay una librería oficial de Python?

No hace falta: la API es REST con JSON y requests alcanza. La especificación OpenAPI está publicada en GET /docs/openapi.yaml por si querés generar un cliente.

¿Puedo probar sin tener la llave criptográfica de Hacienda?

Sí. Usá EMISORPRUEBA como emisorLegalId: emite contra el sandbox real de Hacienda sin subir nada.

¿Cómo uso un CABYS correcto?

Buscalo con GET /catalogs/cabys y guardalo en tu maestro de productos. Un CABYS inexistente es el rechazo -400.

¿Qué pasa si mi script se cae justo después de enviar?

Reintentá con la misma Idempotency-Key. Si la factura ya se emitió, recibís la respuesta original y no se emite otra. Ver idempotencia.

Con la API de factura electrónica de Facturaencr tu código en Python habla JSON y nosotros armamos el XML v4.4, lo firmamos y lo mandamos a Hacienda. El sandbox es gratis, con 100 comprobantes al mes, y la documentación completa trae todos los catálogos. Si venís de cero, empezá por tu primera factura por API.

Empezá a emitir con la API

Sandbox gratis. Tus primeros 100 documentos sin costo. Pagás solo por lo que emitís.