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.
- 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
.invaliddomain never answers, so you can watch failures and retries. See 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, such as failed passwords per email, apply everywhere.
Test and live keys
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,v1today: 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 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
expiredand the payment staysunderpaid. - On a link, a late transfer sends
payment.expiredbeforepayment.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.
lateworks only here, sincePOST /checkoutsanswers 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
underpaidcheckout,exactsends the rest, and the payment succeeds. - A checkout that already received its transfer comes back unchanged, and any value but
lateon an expired one returns 410checkout_expired.
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj/mark-sent" \ -H "Content-Type: application/json" \ -d '{ "simulate": "underpaid" }'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 409no_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_rangewhen 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.
- 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, and is recorded at the time it was due. - Resend a delivery 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, and you can read it back with
GET /events. - 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 to check your handler without a payment. Its payload has
test: true, and itsdataholds only a made-up subject'sid, so skip it before you fulfill.