micfdi · developer docs

Ingestion API — v1

Push each payment your system collects; micfdi turns it into a record plus a public portal link where your client fills their own fiscal data and self-stamps their CFDI 4.0. Poll the record to learn when the invoice exists.

Overview

Productionhttps://micfdi-api-prod.tbrneto.workers.devkeys: mk_live_…
Staginghttps://micfdi-api-stg.tbrneto.workers.devkeys: mk_test_…

The flow: 1) your system POSTs a payment → 2) you deliver the returned portal_url to your client through your own channel → 3) the client claims the record with their RFC and stamps → 4) you poll and reconcile the stamped CFDI. Records arrive without receptor data by design — the client owns their fiscal identity.

Authentication

Every request carries a bearer key issued by the platform admin. Keys are environment-scoped: a worker refuses a key minted for the other environment. The secret is shown once at issuance; revocation is immediate.

Authorization: Bearer mk_live_…   # production
Authorization: Bearer mk_test_…   # staging

401 = missing/unknown/wrong-environment key · 403 key_revoked. Store the key as a secret; rotate by revoking in the admin panel and minting a new one.

Amounts

⚠ unit_price EXCLUDES IVA

The platform adds the IVA traslado on top when stamping, at the record's tasa (iva: 16%, 8%, 0% or exento — see the next section). What you send is the taxable base; the CFDI totals above it.

subtotal = quantity × unit_price     (rounded to 2 decimals per line)
iva      = subtotal × rate           (0.16 · 0.08 · 0 · 0 for exento)
total    = subtotal + iva            ← the CFDI total

unit_price: 15000, quantity: 1, iva: "16"     →  subtotal 15000.00 + IVA 2400.00 = total 17400.00
unit_price: 15000, quantity: 1, iva: "8"      →  subtotal 15000.00 + IVA 1200.00 = total 16200.00
unit_price: 15000, quantity: 1, iva: "exento" →  subtotal 15000.00 + IVA    0.00 = total 15000.00

If your system records the amount actually charged with IVA included, divide by 1 + rate before POSTing: a $17,400.00 charge at 16% is unit_price: 15000.00, and so is a $16,200.00 charge at 8%. The invoice object returned by this API carries the stamped CFDI's subtotal, iva, total, iva_rate and expedition_zip — reconcile against those, never against your own math.

Tasa de IVA and lugar de expedición

Every record carries an effective tasa de IVA (iva: "16" · "8" · "0" · "exento") and, optionally, a lugar de expedición (expedition_zip, the 5-digit CP the CFDI is issued from — LugarExpedicion). When you are silent, both resolve from the emisor's settings (the tenant configures them in Ajustes › Facturación) and the response names every default it applied:

FieldRequiredDescription
iva omittedcreateThe emisor’s default tasa, else "16".
expedition_zip omittedcreateThe emisor’s CP for that tasa → its CP for 16 → none (the CFDI carries the emisor’s fiscal address).
iva: nullre-sendRe-resolve from the emisor. A rate change with expedition_zip omitted re-derives the CP for the new rate — the CP follows the rate.
expedition_zip: nullre-sendRe-resolve for the current tasa.
// 201 — a silent payload on an emisor configured 16 → 06700, 8 → 22010
{
  "result": "created",
  "record": { "…": "…", "iva": "16", "expedition_zip": "06700" },
  "portal_url": "https://…",
  "warnings": [
    { "path": "iva", "code": "iva_defaulted", "message": "iva defaulted to \"16\" (the issuer default)" },
    { "path": "expedition_zip", "code": "expedition_zip_defaulted", "message": "expedition_zip defaulted to \"06700\" (the issuer CP for this rate)" }
  ]
}
  • warnings is additive and only present when something defaulted — send both fields and it never appears. Codes: iva_defaulted · expedition_zip_defaulted.
  • 8% is the estímulo región fronteriza: the PAC accepts it only from a CP with the border mark, for an RFC registered in the padrón, on eligible product keys. micfdi does not pre-validate those; it only refuses iva: "8" when neither you nor the emisor provide a CP for 8 (422 validation_failed, field expedition_zip). A PAC rejection burns no timbre.
  • Once the record is invoiced both fields are frozen — a differing re-send is the usual 409.

Match fields (self-service search)

