Skip to main content
Everything up to now ran on the simulated PAC with zero credentials. To stamp CFDIs that are real at the SAT you switch your keys from test to live and register real fiscal material. This is the checklist and the gotchas.

Test and live are separate worlds

Test and live are fully isolated — separate data, separate resources, and separate keys. sk_test_ keys route to the sandbox/simulated PAC; sk_live_ keys route to the live PAC. A test key never sees live data and vice-versa. Do the whole dance in test first, then switch to your sk_live_ keys.

1. Register live CSDs

  • In live mode the API rejects an expired or not-yet-valid CSD (422 fiscal_profile.csd_expired) — upload a current CSD (Certificado de Sello Digital) issued to the emitter’s RFC. (The bundled fixture expired in 2021 and only works in test/simulated.)
  • The CSD is used to compute the sello locally; the private key never leaves this server and never reaches the PAC.
  • You also need the taxpayer’s FIEL (e.firma) — it signs the carta manifiesto that authorizes stamping (step 2 below), and the same FIEL is reused for descarga masiva. You can attach it here at registration or supply it inline when you sign the manifiesto.

2. Sign the carta manifiesto (per taxpayer)

Every new profile is created on hold: it comes back status: "requires_action" with a single sign_manifest action under next_actions (pending_actions on retrieve/list), and it cannot stamp until the manifiesto is signed. The SAT requires each taxpayer to authorize a PAC to certify their CFDIs, and a separate carta manifiesto must be signed per RFC before that RFC’s CFDIs can be stamped. This API signs it for you — no browser portal needed. The manifiesto is signed with the taxpayer’s FIEL (e.firma), not the CSD. This gate is not live-only — it applies in test/simulated mode too (the simulated PAC signs the manifiesto), so the requires_action → active flow is identical in both worlds. Attempting to stamp against an on-hold profile returns 409 fiscal_profile.manifest_required in either mode. One call, uploading the FIEL files (they are also stored on the profile for descarga masiva):
curl -s -X POST localhost:3000/v1/fiscal_profiles/$FP_ID/manifest \
  -H "authorization: Bearer $API_KEY" \
  -F "email=legal@yourcompany.mx" \
  -F "password=$FIEL_PASS" \
  -F "cer=@fiel.cer" \
  -F "key=@fiel.key"
(Prefer JSON? POST { "fiel": { "cer_base64", "key_base64", "password" }, "email" } as application/json.) If the profile already has a stored FIEL, omit it and just POST { "email": "..." } (or -F "email=..."). On success the response is a manifest object (status: "signed", signed_at, the signed xml_base64 + pdf_base64), the profile’s manifest_status moves pending_signature → signed (so the profile flips to status: "active" and its pending_actions empty out), and a fiscal_profile.manifest_signed webhook fires. An “already signed” result is treated as idempotent success. Signing is once per profile — every stamp call re-checks it. email receives the signed XML + PDF; it falls back to the MANIFEST_EMAIL env default when omitted. Notes:
  • Prefer a current, non-expired FIEL — an expired e.firma is rejected.
  • For descarga masiva there is no separate manifiesto — uploading the FIEL is itself the authorization.

3. Configure production env

See .env.example. The minimum for live stamping:
NODE_ENV=production
APP_ENCRYPTION_KEY=<openssl rand -base64 32>   # REQUIRED in prod; boot fails without it.
                                               # Keep it STABLE — rotating it makes stored
                                               # CSD/FIEL material unreadable.
DATABASE_URL=postgres://user:pass@host:5432/invoice_api

ALLOW_ORG_BOOTSTRAP=false          # provision orgs out-of-band in prod
Once your live keys are in place, sk_live_ traffic is routed to the live PAC automatically.

4. Verify before you cut over

  • GET /healthz returns { "status": "ok", "pac": "..." } with the live PAC active.
  • GET /v1/balance returns your real remaining stamps.
  • Stamp a known-good invoice for a real emitter/receptor and confirm the UUID resolves at the SAT (POST /v1/validations enriches with live SAT status).
  • Confirm webhook endpoints are https, every POST carries an Idempotency-Key, and APP_ENCRYPTION_KEY is a stable, backed-up value.

Descarga masiva at go-live

Descarga masiva has no sandbox — it only runs in production against a live FIEL stored on the profile. Design around the SAT limits before you rely on it: only 2 identical lifetime requests, a 200,000 XML cap per request, and package URLs that expire in ~5 minutes. See massive-download.md.

Failure modes to expect (and how the API surfaces them)

  • Auth (code 1) — non-retryable; fix creds, do not loop (repeated failures lock the account). → 401/403.
  • Contract expired / no transactions (code 2) — renew stamping balance. → mapped pac.contract_invalid.
  • Certificate problems (30x) — revoked/expired/mismatched CSD; re-register. → non-retryable.
  • Duplicate (307) — not an error; resolved as a success-replay of the prior stamp.
  • Transient (4/5) — retry with backoff; idempotency guards against double stamps.
Full mapping: ../errors.md.