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 16% IVA traslado on top when stamping. What you send is the taxable base; the CFDI totals above it.

subtotal = quantity × unit_price     (rounded to 2 decimals per line)
iva      = subtotal × 0.16
total    = subtotal + iva            ← the CFDI total

unit_price: 15000, quantity: 1  →  subtotal 15000.00 + IVA 2400.00 = total 17400.00

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

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",
    "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.
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).
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 } 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. 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.