When your tenant has a claim configuration (ask us to set one up), every record must carry the configured ownership fields inside match_fields. Your clients then find their payments — without the portal link — at your public search page (factura.micfdi.mx/f/<your-slug> or <your-slug>.micfdi.mx) by typing exactly those values.

"match_fields": {
  "policy_number": "POL-00123",     // string — trimmed, UPPERCASED before matching
  "contract_start": "2026-06-01",   // date   — strict ISO yyyy-mm-dd
  "total": "10,440.00"              // amount — "$", "MXN", commas stripped; stored "10440.00"
}

Values are normalized and echoed back on the record as match_fields — what you see there is exactly what your client's search input must normalize to. Send the values as printed on the client's receipt/contract (an amount field is your stated figure, not something we derive from unit_price).

While the configuration is enabled, missing or invalid fields fail with 422 and per-field paths like match_fields.policy_number. Unknown keys are rejected too — a typo never silently stores an unsearchable record. Without a configuration, sending match_fields is also a 422.

On re-sends the fields converge like any other key (see Create or update): different values update the record, an omitted match_fields keeps the stored ones, and null clears them — refused while the configuration is enabled.

Create or update a record

POST/v1/records

One endpoint, convergent by external_ref: POST the payment as your system knows it, as often as you like. A new ref creates the record; an existing one makes the record converge onto your payload — your system is authoritative, so a re-send with different facts edits the record in place (field-level audit trail on our side). The response's result tells you which happened.

curl -X POST https://micfdi-api-prod.tbrneto.workers.dev/v1/records \
  -H "Authorization: Bearer mk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "pay-8841",
    "description": "Póliza jurídica junio",
    "payment_form": "03",
    "unit_price": "15000.00",
    "iva": "16",
    "client": { "name": "Ernesto Huerta", "email": "cliente@ejemplo.mx" }
  }'
FieldRequiredDescription
external_refrequiredYour payment id, unique per tenant. The idempotency key for this endpoint.
descriptionrequiredConcept text printed on the CFDI line.
payment_formrequiredSAT c_FormaPago (2 digits, e.g. "03" = transferencia).
unit_pricerequiredPrice without IVA (see Amounts). String or number, max 2 decimals, never rounded by the API.
quantityoptionalDefaults to 1 on create; omitted on a re-send = stored value unchanged. null reads as omitted.
product_keyoptionalSAT product/service key; falls back to the issuer default.
unit_keyoptionalSAT unit key; falls back to the issuer default.
clientoptionalDisplay hints { name, email } — NOT fiscal data; the client provides that at the portal.
ivaoptionalTasa de IVA: "16" · "8" · "0" · "exento". Omitted on create = the emisor's default; null on a re-send = re-resolve. See Tasa de IVA.
expedition_zipoptional5-digit CP the CFDI is issued from (LugarExpedicion). Omitted = the emisor's CP for that tasa; null on a re-send = re-resolve. See Tasa de IVA.
match_fieldsconditionalOwnership values for the self-service search — required while your claim configuration is enabled (see Match fields).
issuer_idoptionalRequired only if the tenant has more than one active issuer.

Merge semantics on re-sends: an omitted optional key leaves the stored value unchanged; an explicit null clears it ("client": null, "match_fields": null — the latter refused while your claim configuration is enabled). external_ref and issuer_id never change; neither does anything the client owns (their fiscal identity, use).

201 result: "created" — new record: { result, record, portal_url }. Send portal_url to your client; it stays valid as the permanent document page after stamping.

200 result: "unchanged" — the payload matches the stored record; nothing written. On an invoiced record the invoice object rides along.

200 result: "updated" — the record converged onto your payload. A validated record drops back to registered (the tenant re-validates before stamping).

409 record_already_invoiced — your facts differ but the CFDI already exists and cannot change. The body carries the record; reconcile against it, or use a new external_ref for a genuinely new payment.

409 record_rejected — the tenant discarded this ref; every re-send is refused (identical or not) until an operator restores it in the dashboard. The body carries the record.

409 record_conflict — the record changed concurrently mid-request; retry.

