Skip to main content

Retry and idempotency

This is the single most important page for a reliable integration. Read it fully before putting code in production.

Why it matters

Any real HTTP integration eventually hits scenarios like these:

  • Your request reached the server but the response was lost to a network timeout.
  • MH rejected a document because a customer record has a malformed email.
  • Your worker restarted halfway through a batch of issuances.

If you retry any of these without care, you create duplicate documents with consumed fiscal correlatives — a painful problem with Hacienda that you do not want.

API Connect solves this with a single mechanism: idempotency by external_ref. Same external_ref → same document. Always.

How external_ref works

Each DTE is uniquely identified by the pair (company, external_ref). Since your API key is bound to a company, in practice it's: external_ref unique within the scope of your key.

When you POST an issuance request with a specific external_ref, the API first checks whether a document already exists for that reference. Three possible paths:

Path 1 — new external_ref

No prior document exists. The API:

  1. Creates the document in Ocote.
  2. Assigns the fiscal control_number (irreversible).
  3. Signs and sends to MH.
  4. Returns the result with is_duplicate: false.

Path 2 — external_ref exists and was successful

A previous document exists with success: true. The API:

  1. Creates nothing new.
  2. Returns the original document with is_duplicate: true.
  3. reception_stamp, control_number, file URLs — all come from the original document.

This is the network-timeout retry case: your first request actually arrived, you just didn't see the response. Retrying with the same external_ref gives you the result the API already has.

Path 3 — external_ref exists but failed

A previous document exists with rejected: true or contingency: true. The API:

  1. Reuses the same record.
  2. Preserves: id, external_ref, control_number, posted, posted_date_time.
  3. Updates: customer, lines, payment method, conditions, etc. — whatever comes in the new payload.
  4. Re-sends to MH.
  5. Returns the result with is_duplicate: false.

This is the rejection retry case: MH told you the email was invalid, you correct the email, resend with the same external_ref. The fiscal correlative is preserved, data is updated, and the document is re-signed.

The fiscal correlative is never regenerated

Once a document has a control_number, that number sticks no matter what happens. In Path 3, even if the data changes completely, the control_number is fixed. This complies with MH's fiscal-sequence rules and simplifies your accounting reconciliation.

Three concrete scenarios

Scenario A: network timeout

Your HTTP client timed out waiting for the API's response. You don't know if the issuance happened or not. Retry with the same external_ref:

# Attempt 1: timeout
POST /invoice
Body: { "external_ref": "SALE-2026-0042", ... }
# (local timeout, no response seen)

# Attempt 2: same payload, same external_ref
POST /invoice
Body: { "external_ref": "SALE-2026-0042", ... }

→ HTTP 200
is_duplicate: true
success: true
control_number: DTE-01-M001P001-000000000000123
reception_stamp: "20260421..."

If the first attempt did arrive and was issued, you get the original result marked as duplicate. If the first attempt never arrived, the second one issues normally (is_duplicate: false). You do not have to know which was the case.

Scenario B: rejection by data

MH rejects due to a malformed email. You correct the email and resend:

# Attempt 1: corrupted email
POST /invoice
Body: {
"external_ref": "SALE-2026-0042",
"customer": { "name": "X", "email": "bad@domain" },
"lines": [...]
}

→ HTTP 200
success: false
rejected: true
control_number: DTE-01-M001P001-000000000000123
observaciones: ["Campo #/receptor/correo no cumple el formato requerido"]

# Fix email, retry with same external_ref
POST /invoice
Body: {
"external_ref": "SALE-2026-0042",
"customer": { "name": "X", "email": "good@gmail.com" },
"lines": [...]
}

→ HTTP 200
success: true
dte_success: true
is_duplicate: false
control_number: DTE-01-M001P001-000000000000123 ← SAME CORRELATIVE
reception_stamp: "20260421..."

The document is issued with the original correlative and corrected data.

Scenario C: query after success

You just want to check a document you already issued. Don't use /invoice — use GET /status/{external_ref}:

GET /status/SALE-2026-0042
→ Returns document, flags, amounts, URLs.

Querying /status does not count as a retry and has no side effects on MH. It's a pure read.

What you can change between attempts

When retrying a document in rejected or contingency state, you can freely modify:

  • Customer data (name, NIT, NRC, email, address, economic activity, etc.).
  • Lines (description, quantity, price, discounts).
  • Payment method, transaction condition.
  • Redirect email, WhatsApp number.
  • Optional fields in general.

What you CANNOT change between attempts

Three things are fixed once the first POST took a correlative:

  • external_ref — it's the identifier used for idempotency. Changing it creates a new document.
  • doc_type — you cannot convert an Invoice (01) into a CCF (03) mid-retry. Use a different external_ref if the type changes.
  • control_number — computed by Ocote. You do not send it; you cannot change it.

Attempting to change doc_type with the same external_ref returns HTTP 400 with a clear error.

Special cases

Credit Memo (/credit-memo)

The credit memo references an existing CCF via document_id in the payload. When retrying a credit memo:

  • The API preserves the originally referenced CCF (ignores the document_id of the new payload).
  • You can correct any other data (customer, lines, reasons).

If you need to issue a credit memo against a different CCF, use a different external_ref.

Invalidation (/invalidate)

Invalidation is naturally idempotent by route:

  • If already invalidated successfully, retrying returns the same state without changes (the "Already invalidated" case).
  • If the previous invalidation failed (contingency or rejected), retrying attempts it again with MH.

You don't send external_ref to /invalidate — the route uses the id_or_ref of the document to invalidate.

def emit_invoice(sale):
"""
Emit an invoice idempotently.
Calling this multiple times with the same sale.id is safe.
"""
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 # retry — external_ref has us covered

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"]:
# Data error — do NOT retry automatically.
# Return it so the user can fix it.
raise RejectedByMH(data["observaciones"])

raise UpstreamError(r.status_code, r.text)

raise MaxRetriesExceeded()

Three principles in that code:

  • Retrying on timeout or 429 is safe because external_ref guarantees no duplication.
  • Rejection by data (rejected) is NOT retried automatically — it needs human intervention to correct.
  • The external_ref is deterministic (derived from sale.id): if the process dies and restarts, it recomputes the same ref and resumes where it was.

Diagnosing from /status

If you lose a document's state (crash, corrupt DB, dead worker), you can always recover the real state from the API:

GET /status/SALE-42

The response tells you if the document exists, its current state, and gives you back the control_number and file URLs. You need to persist nothing more than your external_ref to be able to reconstruct.

See also