Retry e idempotencia
Esta es la página más importante para una integración confiable. Léela completa antes de poner código en producción.
Por qué importa
Cualquier integración HTTP real enfrenta, tarde o temprano, escenarios como:
- Tu petición llegó al servidor pero la respuesta se perdió por un timeout de red.
- El MH rechazó un documento por un email mal formado que viene del registro de un cliente.
- Tu worker se reinició a la mitad de un batch de emisión.
Si en cualquiera de estos casos reintentas sin cuidado, creas documentos duplicados con correlativos fiscales consumidos — un dolor de cabeza con Hacienda que no quieres.
El API Connect resuelve esto con un solo mecanismo: idempotencia por external_ref. Mismo external_ref → mismo documento. Siempre.
Cómo funciona external_ref
Cada DTE se identifica de forma única por la combinación (empresa, external_ref). Como tu API key está amarrada a una empresa, en la práctica es: external_ref único dentro del scope de tu key.
Cuando envías un POST de emisión con un external_ref específico, el API busca primero si ya existe un documento con esa referencia. Tres caminos posibles:
Camino 1 — external_ref nuevo
No existe documento previo. El API:
- Crea el documento en Ocote.
- Asigna el
control_numberfiscal (irreversible). - Firma y envía al MH.
- Devuelve el resultado con
is_duplicate: false.
Camino 2 — external_ref existe y fue exitoso
Hay un documento previo con success: true. El API:
- No crea nada nuevo.
- Devuelve el documento original con
is_duplicate: true. reception_stamp,control_number, URLs de archivos — todo es del documento original.
Este es el caso del retry por timeout de red: tu primera petición sí llegó, solo no viste la respuesta. Al reintentar con el mismo external_ref, el API te entrega el resultado que ya tenía.
Camino 3 — external_ref existe pero falló
Hay un documento previo con rejected: true o contingency: true. El API:
- Reutiliza el mismo registro.
- Conserva:
id,external_ref,control_number,posted,posted_date_time. - Actualiza: cliente, líneas, método de pago, condiciones, etc. — lo que venga en el payload nuevo.
- Vuelve a intentar el envío al MH.
- Devuelve el resultado con
is_duplicate: false.
Este es el caso del retry por rechazo: el MH te dijo que el email estaba mal, corriges el email, reenvías con el mismo external_ref. El correlativo fiscal se conserva, los datos se actualizan, se firma de nuevo.
Una vez que un documento tiene control_number, ese número se conserva pase lo que pase. En el Camino 3, aunque los datos cambien completamente, el control_number es fijo. Esto cumple la normativa MH de secuencias fiscales y simplifica tu conciliación contable.
Tres escenarios concretos
Escenario A: timeout de red
Tu cliente HTTP timeoutea esperando respuesta del API. No sabes si la emisión ocurrió o no. Reintenta con el mismo external_ref:
# Intento 1: timeout
POST /invoice
Body: { "external_ref": "VENTA-2026-0042", ... }
# (timeout local, no viste respuesta)
# Intento 2: mismo payload, mismo external_ref
POST /invoice
Body: { "external_ref": "VENTA-2026-0042", ... }
→ HTTP 200
is_duplicate: true
success: true
control_number: DTE-01-M001P001-000000000000123
reception_stamp: "20260421..."
Si el primer intento llegó y se emitió, recibes el resultado original marcado como duplicado. Si el primer intento nunca llegó, el segundo lo emite normalmente (is_duplicate: false). Tú no tienes que saber cuál fue.
Escenario B: rechazo por datos
El MH rechaza por un email malformado. Corriges el email y reenvías:
# Intento 1: email corrupto
POST /invoice
Body: {
"external_ref": "VENTA-2026-0042",
"customer": { "name": "X", "email": "mal@dominio" },
"lines": [...]
}
→ HTTP 200
success: false
rejected: true
control_number: DTE-01-M001P001-000000000000123
observaciones: ["Campo #/receptor/correo no cumple el formato requerido"]
# Corregir email, reintentar con mismo external_ref
POST /invoice
Body: {
"external_ref": "VENTA-2026-0042",
"customer": { "name": "X", "email": "bueno@gmail.com" },
"lines": [...]
}
→ HTTP 200
success: true
dte_success: true
is_duplicate: false
control_number: DTE-01-M001P001-000000000000123 ← MISMO CORRELATIVO
reception_stamp: "20260421..."
El documento quedó emitido con el correlativo original y los datos corregidos.
Escenario C: consulta después de éxito
Simplemente quieres revisar el estado de un documento que ya emitiste. No uses /invoice — usa GET /status/{external_ref}:
GET /status/VENTA-2026-0042
→ Trae el documento, flags, montos, URLs.
Consultar con /status no cuenta como reintento y no dispara ningún efecto en el MH. Es una lectura pura.
Qué puedes cambiar entre intentos
Cuando reintentas un documento en estado rejected o contingency, puedes modificar libremente:
- Datos del cliente (nombre, NIT, NRC, email, dirección, actividad económica, etc.).
- Líneas (descripción, cantidad, precio, descuentos).
- Método de pago, condición de transacción.
- Correo de redirección, WhatsApp.
- Campos opcionales en general.
Qué NO puedes cambiar entre intentos
Tres cosas están fijas después del primer POST exitoso en tomar correlativo:
external_ref— es el identificador que usa idempotencia. Cambiarlo crea un documento nuevo.doc_type— no puedes convertir una Factura (01) en CCF (03) a mitad de retry. Usa unexternal_refdistinto si cambia el tipo.control_number— es calculado por Ocote. Tú no lo envías; no puedes cambiarlo.
Si intentas cambiar doc_type con el mismo external_ref, el API responde con HTTP 400 y un error claro.
Casos especiales
Nota de Crédito (/credit-memo)
La NC referencia un CCF existente vía document_id en el payload. En retry de NC:
- El API conserva el CCF referenciado originalmente (ignora el
document_iddel payload nuevo). - Puedes corregir cualquier otro dato (cliente del CCF si aplica, líneas, motivos).
Si necesitas emitir una NC sobre otro CCF, usa un external_ref diferente.
Invalidación (/invalidate)
La invalidación es naturalmente idempotente por ruta:
- Si ya se invalidó con éxito, reintentar devuelve el mismo estado sin tocar nada (caso "Ya estaba invalidado").
- Si la invalidación previa falló (
contingencyorejected), reintentar la reintenta en MH.
No necesitas enviar external_ref a /invalidate — la ruta usa el id_or_ref del documento a invalidar.
Patrón recomendado para tu código
def emit_invoice(sale):
"""
Emite una factura de forma idempotente.
Llamar múltiples veces con el mismo sale.id es seguro.
"""
payload = {
"external_ref": f"SALE-{sale.id}",
"doc_type": "01",
"lines": [...],
# ...
}
for attempt in range(3):
try:
r = requests.post(
"https://ocote.io/api/connect/invoice",
headers={"Authorization": f"Bearer {API_KEY}"},
json=payload,
timeout=30,
)
except requests.Timeout:
time.sleep(2 ** attempt)
continue # reintentar, el external_ref nos cubre
if r.status_code == 429:
time.sleep(int(r.headers["Retry-After"]))
continue
if r.status_code == 200:
data = r.json()
if data["success"]:
return data
if data["rejected"]:
# Error de datos - NO reintentar automaticamente.
# Devolver para que el usuario corrija.
raise RejectedByMH(data["observaciones"])
raise UpstreamError(r.status_code, r.text)
raise MaxRetriesExceeded()
Tres principios en ese código:
- El retry por timeout o 429 es seguro porque
external_refgarantiza no duplicar. - El rechazo por datos (
rejected) NO se reintenta automáticamente — necesita intervención humana para corregir. - El
external_refes determinista (derivado delsale.id): si el proceso muere y reinicia, recalcula el mismo ref y retoma donde quedó.
Diagnóstico desde /status
Si pierdes el estado de un documento (crash, base de datos corrupta, worker que murió), puedes siempre recuperar el estado real desde el API:
GET /status/SALE-42
La respuesta te dice si el documento existe, en qué estado está, y te da de vuelta el control_number y las URLs de archivos. No necesitas persistir nada más que tu external_ref para poder reconstruir.
Ver también
- Respuestas y estados — flags completos del response.
- Contingencia — qué hacer cuando
contingency: true. - Rate limits — cuándo esperar y cómo respetar
Retry-After.