422 issuer_immutable (issuer_id differs from the record's issuer) · validation_failed (per-field errors) · no_active_issuer · issuer_required · issuer_not_found · issuer_inactive.

payment_method is fixed to PUE for now (PPD + Complemento de Pago is on the roadmap).

Fetch one record

GET/v1/records/:id

Returns the record, including invoice once your client self-stamped. Poll this (or the list) to reconcile — outbound webhooks are a later slice. 404 if the id is not yours.

curl https://micfdi-api-prod.tbrneto.workers.dev/v1/records/018f… -H "Authorization: Bearer mk_live_…"

{
  "record": {
    "id": "018f…", "external_ref": "pay-8841", "status": "invoiced", "claimed": true,
    …
    "invoice": { "uuid": "18604FDA-…", "folio": "106", "subtotal": 15000, "iva": 2400, "total": 17400 }
  }
}

List records

GET/v1/records?status=&limit=&cursor=

Newest first. status ∈ registered | validated | invoiced | rejected; limit ≤ 100 (default 20); cursor = the previous response's next_cursor (opaque). next_cursor: null = last page.

curl "https://micfdi-api-prod.tbrneto.workers.dev/v1/records?status=invoiced&limit=50" \
  -H "Authorization: Bearer mk_live_…"

The record object

FieldRequiredDescription
idUUID, stable.
external_refYour payment id.
statusregistered → validated (client claimed) → invoiced (stamped) · rejected.
claimedtrue once the client attached their fiscal identity.
description · product_key · unit_keyThe CFDI line.
quantity · unit_priceunit_price excludes IVA (see Amounts).
iva · expedition_zipThe effective tasa de IVA and lugar de expedición the CFDI will carry (expedition_zip null = the emisor’s fiscal address).
payment_form · payment_methodSAT forma de pago · PUE.
useThe client's uso CFDI, set at claim time; null before.
client{ name, email } hints or null.
match_fieldsThe NORMALIZED ownership values (see Match fields); null when not configured.
portal_urlThe public link; null once revoked by an operator.
created_atISO-8601 UTC.
invoice{ uuid, folio, subtotal, iva, total, iva_rate, expedition_zip } once stamped; null before. Figures are the legal CFDI’s.

Errors

{
  "error": {
    "code": "validation_failed",
    "message": "invalid fields",
    "fields": [
      { "path": "payment_form", "message": "is not a SAT c_FormaPago code (e.g. \"03\" transferencia)" },
      { "path": "match_fields.contract_start", "message": "must be an ISO date (yyyy-mm-dd)" }
    ]
  }
}

⚠ Log error.fields

fields is an array of { path, message } naming exactly what failed. Persist it in your integration logs — a bare "422 validation_failed" without the fields is undebuggable on your side and ours.

FieldRequiredDescription
400 invalid_jsonThe body is not valid JSON.
401 unauthorizedMissing, malformed, unknown, or wrong-environment bearer key.
403 key_revokedThe key was revoked in the admin panel.
404 not_foundNo such record under your tenant.
409 record_already_invoicedThe facts differ but the CFDI exists and cannot change. Body carries the record.
409 record_rejectedThe tenant discarded this external_ref; a dashboard restore is the only recovery. Body carries the record.
409 record_conflictThe record changed concurrently mid-request. Retry.
422 validation_failed …Per-field errors in fields — including iva outside the enum, expedition_zip not a 5-digit CP, and expedition_zip "the issuer has no CP de expedición for 8%". Also: issuer_immutable · no_active_issuer · issuer_required · issuer_not_found · issuer_inactive.
500 internalOur fault; already reported on our side. Retry with backoff.

Codes are stable and safe to branch on; messages are human-oriented and may change.

Integration checklist

  1. Get an API key from the platform admin; store it as a secret.
  2. On each payment (and on each correction): POST /v1/records with your payment id as external_ref and the IVA-exclusive unit_price. Re-send freely — the server decides create vs update, and result tells you which happened.
  3. Treat 409 record_rejected as terminal for that ref: surface it to your operations team — re-sending will not fix it (the tenant must restore the record).
  4. On any 4xx, log the full error body — especially error.fields (see Errors).
  5. Self-service search enabled? Include match_fields with the values as printed on the client's receipt (see Match fields).
  6. Deliver portal_url to your client through your own channel (WhatsApp, email, your app).
  7. Poll GET /v1/records?status=invoiced (or by id) and reconcile against invoice.total.
  8. Key compromised? Revoke in the admin panel, mint a new one, redeploy your secret.