Skip to main content
This walks through the full path from nothing to a SAT-stamped CFDI 4.0. It runs entirely on the simulated PAC — no PAC credentials — so you can copy-paste it against pnpm dev.

0. Start the API

pnpm install && pnpm dev     # http://localhost:3000, simulated PAC, PGlite

1. Get an API key

POST /v1/organizations is open in dev (gated by ALLOW_ORG_BOOTSTRAP) and returns your first test + live keys. Save the test key; keys are shown once.
API_KEY=$(curl -s -X POST localhost:3000/v1/organizations \
  -H 'content-type: application/json' -d '{"name":"Acme"}' | jq -r '.api_keys.test')
Every /v1 call from here needs Authorization: Bearer $API_KEY.

2. Register a fiscal profile (emitter + CSD)

An invoice is issued by a fiscal profile: an RFC plus its CSD (Certificado de Sello Digital). Upload the .cer and .key files directly (multipart) with the private-key password; the RFC, serial and validity window are read from the certificate. The key material is AES-256-GCM encrypted at rest and never returned. The repo bundles a public SAT test CSD at test/cfdi/fixtures/ (RFC AAA010101AAA, password 12345678a). Just upload the files:
FP_ID=$(curl -s -X POST localhost:3000/v1/fiscal_profiles \
  -H "authorization: Bearer $API_KEY" \
  -F "legal_name=ACCEM SERVICIOS EMPRESARIALES SC" \
  -F "tax_regime=601" -F "zip=26015" -F "csd_password=12345678a" \
  -F "csd_cer=@test/cfdi/fixtures/csd.cer" \
  -F "csd_key=@test/cfdi/fixtures/csd.key" \
  | jq -r '.id')
Multipart field names: csd_cer, csd_key, csd_password for the CSD, and fiel_cer, fiel_key, fiel_password if you also attach a FIEL. Prefer JSON? Send the same data as application/json with csd: { cer_base64, key_base64, password } — both content types are accepted.
The fixture CSD expired in 2021. Test/simulated mode tolerates expired CSDs; live mode rejects them (422 fiscal_profile.csd_expired). Use a current CSD to go live.
The profile comes back on hold. A brand-new profile has not yet been authorized to stamp, so its response carries status: "requires_action" and a single next_actions entry telling you exactly what to do next:
{
  "id": "fp_...",
  "rfc": "AAA010101AAA",
  "status": "requires_action",
  "manifest_status": "pending_signature",
  "next_actions": [
    { "type": "sign_manifest",
      "message": "Upload the taxpayer's FIEL (e.firma) and sign the carta manifiesto to authorize CFDI stamping for this RFC.",
      "endpoint": "POST /v1/fiscal_profiles/fp_.../manifest",
      "requires": ["fiel"] }
  ]
}
On retrieve or list the same pending action appears as pending_actions. When the profile is authorized both arrays are [] and status is "active".

3. Sign the carta manifiesto

Prodigia requires a signed carta manifiesto per RFC to authorize timbrado (invoice stamping) for that taxpayer — this is the SAT/PAC authorization, not the CSD. This API signs it for you; there is no browser portal to visit. This gate applies in test/simulated mode too (the simulated PAC signs the manifiesto so onboarding works end to end), so you sign it once here before stamping. The manifiesto is signed with the taxpayer’s FIEL (e.firma). In simulated mode the bundled fixture CSD doubles as the FIEL, so you can reuse the same test/cfdi/fixtures/ files:
curl -s -X POST localhost:3000/v1/fiscal_profiles/$FP_ID/manifest \
  -H "authorization: Bearer $API_KEY" \
  -F "cer=@test/cfdi/fixtures/csd.cer" \
  -F "key=@test/cfdi/fixtures/csd.key" \
  -F "password=12345678a" \
  -F "email=legal@example.mx" | jq '.status'
The manifest endpoint accepts the FIEL inline as -F cer= -F key= -F password= (or fiel: { cer_base64, key_base64, password } in JSON), or reuses a FIEL already stored on the profile. On success the manifiesto is signed once and for all: the profile flips to status: "active" (manifest_status: "signed"), next_actions/pending_actions empty out, and a fiscal_profile.manifest_signed webhook fires. Signing is idempotent — an “already signed” result is treated as success.
Skip this step and the very next stamp attempt returns 409 fiscal_profile.manifest_required (in both test and live), with remediation pointing back to this endpoint. Every stamp call checks the manifiesto, so you only ever sign it once per profile.

4. Create + stamp an invoice

Send friendly JSON. You do not send the issuer — it comes from the profile. Omit taxes and each item defaults to 16% IVA traslado; subtotal/total are computed and rounded to the currency’s decimals.
curl -s -X POST localhost:3000/v1/invoices \
  -H "authorization: Bearer $API_KEY" -H 'content-type: application/json' \
  -H "idempotency-key: first-invoice-1" \
  -d "{
    \"fiscal_profile_id\": \"$FP_ID\",
    \"type\": \"I\",
    \"payment_form\": \"03\",
    \"payment_method\": \"PUE\",
    \"use\": \"G03\",
    \"customer\": { \"rfc\": \"URE180429TM6\", \"name\": \"UNIVERSIDAD ROBOTICA ESPANOLA\", \"zip\": \"65000\", \"tax_regime\": \"601\" },
    \"items\": [ { \"product_key\": \"01010101\", \"unit_key\": \"H87\", \"description\": \"Servicio de consultoría\", \"quantity\": 1, \"unit_price\": \"100.00\" } ]
  }" | jq
You get back status: "stamped", a real uuid (folio fiscal), total: "116.00", serie: "A", folio: "1", the sello_cfd/sello_sat, and xml_url/pdf_url.

Required fields, briefly

FieldNotes
typeI ingreso (default), E egreso/nota de crédito, P pago (complemento 2.0).
payment_formc_FormaPago (03 transferencia, 01 efectivo…). Required for I/E.
payment_methodPUE (una exhibición) or PPD (parcialidades/diferido). Required for I/E.
useUsoCFDI (G03 gastos en general…). Set here or on customer.use.
customerrfc, name, zip, tax_regime — must match the receptor’s Constancia de Situación Fiscal.
items[]product_key (8-digit ClaveProdServ), unit_key (ClaveUnidad), description, quantity, unit_price.
Common coherence rules the API enforces (returned as 422 cfdi.invalid_input with a path):
  • payment_method: "PPD" forces payment_form: "99" (Por definir).
  • Type P must not carry items or payment_method; it needs payments.
  • Type E should carry relation (type 01 nota de crédito or 03 devolución) + original UUID.
  • Receptor XAXX010101000 (público en general) requires global_information.
  • Non-MXN currency requires exchange_rate.

5. Draft, then stamp later (optional)

Pass "draft": true to build and sign without stamping; the invoice returns status: "draft". Stamp it when ready:
curl -s -X POST localhost:3000/v1/invoices/$INV_ID/stamp \
  -H "authorization: Bearer $API_KEY" | jq '.status,.uuid'

6. Fetch the artifacts

curl -s localhost:3000/v1/invoices/$INV_ID/xml -H "authorization: Bearer $API_KEY" -o invoice.xml
curl -s localhost:3000/v1/invoices/$INV_ID/pdf -H "authorization: Bearer $API_KEY" -o invoice.pdf

Idempotency

Send Idempotency-Key: <unique> on POSTs. A retry with the same key + identical body replays the stored response (idempotent-replayed: true) instead of stamping twice; a different body under the same key → 409 idempotency.key_reuse. The PAC’s own duplicate code 307 is also resolved as a success-replay of the prior stamp, so double submits never mint two CFDIs.

Next