Skip to main content
Everything in this API can be exercised end-to-end with zero credentials. In test mode the stamping service is fully simulated: it produces well-formed CFDIs with synthetic UUIDs and sellos, tolerates the bundled (expired) sample CSD, and never touches the SAT. Test and live data are isolated — a sk_test_ key only ever sees test resources.

Test vs live

  • sk_test_... → the simulated stamping service and sandbox behavior. Expired CSDs are tolerated, but the carta manifiesto still has to be signed (the simulated PAC signs it) before a profile can stamp.
  • sk_live_... → real stamping. A live CSD must be non-expired and its manifiesto signed.
The mode is decided entirely by the key; nothing in the request body switches it. Every resource carries livemode so you always know which world you are in.

The manifiesto gate applies in test too

A fiscal profile is created on hold in both modes: it comes back status: "requires_action" with a sign_manifest action, and any stamp attempt against it returns 409 fiscal_profile.manifest_required until you sign the carta manifiesto. Test onboarding is end-to-end because the simulated PAC signs the manifiesto — so the requires_action → active flow you exercise in test is exactly what live does. In simulated mode the bundled fixture CSD doubles as the FIEL, so you can sign the manifiesto with the same test/cfdi/fixtures/ files you registered the CSD with. See Stamping your first invoice for the full sequence.

Deterministic simulator triggers

The simulated stamping service watches a few serie and date fields so you can drive every lifecycle branch on demand — no mocking required. Triggers are purely syntactic (a magic serie or year); they never depend on real RFC or tax semantics.

Stamping — POST /v1/invoices

TriggerEndpointResult
serie: "FAIL"POST /v1/invoices502 pac.internal_error — a retryable transient PAC error (safe to retry with the same Idempotency-Key)
an item whose unit_price makes the CFDI Total exactly 999999.99 (e.g. unit_price: 862068.96 → subtotal 862068.96 + 16% IVA 137931.03 = 999999.99)POST /v1/invoices422 cfdi.emisor_not_in_lco — a definitive SAT rejection (fix the request before resubmitting). The trigger is the comprobante Total attribute, not SubTotal.
malformed XML (via POST /v1/validations / raw stamp)stamp422 cfdi.malformed_xml (provider code 301)
anything elsePOST /v1/invoices201 stamped invoice with a synthetic UUID + sellos

Cancellation — POST /v1/invoices/:id/cancel → status polling

The cancellation lifecycle is driven by the invoice’s serie (and the receptor RFC). After you call cancel, the poller (cancellation-poll job) resolves the terminal state:
Trigger (invoice serie / receptor)cancel returnsPoll resolves to
receptor RFC starts XAXX (público en general)cancelledcancelled immediately (cancelable sin aceptación)
serie: "REJECT"cancel_pendingcancel_rejected on the first poll (receptor rechaza)
serie: "SLOW"cancel_pendingstays cancel_pending for 3 polls, then cancelled (SAT plazo vencido auto-accept)
any other receptorcancel_pendingstays cancel_pending (receptor must accept within 72h)

Massive download — POST /v1/downloads → poll

Descarga masiva has no SAT sandbox, so the simulator models it end to end. The outcome is keyed on the leading year of date_from:
Trigger (date_from)EndpointPoll resolves to
starts 1999…POST /v1/downloadsno_data (SAT 5004 — terminal empty success, emits download.no_data)
starts 2000…POST /v1/downloadsfailed (uncontrolled error — non-retryable, emits download.failed)
starts 2001…POST /v1/downloadsfailed (SAT solicitud expired, emits download.failed)
any other datePOST /v1/downloadsready after 2 polls, serving a real, openable ZIP (emits download.ready)
Every new download also emits download.created the moment its row is created (a deduped replay of an identical request does not re-emit it).
# Force a retryable stamping failure
curl -sX POST $BASE/v1/invoices -H "authorization: Bearer $TEST_KEY" \
  -H 'content-type: application/json' \
  -d '{ "fiscal_profile_id": "fp_...", "serie": "FAIL", "type": "I", ... }'
# → 502 { "code": "pac.internal_error", "remediation": "Retry with backoff; ...", ... }

