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.
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 backstatus: "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
| Trigger | Endpoint | Result |
|---|---|---|
serie: "FAIL" | POST /v1/invoices | 502 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/invoices | 422 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) | stamp | 422 cfdi.malformed_xml (provider code 301) |
| anything else | POST /v1/invoices | 201 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 returns | Poll resolves to |
|---|---|---|
receptor RFC starts XAXX (público en general) | cancelled | cancelled immediately (cancelable sin aceptación) |
serie: "REJECT" | cancel_pending | cancel_rejected on the first poll (receptor rechaza) |
serie: "SLOW" | cancel_pending | stays cancel_pending for 3 polls, then cancelled (SAT plazo vencido auto-accept) |
| any other receptor | cancel_pending | stays 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) | Endpoint | Poll resolves to |
|---|---|---|
starts 1999… | POST /v1/downloads | no_data (SAT 5004 — terminal empty success, emits download.no_data) |
starts 2000… | POST /v1/downloads | failed (uncontrolled error — non-retryable, emits download.failed) |
starts 2001… | POST /v1/downloads | failed (SAT solicitud expired, emits download.failed) |
| any other date | POST /v1/downloads | ready after 2 polls, serving a real, openable ZIP (emits download.ready) |
download.created the moment its row is created (a deduped replay of
an identical request does not re-emit it).
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 underNODE_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:
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 (defaults50/100).RATE_LIMIT_WRITE_RPS/RATE_LIMIT_WRITE_BURST— WRITE bucket (defaults25/50).RATE_LIMIT_RPS/RATE_LIMIT_BURST— legacy single-bucket knobs, used as the fallback default for both buckets when the specificREAD_/WRITE_var is unset.
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.
${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
SendIdempotency-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.