> For the complete index of the 402pay docs, see [llms.txt](https://developer.402pay.co/llms.txt).

# Testing

Build with test keys and rehearse every way a payment can arrive.

Build against a test key, then rehearse every way a payment can arrive: crypto that's exact, short, over, late or on the wrong network, and cards that are approved or declined. A live business takes real payments on real networks, so rehearse the outcomes on a simulated business, such as one in a local 402pay, where 402pay stands in for the networks and the card payment and any outcome is one request away.

## Live and simulated businesses

- A live business takes real transfers: 402pay watches each network, and a payment succeeds once its transfer lands and confirms. Sends from its wallet are relayed to the network, and its webhooks are signed HTTPS requests to your endpoint. Start with small amounts.
- A simulated business, such as one in a local 402pay, stands in for the networks and a card service. A crypto checkout receives its transfer on a timer, card outcomes come from a simulated payment page, and a send is recorded without being broadcast, then confirms on a timer. See [timings](https://developer.402pay.co/testing.md#timings).
- A local 402pay records each webhook delivery with its signed request and a 200 response instead of sending it, so you can read exactly what your endpoint would receive.
- A host under the reserved `.invalid` domain never answers, so you can watch failures and retries. See [webhook failures](https://developer.402pay.co/testing.md#webhook-failures).
- A deployment that can't send email answers signing up, resetting a password and changing an email with 503 `email_unavailable`. A local 402pay sends none: it keeps every email it would have sent, codes and links included, in its dev mailbox at `/dev/mailbox`.
- A local 402pay may keep its data in memory and lose it when it restarts. Create what a test needs as part of the test, rather than relying on it staying.
- Rate limits count per IP address, and requests from the machine a local 402pay runs on skip them. The [limits on sign-in and email](https://developer.402pay.co/api/errors.md#rate-limits), such as failed passwords per email, apply everywhere.

## Test and live keys

> During the beta, test and live keys act on the same business: what you create with a test key shows up in your dashboard and in every list, like anything else, and every event's `mode` is `live`. On a live business a test key can read everything, but creating a payment, a checkout or a remainder, or changing what customers see or pay, answers 403 `test_mode_unavailable`, so a test key never moves real money or changes a live checkout.

- A test key (`402s_test_`) reads what a live key (`402s_live_`) reads, and the business decides whether money is real. On a live business only a live key creates payments, checkouts and remainders. Rehearse on a simulated business, and try a live one with small amounts and a live key.
- Use test keys while you build and in CI, and live keys only in production. A leaked test key is then easy to spot and revoke, and your code is ready the day test keys get data of their own.
- Every event also has an `api_version`, `v1` today: the version of the event's shape, so a handler knows which fields to expect.

## Crypto outcomes

On a simulated business, add `simulate` to a hosted checkout's URL, such as `?simulate=underpaid`, or pass it when you [create a checkout](https://developer.402pay.co/api/checkouts/create.md) through the API. The transfer arrives 20 seconds after the checkout opens. A live business answers `simulate` with 400, so never send it from production code.

| simulate | Checkout | Payment | Webhooks | What happens |
| --- | --- | --- | --- | --- |
| `exact` | `completed` | `succeeded` | `payment.succeeded` | The full amount arrives. Fulfill the order. This is the default. |
| `underpaid` | `underpaid` | `underpaid` | `payment.underpaid` | 90% arrives, beyond any tolerance. Wait for the rest, request it once the quote expires, or accept what arrived. |
| `overpaid` | `completed` | `succeeded` | `payment.succeeded`, then `payment.overpaid` | 110% arrives and all of it lands in your wallet. Fulfill, and send back `method.overpaid_amount` if you choose. |
| `wrong_network` | `needs_review` | `needs_review` | `payment.needs_review` | The transfer lands on another EVM network at the same address. Accept it, or return it. Only for a coin on more than one EVM network, such as USDC. |
| `late` | `needs_review` | `needs_review` | `payment.needs_review` | The transfer lands after the quote expired. Accept it, or return it. Only through mark-sent, below. |

- Every case starts with `payment.created`: when you create a payment through the API, or on a link, once checkout has the customer's email or the transfer arrives.
- An underpaid checkout keeps watching the same address for a fresh quote window. When it ends without the rest, the checkout is `expired` and the payment stays `underpaid`.
- On a link, a late transfer sends `payment.expired` before `payment.needs_review`, since the link's payment ends with its quote.

## Skip the wait

On a simulated business, `POST /checkouts/{id}/mark-sent` records the transfer now instead of after 20 seconds, with the same `simulate` values. It's public, like the rest of checkout, and for testing only.

- `late` works only here, since `POST /checkouts` answers it with 400. On an open checkout it ends the quote on the spot, then records the transfer as arriving after it. Send it before the checkout's own transfer arrives.
- On an `underpaid` checkout, `exact` sends the rest, and the payment succeeds.
- A checkout that already received its transfer comes back unchanged, and any value but `late` on an expired one returns 410 `checkout_expired`.

Skip the wait, cURL:

```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj/mark-sent" \
  -H "Content-Type: application/json" \
  -d '{
    "simulate": "underpaid"
  }'
```

Skip the wait, Node.js:

```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj/mark-sent", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    simulate: "underpaid"
  }),
});
const { data } = await response.json();
```

Skip the wait, Python:

```python
import requests

response = requests.post(
    "https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj/mark-sent",
    json={
        "simulate": "underpaid"
    },
)
data = response.json()["data"]
```

## Card outcomes

A card checkout sends the customer to a secure payment page, at the checkout's `card.payment_url`. On a simulated business it's a mock where you choose the outcome, with a test card filled in. A decline is any outcome but `approved`: `card_declined`, `verification_failed`, `region_unsupported` or `route_timeout`, which becomes the `failure_code` on the checkout and the payment. The simulation uses a test amount range of $5 to $10,000. The hosted checkout shows availability and limits for real payments.

| Outcome | Checkout | Payment | Webhooks | What happens |
| --- | --- | --- | --- | --- |
| `approved` | `processing`, then `completed` | `succeeded` | `payment.succeeded` | The selected crypto arrives and confirms. Fulfill the order. |
| Declined, retry available | `failed` | `pending` | None | The timeline gains `card_attempt_failed`, and the customer can try the another attempt within 30 minutes. |
| Declined, no retry available | `failed` | `failed` | `payment.failed` | No further card-payment attempt is available. |
| No retry in time | `failed` | `failed` | `payment.failed` | The customer didn't try another attempt within 30 minutes of a decline. |
| No outcome in time | `failed` | `failed` | `payment.failed` | The customer didn't finish on the secure payment page within 30 minutes, so it fails with `route_timeout`. |

- The customer can retry when checkout offers it, which calls `POST /checkouts/{id}/card/retry`. When no retry is available it returns 409 `no_route`.
- A failed payment you created through the API isn't final: the customer can open its checkout again and pay another way, which makes it `pending`. On a link, the next try makes a new payment.
- A price outside the card range returns 400 `amount_out_of_range` when the card checkout is created, so offer crypto for it.

## Timings

On a simulated business. A live business moves at the chain's pace: Bitcoin payments usually take about 20 minutes to confirm.

| Step | How long |
| --- | --- |
| Crypto transfer arrives | 20 seconds after the checkout opens, unless you mark it sent. |
| Confirmations | 1.6 seconds each, so about 6 seconds on Polygon and about 3 seconds on Bitcoin. |
| Card approval to transfer | 3 seconds, then the simulated payment's USDC confirms on Solana in about 3 seconds. |
| Card window | 30 minutes to finish on the secure payment page, and again after each decline. |
| Crypto quote | 15 minutes unless you change it in Settings, under Checkout. |
| API payment | Until its `expires_at`, 24 hours unless you set it. |

## Webhook failures and retries

Point an endpoint at a host under `.invalid`, such as `https://orders.invalid/webhooks`, to watch deliveries fail. Each attempt is recorded with no answer, after the 15-second timeout in a local 402pay, and retries follow the [usual schedule](https://developer.402pay.co/guides/webhooks.md#retries).

- A local 402pay may run nothing in the background. Then a retry that came due runs the next time deliveries are read, in the dashboard or with [`GET /webhook-deliveries`](https://developer.402pay.co/api/webhooks/deliveries.md), and is recorded at the time it was due.
- [Resend a delivery](https://developer.402pay.co/api/webhooks/resend.md) to try it again now. A resend doesn't change the retry schedule unless it succeeds.
- A disabled endpoint gets no new deliveries, and retries that come due while it's off are dropped.

## Watch it happen

- Each step arrives at your [webhook endpoint](https://developer.402pay.co/guides/webhooks.md), and you can read it back with [`GET /events`](https://developer.402pay.co/api/events/list.md).
- Every delivery attempt shows in the dashboard under Developers, then Webhooks, with its request and response, and you can resend any of them.
- [Send a test event](https://developer.402pay.co/api/webhooks/test.md) to check your handler without a payment. Its payload has `test: true`, and its `data` holds only a made-up subject's `id`, so skip it before you fulfill.