# Force a cancellation that the receptor rejects
curl -sX POST $BASE/v1/invoices -H "authorization: Bearer $TEST_KEY" \
  -H 'content-type: application/json' \
  -d '{ "fiscal_profile_id": "fp_...", "serie": "REJECT", "type": "I", ... }'
# then POST /v1/invoices/:id/cancel → poll → cancellation.status becomes "cancel_rejected"

# Force a descarga that returns no data
curl -sX POST $BASE/v1/downloads -H "authorization: Bearer $TEST_KEY" \
  -H 'content-type: application/json' \
  -d '{ "fiscal_profile_id": "fp_...", "side": "issued", "type": "cfdi",
        "date_from": "1999-01-01", "date_to": "1999-01-31" }'
# → poll resolves status "no_data" and fires download.no_data
Retryable failures (502) are safe to retry with backoff — send the same Idempotency-Key and the API guards against a duplicate stamp. Definitive verdicts (422) require a change to the request.

Forcing rate limits

Rate limiting is on in production but disabled under NODE_ENV=test so the suite never flakes. To exercise the limiter (and the 429 + Retry-After path your client’s retry logic depends on), force it on:
RATE_LIMIT_FORCE=1 RATE_LIMIT_READ_RPS=1 RATE_LIMIT_READ_BURST=1 \
  RATE_LIMIT_WRITE_RPS=1 RATE_LIMIT_WRITE_BURST=1 pnpm dev
The limiter uses two independent token buckets per key — one for READ (GET/HEAD), one for WRITE (POST/PATCH/DELETE) — so a read burst can’t starve writes. RPS is the sustained refill rate; BURST is the bucket capacity.
  • RATE_LIMIT_FORCE=1 — turn the limiter on even in test/dev.
  • RATE_LIMIT_READ_RPS / RATE_LIMIT_READ_BURST — READ bucket (defaults 50/100).
  • RATE_LIMIT_WRITE_RPS / RATE_LIMIT_WRITE_BURST — WRITE bucket (defaults 25/50).
  • RATE_LIMIT_RPS / RATE_LIMIT_BURSTlegacy single-bucket knobs, used as the fallback default for both buckets when the specific READ_/WRITE_ var is unset.
Hammer any endpoint and you will get 429 with a Retry-After header and a rate_limit.exceeded problem body. Both SDKs honor Retry-After automatically on 429/5xx.

Receiving webhooks locally

scripts/webhook-listen.mjs is a zero-dependency (Node 22+) local receiver. It verifies every signature against your endpoint secret, rejects forged/replayed payloads, and pretty-prints each event; optionally it forwards the raw signed request to your app.
# 1. Start the listener with your endpoint's signing secret
node scripts/webhook-listen.mjs --port 4242 --secret whsec_...

# optional: forward verified deliveries to your local server
node scripts/webhook-listen.mjs --port 4242 --secret whsec_... --forward http://localhost:3000/hooks

# optional flags: --tolerance <secs> (default 300), WEBHOOK_LISTEN_VERBOSE=1 to dump raw bodies

# 2. Expose it (any tunnel) and register the endpoint
curl -sX POST $BASE/v1/webhook_endpoints -H "authorization: Bearer $TEST_KEY" \
  -H 'content-type: application/json' \
  -d '{ "url": "https://<your-tunnel>/", "enabled_events": ["*"] }'
It reproduces the server’s signing scheme exactly — HMAC-SHA256 over ${id}.${timestamp}.${body} with the raw whsec_... secret, emitted as v1=<hex> in Webhook-Signature, with a replay tolerance window. Trigger a delivery by stamping an invoice, then replay any event with POST /v1/events/:id/resend. Both SDKs expose the same verification: verifyWebhook (Node) / verify_webhook (Python). See Webhooks and the scripts/webhook-listen.mjs usage above for details.

Idempotency and retries

Send Idempotency-Key: <unique> on any POST. The first response is stored for 24h and replayed byte-for-byte for identical retries (replays carry idempotent-replayed: true); a different body under the same key is a 409 idempotency.key_reuse. This is what makes retrying a 502 safe.