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
| Field | Notes |
|---|
type | I ingreso (default), E egreso/nota de crédito, P pago (complemento 2.0). |
payment_form | c_FormaPago (03 transferencia, 01 efectivo…). Required for I/E. |
payment_method | PUE (una exhibición) or PPD (parcialidades/diferido). Required for I/E. |
use | UsoCFDI (G03 gastos en general…). Set here or on customer.use. |
customer | rfc, 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