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