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.

Respuesta corta: para emitir una factura electrónica con Python hacés un
POSTconrequestsahttps://api.facturaencr.com/v2/efactura/documents/factura, con las cabecerasX-API-Key,X-API-SecreteIdempotency-Key. La API responde202en 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 firmar | Tu certificado .p12 subido, o el emisor de pruebas EMISORPRUEBA |
Python 3 y requests | pip 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 sinimpuestoo conimpuesto: []la rechaza Hacienda con-1y 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 conGET /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 HTTP | Qué hacer |
|---|---|
400 | Corregí el payload. No consumió consecutivo |
401 / 403 | Revisá cabeceras y scopes de la llave |
409 idempotency_in_progress | Ya hay una emisión en curso con esa clave: esperá retryAfterSeconds y repetí con la misma clave |
409 idempotency_conflict | Reusaste la clave con otro cuerpo |
422 | Regla fiscal incumplible, por ejemplo un certificado vencido |
429 | Respetá Retry-After y reintentá con la misma clave |
500 / 503 | Reintentá 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.
