Create a CFDI
You send the voucher as JSON and the API builds the XML, seals it with your account's CSD, validates it against the SAT schema and stamps it. Field names are the ones in Anexo 20, so you can use it as a guide.
/invoice/createThe request
The voucher goes as JSON text in the json parameter of a form (application/x-www-form-urlencoded). A body sent with Content-Type: application/json isn't read. Send Accept: application/json or no Accept at all: with any other value the response could come back in another format.
# FD_API=https://sandbox-app.facturadigital.com.mx/api/v5
curl -sS -X POST "$FD_API/invoice/create" \
-H "X-Api-Key: $FD_API_KEY" \
-H "Accept: application/json" \
--data-urlencode "json@factura.json"{
"Version": "4.0",
"Serie": "A",
"Folio": "1001",
"Fecha": "AUTO",
"FormaPago": "03",
"MetodoPago": "PUE",
"Moneda": "MXN",
"TipoDeComprobante": "I",
"Exportacion": "01",
"LugarExpedicion": "64000",
"SubTotal": "2501.00",
"Total": "2901.16",
"Emisor": { "RegimenFiscal": "601" },
"Receptor": {
"Rfc": "XIA190128J61",
"Nombre": "XENON INDUSTRIAL ARTICLES",
"DomicilioFiscalReceptor": "76343",
"RegimenFiscalReceptor": "601",
"UsoCFDI": "G03"
},
"Conceptos": [
{
"ClaveProdServ": "01010101",
"NoIdentificacion": "SKU-001",
"Cantidad": "2",
"ClaveUnidad": "H87",
"Unidad": "Pieza",
"Descripcion": "Producto de prueba",
"ValorUnitario": "1250.50",
"Importe": "2501.00",
"ObjetoImp": "02",
"Impuestos": {
"Traslados": [
{ "Base": "2501.00", "Impuesto": "002", "TipoFactor": "Tasa", "TasaOCuota": "0.160000", "Importe": "400.16" }
]
}
}
],
"Impuestos": {
"TotalImpuestosTrasladados": "400.16",
"Traslados": [
{ "Base": "2501.00", "Impuesto": "002", "TipoFactor": "Tasa", "TasaOCuota": "0.160000", "Importe": "400.16" }
]
}
}Send values as text ("2501.00"), as in the example. JSON numbers work too, but they lose trailing zeros (2501.00 gets written as 2501). Each stamped CFDI uses 1 folio; no error before stamping charges one. To check your JSON without stamping, use the JSON validator.
Comprobante (root)
The attributes of the CFDI root. An empty attribute (null, "") isn't written to the XML; if the SAT requires it, the schema rejects it with a 702 before stamping.
Version- Exactly the text
"4.0". The number4.0doesn't pass. Serie- Optional, except with
Folio: "AUTO". Folio- Your folio, or
"AUTO"to take the next one in your range (see Factura Digital fields). FechaAAAA-MM-DDThh:mm:ss, or"AUTO"for the current time at your place of issue. The SAT accepts up to 72 hours back.FormaPago- c_FormaPago key. With
MetodoPagoPPD it's99. In type P, T or N the API removes it. CondicionesDePago- Free text.
SubTotal- Sum of the line items'
Importe. Descuento- Sum of the line items' discounts. The API writes it with 2 decimals.
Moneda- c_Moneda key:
MXN,USD…XXXin payment and transfer vouchers. TipoCambio- Required if the currency isn't MXN or XXX. With MXN it's
1or you leave it out. Total- SubTotal − Descuento + transferred taxes − withheld taxes. The API doesn't recalculate it.
TipoDeComprobanteIincome,Eegreso (credit note),Ttransfer,Npayroll orPpayment.Exportacion01if it isn't an export.MetodoPagoPUEorPPD. In type P or T the API removes it.LugarExpedicion- Postal code where you issue. It also sets the time zone for
Fecha: "AUTO". Confirmacion- Confirmation key from the stamping provider, for amounts or exchange rates out of range.
The API fills in NoCertificado, Certificado and Sello with your account's CSD: anything you send in those keys is ignored.
Issuer (Emisor)
RegimenFiscal- Your regime, from c_RegimenFiscal, the one that applies to this transaction.
FacAtrAdquirente- Operation number to invoice on behalf of an acquirer.
Rfc, Nombre- You don't need to send them: the API uses the RFC and name of the key's account. You can't issue under another RFC.
Recipient (Receptor)
Rfc- 12 characters (company) or 13 (individual).
XAXX010101000for the general public. Nombre- As it appears on your customer's Constancia de Situación Fiscal, without the capital regime.
DomicilioFiscalReceptor- Postal code of their tax address.
RegimenFiscalReceptor- Their regime, from c_RegimenFiscal.
UsoCFDI- c_UsoCFDI key that applies to their regime.
ResidenciaFiscal, NumRegIdTrib- Only with the generic foreign RFC
XEXX010101000.
The SAT checks the RFC, name, postal code and regime against its taxpayer list. The keys it can respond with and the generic RFC fallback are in Recipient errors.
For your PDF you can also send Calle, NoExt, NoInt, Colonia, Localidad, Referencia, Municipio, Estado, Pais, CodigoPostal and a DomicilioEntrega object with those same fields. They don't go into the XML.
Line items (Conceptos)
Conceptos is a list with at least one line item.
ClaveProdServ- Eight-digit c_ClaveProdServ key.
NoIdentificacion- Your SKU or internal code.
Cantidad- Greater than zero.
ClaveUnidad- c_ClaveUnidad key (
H87piece,E48service…). Unidad- Your name for the unit.
Descripcion- Up to 1,000 characters. The API cuts at 999 bytes: with accented characters a little less fits.
ValorUnitario- Unit price before taxes.
Importe- Cantidad × ValorUnitario. The API doesn't recalculate it.
Descuento- The line item's discount.
ObjetoImp01not subject to tax,02subject to tax (carriesImpuestos),03subject to tax with no breakdown required, and the rest of c_ObjetoImp.extra1 … extra15- Free fields for your PDF.
Conceptos[].Impuestos
Traslados- Each one with
Base,Impuesto(002VAT,003IEPS),TipoFactor(Tasa,CuotaorExento),TasaOCuotaandImporte. WithExento, noTasaOCuotaorImporte. Retenciones- Same fields;
001ISR,002VAT.
The API writes TasaOCuota with six decimals: "0.16" comes out as 0.160000.
Other line item nodes
ACuentaTercerosRfcACuentaTerceros,NombreACuentaTerceros,RegimenFiscalACuentaTercerosandDomicilioFiscalACuentaTerceros.InformacionAduanera- List of
{ "NumeroPedimento": "…" }. CuentaPredial{ "Numero": "…" }. One per line item.ComplementoConceptoiedu(educational institutions) orHidroYPetro(hydrocarbons and petroleum products).
Taxes (Impuestos)
The voucher totals. Group the line items' transferred taxes by tax, factor type and rate: one row per combination, with the sum of its bases and amounts. Withholdings go one row per tax. If the CFDI has no taxes, leave the node out.
TotalImpuestosTrasladados- Sum of the
Importevalues inTraslados. TotalImpuestosRetenidos- Sum of the
Importevalues inRetenciones. At zero, the API leaves it out. TrasladosBase,Impuesto,TipoFactor,TasaOCuotaandImporte.RetencionesImpuestoandImporte.
Related CFDIs
With a single relation type (for example 04 substitution or 01 credit note):
"CfdiRelacionadosTipoRelacion": "04",
"CfdiRelacionados": [
{ "UUID": "6F1A2B3C-4D5E-4F60-8172-93A4B5C6D7E8" }
]With several relation types in the same CFDI, use CfdiRelacionadosMultiple, one group per type:
"CfdiRelacionadosMultiple": [
{ "TipoRelacion": "01", "Relacionados": [ { "UUID": "3C9B7A12-4E5D-4F60-B8A1-9D2C6E0F7B34" } ] },
{ "TipoRelacion": "07", "Relacionados": [ { "UUID": "0B7C2E51-8A4D-4E0F-A1C3-7D9E6F2B5A10" } ] }
]Without CfdiRelacionadosTipoRelacion, the UUIDs in CfdiRelacionados aren't written. A CFDI in your account that another one relates to can no longer be cancelled from the platform.
Global invoice
InformacionGlobal
Periodicidad- c_Periodicidad key:
01daily,02weekly,04monthly… Meses- c_Meses key.
Año (or Ano)- Year of the transactions.
Anowrites the same thing and avoids trouble with the ñ.
It goes with the recipient XAXX010101000, name PUBLICO EN GENERAL, regime 616 and use S01. For an invoice to a single customer with no RFC, don't use this node: put their name in Receptor.Nombre.
Factura Digital fields
They aren't from the SAT. They go at the root of the JSON, at the same level as Serie and Folio.
Folio: "AUTO"- Takes the next folio from the range in Configuración › Series y folios (Settings › Series and folios) for that
SerieandTipoDeComprobante; both are required. Only the exact textAUTO. The assigned folio comes back in the response, incfdi.Serieandcfdi.Folio. Fecha: "AUTO"- The current time in your account's time zone or, if you didn't set one, in the time zone of the
LugarExpedicionpostal code. OmitirTimbrado"si"builds, seals and validates the XML and responds without stamping it. It requires a CSD and balance, uses no folio and saves nothing. WithFolio: "AUTO"it does take a folio from the range.plantilla_id- Id of your PDF template (Configuración › Diseño de PDF). If it doesn't exist or isn't active, it responds 400 before stamping.
enviar_mail- Emails the CFDI when it's stamped. See below.
LeyendaFolio- Document title on the PDF.
Observaciones- Free text on the PDF.
NumeroOrdenCompra, FechaOrdenCompra- Your customer's purchase order, for the PDF.
fechalimitepago- Payment due date, for the PDF (lowercase).
EstatusCobranza0unpaid (the default for income vouchers),1partly paid,2paid.MontoCobrado, SaldoInsoluto- Amount collected and amount still owed. By default,
0and theTotal. extra1 … extra30- Free fields for your PDF.
Decimales, DecimalesItem- Decimals for the totals and for the line item amounts on the PDF.
AddendaG500orTERNIUMwith the variables of its template. It's added to the saved XML after stamping.
Email the CFDI when it's stamped
"enviar_mail": {
"destinatarios": "cliente@facturadigital.com.mx",
"bcc": "cobranza@facturadigital.com.mx",
"mensaje": "Gracias por tu compra."
}destinatarios is required inside the object: addresses separated by , or ;, up to 500 characters. bcc takes up to 500 and mensaje up to 2,000. It's validated before stamping and the email goes out in under a minute. In the sandbox, emails don't reach the recipient. To resend later, use POST /invoice/send.
Complements
They go inside "Complemento": { "<llave>": { … } } at the root. The key is case-sensitive. The SAT schema validates them before stamping.
| Complement | Key | Notes |
|---|---|---|
| Payments 2.0 (REP) | Pagos20 | With TipoDeComprobante: "P". Guide: Payment complement. |
| Carta Porte 3.1 | CartaPorte31 | Version exactly "3.1": with any other value the complement isn't written. The SAT's CP rules are checked before the folio is taken. Guide: Carta Porte 3.1. |
| Payroll 1.2 | Nomina12 | With TipoDeComprobante: "N". Overtime also goes in as earning 019. |
| Foreign Trade 2.0 | ComercioExterior20 | With Exportacion other than 01. |
| Local taxes | ImpuestosLocales | Rate as a percentage ("3.00"). The API calculates the totals from the rows. |
| Retail (Detallista) | detallista | Lowercase. |
| INE | INE | |
| Donees (Donatarias) | Donatarias | |
| Currency exchange (Divisas) | Divisas | |
| Airlines | Aerolineas | |
| Public notaries | NotariosPublicos | A list. |
| Payment in kind | PagoEnEspecie | |
| Used vehicle | VehiculoUsado | |
| Partial construction services | ParcialesConstruccion | |
| Hydrocarbon income | IngresosHidrocarburos | |
| Works of art and antiques | obrasarteantiguedades | Lowercase. |
The Postman collection has full examples of Payments, Payroll, Carta Porte and Detallista.
The response
{
"message": "The document was generated successfully",
"code": 200,
"cfdi": {
"NoCertificado": "30001000000500003416",
"UUID": "6F1A2B3C-4D5E-4F60-8172-93A4B5C6D7E8",
"FechaTimbrado": "2026-11-03T10:30:12",
"RfcProvCertif": "SPR190613I52",
"SelloCFD": "Xy…Base64…==",
"NoCertificadoSAT": "30001000000500003456",
"SelloSAT": "Ab…Base64…==",
"CadenaOrigTFD": "||1.1|6F1A2B3C-4D5E-4F60-8172-93A4B5C6D7E8|2026-11-03T10:30:12|SPR190613I52|Xy…==|30001000000500003456||",
"CadenaQR": "https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=6F1A2B3C-…&re=EKU9003173C9&rr=XIA190128J61&tt=2,901.160000&fe=AbCd1234",
"XmlBase64": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4…",
"PDF": "https://sandbox-app.facturadigital.com.mx/docs/pdf/6F1A2B3C-4D5E-4F60-8172-93A4B5C6D7E8",
"XML": "https://sandbox-app.facturadigital.com.mx/docs/xml/EKU9003173C9/6F1A2B3C-4D5E-4F60-8172-93A4B5C6D7E8"
}
}cfdi
UUID- The fiscal folio.
XmlBase64- The stamped XML in Base64. Store it: it's your voucher.
CadenaOrigTFD- Original string of the stamp, for your PDF.
CadenaQR- SAT verification URL for the QR code. Today the total (
tt) comes out with a thousands comma; if you build your own QR, remove it. PDF, XML- Download links for the PDF and the XML.
Serie, Folio- Only with
Folio: "AUTO": the folio that was assigned.
- With
ya_existia: true, the provider returned a UUID your account already had: no new one was saved and no extra folio was charged. - With
OmitirTimbrado: "si", the response is{ "message": "…(SIN TIMBRAR)", "xmlBase64": "…", "code": 200 }, withxmlBase64starting in lowercase. - If something fails after stamping (saving the PDF, queuing the email), the response is still this 200: the CFDI already exists with the SAT.
Errors
| code | When | What to do |
|---|---|---|
| 501 | The key is missing, doesn't exist or belongs to the other environment. | Keys and environments. |
| 602 | Your account has no active CSD. It's checked before the JSON is read. | Upload your CSD in Configuración. |
| 601 | Your account has no folios. This is also checked before the JSON is read. | Buy folios. |
| 400 | json is missing, it isn't a JSON object, a node has another type (json_type_validation), Version isn't "4.0", plantilla_id doesn't exist, enviar_mail has an invalid address, or Folio: "AUTO" has no series or no range. | The message says what to fix; nothing was sent to the SAT. |
400 + cartaporte_validation | A Carta Porte 3.1 rule (CP1xx) isn't met. | errors[].code carries the CP key. |
| 702 | The XML doesn't meet the SAT schema. | errors[] carries field and value; see the matrix. |
| 706 | Your CSD couldn't be used (file or password). | Upload your CSD again in Configuración. |
| 400 + SAT key | The SAT rejected the CFDI (CFDI40143, CRP20…, NOM…). | Look it up in the error matrix. |
400 + timbre_previo | That same XML was already stamped. context.uuid carries its UUID; there's no charge. | Get the CFDI with that UUID. |
| 5037 | The API couldn't connect to the stamping provider; nothing was sent. | Retry as is. |
| 5034 | The CFDI may have been stamped without a readable response. It carries Serie and Folio, and context.uuid if it could be read. | Don't resend blindly: how to check. |
Retry without duplicates
The API has no idempotency key. If a request gets cut off and you repeat it with Fecha: "AUTO" or Folio: "AUTO", the second one is a different XML and can produce another CFDI with another UUID.
- If your system retries on its own, send a fixed
Fechaand your ownFolio. The same XML sent again respondstimbre_previowith the UUID of the first one and isn't charged twice. - With 5037, retry: nothing went out.
- With 5034, first check whether the CFDI exists (in your listings or with
GET /validator/cfdi) and retry only if it doesn't. - Set a timeout of 150 to 180 seconds: the API waits up to 120 seconds for the stamping provider.
What the API adjusts on its own
| What | How |
|---|---|
Emisor.Rfc and Emisor.Nombre | Always the ones of the key's account. |
FormaPago | Removed in type P, T and N. |
MetodoPago | Removed in type P and T. |
TasaOCuota | Six decimals. |
Root Descuento | Two decimals. |
Descripcion | Cut at 999 bytes. |
TotalImpuestosRetenidos at zero | Left out. |
Fecha with a space | The space becomes T. |
Recipient RFC, name and postal code, ClaveProdServ, NoIdentificacion | No leading or trailing spaces. |
Checked against the production API v5 on 2026-11-03.