> ## Documentation Index
> Fetch the complete documentation index at: https://invoiceapi.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing

> Everything in this API can be exercised end-to-end with zero credentials. In test mode the

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](/guides/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`) |

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).

```bash theme={null}
# 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:

```bash theme={null}
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_BURST` — **legacy** 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.

```bash theme={null}
# 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](/guides/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.
