Saltar al contenido principal

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:

  1. Crea el documento en Ocote.
  2. Asigna el control_number fiscal (irreversible).
  3. Firma y envía al MH.
  4. Devuelve el resultado con is_duplicate: false.

Camino 2 — external_ref existe y fue exitoso

Hay un documento previo con success: true. El API:

  1. No crea nada nuevo.
  2. Devuelve el documento original con is_duplicate: true.
  3. 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:

  1. Reutiliza el mismo registro.
  2. Conserva: id, external_ref, control_number, posted, posted_date_time.
  3. Actualiza: cliente, líneas, método de pago, condiciones, etc. — lo que venga en el payload nuevo.
  4. Vuelve a intentar el envío al MH.
  5. 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.

El correlativo fiscal jamás se regenera

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 un external_ref distinto 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_id del 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ó (contingency o rejected), 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_ref garantiza no duplicar.
  • El rechazo por datos (rejected) NO se reintenta automáticamente — necesita intervención humana para corregir.
  • El external_ref es determinista (derivado del sale.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