micfdi · developer docs
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.
| Production | https://micfdi-api-prod.tbrneto.workers.dev | keys: mk_live_… |
| Staging | https://micfdi-api-stg.tbrneto.workers.dev | keys: 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.
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_… # staging401 = 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.
⚠ 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.00If 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.
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:
| Field | Required | Description |
|---|---|---|
| iva omitted | create | The emisor’s default tasa, else "16". |
| expedition_zip omitted | create | The emisor’s CP for that tasa → its CP for 16 → none (the CFDI carries the emisor’s fiscal address). |
| iva: null | re-send | Re-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: null | re-send | Re-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.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.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.
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" }
}'| Field | Required | Description |
|---|---|---|
| external_ref | required | Your payment id, unique per tenant. The idempotency key for this endpoint. |
| description | required | Concept text printed on the CFDI line. |
| payment_form | required | SAT c_FormaPago (2 digits, e.g. "03" = transferencia). |
| unit_price | required | Price without IVA (see Amounts). String or number, max 2 decimals, never rounded by the API. |
| quantity | optional | Defaults to 1 on create; omitted on a re-send = stored value unchanged. null reads as omitted. |
| product_key | optional | SAT product/service key; falls back to the issuer default. |
| unit_key | optional | SAT unit key; falls back to the issuer default. |
| client | optional | Display hints { name, email } — NOT fiscal data; the client provides that at the portal. |
| iva | optional | Tasa 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_zip | optional | 5-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_fields | conditional | Ownership values for the self-service search — required while your claim configuration is enabled (see Match fields). |
| issuer_id | optional | Required 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).
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 }
}
}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_…"| Field | Required | Description |
|---|---|---|
| id | UUID, stable. | |
| external_ref | Your payment id. | |
| status | registered → validated (client claimed) → invoiced (stamped) · rejected. | |
| claimed | true once the client attached their fiscal identity. | |
| description · product_key · unit_key | The CFDI line. | |
| quantity · unit_price | unit_price excludes IVA (see Amounts). | |
| iva · expedition_zip | The effective tasa de IVA and lugar de expedición the CFDI will carry (expedition_zip null = the emisor’s fiscal address). | |
| payment_form · payment_method | SAT forma de pago · PUE. | |
| use | The client's uso CFDI, set at claim time; null before. | |
| client | { name, email } hints or null. | |
| match_fields | The NORMALIZED ownership values (see Match fields); null when not configured. | |
| portal_url | The public link; null once revoked by an operator. | |
| created_at | ISO-8601 UTC. | |
| invoice | { uuid, folio, subtotal, iva, total, iva_rate, expedition_zip } once stamped; null before. Figures are the legal CFDI’s. |
{
"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.
| Field | Required | Description |
|---|---|---|
| 400 invalid_json | The body is not valid JSON. | |
| 401 unauthorized | Missing, malformed, unknown, or wrong-environment bearer key. | |
| 403 key_revoked | The key was revoked in the admin panel. | |
| 404 not_found | No such record under your tenant. | |
| 409 record_already_invoiced | The facts differ but the CFDI exists and cannot change. Body carries the record. | |
| 409 record_rejected | The tenant discarded this external_ref; a dashboard restore is the only recovery. Body carries the record. | |
| 409 record_conflict | The 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 internal | Our fault; already reported on our side. Retry with backoff. |
Codes are stable and safe to branch on; messages are human-oriented and may change.
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.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).error.fields (see Errors).match_fields with the values as printed on the client's receipt (see Match fields).portal_url to your client through your own channel (WhatsApp, email, your app).GET /v1/records?status=invoiced (or by id) and reconcile against invoice.total.