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:
- Creates the document in Ocote.
- Assigns the fiscal
control_number(irreversible). - Signs and sends to MH.
- Returns the result with
is_duplicate: false.
Path 2 — external_ref exists and was successful
A previous document exists with success: true. The API:
- Creates nothing new.
- Returns the original document with
is_duplicate: true. 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:
- Reuses the same record.
- Preserves:
id,external_ref,control_number,posted,posted_date_time. - Updates: customer, lines, payment method, conditions, etc. — whatever comes in the new payload.
- Re-sends to MH.
- 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.
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 differentexternal_refif 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_idof 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 (
contingencyorrejected), 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.
Recommended pattern for your code
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_refguarantees no duplication. - Rejection by data (
rejected) is NOT retried automatically — it needs human intervention to correct. - The
external_refis deterministic (derived fromsale.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
- Responses and states — full response flags.
- Contingency — what to do when
contingency: true. - Rate limits — when to wait and how to respect
Retry-After.