# 402pay developer docs
> 402pay is a non-custodial payments platform: businesses take cards, Apple Pay, Google Pay and crypto in one checkout, and every payment lands in a self-custody wallet only they control.
# Overview
Source: https://developer.402pay.co/
What you can build with the 402pay API, and where to start.
The 402pay API is a REST API. It takes and returns JSON, uses standard HTTP status codes, and authenticates with a secret key. Your server creates payments, customers pay on a hosted checkout, and every confirmed payment lands in a wallet only your business controls, so there's nothing to withdraw.
- [Payments](https://developer.402pay.co/guides/accept-a-payment.md): Create a payment for an exact amount and send the customer to its checkout.
- [Hosted checkout](https://developer.402pay.co/guides/hosted-checkout.md): Cards, Apple Pay, Google Pay and five coins on five networks, on one page.
- [Webhooks](https://developer.402pay.co/guides/webhooks.md): A signed event for every change, retried until your server answers.
- [Wallet](https://developer.402pay.co/guides/settlement.md): Balances and transactions for the self-custody wallet every payment lands in.
## Choose your integration
Every option lands payments in your wallet the same way. They differ in how much you build.
No code
### Payment link
Create a link in the dashboard and share it anywhere: your site, an invoice, a chat or a QR code.
[Payment links](https://developer.402pay.co/guides/payment-links.md)
- **Code needed**: None
- **Time**: Minutes
- **Customization**: The link's name, price and currency, plus your checkout settings
- **Best for**: One price for many customers, such as a product, a donation or a fixed invoice
Recommended
### Your server and hosted checkout
Your server creates a payment for each order and sends the customer to its checkout. A webhook says when it's paid.
[Accept a payment](https://developer.402pay.co/guides/accept-a-payment.md)
- **Code needed**: One API call and a webhook handler
- **Time**: An afternoon
- **Customization**: Amount, reference, metadata, redirects and expiry on every payment
- **Best for**: Stores and apps with their own orders
Most control
### Your own checkout
Your server still creates the payment, and your app shows the payment screens itself, on the public checkout endpoints.
[Build your own](https://developer.402pay.co/guides/hosted-checkout.md#custom-checkout)
- **Code needed**: The server calls, plus your own payment screens
- **Time**: A few days
- **Customization**: Every screen, in your app's own design
- **Best for**: Apps that keep customers on their own pages
## Popular tasks
- [Create a payment and send the customer to checkout](https://developer.402pay.co/guides/accept-a-payment.md)
- [Verify a webhook signature](https://developer.402pay.co/guides/webhooks.md#verify)
- [Find a payment by your order ID](https://developer.402pay.co/api/payments/list.md)
- [Rehearse underpaid and late transfers](https://developer.402pay.co/testing.md)
- [Ask for the rest of an underpaid payment](https://developer.402pay.co/guides/underpayments.md)
- [Limit what a secret key can do](https://developer.402pay.co/authentication.md)
## Base URL
Every path in these docs is relative to it.
Base URL: `https://dash.402pay.co/api/v1`
## Where to next
- [Quickstart](https://developer.402pay.co/quickstart.md): Take your first payment in five steps, from a new account to a fulfilled order.
- [Core concepts](https://developer.402pay.co/concepts.md): Payments, checkouts, links, customers, events and your wallet, and how they fit together.
- [Webhooks](https://developer.402pay.co/guides/webhooks.md): Signed events for every change, retried until your server answers.
- [Introduction](https://developer.402pay.co/api.md): The base URL, how requests and responses are shaped, and every endpoint.
# Quickstart
Source: https://developer.402pay.co/quickstart
Take your first payment in five steps, from a new account to a fulfilled order.
## Before you start
| You need | Why |
| --- | --- |
| A 402pay account | It owns your wallet, your keys and every payment. |
| A server | Secret keys only ever live on a server, never in a browser or app. |
| A public HTTPS endpoint | For the webhook that tells you when a payment succeeds. |
> Never put a secret key in browser or mobile code. Anyone who opens the developer tools could read it and act as your business. Create payments on your server and send the customer only the checkout `url`.
## Create an account
Sign up with your email and a passkey or a password, enter the code we email you, then add your business and create its wallet. It takes a few minutes. [Create an account](https://dash.402pay.co/auth/signup).
## Create a secret key
In the dashboard, open Developers, then API keys, and create a test key. Its secret starts with `402s_test_` and is shown once, so put it in your server's environment.
Shell:
```bash
export PAY402_SECRET_KEY="402s_test_..."
```
> 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. See [test and live keys](https://developer.402pay.co/testing.md#test-and-live).
## Create a payment
Create it from your server when the customer is ready to pay. The response carries a hosted checkout `url`.
cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 4900,
"currency": "USD",
"reference": "order_1042",
"description": "Pro plan, monthly",
"success_url": "https://example.com/thanks"
}'
```
Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 4900,
currency: "USD",
reference: "order_1042",
description: "Pro plan, monthly",
success_url: "https://example.com/thanks"
}),
});
const { data } = await response.json();
```
Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"amount": 4900,
"currency": "USD",
"reference": "order_1042",
"description": "Pro plan, monthly",
"success_url": "https://example.com/thanks"
},
)
data = response.json()["data"]
```
## Send the customer to checkout
Redirect to the payment's `url`. Checkout handles the coin and network, cards, short transfers and the receipt, then sends the customer to your `success_url`.
## Fulfill the order on a webhook
Add a webhook endpoint for `payment.succeeded`, in the dashboard under Developers, then Webhooks, or through the API. Its `url` must be a public `https://` address, so `localhost` and private networks are refused. Save the signing secret in the response: it's shown once.
cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/webhooks" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/402pay",
"event_types": ["payment.succeeded"],
"description": "Fulfill orders"
}'
```
Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/webhooks/402pay",
event_types: ["payment.succeeded"],
description: "Fulfill orders"
}),
});
const { data } = await response.json();
```
Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/webhooks",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"url": "https://example.com/webhooks/402pay",
"event_types": ["payment.succeeded"],
"description": "Fulfill orders"
},
)
data = response.json()["data"]
```
When it arrives, [verify the signature](https://developer.402pay.co/guides/webhooks.md#verify) and fulfill the order its `reference` points to.
webhooks.js, Node.js:
```js
// Fulfill once, when the payment succeeds.
app.post("/webhooks/402pay", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyWebhook(process.env.PAY402_WEBHOOK_SECRET, req.headers, req.body)) {
return res.sendStatus(400);
}
res.sendStatus(200);
const event = JSON.parse(req.body);
// A test delivery names a made-up payment, and its data holds only that id.
if (event.test) return;
if (event.type === "payment.succeeded") {
await orders.markPaid(event.data.reference, event.data.id);
}
});
```
> Each delivery is a signed HTTPS request to your endpoint. A local 402pay sends nothing and records each delivery with its signed request and a 200 response. Either way, read every attempt under Developers, then Webhooks, or with `GET /webhook-deliveries`. See [live and simulated businesses](https://developer.402pay.co/testing.md#sandbox).
## Next steps
- [Testing](https://developer.402pay.co/testing.md): Build with test keys and rehearse every way a payment can arrive.
- [Going live](https://developer.402pay.co/going-live.md): Everything to check before you take real payments, and after.
- [Hosted checkout](https://developer.402pay.co/guides/hosted-checkout.md): Everything between choosing how to pay and the receipt, on one hosted page.
- [Underpayments and overpayments](https://developer.402pay.co/guides/underpayments.md): What happens when a customer sends too little, too much, too late or on the wrong network.
# Core concepts
Source: https://developer.402pay.co/concepts
Payments, checkouts, links, customers, events and your wallet, and how they fit together.
## How a payment flows
1. 01 You ask for money Your server creates a **payment**, or you share a **payment link**.
1. 02 The customer opens checkout Each attempt is a **checkout**. For crypto, it reserves a fresh address in your wallet and locks the rate.
1. 03 Funds arrive A transfer lands on chain, or a card payment is approved. Checkout follows it until it confirms.
1. 04 You hear about it The payment succeeds, an **event** is recorded, and your webhook endpoint gets it.
1. 05 It lands in your wallet The money is a transaction in your **wallet**, and the payer is a **customer** you can look up.
## Businesses
A business is one merchant account, with its own wallet, keys, payments and settings, and one person can own several. A secret key belongs to one business and acts only as it. Each business has exactly one wallet, which receives every payment. IDs start with `biz_`.
[Authentication](https://developer.402pay.co/authentication.md)[Security best practices](https://developer.402pay.co/guides/security.md)
## Payments
A payment is money you're owed for one thing, at an exact amount in a currency you choose, with a status that moves from `pending` to `succeeded` and room for your own `metadata`. One you create through the API has a hosted checkout at its `url` and your own `reference`. One made through a link has its `link_id` instead, and a `url` of `null`. IDs start with `pmt_`.
[Accept a payment](https://developer.402pay.co/guides/accept-a-payment.md)[Fulfill orders reliably](https://developer.402pay.co/guides/order-fulfillment.md)
## Checkouts
A checkout is one attempt to pay a payment or link, in one rail, coin and network. A payment can have several, if the customer changes their mind, but only one can pay it: when the transfer lands on a quote the customer had left, the sibling they moved to closes with `replaced_by` naming the paid one, so open that one instead. IDs start with `chk_`.
[Hosted checkout](https://developer.402pay.co/guides/hosted-checkout.md)
## Remainders
When less than the amount due arrives, the payment becomes `underpaid`. A remainder is a checkout for exactly what's left, in the same coin and network, attached to the same payment. When the rest arrives, that payment succeeds, and no second payment is made.
[Request the rest](https://developer.402pay.co/guides/underpayments.md#request-the-rest)
## Payment links
A link is a reusable checkout with a fixed price. Every customer who pays it creates a new payment that carries the link's `link_id`. IDs start with `lnk_`.
[Payment links](https://developer.402pay.co/guides/payment-links.md)
## Customers
A customer is a person grouped by email, with their payment count and totals. One is added as soon as checkout collects an email, so a customer can have no payments yet. You can block a customer from paying you. IDs start with `cst_`.
[List customers](https://developer.402pay.co/api/customers/list.md)
## Events
An event records something that happened, such as `payment.succeeded`, with a snapshot of the object as it was. Webhooks deliver events, and you can list them any time. IDs start with `evt_`.
[Webhooks](https://developer.402pay.co/guides/webhooks.md)[Webhook event catalog](https://developer.402pay.co/guides/webhook-events.md)
## Wallet
Your wallet is self-custody: its recovery phrase is encrypted in your browser and never reaches 402pay. It keeps a balance per coin and network, and every payment's deposit is a wallet transaction. IDs start with `wal_` and `wtx_`.
[Wallet and deposits](https://developer.402pay.co/guides/settlement.md)[Reporting and reconciliation](https://developer.402pay.co/guides/reconciliation.md)
# Authentication
Source: https://developer.402pay.co/authentication
Authenticate with a secret key, scope it per resource, and keep it safe.
Send your secret key as a bearer token in the `Authorization` header. A key belongs to one business, and every request made with it acts as that business.
Authenticated request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/payments?limit=1" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Authenticated request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments?limit=1", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Authenticated request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/payments?limit=1",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
## Key types
| Prefix | Kind | Use |
| --- | --- | --- |
| `402s_test_` | Secret, test | Server-side requests while you build. |
| `402s_live_` | Secret, live | Server-side requests in production. |
| `402p_…` | Publishable | Reserved for browser-side use later. Nothing accepts it today: an endpoint that needs a key answers it with 401 `invalid_api_key`, so you can ignore it for now. |
> Keep secret keys on your server and out of source control. Only a hash is stored, so a lost secret can't be shown again: create a new key, then revoke the old one.
## Test keys and real money
A test key reads everything a live key can. On a live business, whose payments are real money, it can't move money or change what customers see or pay. These answer 403 `test_mode_unavailable` until test keys get data of their own:
- Creating a payment, opening a checkout or requesting a remainder.
- Creating, changing, archiving, restoring or deleting a payment link.
- Changing checkout settings, such as the coins you accept or who pays the fee.
- Accepting or canceling a payment.
- Blocking or unblocking a customer. Other customer fields, such as a note, can still change.
Use a live key for them. Webhooks, events and everything else a key can reach work as before, and on a simulated business, or one on test networks, a test key does everything a live key does.
## Restricted keys
A secret key has full access, or a level per resource: none, read or write. `GET` requests need read and every other method needs write, so a key that only reports on payments can't create one.
| Resource | Covers |
| --- | --- |
| Payments | Payments and metrics. |
| Links | Payment links. |
| Customers | Customer records. |
| Wallet | Balances, addresses and notes. |
| Webhooks | Endpoints, deliveries and test events. |
| Events | The event log. Read only. |
| Checkout settings | Accepted coins, rails and redirects. |
> Money never moves on an API key. Sends from your wallet happen in the dashboard, signed in your browser with your encryption password.
## Errors
| Status | Code | When |
| --- | --- | --- |
| 401 | `unauthenticated` | No key was sent. The response has a `WWW-Authenticate: Bearer` header. |
| 401 | `invalid_api_key` | The key is invalid or revoked, or it's a publishable key. |
| 401 | `invalid_api_key` | The `Authorization` header isn't `Bearer`, a space and the key, such as when the `Bearer` prefix is missing. |
| 403 | `business_forbidden` | The request also sent a `402pay-Business` header naming another business. A key acts only as its own business, so leave the header out. |
| 403 | `permission_denied` | A restricted key doesn't have the access the route needs. |
| 403 | `test_mode_unavailable` | On a live business, a test key tried to move money or change what customers see or pay. |
| 403 | `session_required` | The route is for the dashboard only, such as managing API keys or sending from your wallet. |
# Testing
Source: https://developer.402pay.co/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.
# Going live
Source: https://developer.402pay.co/going-live
Everything to check before you take real payments, and after.
> 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. See [test and live keys](https://developer.402pay.co/testing.md#test-and-live). Everything you tried while building is already in your live data, so review it before launch.
## Before you launch
- [ ] **Create a live secret key**: Swap your `402s_test_` key for a `402s_live_` one in your production environment, and restrict it to the resources your server uses.
- [ ] **Take out test-only calls**: `simulate` and `POST /checkouts/{id}/mark-sent` exist for testing. Make sure production code never sends either.
- [ ] **Add a production webhook endpoint**: Subscribe to `payment.succeeded`, plus `payment.underpaid` and `payment.needs_review` if you handle them, verify every signature, and skip deliveries whose `webhook-id` you've already handled.
- [ ] **Set your redirect URLs**: Point `success_url` and `cancel_url` at your production pages, on each payment or link. They must use `https://`; `http://` works only for `localhost` and `127.0.0.1`.
- [ ] **Choose what checkout accepts**: Pick your coins and networks, and turn cards on or off, in Settings, under Payments.
- [ ] **Rehearse every outcome**: Run each [simulated outcome](https://developer.402pay.co/testing.md#simulate) through your integration, including underpaid and late transfers and [declined cards](https://developer.402pay.co/testing.md#card-outcomes).
## Secure your account
- [ ] **Add a passkey**: Settings, under General. It signs in without a password or two-step code, and works only on 402pay's own site, so it can't be phished.
- [ ] **Use a long, unique password**: A password manager can make one for you. Change it in Settings if someone may have seen it; that signs out your other devices.
- [ ] **Turn on two-step verification**: Settings, under General. Store the recovery codes somewhere other than your authenticator.
- [ ] **Keep your email account secure**: Password resets and security emails go to the address you sign in with, so protect that mailbox with two-step verification too.
- [ ] **Back up your wallet**: Keep your recovery phrase and encryption password somewhere safe and offline. 402pay can't recover either one.
## After launch
- Watch webhook deliveries in the dashboard, and resend any that failed.
- Handle [underpaid and late payments](https://developer.402pay.co/guides/underpayments.md) as they come in, so no customer is left waiting.
- Rotate a webhook secret or API key whenever someone with access leaves your team.
# Security best practices
Source: https://developer.402pay.co/guides/security
Narrow keys, rotations without downtime, a locked-down webhook endpoint and a secure account.
Your business is reached through your team's email and password sign-in, API keys, webhook signing secrets and, for a wallet made here, a recovery phrase. Keep each one as narrow as it can be, rotate it without downtime, and notice when anything changes.
## Least-privilege keys
A secret key has full access, or a level per resource: none, read or write. Give each integration its own key with only what it uses.
| Integration | Access |
| --- | --- |
| Checkout server that creates payments and reads them back | Payments: write |
| Webhook handler | None to verify, which uses the endpoint's secret. Payments: read if it fetches the payment. |
| Sweep for missed events | Events: read, Payments: read |
| Accounting export | Payments: read, Wallet: read |
| Link manager or AI agent | Links: write, Payments: read |
- `GET` requests need read, and every other method needs write, which includes read.
- Resources you leave out get none, and a restricted key needs access to at least one.
- Events can only be read, and reading one also needs read on its subject's resource, since the event carries a snapshot of it. Asking for `include=customer` on payments needs read on customers.
- A key's access changes only in the dashboard, so no key can widen its own. A key that falls short gets 403 `permission_denied`, naming what it lacks.
## Rotate a secret key
Keys don't expire, and a new key works alongside the old one until you revoke it, so rotating takes no downtime.
1. Create a new secret key with the same access, under Developers, then API keys.
1. Deploy it everywhere the old key is used.
1. Watch the old key's Last used time in the dashboard until it stops moving.
1. Revoke the old key. It stops working at once, and revoking it again changes nothing.
A revoked key gets 401 `invalid_api_key`. Only a hash of each secret is stored, so a lost secret can't be shown again: rotate instead. Rotate whenever someone with access leaves, and at once if a key may have leaked into a commit, a log or a screenshot.
## Rotate a webhook secret
Rotate an endpoint's signing secret in the dashboard or with [`POST /webhooks/{id}/rotate-secret`](https://developer.402pay.co/api/webhooks/rotate-secret.md). The response shows the new secret once. For the next 24 hours every delivery carries a signature from each secret, so deploy the new one within that window and nothing fails in between.
Rotate a signing secret, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/rotate-secret" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Rotate a signing secret, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/rotate-secret", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Rotate a signing secret, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/rotate-secret",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
## Protect your webhook endpoint
- Verify every delivery's signature over the raw body, compare in constant time, and refuse a `webhook-timestamp` more than five minutes off. The [framework recipes](https://developer.402pay.co/guides/frameworks.md) do all three.
- Endpoint URLs must be public `https://` addresses. `http://`, `localhost` and private addresses are refused.
- Skip events with `test: true`, and any `webhook-id` you've already handled.
- An event's `data` is a snapshot. Before you ship something valuable, fetch the payment with `GET /payments/{id}` for its state now.
- Keep the signing secret with your other secrets, never in source control.
## Requests and responses
- A key already names its business, so leave out the `402pay-Business` header. Naming another business with it returns 403 `business_forbidden`.
- Send the key exactly as `Authorization: Bearer 402s_…`. Any other shape, and any publishable `402p_` key, returns 401 `invalid_api_key`.
- Responses that carry a secret, such as a new webhook endpoint or a rotated secret, are sent with `Cache-Control: no-store` and never replayed for an `Idempotency-Key`, so a retry makes another one. If creating an endpoint times out, list your endpoints before you try again.
- Log the `402pay-Request-Id` header with every error, so support can find the request.
## Watch for changes
Subscribe an endpoint to `api_key.created`, `api_key.updated`, `api_key.revoked`, `webhook.created`, `webhook.updated` and `webhook.deleted`, and alert on any you didn't expect. Each event's `actor` says who made the change. API key events are covered by no permission, so subscribe to them from the dashboard or with a key that has full access. The audit log, in Settings, shows what each person on your team did.
## Secure your account
- Sign in with a passkey: add one in Settings, under General, then choose Continue with Passkey. A passkey works only on 402pay's own site, so a lookalike page can't phish it, and there's no password or code for anyone to steal. Unlocking it with your device's fingerprint, face or PIN counts as both steps, so it never asks for a two-step code.
- If you also use a password, make it at least 15 characters and don't use it anywhere else. A password manager can make one, and a few unrelated words work well too.
- Turn on two-step verification in Settings, under General, which asks for your password first and signs out your other devices. Each code works once, and after five wrong codes, counted across sign-in attempts, codes are locked for 15 minutes with 429 `too_many_attempts`.
- Keep the ten recovery codes apart from your authenticator. Making new ones voids the old set.
- Change your password in Settings, under General, if someone may have seen it. Your other devices are signed out.
- If you forget it, reset it by email from the sign-in page. The link works once, for 30 minutes, two-step verification still asks for a code, and every device is signed out. The email lists API keys and webhooks added in the last month, so you can revoke any you don't know.
- Act on our security emails. We email you about sign-ins from a new device, passkeys added or removed, and changes to your email, password, two-step verification and wallet, and they can't be turned off. When your email changes, the old address gets a link that undoes it for 7 days. An undo also turns off API keys and webhooks added since the change, removes every passkey, and holds password resets for a day so the newer address can object.
- Expect to confirm your password or a passkey before changing the wallet, API keys, webhooks, passkeys or a business, unless you signed in within the last 15 minutes. A stolen session alone can't redirect your payments.
- Once you've been paid, deleting your wallet waits 24 hours, and the email about it has a link that cancels it, so nobody can quietly swap in a wallet of their own.
- Review signed-in devices in Settings, under General, and sign out any you don't know. Sessions end after 14 days unused, or 30 days at most.
## Next steps
- [Authentication](https://developer.402pay.co/authentication.md): Authenticate with a secret key, scope it per resource, and keep it safe.
- [Going live](https://developer.402pay.co/going-live.md): Everything to check before you take real payments, and after.
- [Webhook event catalog](https://developer.402pay.co/guides/webhook-events.md): Every event type, what its data holds, and the order a payment's events arrive in.
- [Wallet and deposits](https://developer.402pay.co/guides/settlement.md): The three kinds of wallet, how each payment lands in yours, and who holds the keys.
# Accept a payment
Source: https://developer.402pay.co/guides/accept-a-payment
Create a payment on your server, send the customer to checkout, and fulfill on the webhook.
Your server and hosted checkout
Recommended
- **Code needed**: One API call and a webhook handler
- **Time**: An afternoon
- **Customization**: Amount, reference, metadata, redirects and expiry on every payment
- **Best for**: Stores and apps with their own orders
Create the payment from your server when the customer is ready to pay, then send them to its `url`. Checkout takes it from there, and a webhook tells you when it's paid.
The customer's coins go straight to your wallet. The webhook is how your server hears about it.
- Your server (POST /payments) → Hosted checkout (the payment's url)
- Hosted checkout (the payment's url) → Customer's wallet (sends the coin)
- Customer's wallet (sends the coin) → Your wallet (funds land here)
- Your wallet (funds land here) → 402pay (sees it on chain)
- 402pay (sees it on chain) → Your server (POST /payments): payment.succeeded
## Create the payment
server.js, Node.js:
```js
// When the customer checks out, create the payment and send them to it.
app.post("/checkout", async (req, res) => {
const order = await orders.create(req.body);
const response = await fetch("https://dash.402pay.co/api/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.totalCents,
currency: "USD",
reference: order.id,
description: order.summary,
success_url: `https://example.com/orders/${order.id}`,
metadata: { order_id: order.id },
}),
});
const { data: payment } = await response.json();
res.redirect(303, payment.url);
});
```
- `amount` is in minor units of `currency`: `4900` with `USD` is $49.00.
- `reference` is your own ID. It names one live payment, so a retried create with the same reference, amount and currency returns the payment that already exists, with 200. The same reference with a different amount or currency returns 409 `reference_in_use`. Without a `reference`, every create makes a new payment.
- `expires_at` sets how long the payment can be paid, from 15 minutes to 30 days. It defaults to 24 hours.
- `success_url` is where checkout sends the customer after paying. Without it, they go to the redirect in your checkout settings, or see their receipt when there's none. Both it and `cancel_url` must use `https://`, except on `localhost` and `127.0.0.1`.
## Fulfill the order
Fulfill on the `payment.succeeded` webhook, not the redirect: a customer can close the tab before your success page loads. Answer quickly, then do the work.
webhooks.js, Node.js:
```js
// Fulfill once, when the payment succeeds.
app.post("/webhooks/402pay", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyWebhook(process.env.PAY402_WEBHOOK_SECRET, req.headers, req.body)) {
return res.sendStatus(400);
}
res.sendStatus(200);
const event = JSON.parse(req.body);
// A test delivery names a made-up payment, and its data holds only that id.
if (event.test) return;
if (event.type === "payment.succeeded") {
await orders.markPaid(event.data.reference, event.data.id);
}
});
```
## Payment statuses
| Status | Meaning |
| --- | --- |
| `pending` | Waiting for the customer, or for funds to arrive. |
| `succeeded` | Paid in full, or accepted: the funds landed in your wallet, or paid fees you owed. Fulfill the order. |
| `underpaid` | Less than the amount due arrived. Checkout asks for the rest, or you can accept it. |
| `needs_review` | Funds arrived in a way that needs your decision, such as after the checkout expired. |
| `failed` | A payment was canceled, by the business or by the customer at a link's checkout, or a declined card had no route left or wasn't retried within 30 minutes. No funds landed in the wallet. It isn't always final: an API payment you didn't cancel can still be paid, since the customer's next attempt makes it `pending` again. It's over once `canceled_at` is set. |
| `expired` | Nothing arrived before the payment, or a link's quote, expired. |
## Cancel or accept
Cancel a payment that hasn't received funds with [`POST /payments/{id}/cancel`](https://developer.402pay.co/api/payments/cancel.md). Count an underpaid payment, or one that needs review, as paid with [`POST /payments/{id}/accept`](https://developer.402pay.co/api/payments/accept.md).
## Next steps
- [Webhooks](https://developer.402pay.co/guides/webhooks.md): Signed events for every change, retried until your server answers.
- [Underpayments and overpayments](https://developer.402pay.co/guides/underpayments.md): What happens when a customer sends too little, too much, too late or on the wrong network.
# Fulfill orders reliably
Source: https://developer.402pay.co/guides/order-fulfillment
Keep one payment per order, map each status to the order, and ship exactly once.
An order is paid when its payment succeeds, and only then. This guide covers the parts that make that reliable: one payment per order, what each status means for the order, and how to ship exactly once even when a webhook arrives twice or not at all.
## One payment per order
Pass your order's ID as the payment's `reference`, up to 64 characters, and save the payment's `id` on the order.
Create the payment, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 4900,
"currency": "USD",
"reference": "order_1042",
"description": "Pro plan, monthly",
"success_url": "https://example.com/orders/order_1042/complete",
"cancel_url": "https://example.com/cart"
}'
```
Create the payment, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 4900,
currency: "USD",
reference: "order_1042",
description: "Pro plan, monthly",
success_url: "https://example.com/orders/order_1042/complete",
cancel_url: "https://example.com/cart"
}),
});
const { data } = await response.json();
```
Create the payment, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"amount": 4900,
"currency": "USD",
"reference": "order_1042",
"description": "Pro plan, monthly",
"success_url": "https://example.com/orders/order_1042/complete",
"cancel_url": "https://example.com/cart"
},
)
data = response.json()["data"]
```
- A reference names one live payment: one that isn't expired or canceled. Creating again with the same reference, amount and currency returns that payment with 200 instead of making a new one with 201, so a customer who clicks Pay twice, or a retried request, still gets one payment.
- A succeeded payment keeps its reference, so check the order is still unpaid before you send the customer to checkout again.
- Once a payment expires or is canceled, its reference is free, and the next create makes a new payment for the order.
## When the order changes
A payment's amount never changes: [`PATCH /payments/{id}`](https://developer.402pay.co/api/payments/update.md) only edits its metadata. Creating again with the same reference and a new amount returns 409 `reference_in_use`. When the cart changes, cancel the old payment, then create a new one with the same reference, and save its `id` on the order in place of the old one.
Cancel the old payment, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Cancel the old payment, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Cancel the old payment, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
- Canceling closes any checkout the customer has open for it, and you receive `payment.failed` with `canceled_at` set. Canceling twice returns the payment as it is.
- It returns 409 `payment_in_flight` while a transfer is on its way, and 409 `payment_not_cancelable` once funds have arrived. Wait for the payment to finish, then decide what to do with the difference.
- `expires_at` sets how long the customer has to pay, from 15 minutes to 30 days, and defaults to 24 hours. Match it to how long you hold the order's price or stock, so an abandoned payment ends on its own.
## Map statuses to your order
A payment is finished when it has succeeded, expired, or failed with `canceled_at` set. Everything else can still change.
- pending (customer is paying) → succeeded (fulfill the order): paid in full
- pending (customer is paying) → underpaid (part arrived)
- underpaid (part arrived) → succeeded (fulfill the order)
- pending (customer is paying) → needs_review (late or other network)
- needs_review (late or other network) → succeeded (fulfill the order)
- pending (customer is paying) → expired (nothing arrived)
- expired (nothing arrived) → needs_review (late or other network): funds arrive late
- pending (customer is paying) → failed (declined or canceled)
- failed (declined or canceled) → pending (customer is paying): declined card, tried again
| Status | Final | What to do with the order |
| --- | --- | --- |
| `pending` | No | Hold it while the customer pays. |
| `succeeded` | Yes | Fulfill it. |
| `underpaid` | No | Part of the amount arrived. Wait for the rest, request it, or accept what arrived. |
| `needs_review` | No | Funds arrived late or on another network. Accept them and fulfill, or return them and release the order. |
| `failed` | Only with `canceled_at` | Without `canceled_at`, a card was declined and the customer can try again on the same payment, which then goes back to `pending`. Cancel the payment when you give up on the order. |
| `expired` | Yes, unless funds arrive late | Release the order, or create a new payment for it. A transfer that lands after expiry makes the payment `needs_review`. |
> `failed` works this way only for payments you create through the API, which the customer can pay again from the same `url`. A link payment that fails is final: paying the link again makes a new payment.
## Fulfill on the webhook
Fulfill when [a verified webhook](https://developer.402pay.co/guides/webhooks.md) says `payment.succeeded`, not when the customer lands on your success page: they can close the tab first. Answer with a 2xx right away, then do the work.
webhooks.js, Node.js:
```js
// Called with each verified payment event. Every one carries the
// payment as it was, with the reference you gave it.
async function handlePaymentEvent(event) {
if (event.test) return;
const payment = event.data;
const order = await orders.get(payment.reference);
// A payment you replaced after the order changed no longer speaks for it.
if (!order || order.paymentId !== payment.id) return;
switch (event.type) {
case "payment.succeeded":
// Only an order that's still open changes, so a repeated delivery can't ship twice.
await orders.markPaid(order.id, { received: payment.amount_received });
break;
case "payment.underpaid":
case "payment.needs_review":
await orders.holdForReview(order.id);
break;
case "payment.expired":
await orders.release(order.id);
break;
case "payment.failed":
// A declined card can try again on the same payment; a canceled one can't.
if (payment.canceled_at) await orders.release(order.id);
break;
}
}
```
- Never fulfill on `payment.created`. It only says the payment exists.
- `payment.succeeded` also arrives when you accept an underpaid payment, or one that needs review, and when the rest of an underpaid payment arrives. After an accept, `amount_received` can be less than `amount`, so compare them if a part payment matters to you.
- An overpayment sends `payment.succeeded`, then `payment.overpaid`. Fulfill on the first and treat the second as a note about the excess.
- A delivery that doesn't get a 2xx is retried with the same `webhook-id`, and you can resend any delivery, so the same event can arrive more than once. Make the change itself idempotent: mark the order paid only if it isn't yet, and ship only when that changed something.
## The success page
After a successful payment, checkout sends the customer to your `success_url` with `payment_id` added to its query. Anyone can type that URL, so read the payment from your server and check it names the order before you show a confirmation. Leave fulfillment to the webhook.
server.js, Node.js:
```js
// Checkout adds ?payment_id= to your success_url.
app.get("/orders/:id/complete", async (req, res) => {
const response = await fetch(
`https://dash.402pay.co/api/v1/payments/${encodeURIComponent(req.query.payment_id)}`,
{ headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` } },
);
const payment = response.ok ? (await response.json()).data : null;
// Anyone can edit a query string, so trust only a payment that names this order.
const paid = payment?.reference === req.params.id && payment.status === "succeeded";
res.render(paid ? "order-confirmed" : "order-pending", { orderId: req.params.id });
});
```
`cancel_url` is where checkout's link back to your site goes, for a customer who leaves without paying. The payment stays open until it expires or you cancel it, so the customer can come back to its `url` and pay.
## Catch anything missed
Deliveries are retried for about 28 hours before they're marked failed, so an outage longer than that can lose one. Sweep on a schedule as well: list the `payment.succeeded` events since your last run and pass each one through the same idempotent handler. Events are newest first; follow `next_cursor` until `has_more` is `false`.
Payments that succeeded since the last sweep, cURL:
```bash
curl "https://dash.402pay.co/api/v1/events?type=payment.succeeded&created_after=2026-09-26T00:00:00Z&limit=100" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Payments that succeeded since the last sweep, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/events?type=payment.succeeded&created_after=2026-09-26T00:00:00Z&limit=100", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Payments that succeeded since the last sweep, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/events?type=payment.succeeded&created_after=2026-09-26T00:00:00Z&limit=100",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
A restricted key needs read access to events and to payments for this. See [least-privilege keys](https://developer.402pay.co/guides/security.md#least-privilege).
## Next steps
- [Framework recipes](https://developer.402pay.co/guides/frameworks.md): Create payments, handle the return and verify webhooks in Next.js, Express, Django and Laravel, without an SDK.
- [Webhook event catalog](https://developer.402pay.co/guides/webhook-events.md): Every event type, what its data holds, and the order a payment's events arrive in.
- [Underpayments and overpayments](https://developer.402pay.co/guides/underpayments.md): What happens when a customer sends too little, too much, too late or on the wrong network.
- [Refunds, returns and disputes](https://developer.402pay.co/guides/refunds.md): Send refunds from your wallet, return funds you don't accept, and keep disputes rare.
# Framework recipes
Source: https://developer.402pay.co/guides/frameworks
Create payments, handle the return and verify webhooks in Next.js, Express, Django and Laravel, without an SDK.
The API is HTTPS and JSON, and webhooks follow the Standard Webhooks spec, so there's no SDK to install. Each recipe below does the same things in Next.js, Express, Django and Laravel: create the payment and send the customer to checkout, show the right page when they come back, and fulfill the order once, on a verified webhook.
## Before you start
| Environment variable | Value |
| --- | --- |
| `PAY402_SECRET_KEY` | A secret key that can write payments. It stays on your server. |
| `PAY402_WEBHOOK_SECRET` | The signing secret of your webhook endpoint, shown once when you add it. |
The recipes keep orders in a table like this one. The JavaScript recipes reach it with node-postgres, and Django and Laravel through an `Order` model. `shipOrder`, `ship_order` and `ShipOrder` stand for your own fulfillment.
SQL:
```
create table orders (
id text primary key,
status text not null default 'pending',
total_cents integer not null,
summary text not null,
payment_id text
);
```
## Create the payment
When the customer checks out, create a payment for the order and redirect them to its `url`.
Create, Next.js:
```ts
// app/checkout/route.ts: the cart's form posts here.
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
const API = "https://dash.402pay.co/api/v1";
export async function POST(request: Request) {
const form = await request.formData();
const {
rows: [order],
} = await db.query("select * from orders where id = $1 and status = 'pending'", [form.get("order_id")]);
if (!order) return new Response("Order not found", { status: 404 });
const response = await fetch(`${API}/payments`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.total_cents,
currency: "USD",
reference: order.id,
description: order.summary,
success_url: `https://example.com/orders/${order.id}/complete`,
cancel_url: "https://example.com/cart",
}),
});
const body = await response.json();
// 201 is a new payment; 200 is the live one this order's reference already names.
if (!response.ok) {
console.error("402pay", body.error.code, response.headers.get("402pay-Request-Id"));
return new Response("Couldn't start checkout", { status: 502 });
}
return NextResponse.redirect(body.data.url, 303);
}
```
Create, Express:
```js
// server.js
import crypto from "node:crypto";
import express from "express";
import pg from "pg";
const API = "https://dash.402pay.co/api/v1";
const db = new pg.Pool();
const app = express();
// The cart's form posts here.
app.post("/checkout", express.urlencoded({ extended: false }), async (req, res) => {
const {
rows: [order],
} = await db.query("select * from orders where id = $1 and status = 'pending'", [req.body.order_id]);
if (!order) return res.sendStatus(404);
const response = await fetch(`${API}/payments`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.total_cents,
currency: "USD",
reference: order.id,
description: order.summary,
success_url: `https://example.com/orders/${order.id}/complete`,
cancel_url: "https://example.com/cart",
}),
});
const body = await response.json();
// 201 is a new payment; 200 is the live one this order's reference already names.
if (!response.ok) {
console.error("402pay", body.error.code, response.headers.get("402pay-Request-Id"));
return res.status(502).send("Couldn't start checkout");
}
res.redirect(303, body.data.url);
});
```
Create, Django:
```python
# views.py, routed with path("checkout", views.checkout)
import logging
import os
import requests
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views.decorators.http import require_POST
from .models import Order
API = "https://dash.402pay.co/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}"}
logger = logging.getLogger(__name__)
@require_POST
def checkout(request):
order = get_object_or_404(Order, id=request.POST.get("order_id"), status="pending")
response = requests.post(
f"{API}/payments",
headers=AUTH,
json={
"amount": order.total_cents,
"currency": "USD",
"reference": order.id,
"description": order.summary,
"success_url": f"https://example.com/orders/{order.id}/complete",
"cancel_url": "https://example.com/cart",
},
timeout=10,
)
body = response.json()
# 201 is a new payment; 200 is the live one this order's reference already names.
if not response.ok:
logger.error("402pay %s %s", body["error"]["code"], response.headers.get("402pay-Request-Id"))
return HttpResponse("Couldn't start checkout", status=502)
return redirect(body["data"]["url"])
```
Create, Laravel:
```php
// config/services.php
'pay402' => [
'secret_key' => env('PAY402_SECRET_KEY'),
'webhook_secret' => env('PAY402_WEBHOOK_SECRET'),
],
// routes/web.php
Route::post('/checkout', [CheckoutController::class, 'store']);
Route::get('/orders/{order}/complete', [CheckoutController::class, 'complete']);
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CheckoutController extends Controller
{
public function store(Request $request)
{
$order = Order::where('status', 'pending')->findOrFail($request->input('order_id'));
$response = Http::withToken(config('services.pay402.secret_key'))
->post('https://dash.402pay.co/api/v1/payments', [
'amount' => $order->total_cents,
'currency' => 'USD',
'reference' => $order->id,
'description' => $order->summary,
'success_url' => "https://example.com/orders/{$order->id}/complete",
'cancel_url' => 'https://example.com/cart',
]);
// 201 is a new payment; 200 is the live one this order's reference already names.
if ($response->failed()) {
Log::error('402pay', [
'code' => $response->json('error.code'),
'request_id' => $response->header('402pay-Request-Id'),
]);
abort(502, "Couldn't start checkout");
}
return redirect()->away($response->json('data.url'), 303);
}
```
- The order's ID is the `reference`, so a double click or a retry returns the same payment with 200 instead of a new one with 201. Treat both as success.
- On any other status, log the error's `code` and the `402pay-Request-Id` header. Support can find the request by it.
- `success_url` and `cancel_url` must use `https://`, except on `localhost`.
## Handle the return
After paying, the customer lands on your `success_url` with `payment_id` in the query. Read that payment from your server, and show a confirmation only if it succeeded and names this order.
Return, Next.js:
```ts
// app/orders/[id]/complete/page.tsx
const API = "https://dash.402pay.co/api/v1";
async function getPayment(id: string) {
const response = await fetch(`${API}/payments/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` },
cache: "no-store",
});
return response.ok ? (await response.json()).data : null;
}
// Checkout adds ?payment_id= to your success_url.
export default async function OrderComplete({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ payment_id?: string }>;
}) {
const { id } = await params;
const { payment_id } = await searchParams;
const payment = payment_id ? await getPayment(payment_id) : null;
// Anyone can edit a query string, so trust only a payment that names this order.
const paid = payment?.reference === id && payment.status === "succeeded";
return
{paid ? "Thanks, your order is confirmed." : "We haven't received your payment yet."}
;
}
```
Return, Express:
```js
// server.js, continued. Checkout adds ?payment_id= to your success_url.
app.get("/orders/:id/complete", async (req, res) => {
const paymentId = encodeURIComponent(String(req.query.payment_id ?? ""));
const response = await fetch(`${API}/payments/${paymentId}`, {
headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` },
});
const payment = response.ok ? (await response.json()).data : null;
// Anyone can edit a query string, so trust only a payment that names this order.
const paid = payment?.reference === req.params.id && payment.status === "succeeded";
res.send(paid ? "Thanks, your order is confirmed." : "We haven't received your payment yet.");
});
```
Return, Django:
```python
# views.py, continued; routed with path("orders//complete", views.order_complete)
from urllib.parse import quote
from django.shortcuts import render
def order_complete(request, order_id):
# Checkout adds ?payment_id= to your success_url.
payment_id = request.GET.get("payment_id", "")
payment = None
if payment_id.startswith("pmt_"):
response = requests.get(f"{API}/payments/{quote(payment_id, safe='')}", headers=AUTH, timeout=10)
payment = response.json()["data"] if response.ok else None
# Anyone can edit a query string, so trust only a payment that names this order.
paid = payment is not None and payment["reference"] == order_id and payment["status"] == "succeeded"
return render(request, "orders/complete.html", {"paid": paid})
```
Return, Laravel:
```php
// CheckoutController, continued. Checkout adds ?payment_id= to your success_url.
public function complete(Request $request, Order $order)
{
$paymentId = (string) $request->query('payment_id', '');
$payment = null;
if (str_starts_with($paymentId, 'pmt_')) {
$response = Http::withToken(config('services.pay402.secret_key'))
->get('https://dash.402pay.co/api/v1/payments/'.rawurlencode($paymentId));
$payment = $response->successful() ? $response->json('data') : null;
}
// Anyone can edit a query string, so trust only a payment that names this order.
$paid = $payment !== null
&& $payment['reference'] === (string) $order->id
&& $payment['status'] === 'succeeded';
return view('orders.complete', ['paid' => $paid]);
}
}
```
> The return page only shows the status. Fulfill on the webhook, which arrives even if the customer closes the tab before your page loads.
## Receive webhooks
Add an endpoint for `payment.succeeded` in the dashboard, under Developers, then Webhooks, or with [`POST /webhooks`](https://developer.402pay.co/api/webhooks/create.md). Each delivery is signed over its exact bytes, so every recipe verifies the raw body before it parses anything.
Webhook, Next.js:
```ts
// app/webhooks/402pay/route.ts
import crypto from "node:crypto";
import { db } from "@/lib/db";
import { shipOrder } from "@/lib/fulfillment";
export async function POST(request: Request) {
// The signature covers the exact bytes sent, so read the body as text and verify it first.
const body = await request.text();
if (!verifyWebhook(process.env.PAY402_WEBHOOK_SECRET ?? "", request.headers, body)) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(body);
if (!event.test && event.type === "payment.succeeded") {
const payment = event.data;
// Only a pending order changes, so a repeated delivery can't ship it twice.
const { rowCount } = await db.query(
"update orders set status = 'paid', payment_id = $2 where id = $1 and status = 'pending'",
[payment.reference, payment.id],
);
if (rowCount === 1) await shipOrder(payment.reference);
}
return new Response(null, { status: 204 });
}
function verifyWebhook(secret: string, headers: Headers, body: string): boolean {
const id = headers.get("webhook-id") ?? "";
const timestamp = headers.get("webhook-timestamp") ?? "";
const signatures = headers.get("webhook-signature") ?? "";
// Refuse replays: the delivery must be less than five minutes old.
const sent = Number(timestamp);
if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
// During a rotation the header carries one signature per secret.
return signatures.split(" ").some((entry) => {
const [version, signature = ""] = entry.split(",");
return (
version === "v1" &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
}
```
Webhook, Express:
```js
// server.js, continued. Registered before any app-wide express.json(),
// which would parse the body before this route could verify it.
app.post("/webhooks/402pay", express.raw({ type: "application/json" }), async (req, res) => {
// The signature covers the exact bytes sent, so verify them before parsing.
const body = req.body.toString("utf8");
if (!verifyWebhook(process.env.PAY402_WEBHOOK_SECRET ?? "", req.headers, body)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
if (!event.test && event.type === "payment.succeeded") {
const payment = event.data;
// Only a pending order changes, so a repeated delivery can't ship it twice.
const { rowCount } = await db.query(
"update orders set status = 'paid', payment_id = $2 where id = $1 and status = 'pending'",
[payment.reference, payment.id],
);
if (rowCount === 1) await shipOrder(payment.reference);
}
res.sendStatus(204);
});
function verifyWebhook(secret, headers, body) {
const id = String(headers["webhook-id"] ?? "");
const timestamp = String(headers["webhook-timestamp"] ?? "");
const signatures = String(headers["webhook-signature"] ?? "");
// Refuse replays: the delivery must be less than five minutes old.
const sent = Number(timestamp);
if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
// During a rotation the header carries one signature per secret.
return signatures.split(" ").some((entry) => {
const [version, signature = ""] = entry.split(",");
return (
version === "v1" &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
}
```
Webhook, Django:
```python
# views.py, continued; routed with path("webhooks/402pay", views.webhook)
import base64
import hashlib
import hmac
import json
import time
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt # Deliveries carry a signature instead of a CSRF token.
@require_POST
def webhook(request):
# The signature covers the exact bytes sent, so verify request.body before parsing it.
if not verify_webhook(os.environ["PAY402_WEBHOOK_SECRET"], request.headers, request.body):
return HttpResponse("Invalid signature", status=400)
event = json.loads(request.body)
if not event.get("test") and event["type"] == "payment.succeeded":
payment = event["data"]
# Only a pending order changes, so a repeated delivery can't ship it twice.
changed = Order.objects.filter(id=payment["reference"], status="pending").update(
status="paid", payment_id=payment["id"]
)
if changed:
ship_order(payment["reference"])
return HttpResponse(status=204)
def verify_webhook(secret: str, headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
# Refuse replays: the delivery must be less than five minutes old.
if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
# During a rotation the header carries one signature per secret.
return any(
hmac.compare_digest(entry[3:].encode(), expected)
for entry in headers.get("webhook-signature", "").split(" ")
if entry.startswith("v1,")
)
```
Webhook, Laravel:
```php
// routes/web.php
Route::post('/webhooks/402pay', PaymentWebhookController::class);
// bootstrap/app.php: deliveries carry a signature instead of a CSRF token.
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/402pay']);
})
// app/Http/Controllers/PaymentWebhookController.php
namespace App\Http\Controllers;
use App\Jobs\ShipOrder;
use App\Models\Order;
use Illuminate\Http\Request;
class PaymentWebhookController extends Controller
{
public function __invoke(Request $request)
{
// The signature covers the exact bytes sent, so verify the raw body before parsing it.
$body = $request->getContent();
if (! $this->verify((string) config('services.pay402.webhook_secret'), $request, $body)) {
return response('Invalid signature', 400);
}
$event = json_decode($body, true);
if (empty($event['test']) && $event['type'] === 'payment.succeeded') {
$payment = $event['data'];
// Only a pending order changes, so a repeated delivery can't ship it twice.
$changed = Order::where('id', $payment['reference'])
->where('status', 'pending')
->update(['status' => 'paid', 'payment_id' => $payment['id']]);
if ($changed) {
ShipOrder::dispatch($payment['reference']);
}
}
return response()->noContent();
}
private function verify(string $secret, Request $request, string $body): bool
{
$id = (string) $request->header('webhook-id');
$timestamp = (string) $request->header('webhook-timestamp');
// Refuse replays: the delivery must be less than five minutes old.
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
// During a rotation the header carries one signature per secret.
foreach (explode(' ', (string) $request->header('webhook-signature')) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}
```
| Framework | Raw body | Also |
| --- | --- | --- |
| Next.js | `await request.text()` | Don't call `request.json()` first. |
| Express | `express.raw()` | On the route, registered before any app-wide `express.json()`. |
| Django | `request.body` | `@csrf_exempt`, since deliveries carry no CSRF token. |
| Laravel | `$request->getContent()` | Exclude the route from CSRF checks: `validateCsrfTokens` in `bootstrap/app.php` on Laravel 11 and later, `$except` in `VerifyCsrfToken` before that. |
Answer with a 2xx within 15 seconds. Test deliveries have `test: true` and a made-up payment, so each recipe skips them. See [responses and retries](https://developer.402pay.co/guides/webhooks.md#retries).
## Fulfill once
A delivery that doesn't get a 2xx is retried with the same `webhook-id`, and anyone on your team can resend one, so the same event can arrive twice. Each recipe makes the change itself idempotent: it marks the order paid only while it's still pending, and ships only when that changed a row.
SQL:
```
-- Only a pending order changes, so running this twice ships nothing twice.
update orders set status = 'paid', payment_id = $2 where id = $1 and status = 'pending';
-- For a handler that does more than one thing: remember each event it finished,
-- in the same transaction as the work, and skip an event whose insert changes nothing.
create table processed_webhooks (
id text primary key,
processed_at timestamptz not null default now()
);
insert into processed_webhooks (id) values ($1) on conflict (id) do nothing;
```
If your handler does several things for one event, also keep the `webhook-id` of each event it finished and skip any it has seen. Slow work, such as emails or calls to other services, belongs in a queue, so the delivery gets its answer in time.
## Test locally
Endpoint URLs must be public `https://` addresses: `localhost` and private addresses are refused. When deliveries go out, reach your machine through an HTTPS tunnel and add the tunnel's URL as an endpoint.
> A local 402pay sends no requests, so a tunnel receives nothing from it. Replay its deliveries to your local server instead.
Pay a test payment, then find its delivery under Developers, then Webhooks, or with [`GET /webhook-deliveries`](https://developer.402pay.co/api/webhooks/deliveries.md). This script resends it and posts the fresh copy to your server, signed with your endpoint's secret.
replay.mjs, Node.js:
```js
// replay.mjs: node replay.mjs dlv_... http://localhost:8000/webhooks/402pay
// Resending gives the delivery a fresh timestamp and signature, so it passes
// your handler's five-minute check.
const [deliveryId, target] = process.argv.slice(2);
const response = await fetch(`https://dash.402pay.co/api/v1/webhook-deliveries/${deliveryId}/resend`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` },
});
const { data: delivery } = await response.json();
const local = await fetch(target, {
method: "POST",
headers: delivery.request_headers,
// Compact, with keys in the order they arrived: the bytes that were signed.
body: JSON.stringify(delivery.payload),
});
console.log(local.status, await local.text());
```
To rehearse failures, point an endpoint at a host under the reserved `.invalid` domain, which never answers, and watch the retries. See [testing](https://developer.402pay.co/testing.md) for simulated payment outcomes.
## Next steps
- [Fulfill orders reliably](https://developer.402pay.co/guides/order-fulfillment.md): Keep one payment per order, map each status to the order, and ship exactly once.
- [Webhook event catalog](https://developer.402pay.co/guides/webhook-events.md): Every event type, what its data holds, and the order a payment's events arrive in.
- [Security best practices](https://developer.402pay.co/guides/security.md): Narrow keys, rotations without downtime, a locked-down webhook endpoint and a secure account.
- [Going live](https://developer.402pay.co/going-live.md): Everything to check before you take real payments, and after.
# Payment links
Source: https://developer.402pay.co/guides/payment-links
Reusable hosted checkouts with a fixed price that you can share anywhere.
Payment link
No code
- **Code needed**: None
- **Time**: Minutes
- **Customization**: The link's name, price and currency, plus your checkout settings
- **Best for**: One price for many customers, such as a product, a donation or a fixed invoice
A link is a reusable hosted checkout with a fixed price. Create one in the dashboard or with [`POST /links`](https://developer.402pay.co/api/links/create.md), then share its `url` anywhere: your site, an invoice, a chat or a QR code.
Create a link, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/links" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Team plan, monthly",
"amount": 19900,
"currency": "USD"
}'
```
Create a link, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Team plan, monthly",
amount: 19900,
currency: "USD"
}),
});
const { data } = await response.json();
```
Create a link, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/links",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"name": "Team plan, monthly",
"amount": 19900,
"currency": "USD"
},
)
data = response.json()["data"]
```
## How links work
Every visit to a link makes its own payment, so one link can be paid any number of times.
- Payment link (one url, reused) → Payment (with the link_id)
- Payment (with the link_id) → succeeded (paid in full)
- Payment (with the link_id) → failed (canceled, final)
- Payment (with the link_id) → expired (the quote ran out)
- expired (the quote ran out) → Payment link (one url, reused): the next visit starts a new payment
- Price it in USD, EUR, GBP, CAD and AUD. Customers pay the equivalent in the coin they choose.
- Every payment made through a link carries its `link_id`, so `GET /payments?link_id=…` lists them.
- Archive a link to stop taking payments without losing its history. A checkout a customer already opened keeps working, so they can still finish paying or send the rest.
- A link's payment lasts as long as its quote. If the quote expires before anything arrives, the payment is `expired`, and the customer's next visit starts a new one.
- If the customer cancels at checkout, the link's payment is `failed` and you receive `payment.failed`. Unlike a payment you create through the API, it can't be paid again.
- A link's payments can have their remainder requested when a customer sends too little.
## Links or payments
| Use | When |
| --- | --- |
| A payment link | One price for many customers, shared without code, such as a product or a donation. |
| An API payment | One order for one customer, with your own reference and metadata, created by your server. |
# Recurring and repeat payments
Source: https://developer.402pay.co/guides/recurring-payments
Bill subscriptions and invoices with one payment per period, sent to the customer on your schedule.
402pay has no subscription object and never charges anyone on its own: crypto can't be pulled from a customer's wallet, and cards aren't kept on file. Bill each period with a payment of its own, created by your server on your schedule, and send the customer its `url`.
## One payment per period
Make the `reference` name the subscription and the period, such as `sub_42-2026-10`. A retried run of your billing job then gets the same payment back, and each period gets its own.
Bill a period, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 4900,
"currency": "USD",
"reference": "sub_42-2026-10",
"description": "Pro plan, October 2026",
"customer_email": "harper.wilson@example.com",
"success_url": "https://example.com/billing",
"metadata": {
"subscription_id": "sub_42",
"period": "2026-10"
}
}'
```
Bill a period, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 4900,
currency: "USD",
reference: "sub_42-2026-10",
description: "Pro plan, October 2026",
customer_email: "harper.wilson@example.com",
success_url: "https://example.com/billing",
metadata: {
subscription_id: "sub_42",
period: "2026-10"
}
}),
});
const { data } = await response.json();
```
Bill a period, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"amount": 4900,
"currency": "USD",
"reference": "sub_42-2026-10",
"description": "Pro plan, October 2026",
"customer_email": "harper.wilson@example.com",
"success_url": "https://example.com/billing",
"metadata": {
"subscription_id": "sub_42",
"period": "2026-10"
}
},
)
data = response.json()["data"]
```
- `customer_email` links the payment to the customer, adding one if they're new, and checkout doesn't ask them for it again.
- Put the subscription and period in `metadata` too, so you can list and filter them later.
- The amount is fixed per payment. For a plan change mid-period, bill the new price on the next period's payment, or cancel this one and create it again.
## Send it to the customer
402pay doesn't email customers. Send the payment's `url` yourself, in your billing email or in your app, with the amount and the period it covers. The same link works until the payment is paid, expires or is canceled.
## Due dates and reminders
- Set `expires_at` to the end of your grace period, up to 30 days out. Without it, a payment expires after 24 hours, which is short for an invoice.
- A payment never expires while the customer has a checkout open for it. When it does, you receive `payment.expired`.
- An expired payment frees its reference. To give a late customer another chance, create the payment again with the same reference and send the new `url`.
## Track what's paid
Mark the period paid on `payment.succeeded`, whose payment carries your reference and metadata. To see a subscription's history, filter payments by its metadata.
A subscription's payments, cURL:
```bash
curl -g "https://dash.402pay.co/api/v1/payments?metadata[subscription_id]=sub_42&status=succeeded" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
A subscription's payments, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments?metadata[subscription_id]=sub_42&status=succeeded", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
A subscription's payments, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/payments?metadata[subscription_id]=sub_42&status=succeeded",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
`GET /payments?customer_id=…` lists every payment of one customer, across subscriptions.
## Good to know
- A blocked customer can't pay: checkout refuses their email with 403 `customer_blocked`. Block or unblock someone in the dashboard or with [`PATCH /customers/{id}`](https://developer.402pay.co/api/customers/update.md).
- A [payment link](https://developer.402pay.co/guides/payment-links.md) can take the same price again and again, but its payments have no reference, so you match them to customers by email rather than to periods. For billing, a payment per period is easier to reconcile.
- Amounts are in your currency, and checkout quotes the coin amount when the customer pays, so a period costs the same whatever a coin's price does.
# Hosted checkout
Source: https://developer.402pay.co/guides/hosted-checkout
Everything between choosing how to pay and the receipt, on one hosted page.
Your server and hosted checkout
Recommended
- **Code needed**: One API call and a webhook handler
- **Time**: An afternoon
- **Customization**: Amount, reference, metadata, redirects and expiry on every payment
- **Best for**: Stores and apps with their own orders
Every link, and every payment you create through the API, has a hosted checkout at its `url`. It handles everything between the customer choosing how to pay and their receipt, on any screen. A payment made through a link has no `url` of its own: the customer paid on the link's.
## What the customer sees
- The customer picks card or crypto, then a coin and network from the ones you accept.
- A card goes straight to the card payment page, in a new tab or, on a phone, the same one, and checkout waits on it. See [card payments](https://developer.402pay.co/guides/card-payments.md#checkout-flow).
- Each crypto checkout pays to a fresh address in your wallet, at a rate that holds while it's open, so every transfer matches one payment.
- Checkout spots the transfer and follows it until it has the confirmations it needs.
- A short transfer gets a request for exactly what's left, and an overpayment lands in your wallet in full. See [underpayments](https://developer.402pay.co/guides/underpayments.md).
- When it's paid, the customer gets a receipt, or goes to your `success_url`.
## What checkout handles, and what you own
| Checkout handles | You own |
| --- | --- |
| Coin, network and card choice | Which coins, networks and rails you accept, in Settings |
| Fresh addresses and locked rates | The price, currency and reference of each payment |
| Detection, confirmations and short transfers | Fulfilling the order when the webhook arrives |
| The receipt | Your success and cancel pages |
## Checkout statuses
| Status | Meaning |
| --- | --- |
| `open` | Waiting for the customer to pay. |
| `awaiting_customer` | Waiting for the customer to finish paying by card. |
| `processing` | A card payment is being approved. |
| `confirming` | The transfer arrived and is collecting confirmations. |
| `underpaid` | Part of the amount arrived. The customer can send the rest. |
| `completed` | Paid in full. |
| `expired` | Nothing arrived in time, or the transfer landed on a sibling quote, which `replaced_by` names. |
| `canceled` | The customer or you canceled it. `canceled_by` says which. |
| `failed` | The payment couldn't be completed. |
| `needs_review` | Funds arrived but need your decision, such as after expiry or on another network. |
## Build your own
The hosted page runs on public endpoints you can call yourself. [Create a checkout](https://developer.402pay.co/api/checkouts/create.md) with the payment's code, the last part of its `url`, then [read it](https://developer.402pay.co/api/checkouts/retrieve.md) every few seconds until its `status` is `completed`. When the customer goes back to choose again, supersede the old checkout and pass its ID as `replaces` on the new one, so the same choice keeps the same quote and address. If the transfer then lands on the quote the customer left, that quote is the one being paid and the new one closes with `replaced_by` naming it: read that one instead, and never offer a new quote for a checkout closed that way.
For a card, send the customer to the checkout's `card.payment_url` as it is. Browsers only let a tab open during the customer's tap, so open a blank one as they press your button, before you create the checkout, then point it at the URL; where it can't open, go there in the same tab. The card payment page returns the customer to the checkout's page on the hosted checkout.
# Card payments
Source: https://developer.402pay.co/guides/card-payments
Accept cards, Apple Pay and Google Pay through hosted checkout.
Offer card payments, Apple Pay and Google Pay through the same hosted checkout. Enable cards in Settings under Payments, create a payment or payment link, and send your customer to its URL. Available payment methods depend on the customer's location and the payment amount.
## At checkout
Checkout opens a secure payment page where the customer reviews the total and completes any required identity checks. Card details are entered there and are never sent to your integration. The checkout updates automatically and takes the customer to their receipt or your success page.
## Payment attempts
Checkout handles unsuccessful attempts and offers another try when available. A card approval alone does not mean the payment is complete. Fulfill the order after your server receives and verifies `payment.succeeded`, then reads the payment to confirm its status and amount. Use `payment.failed` and `failure_code` to handle a payment that could not complete.
## Deposit
You receive the payment in a supported coin and network. Its `settlement` field records the destination, amount and transaction. Its `method.rail` names the selected card or wallet payment method. Your integration uses the same payment status and webhooks for cards and crypto. Fees are recorded on your fee statement.
## Failure codes
| Code | Meaning |
| --- | --- |
| `card_declined` | The card was declined. |
| `verification_failed` | The required verification could not be completed. |
| `region_unsupported` | Card payments are unavailable in the customer's region. |
| `route_timeout` | The card payment did not complete in time. |
| `amount_out_of_range` | The amount is outside the available card-payment limits. |
# Underpayments and overpayments
Source: https://developer.402pay.co/guides/underpayments
What happens when a customer sends too little, too much, too late or on the wrong network.
Crypto customers sometimes send too little, too much, too late or on the wrong network. Checkout catches each case, and the payment tells you what happened.
## Underpaid
A transfer that falls short by no more than your underpayment tolerance counts as paid in full. The tolerance is 0.5% unless you change it in Settings, under Checkout, and covers things like a wallet taking its network fee out of the amount.
When less than that arrives, the payment becomes `underpaid` and you receive `payment.underpaid`. Checkout shows the customer exactly what's left and keeps watching the same address at the same rate for a fresh quote window, 15 minutes by default, so most customers simply send the rest. Then you have three choices.
- underpaid (payment.underpaid) → Customer sends the rest (same address and rate)
- underpaid (payment.underpaid) → Request the rest (a checkout for what's left)
- underpaid (payment.underpaid) → Accept what arrived (counts it as paid)
- Customer sends the rest (same address and rate) → succeeded (payment.succeeded)
- Request the rest (a checkout for what's left) → succeeded (payment.succeeded)
- Accept what arrived (counts it as paid) → succeeded (payment.succeeded)
- Wait: if the customer sends the rest in time, the payment succeeds as usual.
- Request the rest once the quote has expired. See below.
- Accept what arrived, if the shortfall doesn't matter to you. See below.
## Request the rest
[`POST /payments/{id}/request-remainder`](https://developer.402pay.co/api/payments/request-remainder.md) opens a checkout for exactly what's left, in the same coin and network, attached to the original payment, and returns a `url` to send the customer. When the rest arrives, the original payment succeeds; no second payment is made.
- It works once the quote has expired, on a link payment or one you created through the API. While the quote is live, the customer can still send the rest to the same address, so it returns 409 `remainder_unavailable`.
- Asking again while the remainder's checkout is open returns the same checkout, and the request needs an `Idempotency-Key`.
Request the rest, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)"
```
Request the rest, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
});
const { data } = await response.json();
```
Request the rest, Python:
```python
import os
import uuid
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
data = response.json()["data"]
```
## Accept what arrived
[`POST /payments/{id}/accept`](https://developer.402pay.co/api/payments/accept.md) makes an `underpaid` or `needs_review` payment `succeeded` with what arrived. `amount_received` shows it, `reporting` counts it, and the timeline gains an `accepted` event. You receive `payment.succeeded`, so fulfill the order as usual, or decide on your side whether a part payment is enough.
## Overpaid
When more than the amount due arrives, the whole amount lands in your wallet, the payment succeeds, and you also receive `payment.overpaid`. `method.overpaid_amount` shows the excess, which you can send back from your wallet if you choose.
## Needs review
Some transfers need your decision: the payment becomes `needs_review` and you receive `payment.needs_review`. The reason is on the payment's checkout, in `review_reason`: read it with [`GET /checkouts/{id}`](https://developer.402pay.co/api/checkouts/retrieve.md), using the payment's `checkout_id`.
| review_reason | What happened |
| --- | --- |
| `late` | The transfer arrived after the checkout expired, so the rate it was quoted at no longer held. |
| `wrong_network` | The transfer arrived on another EVM network at the same address. The checkout's `crypto.detected_network` and the payment's `method.network` name it. |
| `claimed` | The customer said they paid, with a transaction hash, after the checkout closed. |
- A late transfer still reaches your wallet, but the rate it was quoted at no longer holds. Accept it, or return the funds to the customer.
- Every EVM network shares your address, so a transfer on another EVM network still lands in your wallet, on that network. Transfers on unrelated networks can't reach you, which is why checkout names the network so clearly.
- A claim is only the customer's word: check the transaction hash in the timeline's `claimed` event on a block explorer before you accept it.
> Rehearse each case before launch on a simulated business with [simulated outcomes](https://developer.402pay.co/testing.md#simulate).
# Refunds, returns and disputes
Source: https://developer.402pay.co/guides/refunds
Send refunds from your wallet, return funds you don't accept, and keep disputes rare.
There's no refund endpoint and no `refunded` status. Payments go straight to your wallet, and money never moves on an API key, so a refund is a send from your wallet that you make yourself. This guide covers where to send it, how much, and how to keep a record.
## Where to send it
| The customer paid | Send the refund to |
| --- | --- |
| Crypto from their own wallet | `method.from_address`, the address the transfer came from, on the same network. Confirm it with the customer first. |
| Crypto from an exchange | An address the customer gives you. The address the transfer came from often belongs to the exchange, not to them. |
| Card, Apple Pay or Google Pay | An address the customer gives you. Card payments arrive as crypto on the selected network, so the refund goes out in crypto, not back to the card. |
> A transfer to the wrong address or network can't be undone. Ask the customer to confirm the address, coin and network before you send.
## How much to send
- A whole crypto payment: `method.amount` of `method.asset` is what arrived. For a card payment, `amount_received` is what the customer paid, in the payment's currency.
- An overpayment's excess: `method.overpaid_amount`, in `method.asset`.
- A late or wrong-network transfer you don't want to accept: everything that arrived, from the payment that `needs_review`.
- Coin prices move after a payment. Decide whether you refund the same coin amount or the same value in your currency, and say which in your terms.
## Send the refund
From a wallet created or imported in 402pay, open Wallet in the dashboard, choose the coin and network, and send. Each payment landed at its own address, so the send draws on the addresses holding the coin, signed in your browser with your encryption password. From an external wallet, send in your own wallet app, then record the send in the dashboard with its transaction hash, so your history matches. API keys can't send, so refunds go out from the dashboard, which checks each one as [send from the wallet](https://developer.402pay.co/api/wallet/transactions/create.md) describes.
Every send pays a network fee. A token send, such as USDC on Polygon, pays it in the network's own coin, POL on Polygon, so keep a little of it in the wallet; without enough, the send fails with `insufficient_fee_funds`. Estimate the fee first.
Estimate the network fee, cURL:
```bash
curl "https://dash.402pay.co/api/v1/wallet/fee-estimates?network=polygon" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Estimate the network fee, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/fee-estimates?network=polygon", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Estimate the network fee, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/wallet/fee-estimates?network=polygon",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
## Keep a record
The payment stays `succeeded` after a refund, so your own records are the source of truth. Add the refund to the payment's metadata, which merges by key, so you can filter refunded payments later.
Note the refund on the payment, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"metadata": {
"refund_status": "refunded",
"refund_tx_hash": "0x5a1f9c3e8b7d6a2f4c0e9b8a7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c"
}
}'
```
Note the refund on the payment, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
refund_status: "refunded",
refund_tx_hash: "0x5a1f9c3e8b7d6a2f4c0e9b8a7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c"
}
}),
});
const { data } = await response.json();
```
Note the refund on the payment, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"metadata": {
"refund_status": "refunded",
"refund_tx_hash": "0x5a1f9c3e8b7d6a2f4c0e9b8a7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c"
}
},
)
data = response.json()["data"]
```
Give the send itself a note, up to 280 characters, when you make it in the dashboard, or later with `PATCH /wallet/transactions/{id}`.
Note the send, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/wallet/transactions/wtx_Q7vXk2Lp9RmT4sYb" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"note": "Refund for order_1042"
}'
```
Note the send, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/transactions/wtx_Q7vXk2Lp9RmT4sYb", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
note: "Refund for order_1042"
}),
});
const { data } = await response.json();
```
Note the send, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/wallet/transactions/wtx_Q7vXk2Lp9RmT4sYb",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"note": "Refund for order_1042"
},
)
data = response.json()["data"]
```
## Late and wrong-network transfers
A payment that `needs_review` already has its funds in your wallet. If you don't want to [accept it](https://developer.402pay.co/api/payments/accept.md), send the funds back the same way as a refund. The payment stays `needs_review`, so record what you did in its metadata, such as `review` set to `returned`, and filter those out of your review queue.
## Disputes
A crypto transfer can't be reversed by the sender, so crypto payments have no chargebacks. Cardholders can still dispute a card payment with their bank, and you'll be asked for evidence. A clear description of what customers are buying, and your refund policy shown before they pay, keep disputes rare.
# Receipts
Source: https://developer.402pay.co/guides/receipts
The hosted receipt every paid payment gets, and the same receipt as data.
Every paid payment has a hosted receipt, so you don't have to build one. Checkout opens it after payment when there's no `success_url`. 402pay doesn't email receipts, so link customers to the page, or send your own confirmation from your webhook.
## The receipt page
Each receipt lives at `/checkout/receipt/{payment_id}`, such as `https://checkout.402pay.co/checkout/receipt/pmt_QI02vLdJGd48hBbg`. It shows your business, the amount, how the customer paid and, for crypto, the transaction. Customers can print it or save it as a PDF.
## Read it as data
[`GET /public/receipts/{payment_id}`](https://developer.402pay.co/api/checkouts/receipt.md) returns what the page shows, without a key, to build your own confirmation. Anyone with the payment's ID can read it, so it leaves out fees, the deposit and customer details.
Retrieve a receipt, cURL:
```bash
curl "https://dash.402pay.co/api/v1/public/receipts/pmt_QI02vLdJGd48hBbg"
```
Retrieve a receipt, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/public/receipts/pmt_QI02vLdJGd48hBbg");
const { data } = await response.json();
```
Retrieve a receipt, Python:
```python
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/public/receipts/pmt_QI02vLdJGd48hBbg",
)
data = response.json()["data"]
```
# Webhooks
Source: https://developer.402pay.co/guides/webhooks
Signed events for every change, retried until your server answers.
Webhooks tell your server when something changes, so you never poll. Add an endpoint in the dashboard or with [`POST /webhooks`](https://developer.402pay.co/api/webhooks/create.md), choose the event types it receives, and save its signing secret. The secret starts with `whsec_`, the Standard Webhooks prefix, and is shown once.
Delivery, Headers:
```http
POST /webhooks/402pay HTTP/1.1
Content-Type: application/json
webhook-id: evt_JxmQNXpkZZN8mpqE
webhook-timestamp: 1790457795
webhook-signature: v1,Usi/akbM9PxJChbtzMSCZXf783T+z0FaQRv68dL2fwQ=
```
Delivery, Body:
```json
{
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
}
```
## Verify deliveries
Deliveries are signed with the Standard Webhooks scheme, so any library that implements it can check them. Each request carries three headers.
| Header | Value |
| --- | --- |
| `webhook-id` | The event's ID. It stays the same across retries, so use it to skip duplicates. |
| `webhook-timestamp` | Seconds since the Unix epoch. Refuse deliveries more than five minutes old. |
| `webhook-signature` | `v1,` then a base64 HMAC-SHA256 of `{id}.{timestamp}.{body}`, keyed with the base64-decoded part of the secret after `whsec_`. |
The body above is formatted to read. Your server receives it compact, and the signature covers those exact bytes, so verify the raw body before you parse it.
Verify a delivery, Node.js:
```js
import crypto from "node:crypto";
// secret is the endpoint's whsec_ value; body is the raw request body.
export function verifyWebhook(secret, headers, body) {
const id = String(headers["webhook-id"] ?? "");
const timestamp = String(headers["webhook-timestamp"] ?? "");
const signatures = String(headers["webhook-signature"] ?? "");
// Refuse replays: the delivery must be less than five minutes old.
const sent = Number(timestamp);
if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false;
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${body}`)
.digest("base64");
// During a rotation the header carries one signature per secret.
return signatures.split(" ").some((entry) => {
const [version, signature = ""] = entry.split(",");
return (
version === "v1" &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
});
}
```
Verify a delivery, Python:
```python
import base64
import hashlib
import hmac
import time
# secret is the endpoint's whsec_ value; body is the raw request body.
def verify_webhook(secret: str, headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
# Refuse replays: the delivery must be less than five minutes old.
if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
# During a rotation the header carries one signature per secret.
return any(
hmac.compare_digest(entry[3:].encode(), expected)
for entry in headers.get("webhook-signature", "").split(" ")
if entry.startswith("v1,")
)
```
After you rotate a secret, the old one keeps signing alongside the new one for 24 hours, separated by a space, so you can switch without dropping a delivery.
## Responses and retries
Answer with any 2xx status within 15 seconds. Anything else, or no answer, is tried again up to 7 more times, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. Do slow work after you respond, and handle each `webhook-id` once. Delivery is at least once and not in order: a retry can arrive after a later event, so read the object's current state before you act on one. Failed deliveries can be resent from the dashboard or with [`POST /webhook-deliveries/{id}/resend`](https://developer.402pay.co/api/webhooks/resend.md).
- Event (signed delivery) → Your endpoint (answers in 15 seconds)
- Your endpoint (answers in 15 seconds) → Delivered (any 2xx)
- Your endpoint (answers in 15 seconds) → Retry (up to 7 more times)
- Retry (up to 7 more times) → Failed (resend it any time)
- Retry (up to 7 more times) → Your endpoint (answers in 15 seconds): waits longer each time
Turning an endpoint off stops new deliveries, and drops any retry that comes due while it's off, so nothing piles up to arrive at once when you turn it back on. Deleting an endpoint removes its deliveries, pending retries included.
## Event fields
| Field | Meaning |
| --- | --- |
| `id` | The event's ID, the same as the `webhook-id` header. |
| `type` | What happened, such as `payment.succeeded`. |
| `subject` | The `kind` and `id` of the object it's about. |
| `data` | The subject as it was when the event happened. Fetch it again for its state now. |
| `actor` | Who caused it: `user` (a person in the dashboard), `api_key`, `customer` (at checkout; `name` is their email when they gave one) or `system` (402pay, such as a payment confirming), with an `id` and `name` where there is one. |
| `mode` | `test` or `live`, for the kind of key behind the event. Always `live` during the beta, when test and live keys share data. |
| `api_version` | The version of the event's shape, `v1` today. |
| `test` | Only on test deliveries, and then `true`. |
| `created_at` | When it happened. |
## Test deliveries
[`POST /webhooks/{id}/test`](https://developer.402pay.co/api/webhooks/test.md) sends a signed sample of an event type the endpoint receives. Its payload has `test: true` and a made-up subject, such as `pmt_test_wcKbRA2NP8gXhlmr`, so check for `test` before you act on an event. Test deliveries go out once and aren't retried.
## Troubleshooting
| Symptom | Likely cause |
| --- | --- |
| Every signature fails | The body was parsed and re-serialized before verifying. Sign the raw bytes you received. |
| Some signatures fail | Your server's clock is off, or you rotated the secret and still check only the new one. |
| Events arrive twice | A slow response was retried. Skip any webhook-id you've already handled. |
| Nothing arrives | The endpoint is disabled, not subscribed to the event type, or not reachable over public HTTPS. Read each attempt and your server's answer in the dashboard or with GET /webhook-deliveries. A local 402pay records deliveries without sending them. |
## Event types
Every type an endpoint can subscribe to, what its `data` holds and the order a payment's events arrive in are in the [webhook event catalog](https://developer.402pay.co/guides/webhook-events.md#event-types).
# Webhook event catalog
Source: https://developer.402pay.co/guides/webhook-events
Every event type, what its data holds, and the order a payment's events arrive in.
Every change in your business is recorded as an event, and each webhook endpoint receives the types it subscribes to. [`GET /events`](https://developer.402pay.co/api/events/list.md) lists the same events any time. This page shows what an event holds, every type, and the order they arrive in.
## The envelope
Every event has the same fields around its `data`, whether it arrives as a webhook or from `GET /events`.
Event, Real:
```json
{
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
}
```
Event, Test:
```json
{
"id": "evt_AncdRQ0YlMsNNIQD",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_test_wcKbRA2NP8gXhlmr"
},
"data": {
"id": "pmt_test_wcKbRA2NP8gXhlmr"
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"test": true,
"api_version": "v1",
"created_at": "2026-09-26T21:23:17.117Z"
}
```
| Field | Meaning |
| --- | --- |
| `id` | The event's ID, which is also the `webhook-id` header of every delivery of it. |
| `kind` | Always `event`. |
| `type` | What happened, such as `payment.succeeded`. The catalog below lists them all. |
| `subject` | The `kind` and `id` of the object it's about. |
| `data` | A snapshot of the subject when the event fired. See below. |
| `actor` | Who caused it: `user`, `api_key`, `customer` or `system`, with an `id` and `name` where there is one. A customer's `name` is their email, when checkout had one. |
| `mode` | `test` or `live`. Always `live` during the beta. |
| `api_version` | The version of the event's shape, `v1` today. |
| `test` | Only on test deliveries, and then `true`. |
| `created_at` | When it happened. |
## What data holds
`data` is the subject as the API returns it elsewhere, frozen when the event fired. A delivery sends those exact bytes every time, resends included, so fetch the object again when you need its state now.
| subject.kind | data |
| --- | --- |
| `payment` | The payment, as [`GET /payments/{id}`](https://developer.402pay.co/api/payments/retrieve.md) returns it, timeline included. |
| `link` | The [link](https://developer.402pay.co/api/links/retrieve.md), with its payment count and volume. |
| `customer` | The [customer](https://developer.402pay.co/api/customers/retrieve.md), with their stats. |
| `wallet` | Only `id`, `kind`, `name` and `created_at`. Balances and keys are never included. |
| `wallet_transaction` | The [wallet transaction](https://developer.402pay.co/api/wallet/transactions/retrieve.md). |
| `api_key` | The key with its name, mode and permissions. Its secret is redacted, never sent. |
| `webhook` | The [endpoint](https://developer.402pay.co/api/webhooks/list.md) with its `secret_hint`. The signing secret is never sent. |
| `business` | The [business](https://developer.402pay.co/api/businesses/retrieve.md) profile. |
| `checkout_settings` | Your checkout settings. `subject.id` is the business's ID, and `data` has no ID of its own. |
| `referral` | A business you referred, as your Partners page shows it: its name, `status`, when it joined and what you've earned from it. Nothing else about the other business is included. |
| `referral_payout` | A month of referral earnings: its `period`, `amount`, and the wallet address and `tx_hash` it was sent with. |
| `fee` | A [fee entry](https://developer.402pay.co/api/billing/fees/entries.md) of type `collection`, with the `balance` you owed right after it. |
Snapshots, Link:
```json
{
"id": "evt_Lq3VnB8xR2mK7tYc",
"kind": "event",
"type": "link.created",
"subject": {
"kind": "link",
"id": "lnk_nt842tPNne3lf0ka"
},
"data": {
"id": "lnk_nt842tPNne3lf0ka",
"kind": "link",
"code": "v6e4mrczwg",
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"success_url": null,
"status": "active",
"disabled_at": null,
"payments_count": 0,
"volume": {
"amount": 0,
"currency": "USD"
},
"created_at": "2026-09-26T21:22:46.992Z",
"updated_at": "2026-09-26T21:22:46.992Z"
},
"actor": {
"kind": "api_key",
"id": "key_7fQm2VxL0cR9tB4n",
"name": "Order server"
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:22:46.992Z"
}
```
Snapshots, Customer:
```json
{
"id": "evt_Cw9HsE4pZ1uN6jXa",
"kind": "event",
"type": "customer.created",
"subject": {
"kind": "customer",
"id": "cst_gWFWcc7Ga0Pv7LSC"
},
"data": {
"id": "cst_gWFWcc7Ga0Pv7LSC",
"kind": "customer",
"name": "Harper Wilson",
"email": "harper.wilson@example.com",
"blocked": false,
"note": "",
"stats": {
"payments_count": 2,
"incomplete_count": 2,
"volume": {
"amount": 22810,
"currency": "USD"
},
"average": {
"amount": 11405,
"currency": "USD"
},
"last_payment_at": "2026-09-26T21:22:47.082Z",
"preferred_rail": "crypto"
},
"created_at": "2026-09-26T21:01:16.809Z",
"updated_at": "2026-09-26T21:01:16.809Z"
},
"actor": {
"kind": "customer",
"id": null,
"name": "harper.wilson@example.com"
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:01:16.809Z"
}
```
Snapshots, Wallet:
```json
{
"id": "evt_Wr5TgM0kD8bQ2vLe",
"kind": "event",
"type": "wallet.created",
"subject": {
"kind": "wallet",
"id": "wal_1fIGZOrILmsCO0jw"
},
"data": {
"id": "wal_1fIGZOrILmsCO0jw",
"kind": "wallet",
"name": "Treasury",
"created_at": "2026-05-09T21:22:00.813Z"
},
"actor": {
"kind": "user",
"id": "usr_R4nW8kTq2LmZ6vXc",
"name": "Dana Whitfield"
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-05-09T21:22:00.813Z"
}
```
Snapshots, Deleted:
```json
{
"id": "evt_Dz2PfJ6cY9hA4sRo",
"kind": "event",
"type": "wallet.deleted",
"subject": {
"kind": "wallet",
"id": "wal_1fIGZOrILmsCO0jw"
},
"data": {
"id": "wal_1fIGZOrILmsCO0jw",
"kind": "wallet",
"deleted": true
},
"actor": {
"kind": "user",
"id": "usr_R4nW8kTq2LmZ6vXc",
"name": "Dana Whitfield"
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:40:02.118Z"
}
```
- `link.deleted` and `webhook.deleted` carry the object as it was just before it went. `wallet.deleted`, and any event whose subject was already gone when it fired, carries only `id`, `kind` and `deleted: true`.
- A link's `payments_count` and `volume`, a customer's `stats`, and a referral's `payments_count`, `volume`, `fees` and `earned` are running totals. A delivery carries them as they were when it was sent; `GET /events` shows them as they are now.
## Event types
Every type below can be sent to a webhook. [`GET /event-types`](https://developer.402pay.co/api/events/types.md) returns the same list.
### Payments
| Type | When |
| --- | --- |
| `payment.created` | A payment was created and is waiting for the customer. For an API payment, when you create it. For a link, when checkout collects the customer's email, or when the transfer arrives if it never asked for one. |
| `payment.succeeded` | A payment was paid in full, or accepted, and its funds landed in the wallet or went to 402pay to pay the fees this business owes. That includes when the rest of an underpaid payment arrives. |
| `payment.underpaid` | A payment received less than the amount due. |
| `payment.overpaid` | A payment received more than the amount due and succeeded, or received a further transfer after it succeeded. Always right after `payment.succeeded`. |
| `payment.needs_review` | A payment needs a decision before it counts as paid. |
| `payment.failed` | A payment was canceled, by the business or by the customer at a link's checkout, or a declined card had no route left or wasn't retried within 30 minutes. No funds landed in the wallet. A decline with another card route left adds to the timeline instead. |
| `payment.expired` | A payment expired before any funds arrived. |
### Links
| Type | When |
| --- | --- |
| `link.created` | A payment link was created. |
| `link.updated` | A payment link's details changed. |
| `link.archived` | A payment link stopped taking payments. |
| `link.restored` | An archived payment link started taking payments again. |
| `link.deleted` | A payment link was deleted. |
### Customers
| Type | When |
| --- | --- |
| `customer.created` | A customer was added, by hand or when checkout collected their email. |
| `customer.updated` | A customer's details changed. |
| `customer.blocked` | A customer can no longer pay. |
| `customer.unblocked` | A blocked customer can pay again. |
### Wallet
| Type | When |
| --- | --- |
| `wallet.created` | The business wallet was created. |
| `wallet.deleted` | The business wallet was removed. |
| `wallet_transaction.created` | A send from the wallet started. Payments that land in the wallet send payment events instead. |
| `wallet_transaction.confirmed` | A send from the wallet was confirmed on chain. |
| `wallet_transaction.failed` | A send from the wallet failed on chain. When a send relayed from the dashboard fails on the network or is never seen there. A simulated business never sends it. |
### Business
| Type | When |
| --- | --- |
| `business.updated` | The business profile changed. A new `alert_email` counts once its code is [confirmed](https://developer.402pay.co/api/businesses/alert-email/confirm.md), not while it waits in `pending_alert_email`. |
| `business.activated` | The business finished setup and can take payments. |
| `checkout_settings.updated` | Checkout settings changed. |
### Billing
| Type | When |
| --- | --- |
| `fee.collected` | A payment's funds went to 402pay to pay the fees this business owes, instead of to the wallet. Right before `payment.succeeded` for a payment whose funds went to pay your fees. See [fees and billing](https://developer.402pay.co/guides/fees.md). |
### Developers
| Type | When |
| --- | --- |
| `api_key.created` | An API key was created. |
| `api_key.updated` | An API key's name or permissions changed. |
| `api_key.revoked` | An API key was revoked and stopped working. |
| `webhook.created` | A webhook endpoint was added. |
| `webhook.updated` | A webhook endpoint's settings or signing secret changed. |
| `webhook.deleted` | A webhook endpoint was removed. |
> An endpoint created with a restricted key can only subscribe to types whose subject that key can read. API key, business and referral events aren't covered by any permission, so only the dashboard or a key with full access can subscribe to them.
## Account events
These belong to a person, not to a business. They're recorded for the person's own security history, so webhooks never carry them and API keys can't list them.
| Type | When |
| --- | --- |
| `password.changed` | The password was changed in settings. |
| `password.reset` | The password was reset from an emailed link. |
| `email.changed` | The account's email address changed after the new one was confirmed. |
| `email.change_undone` | The email address was switched back from a link sent to the old one. |
| `passkey.added` | A passkey was added for signing in. |
| `passkey.removed` | A passkey was removed and can't sign in anymore. |
| `two_factor.enabled` | Sign-in now asks for an authenticator code. |
| `two_factor.disabled` | Sign-in no longer asks for an authenticator code. |
| `two_factor.recovery_code_used` | A recovery code stood in for the authenticator code. |
| `two_factor.recovery_codes_regenerated` | New recovery codes were made and the old ones stopped working. |
| `session.revoked` | A signed-in device was signed out from settings. |
## Sequences
What a payment sends, from creation to the end, in each case.
| Case | Events |
| --- | --- |
| Paid in full | `payment.created`, then `payment.succeeded` |
| Paid in full and collected as fees | `payment.created`, then `fee.collected`, then `payment.succeeded` |
| Paid too much | `payment.created`, then `payment.succeeded`, then `payment.overpaid` |
| Paid too little, then the rest arrived or you accepted | `payment.created`, then `payment.underpaid`, then `payment.succeeded` |
| Late or on another network, then accepted | `payment.created`, then `payment.needs_review`, then `payment.succeeded` |
| Canceled, or declined on every card route | `payment.created`, then `payment.failed` |
| Nothing arrived in time | `payment.created`, then `payment.expired` |
- A card declined with another attempt available isn't a webhook: the payment stays `pending` and its timeline gains a `card_attempt_failed` event.
- Each delivery is retried on its own schedule, so a retried `payment.created` can land after `payment.succeeded`. Go by the event's `created_at`, or fetch the payment, rather than the order deliveries arrive in.
- A new customer's `customer.created` arrives alongside their first payment's events, when checkout collects their email or you pass `customer_email`.
## Test events
[`POST /webhooks/{id}/test`](https://developer.402pay.co/api/webhooks/test.md) sends a signed sample of a type the endpoint receives, with `test: true`, a made-up subject such as `pmt_test_wcKbRA2NP8gXhlmr`, and only that ID in `data`. It checks your signature code and routing, not your handling of real data, so build against the shapes on this page.
See [webhooks](https://developer.402pay.co/guides/webhooks.md) for signatures, retries and resends.
# Wallet and deposits
Source: https://developer.402pay.co/guides/settlement
The three kinds of wallet, how each payment lands in yours, and who holds the keys.
Every business has one wallet, and its payments land straight in it, apart from those that pay your fees. 402pay never holds your funds, so there's no balance to withdraw and no payout to wait for. Set it up in the dashboard, under Wallet: create a new one, import one, or connect a wallet you already use.
## Three kinds of wallet
| source | What it is | Where you send from |
| --- | --- | --- |
| `created` | A new wallet whose recovery phrase is made in your browser. | The dashboard, signed in your browser. |
| `imported` | A wallet from another app, brought in with its 12 or 24-word recovery phrase. | The dashboard, signed in your browser. |
| `external` | A wallet that keeps its own keys, such as a hardware wallet. You share only public keys or addresses. | Your wallet app. Record each send in the dashboard by its transaction hash. |
- A business has exactly one wallet. Creating another returns 409 `wallet_exists`.
- [`GET /wallet`](https://developer.402pay.co/api/wallet/retrieve.md) returns its `source`, balances and main addresses.
- A created or imported wallet is $5 a month, and an external one $15. See [pricing](https://402pay.co/pricing).
## How payments land
- A crypto payment lands at its checkout's address, in the coin and network the customer paid in.
- A card payment lands in an enabled coin and network your wallet can receive. Checkout selects a compatible route before reserving the destination.
- Each deposit is an `in` wallet transaction with the `payment_id` it came from and the whole amount that arrived, since fees never come out of it. The deposit, in the payment's `settlement` field, names the address and transaction.
- Balances are per coin and network, adding up every address. Sends draw on whichever of the wallet's addresses hold the coin, largest first.
- While you owe 402pay fees, some payments go to its address instead, to pay them. Their `settlement.destination` is `fees` and they add nothing to your wallet. See [fees and billing](https://developer.402pay.co/guides/fees.md).
## Addresses
A wallet derives its addresses from public keys, so 402pay can hand out new ones without being able to spend from them.
- Index 0 on each network is the main address, the one Receive shows. Every checkout reserves the next unused index, so each transfer matches exactly one payment.
- Polygon and Ethereum share one set of addresses and count indexes together, so an address never serves two payments on any of them.
## Solana addresses
Every Solana checkout pays to a fresh address as well. Solana's usual key derivation needs the recovery phrase for each new address, so when you create or import a wallet, your browser also shares a Solana checkout key: a public key that derives a new address for every checkout, the way an EVM account's extended public key does. Only the recovery phrase makes the keys that spend from these addresses, so 402pay can't move the funds.
Index 0 is the main address, the one Receive shows and Solana wallet apps find from your phrase. Checkout addresses sit on a branch of their own that wallet apps don't scan, so move their funds with Send in the dashboard, which signs for them in your browser, or with only your recovery phrase on the [Solana recovery page](https://dash.402pay.co/recover/solana). `address_counts.solana` on `GET /wallet` counts the Solana indexes handed out. The [Solana checkout addresses](https://developer.402pay.co/guides/solana-checkout-addresses.md) guide publishes the derivation.
## External wallets
An external wallet shares only what receiving needs. Networks you leave out aren't offered at checkout.
| Networks | Share |
| --- | --- |
| Polygon, Ethereum | An account's extended public key, `xpub…`, or one `0x` address. One covers both networks. |
| Bitcoin | An `xpub…` or `zpub…`, or one address. |
| Solana | One address. |
| Tron | One address. |
- With an extended public key, every checkout gets its own address. With a single address, every checkout on those networks pays that same address, so two customers paying at once can't be told apart by address. Share an extended public key wherever your wallet exports one.
- Extended private keys are refused.
- To take cards, connect a wallet that can receive a coin and network supported by an available card route.
- Sends happen in your wallet app. Record each one in the dashboard with its transaction hash, so your wallet's history here matches. A real-chain send stays pending with no effect on balances until the network verifies the sender, recipient, coin and amount against that hash. Its USD value remains zero while unverified; its fee stays zero because no fee amount is verified. An unrelated transfer cannot confirm it. Simulated external hash records fail after the pending timer because there is no chain evidence to verify.
## Your keys
> A created or imported wallet's recovery phrase never reaches 402pay. It's encrypted in your browser with an encryption password only you know, and only the encrypted copy is stored. That password is all that protects the copy, so the dashboard refuses one that's easy to guess, such as a common password, a word or name, a date, a keyboard pattern or your business's name. Sends and anything else that needs the phrase unlock it in your browser. An external wallet has no phrase here at all.
> 402pay can't recover a lost recovery phrase or encryption password. Back both up before you take real payments.
Money never moves on an API key. A key can read balances and transactions, and add notes, but sends are made or recorded only in the dashboard.
## Deleting a wallet
Once your business has received a payment (one collected as fees, or a referral payout, counts too), [deleting its wallet](https://developer.402pay.co/api/wallet/delete.md) takes effect 24 hours after you ask. The wallet keeps receiving payments until then, so someone who got into your account can't swap in a wallet of their own before you see the email. The email has a link that cancels the deletion without signing in, and the Wallet page shows it with a Cancel deletion button. A business still being set up loses its wallet right away, and changing the encryption password is never held.
## Next steps
- [Reporting and reconciliation](https://developer.402pay.co/guides/reconciliation.md): Which amounts to report, how payments match your wallet, and how to export a period.
- [Refunds, returns and disputes](https://developer.402pay.co/guides/refunds.md): Send refunds from your wallet, return funds you don't accept, and keep disputes rare.
- [Supported networks](https://developer.402pay.co/guides/networks.md): Every coin and network checkout accepts, and the confirmations each one needs.
- [Security best practices](https://developer.402pay.co/guides/security.md): Narrow keys, rotations without downtime, a locked-down webhook endpoint and a secure account.
# Solana checkout addresses
Source: https://developer.402pay.co/guides/solana-checkout-addresses
How each Solana checkout gets a fresh address from a public key, the derivation to reproduce it, and moving the funds with only the recovery phrase.
Every Solana checkout pays to a fresh address that no other payment uses. 402pay derives these addresses from a public key your browser shares, as EVM, Tron and Bitcoin checkouts do from an account's extended public key, so it never holds a key that can move your funds. This page publishes the derivation, so anyone can reproduce it.
## What they are
When you create or import a wallet, your browser derives a Solana checkout key from the recovery phrase and shares only its public half: a public key and a chain code. 402pay derives checkout address `n` from it for checkout `n`. Only the recovery phrase makes the private keys behind these addresses, so only you can move what they hold. The checkout key can't spend anything, but it links every checkout address to your wallet, as any extended public key does.
## Where funds show
- The main address, `m/44'/501'/0'/0'`, is the one Solana wallet apps show when you restore your recovery phrase, and the one Receive shows in the dashboard.
- Checkout addresses sit on a branch of their own, `m/402'/501'/0'`, whose children use public (soft) derivation. Wallet apps don't scan it, so they don't show these funds.
- Move them with Send in the dashboard, which signs for checkout addresses in your browser, or with nothing but your recovery phrase on the [Solana recovery page](https://dash.402pay.co/recover/solana).
> Locked out of the dashboard, or 402pay unreachable? The [recovery page](https://dash.402pay.co/recover/solana) finds your main and checkout addresses from the phrase, reads their balances from Solana's public RPC and moves everything to an address you choose. The phrase stays in your browser, and the page makes no request to 402pay.
## The derivation
402pay Solana checkout keys, version 1, is BIP32-Ed25519 (Khovratovich and Law, the V2 scheme of Cardano's ed25519-bip32) from a SLIP-0010 root. Below, `B` is the Ed25519 base point, `l` its group order, `2^252 + 27742317777372353535851937790883648493`, numbers are read and written little-endian, `x[a..b]` is bytes `a` up to `b`, and `||` joins bytes.
1. `seed` is the BIP39 seed of the recovery phrase, its words in lowercase and one space apart, with no passphrase.
1. SLIP-0010 derivation for Ed25519 of `seed` along `m/402'/501'/0'`, all three steps hardened, gives a 32-byte key `k` and a 32-byte chain code `c`. Purpose 402' keeps the branch apart from the `m/44'/501'` accounts wallet apps show.
1. `h = SHA-512(k)`, `kL = h[0..32]` and `kR = h[32..64]`. Clamp `kL`: `kL[0] &= 0xF8`, `kL[31] &= 0x1F`, `kL[31] |= 0x40`. The root public key is `A = kL * B`.
1. The checkout key your browser shares is `base58(A || c)`: 64 bytes in Bitcoin's base58 alphabet, with no checksum. A valid one decodes to exactly 64 bytes, is at most 88 characters with no whitespace, and its `A` is a canonical encoding of a point in the prime-order subgroup that isn't of small order, what libsodium's `crypto_core_ed25519_is_valid_point` accepts.
1. Child `i`, for `0 <= i < 2^31`: `Z = HMAC-SHA512(key = c, data = 0x02 || A || le32(i))`, `ZL = Z[0..28]` and `ZR = Z[32..64]`. Its public key is `A_i = A + (8 * ZL) * B`, and checkout address `i` is `base58(A_i)`. Its private key, which only the phrase can make, is `kL_i = kL + 8 * ZL` (a 256-bit addition, not reduced) and `kR_i = (kR + ZR) mod 2^256`, and `kL_i * B` must equal `A_i`.
1. To sign message `M` with child `i`: `r = SHA-512(kR_i || M) mod l`, `R = r * B`, `x = SHA-512(R || A_i || M) mod l` and `S = (r + x * (kL_i mod l)) mod l`. The signature is `R || S`, and it verifies under standard Ed25519 verification (RFC 8032), strict checks included.
## Indexes
- Solana index 0 is the main address's slot, so child 0 of the checkout key is never handed out.
- Checkout `n`, from 1 up, pays to child `n`. Indexes count up per wallet and never go back, so an address never serves two payments. Card payments that settle on Solana take one too.
- To find funds, scan from child 0. [`address_counts.solana`](https://developer.402pay.co/api/wallet/retrieve.md) on `GET /wallet` is one more than the highest Solana index handed out, so it says how far to look.
- A child whose public key would be the identity point is skipped and never handed out. The chance of one is about 2^-250.
## Test vectors
From the published BIP39 test phrase, which is never a wallet to send funds to. Independent implementations give the same values. The message to sign is hex, the bytes of "402pay".
Test vectors, JSON:
```json
{
"phrase": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
"main_address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk",
"checkout_key": "sHz8kwCJhANstUZXuSv3SDDFbcVmqpH2ojjvY1VNvCPSTcqm9rx2r65ECEryDo3oxWpZCv9mMmi4twijuvbf7F7",
"children": [
{ "index": 0, "address": "FFWTFNVcavC2RVTDsjNHfJwPAZuSaKhvABNk7goBWkTX" },
{ "index": 1, "address": "EgsxatR8QNRxCBkf6ZBf3Z9uoaeN23yBhFWn2sk91h3E" },
{ "index": 2, "address": "ERRm1NNCfSnBaf9YzrSJwKVDYR5LfREkaRarxGWbyJpf" },
{ "index": 2147483647, "address": "7FMbTxB3Ke1NFknB1SCZJ2crt4pwmSTz3WNPSmzhtqB9" }
],
"signature": {
"index": 1000,
"address": "HP3xenGuaNHgbeLhrZ4W7th5nqxCxwscYNHLi2KtSU5p",
"message": "343032706179",
"signature": "1095be7d64ba627ddd82081fc35a7f76e00a64a289656949ea2e0db101905f6f40cc8fb2ea410ae779bf8a90e67673bb5e81b18ecab3f277b876da13ba796d06"
}
}
```
## Recover with your own code
You don't need 402pay's code to move these funds. Anything that follows the steps above finds the same addresses and makes the same keys: any Ed25519 library that signs with an expanded key (`kL`, `kR`) can sign for a checkout address, and BIP32-Ed25519 libraries, such as Cardano's ed25519-bip32, derive the same children from the root key (`kL`, `kR`, `c`). The signature is a standard Ed25519 signature, which Solana validators accept like any other, so a transfer from a checkout address is an ordinary Solana transaction.
1. Derive the main address and the checkout key from your recovery phrase.
1. Derive checkout addresses from child 0 up, and read each one's SOL and its USDC and USDT associated token accounts from any Solana RPC.
1. For each funded address, sign a transaction with its child key that moves its tokens and SOL where you choose, with an address that holds SOL, such as the main address, paying the fee.
## Next steps
- [Wallet and deposits](https://developer.402pay.co/guides/settlement.md): The three kinds of wallet, how each payment lands in yours, and who holds the keys.
- [Security best practices](https://developer.402pay.co/guides/security.md): Narrow keys, rotations without downtime, a locked-down webhook endpoint and a secure account.
- [Supported networks](https://developer.402pay.co/guides/networks.md): Every coin and network checkout accepts, and the confirmations each one needs.
# Reporting and reconciliation
Source: https://developer.402pay.co/guides/reconciliation
Which amounts to report, how payments match your wallet, and how to export a period.
Every payment carries what you charged, what arrived, what it was worth in US dollars and where it landed, and every deposit is a transaction in your wallet. This page shows which field to use for what, and how to export a period.
## Amounts on a payment
| Field | What it holds |
| --- | --- |
| `amount, currency` | The price you charged, in minor units of your currency. |
| `fee_payer, customer_fee` | Who pays 402pay's fee, `business` or `customer`, and what the customer pays toward it, in the same currency: 0 when you pay it, and until the customer picks how to pay. |
| `amount_received` | What arrived, in the same currency at the quoted rate. 0 until funds arrive, and `amount` plus `customer_fee` once it's paid in full. |
| `reporting` | `amount`, `fee`, `transaction_fee`, `customer_fee` and `net` in US cents, for totals across currencies. `fee` is the rate and `transaction_fee` the flat fee, and `net` is what you keep after them: `amount` plus `customer_fee` less `fee` and `transaction_fee`. `fee` and `net` are final once the payment succeeds. |
| `fee_rate_bps` | The rail's rate in basis points: 0 for crypto (0%) and 300 for cards (3%). |
| `method` | How the customer paid: the coin, network and amount as decimal strings, the sending address and transaction, and `overpaid_amount`. For cards, the payment rail without card details, which the customer enters on the secure payment page. |
| `settlement` | Where the funds went: `destination` is `wallet` for the deposit that reached your wallet, whole, or `fees` when the payment went to 402pay to pay fees you owed. Then the asset, network, amount, address and transaction. |
| `confirmed_at` | When the payment succeeded. |
- An overpayment keeps `reporting.amount` at the price, while `amount_received` and `settlement.amount` include the excess.
- An accepted payment reports what actually arrived: `reporting.amount` is its value less any `customer_fee`, and the fee is taken on that.
- A payment finished by a remainder landed in more than one transfer. `settlement.amount` adds them up, and `settlement.tx_hash` names the latest.
## Fees
`reporting.transaction_fee` and `reporting.net` stay 0 until the payment succeeds.`reporting.fee` is the rate on the payment and `reporting.transaction_fee` the $0.25 flat fee, both on the [pricing page](https://402pay.co/pricing). Neither comes out of the deposit: every payment, by card or crypto, lands in your wallet whole (the price, plus the fee when your customer pays it). What you owe, the rate, the flat fee and your wallet's monthly plan, goes on your fee statement, which 402pay collects by sending some whole payments to its own address. Those payments have `settlement.destination` set to `fees` and no wallet transaction. See [fees and billing](https://developer.402pay.co/guides/fees.md).
## Passing fees on
In the dashboard, under Settings, Payments, you choose who pays 402pay's fees. With your customers paying, the hosted checkout adds a processing fee to each customer's total: the rate for the method they pick, plus the $0.25 flat fee, in the price's currency. It's worked out on the price, never on a total that already includes it, so the payment lands at the price plus the whole fee, and the same fee owed on your fee statement as usual leaves you the whole price. A payment you create with `POST /payments` follows the setting unless its own `fee_payer` says otherwise.
| A $49.00 card payment | Amount |
| --- | --- |
| Price | $49.00 |
| Processing fee (3% plus $0.25) | $1.72 |
| The customer pays | $50.72 |
| Lands in your wallet | $50.72 |
| The rate plus $0.25, on your fee statement | -$1.72 |
| You keep | $49.00 |
A crypto customer sends the price plus the fee in their coin. A card customer reviews the final total, including any additional fees, on the secure payment page. The payment says who paid in `fee_payer` and how much in `customer_fee`, while `amount` stays the price.
Network fees are separate: the customer's wallet pays them in the network's own coin on top of what it sends, so they're never in a payment's amounts. The hosted checkout shows an estimate beside the total.
## Wallet transactions
Every deposit is an `in` wallet transaction whose `payment_id` names the payment, with its `value_usd` at the time. A payment collected as fees has none, since its funds went to 402pay. Sends are `out` transactions with their `network_fee_usd` and a note of up to 280 characters.
> Funds from a payment that's `underpaid` or `needs_review` can already be in your wallet, as `in` transactions with its `payment_id`, before the payment succeeds. Match wallet transactions to payments by `payment_id`, not by status.
Money in during September, cURL:
```bash
curl "https://dash.402pay.co/api/v1/wallet/transactions?direction=in&created_after=2026-09-01T00:00:00Z&created_before=2026-10-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Money in during September, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/transactions?direction=in&created_after=2026-09-01T00:00:00Z&created_before=2026-10-01T00:00:00Z&limit=100", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Money in during September, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/wallet/transactions?direction=in&created_after=2026-09-01T00:00:00Z&created_before=2026-10-01T00:00:00Z&limit=100",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
## Export a period
List payments with `created_after` and `created_before`, follow `next_cursor` until it's `null`, and write a row per payment. The start is included and the end isn't, so back-to-back periods never overlap.
export.mjs, Node.js:
```js
// Every payment created in September 2026, oldest first, as CSV.
const API = "https://dash.402pay.co/api/v1";
const headers = { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` };
const csv = (cell) => `"${String(cell ?? "").replaceAll('"', '""')}"`;
const rows = [["id", "status", "reference", "currency", "amount", "usd_amount", "usd_fee", "usd_net", "confirmed_at"]];
let cursor = null;
do {
const query = new URLSearchParams({
created_after: "2026-09-01T00:00:00Z",
created_before: "2026-10-01T00:00:00Z",
sort: "created_at",
limit: "100",
});
if (cursor) query.set("cursor", cursor);
const page = await (await fetch(`${API}/payments?${query}`, { headers })).json();
for (const p of page.data) {
rows.push([p.id, p.status, p.reference, p.currency, p.amount, p.reporting.amount, p.reporting.fee, p.reporting.net, p.confirmed_at]);
}
cursor = page.next_cursor;
} while (cursor);
console.log(rows.map((row) => row.map(csv).join(",")).join("\n"));
```
The dates filter on `created_at`. To book payments by when they succeeded, filter the rows on `confirmed_at`, or list `payment.succeeded` events for the period with [`GET /events`](https://developer.402pay.co/api/events/list.md).
## Match records
| To match | Use |
| --- | --- |
| An order to its payment | `reference`, or your own `metadata`. Both filter `GET /payments`. |
| A payment to the chain | `method.tx_hash` and `method.explorer_url`. |
| A payment to your wallet | `settlement.tx_hash`, and the `payment_id` on each wallet transaction. |
| A payment to a customer | `customer_id`. |
| A send to its purpose | Its `note`. |
## Exchange rates
A crypto checkout locks the coin amount when it opens, and `reporting` converts your currency to US dollars at the rates [`GET /exchange-rates`](https://developer.402pay.co/api/exchange-rates.md) returns. Those rates follow the market, except on a simulated business, where they're fixed.
# Fees and billing
Source: https://developer.402pay.co/guides/fees
How fees accrue, how 402pay collects them from your payments, and how to read your fee statement.
Every payment, by card or crypto, lands in your own wallet whole, so 402pay never holds the money its fees would come out of and never takes anything out of a deposit. Instead, fees add up on your fee statement, and once you owe enough, 402pay collects them by sending some of your payments to its own address. Your customers pay exactly as they always do.
## How fees accrue
| Fee | When |
| --- | --- |
| The rate (0% for crypto, 3% for cards) plus $0.25 | Each payment that succeeds, whoever pays the fee. |
| The wallet plan: $5 for a wallet made or imported here, $15 for an external one | Once for each calendar month you have a wallet, starting when it's made. Replacing it never bills a month twice. |
When your customers pay the fee, it's added to their total and lands in your wallet with the payment, so you owe it on your statement like any other. Payments that don't succeed owe nothing. Fees on test networks stay there: a business that moves to mainnet starts a new statement, and test fees are never collected with real money.
## How fees are collected
Once you owe at least $25.00, a new checkout whose whole total fits what you owe goes to 402pay's own address instead of your wallet. It's decided as the checkout opens, for crypto and card alike, and a checkout is never split: all of it goes to 402pay, or all of it goes to you.
- Payments already on their way to 402pay count against what you owe: a checkout's whole total once its payment arrives or its card is approved, and what arrived of an underpaid one. Only one ordinary fee checkout can be open, waiting on the customer, processing or confirming for your business at a time. A checkout nobody has paid yet holds no debt, and late transfers can together pay down more than you owed.
- The customer sees the same pay page, amount, receipt and success URL. Only the address differs.
- The rest of an underpaid payment goes where the payment went: to 402pay for one collected as fees, and to your wallet for one paid to it, so no payment is split between the two.
- The payment still owes its own fee when it succeeds, so what you owe drops by its total less that fee.
> An overpayment, a transfer that arrives late, or checkouts paid at about the same time can pay down more than you owed. What's left is credit against future fees, never a refund, and your statement shows it as a negative `owed`.
## What you see
A payment collected as fees is a normal payment: it succeeds, sends `payment.succeeded`, and counts in your metrics. Its `settlement.destination` is `fees` instead of `wallet`, its `wallet_id` is `null`, and its address and transaction are 402pay's. Nothing is added to your wallet, so it has no wallet transaction.
Settlement, JSON:
```json
{
"destination": "fees",
"asset": "USDC",
"network": "solana",
"amount": "25.00",
"wallet_id": null,
"address": "7DmM84gY7ZokTyQcKydWauBdvwithGVG1pgDH44qWTG8",
"tx_hash": "a1ot1F4sVTnAMfnC2UwnMCxBkLGNrfBGuHsx2GAW5qFNTkSXMZdZ5SYjxToTw9ct5JvjzxixQbV18Y4ZcvAe7oE",
"explorer_url": "https://solscan.io/tx/a1ot1F4sVTnAMfnC2UwnMCxBkLGNrfBGuHsx2GAW5qFNTkSXMZdZ5SYjxToTw9ct5JvjzxixQbV18Y4ZcvAe7oE"
}
```
Right before its `payment.succeeded`, a `fee.collected` event carries the collection as a [fee entry](https://developer.402pay.co/api/billing/fees/entries.md). In the dashboard, the payment says it was collected as fees, and Settings, Payments shows your statement.
## Your statement
[`GET /billing/fees`](https://developer.402pay.co/api/billing/fees.md) returns what you owe, what's on its way to 402pay, and what accrued and was collected this month. [`GET /billing/fees/entries`](https://developer.402pay.co/api/billing/fees/entries.md) lists every fee, collection and adjustment behind it, each with what you owed right after it.
Statement and entries, cURL:
```bash
curl "https://dash.402pay.co/api/v1/billing/fees" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Statement and entries, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/billing/fees", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Statement and entries, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/billing/fees",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, Statement:
```json
{
"data": {
"kind": "fee_statement",
"currency": "USD",
"owed": 3140,
"reserved": 2500,
"accrued": 10250,
"collected": 7110,
"adjusted": 0,
"this_month": {
"period": "2026-09",
"accrued": 1200,
"collected": 2500
},
"collection": {
"enabled": true,
"threshold": 2500,
"active": true
}
}
}
```
Response, Entry:
```json
{
"id": "fee_Eor0UAv0Ama0Pcnu",
"kind": "fee_entry",
"type": "collection",
"amount": -2500,
"currency": "USD",
"balance": 640,
"payment_id": "pmt_IEizRuSvGtJU0tPQ",
"checkout_id": "chk_Hq9bLRPRqpYqhWv6",
"wallet_id": null,
"plan_month": null,
"collection": {
"asset": "USDC",
"network": "solana",
"amount": "25.00",
"address": "7DmM84gY7ZokTyQcKydWauBdvwithGVG1pgDH44qWTG8",
"tx_hash": "a1ot1F4sVTnAMfnC2UwnMCxBkLGNrfBGuHsx2GAW5qFNTkSXMZdZ5SYjxToTw9ct5JvjzxixQbV18Y4ZcvAe7oE",
"explorer_url": "https://solscan.io/tx/a1ot1F4sVTnAMfnC2UwnMCxBkLGNrfBGuHsx2GAW5qFNTkSXMZdZ5SYjxToTw9ct5JvjzxixQbV18Y4ZcvAe7oE"
},
"note": null,
"created_at": "2026-09-29T12:00:00.000Z"
}
```
## Reporting
On every payment, `reporting.fee` is the rate and `reporting.transaction_fee` the $0.25 flat fee, and `reporting.net` is what you keep after both. The flat fee and net stay 0 until the payment succeeds. The [metrics](https://developer.402pay.co/api/metrics.md)' `net_revenue` counts the same way. See [reporting and reconciliation](https://developer.402pay.co/guides/reconciliation.md) for every amount on a payment.
## Next steps
- [Reporting and reconciliation](https://developer.402pay.co/guides/reconciliation.md): Which amounts to report, how payments match your wallet, and how to export a period.
- [Webhook event catalog](https://developer.402pay.co/guides/webhook-events.md): Every event type, what its data holds, and the order a payment's events arrive in.
- [Retrieve the fee statement](https://developer.402pay.co/api/billing/fees.md): Get what your business owes 402pay in fees, what's on its way to 402pay, and this month's totals.
- [List fee entries](https://developer.402pay.co/api/billing/fees/entries.md): List every fee, collection and adjustment behind your fee statement, newest first.
# Supported networks
Source: https://developer.402pay.co/guides/networks
Every coin and network checkout accepts, and the confirmations each one needs.
Checkout offers every coin and network below that you turn on in Settings, under Payments.
## Assets
| Asset | Name | Networks |
| --- | --- | --- |
| `USDC` | USD Coin | Solana, Polygon, Ethereum |
| `USDT` | Tether | Solana, Tron, Ethereum |
| `BTC` | Bitcoin | Bitcoin |
| `ETH` | Ethereum | Ethereum |
| `SOL` | Solana | Solana |
## Networks
| Network | Value | Confirmations |
| --- | --- | --- |
| Solana | `solana` | 2 |
| Polygon | `polygon` | 4 |
| Ethereum | `ethereum` | 4 |
| Tron | `tron` | 3 |
| Bitcoin | `bitcoin` | 2 |
## Good to know
- Polygon and Ethereum share one address per checkout, since both are EVM networks.
- A payment succeeds once its transfer has the confirmations above on its network.
# AI agents
Source: https://developer.402pay.co/guides/ai-agents
Coming soon.
An MCP server for Claude Code, Codex and other agents is coming soon. Until then, agents can use the API.
When it's ready, any client that speaks the Model Context Protocol will be able to create payment links and check payments through 402pay. It isn't available yet, so there's nothing to connect today.
## Use the API today
Any agent that can make HTTP requests can use the API now. Create a restricted secret key with only the access the agent needs, such as write on links and read on payments, give it to the agent through its environment, and point it at these docs.
Create a link, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/links" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro plan",
"amount": 4900,
"currency": "USD"
}'
```
Create a link, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Pro plan",
amount: 4900,
currency: "USD"
}),
});
const { data } = await response.json();
```
Create a link, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/links",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"name": "Pro plan",
"amount": 4900,
"currency": "USD"
},
)
data = response.json()["data"]
```
To point an agent at these docs, give it [llms.txt](https://developer.402pay.co/llms.txt), which lists every page with a line about each, linked to its Markdown. Any page's Markdown is at its URL plus `.md`, [llms-full.txt](https://developer.402pay.co/llms-full.txt) has every page in one file, and the menu beside Copy page opens a page in Claude or ChatGPT.
## What to ask
- "Create a $49 payment link for the Pro plan."
- "Which payments are underpaid right now?"
- "What's my USDC balance on Polygon?"
> Money never moves on an API key. An agent can read your wallet's balances, but sends happen only in the dashboard, signed in your browser with your encryption password.
> An agent with a key acts as your business within that key's access. Only give keys to agents you trust, and revoke the key when you stop using it.
# Frequently asked questions
Source: https://developer.402pay.co/faq
What businesses ask most about 402pay, grouped by payments, webhooks, wallet, keys and billing.
- [Payments and checkout](https://developer.402pay.co/faq.md#payments-and-checkout): What customers can pay with, and what happens when they pay the wrong amount. 8 questions
- [Webhooks](https://developer.402pay.co/faq.md#webhooks): Deliveries that don't arrive, retries, and testing your handler. 4 questions
- [Wallet](https://developer.402pay.co/faq.md#wallet): Custody, deposits, and connecting a wallet you already use. 5 questions
- [Keys and security](https://developer.402pay.co/faq.md#keys-and-security): Signing in, passwords, API keys, and what to do when one leaks. 8 questions
- [Billing](https://developer.402pay.co/faq.md#billing): Rates, flat fees, your fee statement and how fees are collected. 4 questions
Looking up an error? See [troubleshooting](https://developer.402pay.co/troubleshooting.md). Not sure what a term means? See the [glossary](https://developer.402pay.co/glossary.md).
## Payments and checkout
### What is 402pay?
402pay is a payment processing platform for businesses that want to accept cards and crypto through one checkout, without handing custody of their revenue to a third party.
Your team shares hosted checkouts or creates payments from your own systems through the API. Customers pay by card, Apple Pay, Google Pay or crypto, and every payment lands directly in a self-custody wallet your business controls.
There are no balances to withdraw, payout schedules or reserves: funds are yours as soon as the network confirms them.
### Do we need to write code?
No. Create a payment link in the dashboard and share it anywhere. When you're ready to integrate, the [API and signed webhooks](https://developer.402pay.co/index.md) let you create payments from your own systems, and test keys let you rehearse every outcome before you go live.
### Which assets and networks are supported?
USDC, USDT, Bitcoin, Ether and Solana, across Polygon, Ethereum, Solana, Tron and Bitcoin. You decide which ones checkout offers. See [supported networks](https://developer.402pay.co/guides/networks.md).
### Can customers pay by card?
Yes. Enable cards in Settings and send customers to the payment or payment-link URL. Hosted checkout shows the available methods and opens a secure page for card details, the final total and any required identity checks.
Your deposit arrives in a supported coin and network. Read the payment's settlement details and fulfill the order after verifying `payment.succeeded`. Checkout handles unsuccessful attempts and offers another try when available.
### What if a customer sends the wrong amount?
A transfer short by no more than your underpayment tolerance, 0.5% unless you change it, counts as paid in full. Less than that makes the payment `underpaid`: checkout asks the customer for the rest, and you can request it later or accept what arrived. More than the amount due lands in your wallet in full, and `payment.overpaid` tells you the excess. See [underpayments and overpayments](https://developer.402pay.co/guides/underpayments.md).
### Can a customer pay after the checkout expires?
A transfer that arrives after the quote expired still reaches your wallet, but the rate no longer holds, so the payment becomes `needs_review`. Accept it as it is, or send the funds back. See [needs review](https://developer.402pay.co/guides/underpayments.md#needs-review).
### How are refunds and disputes handled?
Refunds stay under your control: you send them from your wallet, in the asset and amount you choose, for card and crypto payments alike. There's no refund endpoint, since money never moves on an API key. See [refunds, returns and disputes](https://developer.402pay.co/guides/refunds.md).
Cardholders can still dispute a card payment with their bank. You'll be asked for evidence, and a clear description of what customers are buying, with your refund policy shown at checkout, keeps disputes rare.
### Does 402pay email our customers?
No. Every paid payment has a hosted [receipt](https://developer.402pay.co/guides/receipts.md) the customer sees after paying. Link to it, or send your own confirmation when your webhook hears the payment succeeded.
## Webhooks
### Why didn't a webhook arrive?
Every attempt is recorded with its signed request and your server's answer, which you can read in the dashboard under Developers, then Webhooks. Check that the endpoint is turned on, subscribes to the event type and answers over HTTPS. A local 402pay records deliveries without sending them. See [troubleshooting webhooks](https://developer.402pay.co/guides/webhooks.md#troubleshooting).
### How long are failed deliveries retried?
A delivery gets 8 attempts in all, spread over about 28 hours and starting 5 seconds after the first one fails. After the last, resend it from the dashboard or with `POST /webhook-deliveries/{id}/resend`.
### Can we get events we missed?
Yes. [`GET /events`](https://developer.402pay.co/api/events/list.md) lists every event with a snapshot of its subject, whether or not a webhook delivered it, and any delivery can be resent.
### How do we test our handler?
Send a test event from the dashboard or with `POST /webhooks/{id}/test`. It's signed like any delivery, with `test: true` and a made-up subject whose `data` holds only its `id`, so skip it before you fulfill anything.
## Wallet
### Do you hold our funds?
No. 402pay is non-custodial. Payments land in a wallet whose keys only your business holds, so we can't move, freeze or reverse your funds.
### How quickly do payments reach our wallet?
As fast as the network allows. A crypto payment is in your wallet once it has the confirmations its network needs, typically seconds on Polygon or Solana and minutes on Bitcoin. Card payments follow the same network confirmations after the payment reaches its destination.
### Can we use a wallet we already have?
Yes. Connect it as an external wallet by its public keys: an xpub or zpub, or one fixed address, for each network family. Networks you leave out aren't offered at checkout, and sends happen in that wallet's own app. See [wallet and deposits](https://developer.402pay.co/guides/settlement.md).
### Can the API move money out of our wallet?
No. An API key can read balances and transactions, but sends happen only in the dashboard, signed in your browser with your encryption password. An external wallet sends from its own app.
### What if we lose our recovery phrase or encryption password?
402pay can't recover either one: the phrase is encrypted in your browser, and only the encrypted copy is stored. Back both up somewhere safe and offline before you take real payments.
## Keys and security
### How is our account secured?
Your team signs in with a passkey, or with an email and a password, confirmed by a code we email, plus optional two-step verification, and sees a list of every active session. We email you about new sign-ins, passkeys added or removed, and changes to your email, password or two-step verification. API keys can be limited to exactly what each integration needs, every webhook is signed, and your wallet's recovery phrase is encrypted in your browser before anything reaches us.
### What if I forget my password?
Use Forgot password on the sign-in page. We email a link that works once, for 30 minutes. If two-step verification is on, you'll also need a code from your app or a recovery code. Resetting signs out every device. If you've lost those too, contact us at [support@402pay.co](mailto:support@402pay.co).
### What if the confirmation code doesn't arrive?
Check your spam folder. A minute after the last code, you can ask for a new one on the same page. Each code works for 15 minutes, and a sign-up waits a day to be confirmed. If you closed the page, sign in with the same email and password in the same browser to pick up where you left off, or open the link in the email.
### Why does the dashboard ask for my password again?
Some changes decide where payments go or who can reach your business: adding or deleting the wallet, changing its encryption password, creating API keys or webhooks, changing a webhook's URL, adding or removing a passkey and deleting a business. If you haven't signed in or confirmed it's you in the last 15 minutes, the dashboard asks first: use a passkey, or your password and, when two-step verification is on, a code. We also email you whenever the wallet changes.
### Can we limit what an API key can do?
Yes. A restricted key gets none, read or write on each resource: `GET` needs read and every other method needs write. Give each integration only what it uses. See [restricted keys](https://developer.402pay.co/authentication.md#restricted-keys).
### What should we do if a secret key leaks?
Create a new key in the dashboard under Developers, then API keys, deploy it, then revoke the old one, which stops working at once. Only a hash of each secret is stored, so a lost secret can't be shown again either. See [security best practices](https://developer.402pay.co/guides/security.md).
### What happens after too many wrong two-step codes?
Five wrong codes in a row lock that person's codes for 15 minutes, counted across every sign-in, so starting over doesn't help. Wait for the lock to end, then use a code from your authenticator or one of your recovery codes.
### What happens after too many wrong passwords?
Five failed sign-ins in a row lock that email for a minute, and each further five lock it for longer, up to an hour. A browser you've signed in with before keeps its own count, so someone guessing from elsewhere can't lock you out of it. After 100 in a row from other browsers, the password stops working in them until it's reset, and we email the account. Signing in successfully or resetting the password clears the count.
## Billing
### What does it cost?
An intro rate of 0% per crypto payment and 3% per card payment. On top of that, $0.25 per successful payment, and $5 a month for a self-custody wallet or $15 a month for an external one. There's no setup fee. See [pricing](https://402pay.co/pricing) for every add-on.
### Does anything come out of a payment?
No. Every payment, by card or crypto, lands in your wallet whole. Its rate, in `reporting.fee`, and the $0.25 per transaction, in `reporting.transaction_fee`, go on your fee statement with the wallet's monthly fee. `reporting.net` is what you keep once they're paid.
### How do I pay the fees on my statement?
You don't have to do anything. Once you owe $25.00, 402pay sends some of your payments to its own address instead of your wallet, whole, until what you owe is paid. Your customers pay as usual, and those payments show as collected as fees. An overpayment becomes credit for future fees. See [fees and billing](https://developer.402pay.co/guides/fees.md).
### Can my customers pay the fees?
Yes. Under Settings, Payments, choose who pays 402pay's fees. When your customers do, checkout adds the rate for their method and the $0.25 flat fee to their total, worked out on your price, so you keep the whole price. Each payment shows it in `customer_fee`, and an API payment can choose for itself with `fee_payer`. See [passing fees on](https://developer.402pay.co/guides/reconciliation.md#passing-fees-on).
# Troubleshooting
Source: https://developer.402pay.co/troubleshooting
From what you're seeing to the error code behind it and the fix.
Find what you're seeing, then the fix. Each error's `code` is stable, and [errors](https://developer.402pay.co/api/errors.md) lists every one. When you contact support, include the error's `request_id`.
## Creating payments
- A create is refused because of its reference 409 `reference_in_use` A reference names one live payment until it expires or you cancel it, and this one is taken by a payment for another amount or currency, or one that already received funds. The message names that payment. Cancel it with [`POST /payments/{id}/cancel`](https://developer.402pay.co/api/payments/cancel.md) and create again, or use a new reference.
- A retried create made a second payment Without a `reference`, every create makes a new payment. Send your order's ID as the `reference`, or an `Idempotency-Key` header, so a retry returns the first payment.
- The business can't take payments yet 409 `not_accepting` The business has no wallet that can receive, so checkout would have nothing to pay into. Finish setup in the dashboard: add the business, then create or connect its wallet.
- expires_at is refused 400 `invalid_request` `expires_at` must be an ISO 8601 date-time from 15 minutes to 30 days from now. Leave it out for 24 hours.
## Checkout
- Checkout asks for an email, or refuses to start without one 400 `email_required` Card payments always need the customer's email, and crypto ones do too while checkout collects emails, which is on by default in Settings, under Checkout. Set `customer_email` when you create the payment and checkout won't ask, or send `email` when you create a checkout yourself.
- The payment's page says it's already paid 409 `payment_paid` Funds already reached the payment: it's `succeeded`, `underpaid` or `needs_review`, so it can't start another attempt. Check its status, and request the rest or accept it if it's short.
- The payment's page says it expired or was canceled 410 `payment_expired` A payment past its `expires_at`, or one you canceled (409 `payment_canceled`), can't be paid. Create a new one: its reference is free again.
- A customer can't pay you at all 403 `customer_blocked` You blocked the customer with that email. Unblock them in the dashboard, or with [`PATCH /customers/{id}`](https://developer.402pay.co/api/customers/update.md) and `blocked: false`.
- A card checkout is refused for its price 400 `amount_out_of_range` Cards take $5 to $10,000. Outside that range, customers pay with crypto.
## Underpayments
- Requesting the rest is refused 409 `remainder_unavailable` The underpaid quote is still live, so the customer can send the rest to the same address. The message says until when, and gives the checkout URL to send them back to. Try again once it expires.
- Nothing is left to request 409 `nothing_due` The rest already arrived, or what arrived covers the price. Check the payment's status.
- The wallet can't collect the rest 409 `not_accepting` Your wallet no longer receives on the network the customer paid on, such as an external wallet that left it out. Add that network back, or [accept what arrived](https://developer.402pay.co/api/payments/accept.md).
## Keys and requests
- Every request is refused 401 `invalid_api_key` Send `Authorization: Bearer` and a secret key, starting `402s_`. Publishable keys and revoked keys can't authenticate.
- The key works, but not for this business 403 `business_forbidden` A key acts only as its own business. Leave out the `402pay-Business` header.
- A restricted key is refused 403 `permission_denied` The key needs read on the resource for `GET`, and write for anything else. Embedding with `include=customer` also needs read on customers.
- A body is refused before it's read 415 `unsupported_media_type` Send `Content-Type: application/json` with every request that has a body.
- A request comes back 405 with an empty body The path exists but doesn't take that method, such as DELETE on a payment. Check the endpoint's page for the methods it takes.
- A retry with the same idempotency key is refused 409 `idempotency_key_reused` The key was already used for a different request. Use a new random key for each operation, and the same one only to retry it.
- Requests are being turned away 429 `rate_limited` Too many requests came from one IP address. Wait for the `Retry-After` seconds.
## Lists
- The next page is refused 400 `invalid_request` A cursor only works with the list and filters it came from, and names the last item of the page before. If the filters changed or that item is gone, start again without `cursor`.
## Webhooks
- An endpoint URL is refused 400 `invalid_request` It must be a public `https://` address. `localhost`, private networks and link-local addresses are refused.
- Deliveries never reach the server Read each attempt, with your server's answer, in the dashboard or with `GET /webhook-deliveries`. The endpoint must be turned on, subscribed to the event type and reachable over public HTTPS. A local 402pay records deliveries without sending them. See [troubleshooting webhooks](https://developer.402pay.co/guides/webhooks.md#troubleshooting) for signatures that fail and events that arrive twice.
# Glossary
Source: https://developer.402pay.co/glossary
The terms the API and these docs use, from actor to webhook endpoint.
The words these docs and the API use, in one place. Each one links to the page that covers it in full.
## A
- **Actor**: Who caused an event: `user` for a person in the dashboard, `api_key`, `customer` at checkout, or `system` for 402pay itself, such as a payment confirming. See [event fields](https://developer.402pay.co/guides/webhooks.md#event-fields).
- **API payment**: A payment your server creates with `POST /payments`, for an exact amount, with your `reference` and `metadata` and a hosted checkout of its own. Every checkout opened on it is one attempt at the same payment. See [accept a payment](https://developer.402pay.co/guides/accept-a-payment.md).
## B
- **Business**: One merchant account, with its own wallet, keys, payments and settings. One person can own several, and a secret key acts as exactly one. IDs start with `biz_`. See [core concepts](https://developer.402pay.co/concepts.md#businesses).
## C
- **Checkout**: One attempt to pay a payment or link, in one rail, coin and network. A crypto checkout reserves a fresh address in your wallet and locks a quote. IDs start with `chk_`. See [hosted checkout](https://developer.402pay.co/guides/hosted-checkout.md).
- **Checkout address**: The fresh address in your wallet that one crypto checkout pays to, so every transfer matches one payment. Every EVM network shares it. See [wallet and deposits](https://developer.402pay.co/guides/settlement.md).
- **Confirmation**: A block added on top of the one holding a transfer. Each network needs a set number before a payment succeeds. See [supported networks](https://developer.402pay.co/guides/networks.md).
- **Customer**: A person grouped by email, with their payment count and totals, added as soon as checkout has their email. You can block one from paying. IDs start with `cst_`. See [list customers](https://developer.402pay.co/api/customers/list.md).
## D
- **Deposit**: Where a paid payment's funds landed: a wallet transaction into your wallet, in the coin and network paid, for the whole amount that arrived. The payment's `settlement` field shows it, or that the payment was collected as fees instead. See [wallet and deposits](https://developer.402pay.co/guides/settlement.md).
## E
- **Event**: A record of something that happened, such as `payment.succeeded`, with a snapshot of its subject as it was then. Webhooks deliver events. IDs start with `evt_`. See [list events](https://developer.402pay.co/api/events/list.md).
- **External wallet**: A wallet you already use, connected by its public keys, so 402pay never sees its recovery phrase. Payments land in it, and sends happen in its own app. See [wallet and deposits](https://developer.402pay.co/guides/settlement.md).
## F
- **Fee statement**: What your business owes 402pay in fees, and the entries behind it. Once you owe enough, 402pay collects it by sending whole payments to its own address, whose `settlement.destination` is `fees`. Entry IDs start with `fee_`. See [fees and billing](https://developer.402pay.co/guides/fees.md).
## I
- **Idempotency key**: A random value sent in a header that makes a POST safe to retry: the same key returns the first response instead of doing the work twice. See [idempotency](https://developer.402pay.co/api/idempotency.md).
## M
- **Main address**: The first address on each network in your wallet, the one to share for deposits. Sends draw on whichever of the wallet's addresses hold the coin, largest first.
- **Metadata**: Your own key-value strings on a payment, returned with it and with every event about it. See [metadata](https://developer.402pay.co/api/metadata.md).
- **Mode**: `test` or `live`, for the kind of key behind an event. Always `live` during the beta, when test and live keys share data. See [test and live keys](https://developer.402pay.co/testing.md#test-and-live).
## P
- **Payment link**: A reusable hosted checkout with a fixed price. Every customer who pays it makes a new payment that carries its `link_id`. IDs start with `lnk_`. See [payment links](https://developer.402pay.co/guides/payment-links.md).
## Q
- **Quote**: The coin amount a crypto checkout asks for, at a rate locked for a window, 15 minutes by default. A transfer after it expires needs your review. See [underpayments and overpayments](https://developer.402pay.co/guides/underpayments.md).
## R
- **Rail**: How the customer pays: `crypto` or `card`. Card payments arrive in the coin and network selected for that checkout. See [card payments](https://developer.402pay.co/guides/card-payments.md).
- **Reference**: Your own ID for an API payment, such as an order number. It names one live payment, so a retried create returns the payment that already exists. See [create the payment](https://developer.402pay.co/guides/accept-a-payment.md#create-the-payment).
- **Remainder**: A checkout for exactly what's left of an underpaid payment, in the same coin and network and attached to the same payment, so no second payment is made. See [request the rest](https://developer.402pay.co/guides/underpayments.md#request-the-rest).
- **Request ID**: The `req_` ID in every response's `402pay-Request-Id` header, repeated in every error as `request_id`. Include it when you contact support.
- **Restricted key**: A secret key with none, read or write on each resource, rather than full access. See [restricted keys](https://developer.402pay.co/authentication.md#restricted-keys).
- **Review reason**: Why a checkout's funds need your decision: `late`, `wrong_network` or `claimed`. It's on the checkout, in `review_reason`. See [needs review](https://developer.402pay.co/guides/underpayments.md#needs-review).
## T
- **Tolerance**: How far short a crypto transfer can fall and still count as paid in full, 0.5% unless you change it in Settings, under Checkout. See [underpaid](https://developer.402pay.co/guides/underpayments.md#underpaid).
## W
- **Webhook endpoint**: A URL on your server that receives signed events of the types you choose, retried until it answers. IDs start with `whk_`. See [webhooks](https://developer.402pay.co/guides/webhooks.md).
# Introduction
Source: https://developer.402pay.co/api
The base URL, how requests and responses are shaped, and every endpoint.
The 402pay API is organized around resources, with predictable URLs, JSON bodies and standard HTTP status codes. Requests use your [secret key](https://developer.402pay.co/authentication.md), except the public checkout, exchange rate, event type and health endpoints, and the ones only the dashboard can call, such as sending from your wallet and changing your business's profile.
## Base URL
Base URL: `https://dash.402pay.co/api/v1`
[API v1](https://developer.402pay.co/changelog.md)
`GET /health` answers without a key, so it's a quick way to check that your server can reach the API.
## Conventions
- Send and receive JSON. A request with a body needs `Content-Type: application/json`, or it returns 415 `unsupported_media_type`. Fields and query parameters are snake_case.
- Every object has a `kind`, such as `payment` or `checkout`, and an ID whose prefix says what it is.
- Timestamps are ISO 8601 in UTC.
- Money is an integer in minor units with its `currency`: `4900` with `USD` is $49.00. USD totals for reporting live under `reporting`.
- Coin amounts are decimal strings, such as `"49.00"`, so no precision is lost.
- A single object comes back as `{ data }`, a create returns 201, and a delete returns the object's `id` and `kind` with `deleted: true`.
- A path that doesn't exist returns 404 `not_found` in the same [error envelope](https://developer.402pay.co/api/errors.md) as every other error. A method a path doesn't take, such as `DELETE /payments/{id}`, returns a bare 405 instead, with no body.
- Every response except a 405 has a `402pay-Request-Id` header, and every error repeats it as `request_id`. Include it when you contact support.
## ID prefixes
| Prefix | Object |
| --- | --- |
| `biz_` | Business, as in the 402pay-Business header |
| `pmt_` | Payment |
| `chk_` | Checkout |
| `lnk_` | Payment link |
| `cst_` | Customer |
| `evt_` | Event |
| `whk_` | Webhook endpoint |
| `dlv_` | Webhook delivery |
| `wal_` | Wallet |
| `wtx_` | Wallet transaction |
| `key_` | API key, and an event's `actor.id` when a key made the change |
| `usr_` | A person on your team, as an event's `actor.id` |
| `req_` | Request, in errors and the request ID header |
## Versioning
- The version is part of the base URL, `/api/v1`. There's no version header, and nothing to pin per request.
- Every event carries `api_version`, `v1` today: the version of its shape, so a webhook handler knows which fields to expect.
- New fields and event types can appear within a version, so ignore the ones your code doesn't know rather than refusing them.
- Every change is in the [changelog](https://developer.402pay.co/changelog.md).
## Endpoints
Payments
- [POST](https://developer.402pay.co/api/payments/create.md): /payments
- [GET](https://developer.402pay.co/api/payments/retrieve.md): /payments/{id}
- [GET](https://developer.402pay.co/api/payments/list.md): /payments
- [PATCH](https://developer.402pay.co/api/payments/update.md): /payments/{id}
- [POST](https://developer.402pay.co/api/payments/cancel.md): /payments/{id}/cancel
- [POST](https://developer.402pay.co/api/payments/accept.md): /payments/{id}/accept
- [POST](https://developer.402pay.co/api/payments/request-remainder.md): /payments/{id}/request-remainder
Checkouts
- [POST](https://developer.402pay.co/api/checkouts/create.md): /checkouts
- [GET](https://developer.402pay.co/api/checkouts/retrieve.md): /checkouts/{id}
- [POST](https://developer.402pay.co/api/checkouts/cancel.md): /checkouts/{id}/cancel
- [POST](https://developer.402pay.co/api/checkouts/supersede.md): /checkouts/{id}/supersede
- [POST](https://developer.402pay.co/api/checkouts/claim.md): /checkouts/{id}/claim
- [POST](https://developer.402pay.co/api/checkouts/card/retry.md): /checkouts/{id}/card/retry
- [POST](https://developer.402pay.co/api/checkouts/mark-sent.md): /checkouts/{id}/mark-sent
- [GET](https://developer.402pay.co/api/checkouts/public-link.md): /public/links/{code}
- [GET](https://developer.402pay.co/api/checkouts/receipt.md): /public/receipts/{id}
Links
- [POST](https://developer.402pay.co/api/links/create.md): /links
- [GET](https://developer.402pay.co/api/links/list.md): /links
- [GET](https://developer.402pay.co/api/links/retrieve.md): /links/{id}
- [PATCH](https://developer.402pay.co/api/links/update.md): /links/{id}
- [DELETE](https://developer.402pay.co/api/links/delete.md): /links/{id}
Customers
- [POST](https://developer.402pay.co/api/customers/create.md): /customers
- [GET](https://developer.402pay.co/api/customers/list.md): /customers
- [GET](https://developer.402pay.co/api/customers/retrieve.md): /customers/{id}
- [PATCH](https://developer.402pay.co/api/customers/update.md): /customers/{id}
Events
- [GET](https://developer.402pay.co/api/events/list.md): /events
- [GET](https://developer.402pay.co/api/events/retrieve.md): /events/{id}
- [GET](https://developer.402pay.co/api/events/types.md): /event-types
Webhooks
- [POST](https://developer.402pay.co/api/webhooks/create.md): /webhooks
- [GET](https://developer.402pay.co/api/webhooks/list.md): /webhooks
- [GET](https://developer.402pay.co/api/webhooks/retrieve.md): /webhooks/{id}
- [PATCH](https://developer.402pay.co/api/webhooks/update.md): /webhooks/{id}
- [DELETE](https://developer.402pay.co/api/webhooks/delete.md): /webhooks/{id}
- [POST](https://developer.402pay.co/api/webhooks/test.md): /webhooks/{id}/test
- [POST](https://developer.402pay.co/api/webhooks/rotate-secret.md): /webhooks/{id}/rotate-secret
- [GET](https://developer.402pay.co/api/webhooks/deliveries.md): /webhook-deliveries
- [GET](https://developer.402pay.co/api/webhooks/deliveries/retrieve.md): /webhook-deliveries/{id}
- [GET](https://developer.402pay.co/api/webhooks/endpoint-deliveries.md): /webhooks/{id}/deliveries
- [POST](https://developer.402pay.co/api/webhooks/resend.md): /webhook-deliveries/{id}/resend
Wallet
- [GET](https://developer.402pay.co/api/wallet/retrieve.md): /wallet
- [PATCH](https://developer.402pay.co/api/wallet/update.md): /wallet
- [GET](https://developer.402pay.co/api/wallet/transactions.md): /wallet/transactions
- [GET](https://developer.402pay.co/api/wallet/transactions/retrieve.md): /wallet/transactions/{id}
- [PATCH](https://developer.402pay.co/api/wallet/transactions/update.md): /wallet/transactions/{id}
- [GET](https://developer.402pay.co/api/wallet/fee-estimates.md): /wallet/fee-estimates
- [GET](https://developer.402pay.co/api/wallet/send-context.md): /wallet/send-context
- [POST](https://developer.402pay.co/api/wallet/transactions/create.md): /wallet/transactions
- [DELETE](https://developer.402pay.co/api/wallet/delete.md): /wallet
- [POST](https://developer.402pay.co/api/wallet/changes/cancel.md): /wallet/changes/{id}/cancel
Businesses
- [GET](https://developer.402pay.co/api/businesses/retrieve.md): /businesses/{id}
- [PATCH](https://developer.402pay.co/api/businesses/update.md): /businesses/{id}
- [POST](https://developer.402pay.co/api/businesses/alert-email/confirm.md): /businesses/{id}/alert-email/confirm
- [POST](https://developer.402pay.co/api/businesses/alert-email/resend.md): /businesses/{id}/alert-email/resend
- [POST](https://developer.402pay.co/api/businesses/alert-email/cancel.md): /businesses/{id}/alert-email/cancel
Settings
- [GET](https://developer.402pay.co/api/settings/retrieve.md): /settings/checkout
- [PATCH](https://developer.402pay.co/api/settings/update.md): /settings/checkout
Billing
- [GET](https://developer.402pay.co/api/billing/fees.md): /billing/fees
- [GET](https://developer.402pay.co/api/billing/fees/entries.md): /billing/fees/entries
Metrics
- [GET](https://developer.402pay.co/api/metrics.md): /metrics
Search
- [GET](https://developer.402pay.co/api/search.md): /search
Exchange rates
- [GET](https://developer.402pay.co/api/exchange-rates.md): /exchange-rates
# Errors
Source: https://developer.402pay.co/api/errors
Status codes, the error envelope, and the codes to branch on.
Errors use standard HTTP status codes and one envelope. `code` is stable, so branch on it. `message` is written for people and may change. `field` names the input at fault, when there is one, and `request_id` matches the `402pay-Request-Id` header.
## Status codes
| Status | Meaning |
| --- | --- |
| 400 | The request is malformed or a field is invalid. |
| 401 | There's no valid key. The response has a WWW-Authenticate: Bearer header. |
| 403 | The key can't do this, or the request came from the wrong place. |
| 404 | Nothing with that ID belongs to your business, or the path doesn't exist. |
| 405 | The path exists but doesn't take this method. The response is empty, with no request ID. |
| 409 | The request conflicts with the object's current state. |
| 410 | What's being paid has expired. |
| 415 | The body isn't JSON. |
| 429 | Too many requests or attempts. |
| 500 | Something failed on our side. |
| 503 | This deployment can't do it right now, such as sending an email or reaching a network. |
## Error codes
### Requests and keys
| Code | Status | When |
| --- | --- | --- |
| `invalid_request` | 400 | A field is missing, unknown or invalid. `field` names it. Metadata refuses reserved names with "Metadata keys can't be named {key}." On `POST /wallet`, reused keys answer "These keys are already connected to another business." Text fields can't contain null characters ("{field} must not contain null characters.") or an escaped unpaired surrogate such as `\ud800` ("{field} must be valid text."). |
| `invalid_json` | 400 | The body isn't valid JSON, or isn't UTF-8. |
| `idempotency_key_required` | 400 | `POST /checkouts`, `POST /payments/{id}/request-remainder` and `POST /wallet/transactions` need an `Idempotency-Key` header. |
| `unsupported_media_type` | 415 | A body was sent without `Content-Type: application/json`. |
| `payload_too_large` | 413 | The request body is larger than 1 MiB. |
| `unauthenticated` | 401 | No key was sent. |
| `invalid_api_key` | 401 | The key is invalid or revoked, it's a publishable key, or the `Authorization` header isn't `Bearer` followed by the key. |
| `business_forbidden` | 403 | The `402pay-Business` header names a business the key or the signed-in person can't act for. With a secret key, leave the header out. |
| `permission_denied` | 403 | A restricted key doesn't have the access this route needs. |
| `test_mode_unavailable` | 403 | On a live business, whose payments are real money, a test key tried to create a payment, open a checkout or request a remainder, or to change what customers see or pay: links, checkout settings, accepting or canceling a payment, or blocking a customer. |
| `session_required` | 403 | The route is for the dashboard only, such as managing API keys or sending from your wallet, so an API key can't use it. |
| `cross_site_request` | 403 | A request signed in with a cookie came from another site. |
| `edge_forbidden` | 403 | The request didn't come through `https://dash.402pay.co/api/v1`, the API's only address. |
| `not_found` | 404 | Nothing with that ID belongs to your business, or the path doesn't exist. |
| `idempotency_key_reused` | 409 | With a secret key or a session, the key was already used for a different request. |
| `idempotency_key_in_use` | 409 | The first request with this key is still running. Try again in a moment. |
| `rate_limited` | 429 | Too many requests from one IP address, or more than 10 webhook test events and resends in a minute for one business. Wait for the `Retry-After` seconds. |
| `internal_error` | 500 | Something failed on our side. Retry with the same Idempotency-Key. |
| `temporarily_unavailable` | 503 | 402pay paused something for now, such as new checkouts, card payments, a network or new sign-ups. Try again later. |
| `upstream_unavailable` | 503 | 402pay can't be reached right now. Retry with the same Idempotency-Key. |
### Payments
| Code | Status | When |
| --- | --- | --- |
| `invalid_email` | 400 | `customer_email` on a payment, or `email` on a checkout, isn't a valid address. Other routes, such as customers, answer `invalid_request` with `field` set instead. |
| `reference_in_use` | 409 | The reference names a live payment for another amount or currency, or one that already received funds. |
| `not_accepting` | 409 | The business has no wallet yet, or its wallet can't receive on the network a remainder needs. |
| `business_suspended` | 403 | 402pay suspended the business, so it can't create payments or open checkouts. Checkouts already open can still finish. |
| `payment_not_cancelable` | 409 | Funds have arrived, or the payment came from a link. |
| `payment_in_flight` | 409 | A transfer for the payment is on its way, so it can't be canceled. |
| `payment_not_acceptable` | 409 | Only `underpaid` and `needs_review` payments can be accepted. |
| `payment_not_underpaid` | 409 | Only an `underpaid` payment has a remainder to request. |
| `remainder_unavailable` | 409 | The quote is still live and the customer can still send the rest, or the payment has no checkout page left. |
| `nothing_due` | 409 | Nothing is left to collect on the payment. |
### Checkouts and receipts
| Code | Status | When |
| --- | --- | --- |
| `email_required` | 400 | The rail or the business needs the customer's email, and none was sent. |
| `rail_unavailable` | 400 | The business doesn't accept this rail. |
| `asset_unavailable` | 400 | The business doesn't accept this coin on this network. |
| `amount_out_of_range` | 400 | The price is outside what cards allow, $5 to $10,000. |
| `invalid_tx_hash` | 400 | A claim's transaction hash doesn't fit the checkout's network. |
| `customer_blocked` | 403 | The business has blocked the customer with this email. |
| `link_not_found` | 404 | No active link has this code. |
| `payment_not_found` | 404 | No payment has this code. |
| `payment_paid` | 409 | Funds already reached the payment: it's `succeeded`, `underpaid` or `needs_review`, so its page can't start another attempt. |
| `payment_canceled` | 409 | The business canceled the payment. |
| `wrong_rail` | 409 | The action is for crypto checkouts, and this one is paid by card. |
| `checkout_canceled` | 409 | The checkout was canceled. |
| `checkout_failed` | 409 | The checkout failed. Start a new one. |
| `checkout_not_cancelable` | 409 | Funds may already be on the way. |
| `checkout_not_supersedable` | 409 | The checkout can't be replaced now, because funds may be on the way or it collects a remainder. |
| `checkout_not_claimable` | 409 | Only an expired or canceled checkout takes a claim. |
| `already_claimed` | 409 | The business is already reviewing a claim for this checkout. |
| `checkout_not_retryable` | 409 | The card checkout can't start another attempt now. |
| `route_in_progress` | 409 | The customer's payment on the current secure payment page may still go through, so the checkout can't start another attempt until it finishes. |
| `no_route` | 409 | No further card-payment attempt is available. |
| `stale_route` | 409 | This card payment page is out of date. |
| `checkout_not_awaiting` | 409 | The checkout is not waiting for a card payment. |
| `payment_expired` | 410 | The payment passed its `expires_at`. |
| `checkout_expired` | 410 | The checkout's quote expired. Start a new one for a fresh address and amount. |
| `link_archived` | 410 | The link was archived, so its page takes no new payments. |
### Links and customers
| Code | Status | When |
| --- | --- | --- |
| `link_has_payments` | 409 | A link with payments can't be deleted. Archive it instead. |
| `link_disabled` | 409 | 402pay disabled the link, so it can't be restored. |
| `customer_exists` | 409 | Another customer already has this email. |
### Dashboard and account
These come from routes only the dashboard and its sign-up and sign-in pages use.
| Code | Status | When |
| --- | --- | --- |
| `business_required` | 400 | The request didn't name a business in the 402pay-Business header. |
| `invalid_credentials` | 400 | The email and password don't match an account, or the current password is wrong. An unknown email, or an account without a password, gets the same answer as a wrong password. |
| `challenge_failed` | 400 | Sign-up, sign-in or a password reset came without a passed Cloudflare Turnstile check in the `402pay-Challenge` header, or with one Cloudflare refused, one already used or one from another form. Complete the check again and resend. |
| `weak_password` | 400 | The new password breaks a rule: too short or long, too repetitive, without letters, too common, or found in a data breach. The message says which. |
| `password_required` | 400 | The sign-up is being finished in a different browser, so the password chosen at sign-up is needed too. |
| `signup_browser_required` | 400 | A sign-up with a passkey is being finished in a different browser. It has no password to prove who started it, so enter the code in the browser that did. |
| `invalid_code` | 400 | The code from the email, the authenticator code or the recovery code is wrong or malformed. |
| `code_expired` | 400 | The emailed code is more than 15 minutes old. Send a new one. |
| `verification_expired` | 400 or 409 | The sign-up, email change or new alert email was finished, canceled or replaced, or is more than a day old. Start again. |
| `invalid_token` | 400 | The password reset, undo or wallet change link was already used, replaced by a newer one or expired. |
| `challenge_expired` | 400 | Two-step sign-in timed out or was already used. Sign in again. |
| `passkey_failed` | 400 | The passkey couldn't be verified: its challenge is unknown, expired, already used or another browser's, or its signature, site, user verification or sign counter doesn't check out. Start again for a new challenge. |
| `passkey_unknown` | 400 | The passkey isn't on any account, because it was removed or never added, or at a step-up it isn't one of this account's. |
| `password_reset_required` | 403 | The password stopped working after too many failed sign-ins, or after an email change was undone. Reset it to sign in. The message says which. |
| `reset_on_hold` | 409 | An email change was undone, so nobody can choose a new password until the hold ends, a day later. `Retry-After` says when. |
| `step_up_required` | 403 | The change decides where payments go or who can reach the business, and the session hasn't proven its password or a passkey in the last 15 minutes. Confirm it at `POST /sessions/current/step-up` and send the request again. API keys are never asked. |
| `too_many_attempts` | 429 | Too many wrong codes or passwords: five wrong two-step codes lock the person's codes for 15 minutes, five wrong tries use up an emailed code, ten wrong sign-up codes in a day lock an address's sign-ups, and failed passwords lock the email (or the session, for a current password) for a while. Wait for the `Retry-After` seconds; signing in again doesn't reset a lock. |
| `too_many_emails` | 429 | We've sent this address as many emails as we can for now, a new code was asked for too soon, or the person started too many email changes or new alert emails in the last hour. `Retry-After` says when to try again. |
| `email_unavailable` | 503 | This deployment can't send email, so sign-up, password resets, email changes and new alert emails aren't available on it. |
| `email_taken` | 409 | Another account already uses this email address. |
| `two_factor_setup_required` | 409 | The two-step setup ended: it was never started in this session, or its QR code is more than 10 minutes old. Start again for a new one. |
| `two_factor_enabled` | 409 | Two-step verification is already on. |
| `two_factor_disabled` | 409 | Two-step verification is off. |
| `passkey_exists` | 409 | The passkey being added is already on an account. |
| `passkey_limit` | 409 | The account already has 10 passkeys. Remove one to add another. |
| `last_sign_in_method` | 409 | Removing this passkey would leave the account no way to sign in. Add another passkey or set a password first. |
| `no_passkeys` | 409 | A passkey step-up was asked for on an account without passkeys. Confirm with the password instead. |
| `current_session` | 409 | Sign out to end the session you're using. |
| `publishable_key` | 409 | A publishable key has no name or permissions to change. |
| `api_key_revoked` | 409 | A revoked key can't be changed. |
| `wallet_exists` | 409 | The business already has a wallet. |
| `wallet_required` | 409 | The business needs a wallet before it can finish setup. |
| `referral_locked` | 409 | The business already has a wallet, so the referral code it signed up with can't change. |
| `external_wallet` | 409 | The wallet keeps its own keys, so there's no recovery phrase here. |
| `wallet_change_pending` | 409 | The wallet is already set to be deleted. Cancel the deletion first, or wait for it. |
| `wallet_change_applied` | 409 | The wallet change already took effect, so it can't be canceled. |
| `invalid_address` | 400 | A send's address isn't valid for the network, or belongs to this wallet. |
| `insufficient_funds` | 409 | A send is for more than the wallet holds. |
| `insufficient_fee_funds` | 409 | A token send needs the network's own coin for its fee, and the wallet doesn't hold enough of it. |
| `duplicate_transaction` | 409 | That transaction was already sent, so it's on the wallet's history. |
| `transaction_rejected` | 409 | The network refused a send, such as when another transaction from the address got there first or the fee was too low. Nothing was sent, so read a fresh send context and sign again. |
| `network_unavailable` | 503 | The network a send or its context needs couldn't be reached. Retry with the same `Idempotency-Key`: the same signed transaction never goes out twice. |
## Rate limits
Some routes limit how often one IP address can call them, and answer 429 `rate_limited` with a `Retry-After` header, in seconds, once it's used up.
| Route | Limit per IP address |
| --- | --- |
| `POST /checkouts` | 30 per 10 minutes. |
| `POST /accounts` | 10 per hour. |
| `POST /accounts/verify` and `/accounts/verify/resend` | 20 per 10 minutes, together. |
| `POST /sessions` | 30 per 10 minutes. |
| `POST /sessions/two-factor` | 30 per 10 minutes. |
| `POST /passkeys/authentication/options` and `/passkeys/authentication/verify` | 60 per 10 minutes, together. |
| `POST /me/password`, `/me/two-factor`, `/sessions/current/step-up` and `/sessions/current/step-up/options` | 20 per 10 minutes, together. |
| `POST /me/passkeys/registration/options` and `POST /me/passkeys` | 20 per 10 minutes, together. |
| `POST /me/email` | 10 per hour. |
| `POST /password-resets` | 5 per hour. |
| `POST /password-resets/check`, `/password-resets/confirm`, `POST /email-changes/undo` and `POST /wallet-changes/cancel` | 20 per 10 minutes, together. |
| `GET /public/referrals/{code}` | 60 per 10 minutes. |
Sign-in and email also have limits that count per person, email address or code rather than per IP address, so they apply wherever the request comes from, and starting a new sign-in doesn't reset them:
| What | Limit |
| --- | --- |
| Failed passwords, per email | 5 in a row lock it for 1 minute, 10 for 5 minutes, 15 for 15 minutes, and 20 and every 5 after for an hour, with 429 `too_many_attempts`. After 100, the password stops working until it's reset: 403 `password_reset_required`. A success or a reset clears the count. A browser that signed in to the account before keeps a count of its own, which locks the same way but never turns the password off, so failures from elsewhere can't lock its owner out. |
| Wrong current passwords, per session | The same locks for `current_password` on `/me/password`, `/me/email`, `/me/two-factor` and `/sessions/current/step-up`, counted per session. |
| Two-step codes, per person | 5 wrong codes, counted across every challenge, lock that person's codes for 15 minutes. Every code check then answers 429 `too_many_attempts`. |
| Emailed codes, per code | 5 wrong tries, then 429 `too_many_attempts` until a new code is sent. A new code can be sent a minute after the last, up to 5 per sign-up, email change or new alert email. |
| Emails, per address | 5 an hour and 20 a day from sign-ups, password resets, email changes and new alert emails, and at most 3 reset links an hour. Past that, a resend or a new alert email answers 429 `too_many_emails`, and other requests answer as usual but send nothing. |
| Sign-up codes, per address | 10 wrong codes a day, across every sign-up for the address, then 429 `too_many_attempts`. |
| Email changes, per person | 3 started an hour, whether or not the address was free, then 429 `too_many_emails`. |
| New alert emails, per person | 3 started an hour across all their businesses, including ones since replaced or canceled, then 429 `too_many_emails`. Resending a code and addresses that apply at once don't count. |
Each of these 429 answers carries a `Retry-After` header, in seconds.
Response, 400 Bad Request:
```json
{
"error": {
"code": "invalid_request",
"message": "amount must be a whole number of minor units between $1.00 and $1,000,000.00.",
"field": "amount",
"request_id": "req_6eb3d90a19234e358f603a74e004c874"
}
}
```
# Idempotency
Source: https://developer.402pay.co/api/idempotency
Retry any request safely with an Idempotency-Key header.
Send an `Idempotency-Key` header with any POST to make it safe to retry. Use a new random value, such as a UUID, for each operation.
- A successful response, any 2xx, is kept for 24 hours. A retry with the same key, method, path and body returns it again, with `Idempotent-Replayed: true`.
- An error isn't kept. It frees the key, so you can fix the request and send it again with the same key.
- With a secret key or a session, the same key with a different method, path or body returns 409 `idempotency_key_reused`.
- A retry while the first request is still running returns 409 `idempotency_key_in_use`. Wait a moment and try again.
- A key is 1 to 255 characters, or the request returns 400 `invalid_request`.
- Keys are kept per business, so they never collide with another business's. A request made without a key or session, like a checkout page's `POST /checkouts`, is kept by its key and its body together: a retry with both replays from any network, and the same key with a different body is a new request rather than a 409, so use a new random value for each operation.
## Where it's required or ignored
- `POST /checkouts`, `POST /payments/{id}/request-remainder` and `POST /wallet/transactions` require a key, and answer 400 `idempotency_key_required` without one.
- Only POST uses it. `PATCH` and `DELETE` ignore the header: a repeated `PATCH` sets the same values again, and a repeated `DELETE` answers 404 once the object is gone.
- A POST whose response carries a secret ignores it too, so a replay never hands the secret out again: `POST /webhooks` and `POST /webhooks/{id}/rotate-secret`. A retried `POST /webhooks` adds a second endpoint, so list your endpoints before you try again.
> Creating a payment without a key is safe to retry only when you send a `reference`: a retry with the same reference, amount and currency returns the payment the first request made. Without a `reference`, send an `Idempotency-Key`, or a retry makes a second payment.
Request a remainder, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)"
```
Request a remainder, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
});
const { data } = await response.json();
```
Request a remainder, Python:
```python
import os
import uuid
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"kind": "remainder_request",
"payment": {
"id": "pmt_uYs2XjkL1jGJt44v",
"kind": "payment",
"status": "underpaid",
"amount": 19900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 17910,
"reporting": {
"currency": "USD",
"amount": 19900,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 0
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "179.10",
"expected_amount": "199.00",
"overpaid_amount": null,
"from_address": "0xd2554462b44e258265af12a7d051e47bda66e5b6",
"tx_hash": "0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812",
"explorer_url": "https://polygonscan.com/tx/0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "179.10",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x7Ba69aE8cEf2987739F6E70e903F95a5028e3531",
"tx_hash": "0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812",
"explorer_url": "https://polygonscan.com/tx/0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": "lnk_nt842tPNne3lf0ka",
"url": null,
"reference": null,
"metadata": {},
"success_url": null,
"cancel_url": null,
"expires_at": null,
"canceled_at": null,
"checkout_id": "chk_OuPmQnhYyQqpp2qZ",
"description": "Team plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": null,
"created_at": "2026-09-26T21:22:47.055Z",
"updated_at": "2026-09-26T21:38:24.275Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.055Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.055Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:22:47.069Z",
"data": {
"amount": "179.10",
"network": "polygon",
"tx_hash": "0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812"
}
},
{
"type": "underpaid",
"created_at": "2026-09-26T21:22:47.069Z",
"data": {
"expected": "199.00",
"received": "179.10"
}
},
{
"type": "expired",
"created_at": "2026-09-26T21:37:47.069Z",
"data": null
},
{
"type": "remainder_requested",
"created_at": "2026-09-26T21:38:24.275Z",
"data": {
"checkout_id": "chk_XKOWx49himYIAnet",
"amount": 1990,
"currency": "USD"
}
}
]
},
"checkout": {
"id": "chk_XKOWx49himYIAnet",
"kind": "checkout",
"status": "open",
"rail": "crypto",
"link_id": "lnk_nt842tPNne3lf0ka",
"payment_id": "pmt_uYs2XjkL1jGJt44v",
"amount": 1990,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 1990,
"processing_fee": null,
"network_fee": 2,
"total": 1992
},
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "19.90",
"address": "0xb5C513376614F24fBf807B8c2B1FA3fD1E637A52",
"payment_uri": "ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0xb5C513376614F24fBf807B8c2B1FA3fD1E637A52&uint256=19900000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": true,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": null,
"expires_at": "2026-09-26T21:53:24.271Z",
"sent_at": null,
"created_at": "2026-09-26T21:38:24.271Z",
"updated_at": "2026-09-26T21:38:24.271Z",
"email": "harper.wilson@example.com",
"reporting": {
"currency": "USD",
"amount": 1990,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 1990
},
"fee_rate_bps": 0
},
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg?s=chk_XKOWx49himYIAnet"
}
}
```
# Pagination
Source: https://developer.402pay.co/api/pagination
Walk every list endpoint with limit and cursor.
List endpoints return `{ data, has_more, next_cursor }`. Pass `limit` for the page size, 25 by default and up to 100, and pass `next_cursor` back as `cursor` for the next page.
A cursor only works with the list and filters it came from. If it doesn't match, or isn't one the API gave you, the request returns 400 `invalid_request` with `field: "cursor"`, so start again without it.
GET /links?limit=1, 200 OK:
```json
{
"data": [
{
"id": "lnk_nt842tPNne3lf0ka",
"kind": "link",
"code": "v6e4mrczwg",
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"success_url": null,
"status": "active",
"disabled_at": null,
"payments_count": 0,
"volume": {
"amount": 0,
"currency": "USD"
},
"created_at": "2026-09-26T21:22:46.992Z",
"updated_at": "2026-09-26T21:22:46.992Z"
}
],
"has_more": true,
"next_cursor": "lnk_nt842tPNne3lf0ka"
}
```
Every page, Node.js:
```js
// Walks every page of a list, 100 at a time.
async function* listAll(path) {
let cursor = null;
do {
const url = new URL(`https://dash.402pay.co/api/v1${path}`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}` },
});
const page = await response.json();
yield* page.data;
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
}
for await (const payment of listAll("/payments?status=succeeded")) {
console.log(payment.id, payment.amount);
}
```
# Metadata
Source: https://developer.402pay.co/api/metadata
Attach your own key-value data to payments and filter by it.
Attach your own data to a payment with `metadata`: up to 20 keys of up to 40 characters, with string values up to 500 characters. Keys use only letters, digits, `_`, `.` and `-`.
- [`PATCH /payments/{id}`](https://developer.402pay.co/api/payments/update.md) merges by key. Set a key to an empty string or `null` to remove it, or send `"metadata": null` to clear them all.
- Filter lists by it, such as `GET /payments?metadata[order_id]=1042`.
- It comes back on the payment and in every event about it, so your webhook handler has what it needs.
Update metadata, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"metadata": {
"plan": "pro",
"seats": "5",
"coupon": ""
}
}'
```
Update metadata, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
plan: "pro",
seats: "5",
coupon: ""
}
}),
});
const { data } = await response.json();
```
Update metadata, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"metadata": {
"plan": "pro",
"seats": "5",
"coupon": ""
}
},
)
data = response.json()["data"]
```
# Amounts, currencies and exchange rates
Source: https://developer.402pay.co/api/currencies
How prices, US dollar reporting and coin amounts are written, and how one becomes another.
Three kinds of amount appear in the API: the price, in the currency you set; its value in US dollars, for reporting; and coin amounts, for what customers send and what reaches your wallet.
## Prices
A price is an integer in minor units with its `currency`: `4900` with `EUR` is €49.00. Links and payments take any of these currencies, from the equivalent of $1.00 up to 1,000,000.00 in the currency. Card payment limits depend on the available route, currency and customer region.
| currency | Name | Example usd_rate | Example minimum |
| --- | --- | --- | --- |
| `USD` | US dollar | 1 | $1.00 |
| `EUR` | Euro | 1.08 | €0.93 |
| `GBP` | British pound | 1.27 | £0.79 |
| `CAD` | Canadian dollar | 0.73 | CA$1.37 |
| `AUD` | Australian dollar | 0.66 | A$1.52 |
These rates and minimums are examples. The backend uses its current exchange rate to enforce the minimum price when you create or update a link.
The customer pays the price in its own currency, and the receipt shows it that way. A payment's `customer_fee` and `amount_received` are in minor units of its `currency` too: any fee passed on to the customer, and how much of the price and that fee has arrived.
## USD reporting
Everything you add up is in US cents: a payment's `reporting`, with its `amount`, `fee`, `customer_fee` and `net`, a link's `volume`, a customer's `stats`, [metrics](https://developer.402pay.co/api/metrics.md), and every `*_usd` field on the wallet. A price becomes US cents at its currency's `usd_rate`, rounded to the nearest cent: €49.00 is 4900 × 1.08 = 5292, or $52.92.
Fees are taken on that US dollar value at the rail's `fee_rate_bps`: 0 for crypto and 300 for cards, in hundredths of a percent. A payment shows a fee once it succeeds. When you [pass fees on](https://developer.402pay.co/guides/reconciliation.md#passing-fees-on), the customer's share is worked out in the price's currency, with the flat fee converted from US cents at its `usd_rate` and rounded to the nearest minor unit.
€49.00, as a payment reports it, Payment:
```json
{
"amount": 4900,
"currency": "EUR",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 0,
"reporting": {
"currency": "USD",
"amount": 5292,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 0
}
}
```
## Coin amounts
Coin amounts are decimal strings, such as `"52.92"`, so no precision is lost. Each coin is quoted to a fixed number of decimal places.
| asset | Name | Decimal places |
| --- | --- | --- |
| `USDC` | USD Coin | 2 |
| `USDT` | Tether | 2 |
| `BTC` | Bitcoin | 8 |
| `ETH` | Ethereum | 6 |
| `SOL` | Solana | 4 |
A checkout works out the coin amount from the price's US dollar value and the coin's price, rounded to the coin's decimal places, and shows the rate it used in `crypto.rate`: one coin in minor units of the price's currency. Stablecoins count as one US dollar each.
€49.00, as a checkout quotes it, USDC:
```json
{
"asset": "USDC",
"network": "polygon",
"amount": "52.92",
"rate": {
"amount": 93,
"currency": "EUR"
}
}
```
€49.00, as a checkout quotes it, BTC:
```json
{
"asset": "BTC",
"network": "bitcoin",
"amount": "0.00053780",
"rate": {
"amount": 9111111,
"currency": "EUR"
}
}
```
- The checkout locks the amount and rate when it opens, for the quote window in your [checkout settings](https://developer.402pay.co/api/settings/retrieve.md), 15 minutes unless you change it.
- A payment's `method.amount` is what arrived in the coin, and `settlement.amount` what reached your wallet after the fee.
- A card payment reaches your wallet in the selected coin and network. Read its deposit in`settlement` for the delivered asset, network and amount, whatever the price's currency.
## Exchange rates
[`GET /exchange-rates`](https://developer.402pay.co/api/exchange-rates.md) returns the rates checkout quotes with: each coin's `price_usd` in US cents, and each currency's `usd_rate` in US dollars. It's public, so a page you build can show a price in coins before the customer opens a checkout.
> Coin prices follow the market, so read them when you need them rather than keeping a copy. A checkout's quote keeps the rate it opened with for its whole quote window.
# Create a payment
Source: https://developer.402pay.co/api/payments/create
Create a payment for an exact amount and get a hosted checkout URL for it.
`POST https://dash.402pay.co/api/v1/payments`
Returns 201 with the new payment and its hosted checkout at `url`. When `reference` already names a live payment for the same amount and currency, it returns 200 with that payment instead, so a retried create never makes a second one.
### Body
- `amount` (integer, required): The price in minor units of `currency`: `4900` with `USD` is $49.00. From the equivalent of $1.00 up to 1,000,000.00 in the currency.
- `currency` (string): USD, EUR, GBP, CAD, AUD. Defaults to USD.
- `reference` (string): Your ID for the payment, such as an order number, up to 64 characters. It names one live payment: one that isn't canceled or expired.
- `description` (string): Shown in the checkout's details and on the receipt, up to 300 characters.
- `customer_email` (string): Links the payment to the customer with this email, adding one if needed. Without it, `customer_id` stays `null` until checkout collects an email.
- `success_url` (string): Where checkout sends the customer after paying, with `payment_id` added to its query. Use `https://`; `http://` works only for `localhost` and `127.0.0.1`. Defaults to the redirect in your checkout settings, then the receipt.
- `cancel_url` (string): Where to send the customer if they leave without paying. Use `https://`; `http://` works only for `localhost` and `127.0.0.1`.
- `expires_at` (timestamp): From 15 minutes to 30 days after 402pay receives the request, so give a 15-minute expiry a few seconds' margin. Defaults to 24 hours.
- `metadata` (object): Up to 20 string pairs of your own. Keys are up to 40 letters, digits, `_`, `.` or `-`. See [metadata](https://developer.402pay.co/api/metadata.md).
- `fee_payer` (string): Who pays 402pay's fee on this payment: `business` or `customer`. Defaults to your checkout settings. With `customer`, checkout adds the fee for the method the customer picks to their total, as [passing fees on](https://developer.402pay.co/guides/reconciliation.md#passing-fees-on) describes.
### Errors
- 400 `invalid_request` A field is missing or out of range. `field` names it.
- 400 `invalid_email` `customer_email` isn't a valid email address.
- 409 `reference_in_use` The reference names a live payment for another amount or currency, or one that already received funds.
- 403 `business_suspended` 402pay suspended your business, so it can't take new payments.
- 403 `test_mode_unavailable` A test key can't create payments on a live business, so use a live key.
- 409 `not_accepting` Your business has no wallet yet, so it can't take payments.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 4900,
"currency": "USD",
"reference": "order_1042",
"description": "Pro plan, monthly",
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"metadata": {
"order_id": "1042"
}
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: 4900,
currency: "USD",
reference: "order_1042",
description: "Pro plan, monthly",
success_url: "https://example.com/thanks",
cancel_url: "https://example.com/cart",
metadata: {
order_id: "1042"
}
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"amount": 4900,
"currency": "USD",
"reference": "order_1042",
"description": "Pro plan, monthly",
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"metadata": {
"order_id": "1042"
}
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "pending",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 0,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 0
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": null,
"network": null,
"amount": null,
"expected_amount": null,
"overpaid_amount": null,
"from_address": null,
"tx_hash": null,
"explorer_url": null
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "0.00",
"wallet_id": null,
"address": null,
"tx_hash": null,
"explorer_url": null
},
"customer_id": null,
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": null,
"description": "Pro plan, monthly",
"country": "",
"failure_code": null,
"failure_message": null,
"confirmed_at": null,
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:22:47.038Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
}
]
}
}
```
# Retrieve a payment
Source: https://developer.402pay.co/api/payments/retrieve
Get a payment with its method, deposit and timeline.
`GET https://dash.402pay.co/api/v1/payments/{id}`
Returns the payment with its method, its deposit in `settlement`, and a timeline of what happened in `events`, oldest first. A payment made through a link has `link_id` set and a `url` of `null`, since the link's own URL is what customers open.
`settlement.destination` says where the funds went: `wallet`, or `fees` when the payment went to 402pay's address to pay fees you owed. Then `wallet_id` is `null`, the address and transaction are 402pay's, and nothing reached your wallet. `reporting.transaction_fee` is the flat fee, and `reporting.net` what you keep after it and the rate. Both are 0 until the payment succeeds. See [fees and billing](https://developer.402pay.co/guides/fees.md).
A card payment's `method.rail` is `card`, `apple_pay` or `google_pay`. Card details are not returned. Its `asset`, `amount`, `from_address` and `tx_hash` stay `null`: read `settlement` for the coin, network, amount and transaction that reached the destination.
A crypto payment's `method.network` is the network the transfer arrived on. That's the one quoted, unless the customer sent on another EVM network, which puts the payment in `needs_review`: the explorer links and `settlement.network` follow the funds there.
The card example's business passes 402pay's fee on: `fee_payer` is `customer`, `customer_fee` is what the customer paid toward the fee on top of `amount`, which stays the price, and `amount_received` counts what arrived against both. `reporting` has the same fee in US cents, and `net` includes it. See [passing fees on](https://developer.402pay.co/guides/reconciliation.md#passing-fees-on).
### Path
- `id` (string, required): The payment's ID, such as `pmt_QI02vLdJGd48hBbg`.
### Query
- `include` (string): `customer` adds the customer record, or `null` when there's none yet. A restricted key needs read access to customers for it.
### Timeline events
| type | data | When |
| --- | --- | --- |
| `created` | `null` | The payment was created. |
| `method_selected` | `rail`, then `asset` and `network` for crypto | The customer chose how to pay. Each new attempt adds one. |
| `detected` | `amount`, `network`, `tx_hash`, and `late` for a transfer after expiry | A transfer arrived. `amount` is what this one transfer carried, in the coin. |
| `confirmation` | `current`, `required` | The transfer gained a confirmation on its network. |
| `underpaid` | `expected`, `received` | Less than the amount due has arrived, in the coin. |
| `succeeded` | `null` | The payment was paid in full. A payment finished by a remainder gets `remainder_paid` instead, and one you accept gets `accepted`. |
| `overpaid` | `amount`, `asset` | More than the amount due arrived. `amount` is the excess. |
| `expired` | `null` | The quote or the payment expired before funds arrived. |
| `card_attempt_failed` | `code` | A card-payment attempt failed, with another attempt available for the customer to try. The payment stays pending, and no webhook is sent. |
| `failed` | `code` for a card, or `reason` of `canceled` | No further card-payment attempt was available, the customer didn't try another within 30 minutes of a decline, or the payment was canceled. |
| `claimed` | `tx_hash` | The customer said they paid after their checkout closed. |
| `accepted` | `null` | You accepted the payment with what arrived. |
| `remainder_requested` | `checkout_id`, `amount`, `currency` | You asked the customer for the rest. `amount` is in minor units of `currency`. |
| `remainder_paid` | `checkout_id`, `amount`, `asset` | The rest arrived and the payment succeeded. `amount` is what that transfer carried, in the coin. |
### Errors
- 404 `not_found` No payment with that ID belongs to your business.
- 400 `invalid_request` `include` has a value other than `customer`.
- 403 `permission_denied` `include=customer` was sent with a restricted key that can't read customers.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, Crypto:
```json
{
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
}
}
```
Response, Card:
```json
{
"data": {
"id": "pmt_wTlCzIlsyPJmYDCn",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "customer",
"customer_fee": 172,
"amount_received": 5072,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 147,
"transaction_fee": 25,
"customer_fee": 172,
"net": 4900
},
"fee_rate_bps": 300,
"method": {
"rail": "card",
"asset": null,
"network": null,
"amount": null,
"expected_amount": null,
"overpaid_amount": null,
"from_address": null,
"tx_hash": null,
"explorer_url": null
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "solana",
"amount": "50.72",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "DByH4SLDQ7beybEaCy8t8p1y5fF81Dnfq1EdHyfN7Rhi",
"tx_hash": "5QSUHU3JWDNDQQLw3YRiFERpnezFkyM5bXr4UMccFpfHACJa7NAY8Gptbj59BHpoA1z1SrGS74KQAL7tHgbA99zu",
"explorer_url": "https://solscan.io/tx/5QSUHU3JWDNDQQLw3YRiFERpnezFkyM5bXr4UMccFpfHACJa7NAY8Gptbj59BHpoA1z1SrGS74KQAL7tHgbA99zu"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/uu5fx45hz6",
"reference": "order_1044",
"metadata": {
"order_id": "1044"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-28T18:12:25.070Z",
"canceled_at": null,
"checkout_id": "chk_BV9TkjSIsguX6RNU",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-27T18:12:31.523Z",
"created_at": "2026-09-27T18:12:25.070Z",
"updated_at": "2026-09-27T18:12:31.523Z",
"events": [
{
"type": "created",
"created_at": "2026-09-27T18:12:25.070Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-27T18:12:25.108Z",
"data": {
"rail": "card"
}
},
{
"type": "card_attempt_failed",
"created_at": "2026-09-27T18:12:25.242Z",
"data": {
"code": "card_declined"
}
},
{
"type": "method_selected",
"created_at": "2026-09-27T18:12:25.311Z",
"data": {
"rail": "card"
}
},
{
"type": "detected",
"created_at": "2026-09-27T18:12:28.323Z",
"data": {
"amount": "50.72",
"network": "solana",
"tx_hash": "5QSUHU3JWDNDQQLw3YRiFERpnezFkyM5bXr4UMccFpfHACJa7NAY8Gptbj59BHpoA1z1SrGS74KQAL7tHgbA99zu"
}
},
{
"type": "confirmation",
"created_at": "2026-09-27T18:12:29.923Z",
"data": {
"current": 1,
"required": 2
}
},
{
"type": "confirmation",
"created_at": "2026-09-27T18:12:31.523Z",
"data": {
"current": 2,
"required": 2
}
},
{
"type": "succeeded",
"created_at": "2026-09-27T18:12:31.523Z",
"data": null
}
]
}
}
```
# List payments
Source: https://developer.402pay.co/api/payments/list
List payments, filtered by status, rail, reference, customer, link, metadata or date.
`GET https://dash.402pay.co/api/v1/payments`
Returns payments newest first, filtered by any of these.
### Query
- `q` (string): Searches the ID, description, reference, metadata values, customer name and email, transaction hash, sender address and amount.
- `status` (string): `pending`, `succeeded`, `underpaid`, `needs_review`, `failed` or `expired`.
- `rail` (string): `crypto` or `card`.
- `reference` (string): Payments with this reference.
- `customer_id` (string): Payments from this customer.
- `link_id` (string): Payments made through this link.
- `metadata[key]` (string): Payments whose metadata has this value for the key.
- `created_after` (timestamp): Only objects created at or after this ISO 8601 time.
- `created_before` (timestamp): Only objects created before this ISO 8601 time.
- `sort` (string): `created_at` or `amount`, with `-` for descending. Defaults to `-created_at`.
- `include` (string): `customer`, `status_counts` or both, comma separated. `customer` adds each payment's customer. `status_counts` adds `status_counts` to the list itself, beside `data`: how many payments have each status under the same filters, ignoring `status`.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` A filter has a value it can't take, or the `cursor` belongs to another list.
- 403 `permission_denied` `include=customer` was sent with a restricted key that can't read customers.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/payments?status=succeeded&limit=10" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments?status=succeeded&limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/payments?status=succeeded&limit=10",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
}
],
"has_more": true,
"next_cursor": "pmt_QI02vLdJGd48hBbg"
}
```
# Update a payment
Source: https://developer.402pay.co/api/payments/update
Change a payment's metadata.
`PATCH https://dash.402pay.co/api/v1/payments/{id}`
Changes a payment's metadata. Checkout sets everything else, so it can't change.
### Path
- `id` (string, required): The payment's ID, such as `pmt_QI02vLdJGd48hBbg`.
### Body
- `metadata` (object): Merged by key. An empty string or `null` removes a key, and `null` clears them all.
### Errors
- 400 `invalid_request` A field other than `metadata` was sent, a key or value breaks the rules, or the result would hold more than 20 keys.
- 404 `not_found` No payment with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"metadata": {
"shipped": "true"
}
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
shipped: "true"
}
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/payments/pmt_QI02vLdJGd48hBbg",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"metadata": {
"shipped": "true"
}
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042",
"shipped": "true"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
}
}
```
# Cancel a payment
Source: https://developer.402pay.co/api/payments/cancel
Stop a payment created through the API before any funds arrive.
`POST https://dash.402pay.co/api/v1/payments/{id}/cancel`
Works on a payment you created through the API that hasn't received funds. It becomes `failed` with `canceled_at` set, its timeline ends with a `failed` event whose `reason` is `canceled`, and any open checkout for it closes. You receive `payment.failed`.
Canceling twice returns the payment as it is, so a retry can't fail.
### Path
- `id` (string, required): The payment's ID, such as `pmt_QI02vLdJGd48hBbg`.
### Errors
- 404 `not_found` No payment with that ID belongs to your business.
- 403 `test_mode_unavailable` A test key can't cancel a payment on a live business, so use a live key.
- 409 `payment_not_cancelable` Funds have arrived, or the payment came from a link. Archive the link instead.
- 409 `payment_in_flight` A transfer for the payment is on its way.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "pmt_cQL3ct8r16YeUw4l",
"kind": "payment",
"status": "failed",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 0,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 0
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": null,
"network": null,
"amount": null,
"expected_amount": null,
"overpaid_amount": null,
"from_address": null,
"tx_hash": null,
"explorer_url": null
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "0.00",
"wallet_id": null,
"address": null,
"tx_hash": null,
"explorer_url": null
},
"customer_id": null,
"link_id": null,
"url": "https://checkout.402pay.co/checkout/cd6dfqk65a",
"reference": "order_1043",
"metadata": {
"order_id": "1043"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:23:16.653Z",
"canceled_at": "2026-09-26T21:23:16.669Z",
"checkout_id": null,
"description": "Pro plan, monthly",
"country": "",
"failure_code": null,
"failure_message": "The business canceled the payment.",
"confirmed_at": null,
"created_at": "2026-09-26T21:23:16.653Z",
"updated_at": "2026-09-26T21:23:16.669Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:23:16.653Z",
"data": null
},
{
"type": "failed",
"created_at": "2026-09-26T21:23:16.669Z",
"data": {
"reason": "canceled"
}
}
]
}
}
```
# Accept a payment
Source: https://developer.402pay.co/api/payments/accept
Count an underpaid payment, or one that needs review, as paid with what arrived.
`POST https://dash.402pay.co/api/v1/payments/{id}/accept`
Works on an `underpaid` payment or one that `needs_review`, when what arrived is enough for you. The payment becomes `succeeded` with what arrived: `amount_received` shows it, `reporting.amount` is its USD value, less any `customer_fee`, with the fee taken on that, and `settlement.amount` adds up every transfer, whole. The timeline gains an `accepted` event, and you receive `payment.succeeded`.
The example accepts a link payment that received 90% of its price. Any open checkout for the rest stops waiting.
### Path
- `id` (string, required): The payment's ID, such as `pmt_QI02vLdJGd48hBbg`.
### Errors
- 404 `not_found` No payment with that ID belongs to your business.
- 403 `test_mode_unavailable` A test key can't accept a payment on a live business, so use a live key.
- 409 `payment_not_acceptable` The payment isn't `underpaid` or `needs_review`.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_Kson1iADtxwHYH5M/accept" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_Kson1iADtxwHYH5M/accept", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments/pmt_Kson1iADtxwHYH5M/accept",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "pmt_Kson1iADtxwHYH5M",
"kind": "payment",
"status": "succeeded",
"amount": 19900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 17910,
"reporting": {
"currency": "USD",
"amount": 17910,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 17885
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "179.10",
"expected_amount": "199.00",
"overpaid_amount": null,
"from_address": "0xadb252a42ccdd382c58673cb88a05d0f52e0ab6e",
"tx_hash": "0xca57b032a212f2940f33dbfd296f14758a44534efe87d126c42e1b1838b35639",
"explorer_url": "https://polygonscan.com/tx/0xca57b032a212f2940f33dbfd296f14758a44534efe87d126c42e1b1838b35639"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "179.10",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0xee44DbeB8F3d1F216f7317c5B08C1fA1C8b8CA81",
"tx_hash": "0xca57b032a212f2940f33dbfd296f14758a44534efe87d126c42e1b1838b35639",
"explorer_url": "https://polygonscan.com/tx/0xca57b032a212f2940f33dbfd296f14758a44534efe87d126c42e1b1838b35639"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": "lnk_nt842tPNne3lf0ka",
"url": null,
"reference": null,
"metadata": {},
"success_url": null,
"cancel_url": null,
"expires_at": null,
"canceled_at": null,
"checkout_id": "chk_cT8iEk2tGkc9wC46",
"description": "Team plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:16.700Z",
"created_at": "2026-09-26T21:22:47.082Z",
"updated_at": "2026-09-26T21:23:16.700Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.082Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.082Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:22:47.098Z",
"data": {
"amount": "179.10",
"network": "polygon",
"tx_hash": "0xca57b032a212f2940f33dbfd296f14758a44534efe87d126c42e1b1838b35639"
}
},
{
"type": "underpaid",
"created_at": "2026-09-26T21:22:47.098Z",
"data": {
"expected": "199.00",
"received": "179.10"
}
},
{
"type": "accepted",
"created_at": "2026-09-26T21:23:16.700Z",
"data": null
}
]
}
}
```
# Request the remainder
Source: https://developer.402pay.co/api/payments/request-remainder
Ask the customer of an underpaid payment for exactly what's left, once its quote has expired.
`POST https://dash.402pay.co/api/v1/payments/{id}/request-remainder`
For a payment that's `underpaid` after its quote expired, from a link or the API. It opens a checkout for exactly what's left, in the same coin and network, attached to the same payment: its `remainder` is `true`. When the rest arrives, the payment gains a `remainder_paid` event and succeeds, and no second payment is made. Send the customer to the `url` in the response.
Returns the payment, with a `remainder_requested` event in its timeline, and the new checkout. Asking again while that checkout is open returns the same one. Requires an `Idempotency-Key`.
> While the original quote is live, 15 minutes after the short transfer by default, checkout is still asking the customer for the rest at the same address, so this returns 409 `remainder_unavailable` with the time the quote ends.
### Path
- `id` (string, required): The payment's ID, such as `pmt_QI02vLdJGd48hBbg`.
### Errors
- 404 `not_found` No payment with that ID belongs to your business.
- 400 `idempotency_key_required` The request has no `Idempotency-Key` header.
- 409 `payment_not_underpaid` The payment isn't `underpaid`.
- 409 `remainder_unavailable` The quote is still live, so the customer can still send the rest to the same address, or the payment has no checkout page left, such as a deleted link's.
- 409 `nothing_due` Nothing is left to collect.
- 409 `not_accepting` Your wallet can no longer receive on the payment's network.
- 403 `business_suspended` 402pay suspended your business, so it can't open new checkouts.
- 403 `test_mode_unavailable` A test key can't request a remainder on a live business, so use a live key.
- 503 `temporarily_unavailable` 402pay paused new checkouts, or payments on the payment's network, for now. Try again later.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Idempotency-Key": crypto.randomUUID(),
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import uuid
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/payments/pmt_uYs2XjkL1jGJt44v/request-remainder",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"kind": "remainder_request",
"payment": {
"id": "pmt_uYs2XjkL1jGJt44v",
"kind": "payment",
"status": "underpaid",
"amount": 19900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 17910,
"reporting": {
"currency": "USD",
"amount": 19900,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 0
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "179.10",
"expected_amount": "199.00",
"overpaid_amount": null,
"from_address": "0xd2554462b44e258265af12a7d051e47bda66e5b6",
"tx_hash": "0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812",
"explorer_url": "https://polygonscan.com/tx/0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "179.10",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x7Ba69aE8cEf2987739F6E70e903F95a5028e3531",
"tx_hash": "0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812",
"explorer_url": "https://polygonscan.com/tx/0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": "lnk_nt842tPNne3lf0ka",
"url": null,
"reference": null,
"metadata": {},
"success_url": null,
"cancel_url": null,
"expires_at": null,
"canceled_at": null,
"checkout_id": "chk_OuPmQnhYyQqpp2qZ",
"description": "Team plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": null,
"created_at": "2026-09-26T21:22:47.055Z",
"updated_at": "2026-09-26T21:38:24.275Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.055Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.055Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:22:47.069Z",
"data": {
"amount": "179.10",
"network": "polygon",
"tx_hash": "0x1d346f1e2fdd7e6a7bdd4d0193dba2a938dfdb7beef4fab2669d55fef2607812"
}
},
{
"type": "underpaid",
"created_at": "2026-09-26T21:22:47.069Z",
"data": {
"expected": "199.00",
"received": "179.10"
}
},
{
"type": "expired",
"created_at": "2026-09-26T21:37:47.069Z",
"data": null
},
{
"type": "remainder_requested",
"created_at": "2026-09-26T21:38:24.275Z",
"data": {
"checkout_id": "chk_XKOWx49himYIAnet",
"amount": 1990,
"currency": "USD"
}
}
]
},
"checkout": {
"id": "chk_XKOWx49himYIAnet",
"kind": "checkout",
"status": "open",
"rail": "crypto",
"link_id": "lnk_nt842tPNne3lf0ka",
"payment_id": "pmt_uYs2XjkL1jGJt44v",
"amount": 1990,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 1990,
"processing_fee": null,
"network_fee": 2,
"total": 1992
},
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "19.90",
"address": "0xb5C513376614F24fBf807B8c2B1FA3fD1E637A52",
"payment_uri": "ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0xb5C513376614F24fBf807B8c2B1FA3fD1E637A52&uint256=19900000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": true,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": null,
"expires_at": "2026-09-26T21:53:24.271Z",
"sent_at": null,
"created_at": "2026-09-26T21:38:24.271Z",
"updated_at": "2026-09-26T21:38:24.271Z",
"email": "harper.wilson@example.com",
"reporting": {
"currency": "USD",
"amount": 1990,
"fee": 0,
"transaction_fee": 0,
"customer_fee": 0,
"net": 1990
},
"fee_rate_bps": 0
},
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg?s=chk_XKOWx49himYIAnet"
}
}
```
# Create a checkout
Source: https://developer.402pay.co/api/checkouts/create
Start an attempt at paying a link or payment, and reserve a fresh address for crypto.
`POST https://dash.402pay.co/api/v1/checkouts`
Public, so a page you build can call it from the browser. Requires an `Idempotency-Key`, and allows 30 checkouts per 10 minutes from one IP address.
Each call is one attempt: a crypto checkout reserves a fresh address in the business's wallet and locks the rate, and when `email` is known it creates a `pending` payment right away. Card availability and limits depend on the amount and customer location. Customers review the total and complete any required identity checks on the secure payment page.
Called with your secret key, the response also has `email`, `reporting` and `fee_rate_bps`, as described on [retrieve a checkout](https://developer.402pay.co/api/checkouts/retrieve.md).
### Body
- `payment_code` (string): The code at the end of a payment's `url`. Send this or `link_code`.
- `link_code` (string): The code at the end of a link's `url`.
- `rail` (string): `crypto` or `card`. Defaults to `crypto`.
- `asset` (string): For crypto: `USDC`, `USDT`, `BTC`, `ETH` or `SOL`.
- `network` (string): For crypto: a network the business accepts the asset on, such as `polygon`.
- `email` (string): The customer's email, which you see on the payment. Required for cards, and when the business asks for it.
- `country` (string): The customer's two-letter country code, used to determine card-payment availability.
- `replaces` (string): The ID of the checkout this one replaces, when the customer goes back to choose again. The same choice returns the same quote and address, and a different one carries a link's pending payment over. [Supersede](https://developer.402pay.co/api/checkouts/supersede.md) the old checkout when the customer steps back: it stops being the quote on screen, but funds already on the way still count and can still be claimed.
- `simulate` (string): For testing only: how the transfer arrives, `exact`, `underpaid`, `overpaid` or `wrong_network`. `late` returns 400 here; it's only reachable through [simulating a transfer](https://developer.402pay.co/api/checkouts/mark-sent.md). See [testing](https://developer.402pay.co/testing.md#simulate).
### Errors
- 400 `invalid_request` A field is missing or invalid, such as a `simulate` value it doesn't take.
- 400 `idempotency_key_required` The request has no `Idempotency-Key` header.
- 400 `email_required` The rail or the business needs the customer's email, and none was sent.
- 400 `invalid_email` `email` isn't a valid email address.
- 400 `rail_unavailable` The business doesn't accept this rail.
- 400 `asset_unavailable` The business doesn't accept this coin on this network.
- 400 `amount_out_of_range` The price is outside the available card-payment limits.
- 403 `customer_blocked` The business has blocked the customer with this email.
- 403 `business_suspended` 402pay suspended the business, so it can't take new payments.
- 403 `test_mode_unavailable` A test key can't open a checkout on a live business, so send none, as a customer's browser does, or a live key.
- 404 `link_not_found` No active link has this code.
- 404 `payment_not_found` No payment has this code.
- 409 `not_accepting` The business isn't taking payments yet.
- 409 `payment_paid` Funds already arrived for the payment: it succeeded, or it's `underpaid` or `needs_review`.
- 409 `payment_canceled` The business canceled the payment.
- 410 `payment_expired` The payment passed its `expires_at`.
- 429 `rate_limited` Too many checkouts from one IP address. Wait for Retry-After seconds.
- 503 `temporarily_unavailable` 402pay paused new checkouts, card payments, or payments on this network for now. Try again later.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"payment_code": "2jrsrcxv7k",
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"email": "harper.wilson@example.com"
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
payment_code: "2jrsrcxv7k",
rail: "crypto",
asset: "USDC",
network: "polygon",
email: "harper.wilson@example.com"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import uuid
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/checkouts",
headers={
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"payment_code": "2jrsrcxv7k",
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"email": "harper.wilson@example.com"
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"id": "chk_C5yhPQvPqpYgdDgj",
"kind": "checkout",
"status": "open",
"rail": "crypto",
"link_id": null,
"payment_id": "pmt_QI02vLdJGd48hBbg",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": 2,
"total": 4902
},
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"payment_uri": "ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe&uint256=49000000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-26T21:37:47.047Z",
"sent_at": null,
"created_at": "2026-09-26T21:22:47.047Z",
"updated_at": "2026-09-26T21:22:47.047Z"
}
}
```
# Retrieve a checkout
Source: https://developer.402pay.co/api/checkouts/retrieve
Get a checkout's address, amounts and confirmations while the customer pays.
`GET https://dash.402pay.co/api/v1/checkouts/{id}`
Public. Read it every few seconds while the customer pays: progress runs on elapsed time, so each read brings it up to date.
### Path
- `id` (string, required): The checkout's ID, such as `chk_C5yhPQvPqpYgdDgj`.
### Fields to know
- `breakdown` (object): What the customer pays, row by row, in minor units of `currency`: the `price`, the `processing_fee` when the business passes 402pay's fee on (`null` otherwise), an estimated `network_fee` for crypto, which the customer's wallet pays in the network's own coin on top of `crypto.amount` (`null` for cards or without an estimate), and their `total`. `crypto.amount` covers the price and the processing fee, never the network fee.
- `remainder` (boolean): `true` when the checkout collects the rest of an underpaid payment, which `payment_id` names.
- `card` (object): For card payments, `payment_url` opens the secure payment page and `can_retry`says whether checkout can offer another attempt. `null` for crypto. Open the URL unchanged in a browser tab; do not construct its parameters or embed it in an iframe. The hosted checkout handles this automatically.
- `canceled_by` (string): `customer`, or `business` when you canceled its API payment. `null` until the checkout is canceled.
- `replaced_by` (string): The sibling quote whose transfer closed this one, when the customer went back and the funds landed on the quote they had left; open it instead. `null` otherwise.
### With your secret key
- `email` (string): The customer's email, or null when checkout hasn't collected one.
- `reporting` (object): The checkout's `amount`, `fee`, `transaction_fee`, `customer_fee` and `net` in US cents: the price, 402pay's rate on it, the flat fee (0 on a remainder, since its payment counts it once), what the customer pays toward the fees, and what you keep (`amount` plus `customer_fee` less `fee` and `transaction_fee`).
- `fee_rate_bps` (integer): The fee rate for the checkout's rail, in basis points.
### Review reasons
A checkout in `needs_review` says why in `review_reason`, and its payment is `needs_review` too.
| review_reason | Meaning |
| --- | --- |
| `late` | The transfer arrived after the checkout expired, so the rate it was quoted at no longer held. |
| `wrong_network` | The transfer arrived on another EVM network at the same address. The checkout's `crypto.detected_network` and the payment's `method.network` name it. |
| `claimed` | The customer said they paid, with a transaction hash, after the checkout closed. |
### Errors
- 404 `not_found` No checkout has that ID.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj");
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj",
)
data = response.json()["data"]
```
Response, Crypto:
```json
{
"data": {
"id": "chk_C5yhPQvPqpYgdDgj",
"kind": "checkout",
"status": "open",
"rail": "crypto",
"link_id": null,
"payment_id": "pmt_QI02vLdJGd48hBbg",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": 2,
"total": 4902
},
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"payment_uri": "ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe&uint256=49000000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-26T21:37:47.047Z",
"sent_at": null,
"created_at": "2026-09-26T21:22:47.047Z",
"updated_at": "2026-09-26T21:22:47.047Z"
}
}
```
Response, Card:
```json
{
"data": {
"id": "chk_BV9TkjSIsguX6RNU",
"kind": "checkout",
"status": "completed",
"rail": "card",
"link_id": null,
"payment_id": "pmt_wTlCzIlsyPJmYDCn",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": 172,
"network_fee": null,
"total": 5072
},
"crypto": {
"asset": "USDC",
"network": "solana",
"amount": "50.72",
"address": "DByH4SLDQ7beybEaCy8t8p1y5fF81Dnfq1EdHyfN7Rhi",
"payment_uri": "solana:DByH4SLDQ7beybEaCy8t8p1y5fF81Dnfq1EdHyfN7Rhi?amount=50.72&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "50.72",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": "5QSUHU3JWDNDQQLw3YRiFERpnezFkyM5bXr4UMccFpfHACJa7NAY8Gptbj59BHpoA1z1SrGS74KQAL7tHgbA99zu",
"explorer_url": "https://solscan.io/tx/5QSUHU3JWDNDQQLw3YRiFERpnezFkyM5bXr4UMccFpfHACJa7NAY8Gptbj59BHpoA1z1SrGS74KQAL7tHgbA99zu"
},
"card": {
"payment_url": "/checkout/uu5fx45hz6/card/chk_BV9TkjSIsguX6RNU?attempt=att_example_2",
"can_retry": true
},
"confirmations": {
"current": 2,
"required": 2
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-27T18:42:25.311Z",
"sent_at": null,
"created_at": "2026-09-27T18:12:25.108Z",
"updated_at": "2026-09-27T18:12:31.523Z"
}
}
```
# Cancel a checkout
Source: https://developer.402pay.co/api/checkouts/cancel
Close a checkout the customer is leaving, before any funds are on the way.
`POST https://dash.402pay.co/api/v1/checkouts/{id}/cancel`
For a customer who leaves without paying. Public, like [creating a checkout](https://developer.402pay.co/api/checkouts/create.md): the checkout's ID is all it takes. Called with your secret key, the response also has `email`, `reporting` and `fee_rate_bps`.
Works while the checkout is `open` or `awaiting_customer`. It becomes `canceled` with `canceled_by` set to `customer`. The secure payment page of a card attempt isn't closed, though: a payment the customer still finishes there reaches you in `needs_review`, and you receive `payment.needs_review`. Canceling twice returns the checkout as it is.
What happens to the payment depends on where it came from. A link's payment fails, with a `failed` event whose `reason` is `canceled`, and you receive `payment.failed`. A payment you created through the API stays `pending`, so the customer can come back and try again until it expires. On a remainder checkout, what already arrived stays with the payment.
> A customer who paid anyway can report the transfer with [a claim](https://developer.402pay.co/api/checkouts/claim.md).
### Path
- `id` (string, required): The checkout's ID, such as `chk_8zXVc2SbCBDho78Q`.
### Errors
- 404 `not_found` No checkout has that ID.
- 409 `checkout_not_cancelable` The checkout isn't `open` or `awaiting_customer`: funds may already be on the way, or it has ended.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_8zXVc2SbCBDho78Q/cancel"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts/chk_8zXVc2SbCBDho78Q/cancel", {
method: "POST",
});
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/checkouts/chk_8zXVc2SbCBDho78Q/cancel",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "chk_8zXVc2SbCBDho78Q",
"kind": "checkout",
"status": "canceled",
"rail": "crypto",
"link_id": null,
"payment_id": "pmt_pxYW5vHfVt1xTIT2",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": 90,
"total": 4990
},
"crypto": {
"asset": "USDC",
"network": "ethereum",
"amount": "49.00",
"address": "0xB82AFd9E40ED990A37c7eeB43e8d04528732ef39",
"payment_uri": "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xB82AFd9E40ED990A37c7eeB43e8d04528732ef39&uint256=49000000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": false,
"canceled_by": "customer",
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-27T18:27:57.906Z",
"sent_at": null,
"created_at": "2026-09-27T18:12:57.906Z",
"updated_at": "2026-09-27T18:12:57.975Z"
}
}
```
# Supersede a checkout
Source: https://developer.402pay.co/api/checkouts/supersede
Set a quote aside when the customer steps back to choose another way to pay.
`POST https://dash.402pay.co/api/v1/checkouts/{id}/supersede`
For when the customer steps back from a quote to choose another way to pay. Public, like [creating a checkout](https://developer.402pay.co/api/checkouts/create.md): the checkout's ID is all it takes. Called with your secret key, the response also has `email`, `reporting` and `fee_rate_bps`.
Works on an `open` or `awaiting_customer` checkout, or a card checkout that `failed` on a decline. The checkout keeps its status and stays watched until it expires, so a transfer already on its way still counts and can still be claimed. It just stops being the quote the customer is looking at. Superseding twice changes nothing.
Then create the next checkout with `replaces` set to this one's ID. The same coin, network and email again return this same quote, address and all, while more than two minutes of it are left, so going back and forth doesn't use up addresses. A different choice opens a new checkout and carries a link's pending payment over to it. An API payment has one live quote at a time, so a new checkout for one sets its earlier quotes aside on its own.
> On a simulated business, only the quote on screen receives the simulated transfer, never a superseded one.
### Path
- `id` (string, required): The checkout's ID, such as `chk_GduyAoexkN1Sg8sz`.
### Errors
- 404 `not_found` No checkout has that ID.
- 409 `checkout_not_supersedable` The checkout collects a remainder, or it's past waiting for the customer and funds may already be on the way.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_GduyAoexkN1Sg8sz/supersede"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts/chk_GduyAoexkN1Sg8sz/supersede", {
method: "POST",
});
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/checkouts/chk_GduyAoexkN1Sg8sz/supersede",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "chk_GduyAoexkN1Sg8sz",
"kind": "checkout",
"status": "open",
"rail": "crypto",
"link_id": null,
"payment_id": "pmt_pxYW5vHfVt1xTIT2",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": 2,
"total": 4902
},
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"address": "0xe37eE6F796f4059541AD6Bd3A74618DC6f937B31",
"payment_uri": "ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0xe37eE6F796f4059541AD6Bd3A74618DC6f937B31&uint256=49000000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-27T18:27:57.616Z",
"sent_at": null,
"created_at": "2026-09-27T18:12:57.616Z",
"updated_at": "2026-09-27T18:12:57.891Z"
}
}
```
# Claim a transfer
Source: https://developer.402pay.co/api/checkouts/claim
Let a customer report a transfer by its hash after their checkout closed.
`POST https://dash.402pay.co/api/v1/checkouts/{id}/claim`
For a customer who paid after their checkout closed, or after canceling it. Public, like [creating a checkout](https://developer.402pay.co/api/checkouts/create.md): the checkout's ID is all it takes. Called with your secret key, the response also has `email`, `reporting` and `fee_rate_bps`.
Works on an `expired` or `canceled` crypto checkout. The checkout and its payment become `needs_review` with a `review_reason` of `claimed`, the payment's timeline gains a `claimed` event with the hash, and you receive `payment.needs_review`.
> Nothing checks the hash for you. Look it up on the network first, then [accept the payment](https://developer.402pay.co/api/payments/accept.md): accepting a claim counts the full quoted amount as received.
### Path
- `id` (string, required): The checkout's ID, such as `chk_8zXVc2SbCBDho78Q`.
### Body
- `tx_hash` (string, required): The transaction hash from the customer's wallet, on the checkout's network.
- `email` (string): The customer's email, kept when the checkout has none yet, so you can reach them.
### Errors
- 400 `invalid_request` A field other than `tx_hash` or `email` was sent.
- 400 `invalid_tx_hash` `tx_hash` isn't a transaction hash on the checkout's network.
- 400 `invalid_email` `email` isn't a valid email address.
- 404 `not_found` No checkout has that ID.
- 409 `wrong_rail` The checkout is for a card, which finishes on the secure payment page.
- 409 `payment_paid` The payment has already succeeded.
- 409 `already_claimed` The checkout already needs review.
- 409 `checkout_not_claimable` The checkout isn't expired or canceled, so it's still watching for the transfer.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_8zXVc2SbCBDho78Q/claim" \
-H "Content-Type: application/json" \
-d '{
"tx_hash": "0x5e2b8c0f4a91d37e6b0c2f8a14d9e7c3b6a05f1d28e4c9b7a3f6d0e1c8b2a4f7"
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts/chk_8zXVc2SbCBDho78Q/claim", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
tx_hash: "0x5e2b8c0f4a91d37e6b0c2f8a14d9e7c3b6a05f1d28e4c9b7a3f6d0e1c8b2a4f7"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/checkouts/chk_8zXVc2SbCBDho78Q/claim",
json={
"tx_hash": "0x5e2b8c0f4a91d37e6b0c2f8a14d9e7c3b6a05f1d28e4c9b7a3f6d0e1c8b2a4f7"
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "chk_8zXVc2SbCBDho78Q",
"kind": "checkout",
"status": "needs_review",
"rail": "crypto",
"link_id": null,
"payment_id": "pmt_pxYW5vHfVt1xTIT2",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": 90,
"total": 4990
},
"crypto": {
"asset": "USDC",
"network": "ethereum",
"amount": "49.00",
"address": "0xB82AFd9E40ED990A37c7eeB43e8d04528732ef39",
"payment_uri": "ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48@1/transfer?address=0xB82AFd9E40ED990A37c7eeB43e8d04528732ef39&uint256=49000000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": "claimed",
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-27T18:27:57.906Z",
"sent_at": null,
"created_at": "2026-09-27T18:12:57.906Z",
"updated_at": "2026-09-27T18:13:10.639Z"
}
}
```
# Retry a card payment
Source: https://developer.402pay.co/api/checkouts/card/retry
Offer another card-payment attempt when checkout allows it.
`POST https://dash.402pay.co/api/v1/checkouts/{id}/card/retry`
Offers another card-payment attempt when checkout allows it. Public, like [creating a checkout](https://developer.402pay.co/api/checkouts/create.md): the checkout's ID is all it takes. Called with your secret key, the response also has `email`, `reporting` and `fee_rate_bps`.
Call only when `card.can_retry` is `true`. The response returns a fresh`card.payment_url` and an `awaiting_customer` checkout. Open that URL unchanged; do not reuse a previous payment URL. The payment remains `pending` until it completes.
The hosted checkout handles retries for you. If you build a custom checkout, wait for any payment already in progress before offering another attempt. See [card payments](https://developer.402pay.co/guides/card-payments.md#retries).
### Path
- `id` (string, required): The checkout's ID, such as `chk_BV9TkjSIsguX6RNU`.
### Errors
- 404 `not_found` No checkout has that ID.
- 409 `wrong_rail` The checkout is for crypto, so card-payment actions are unavailable.
- 409 `checkout_not_retryable` The checkout isn't waiting on the customer or declined, or it failed on `amount_out_of_range`, which cannot be retried.
- 409 `no_route` No further card-payment attempt is available. The customer can choose another way to pay.
- 409 `route_in_progress` The customer's payment on the current secure payment page may still go through. Wait for it to finish.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_BV9TkjSIsguX6RNU/card/retry"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/checkouts/chk_BV9TkjSIsguX6RNU/card/retry", {
method: "POST",
});
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/checkouts/chk_BV9TkjSIsguX6RNU/card/retry",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "chk_BV9TkjSIsguX6RNU",
"kind": "checkout",
"status": "awaiting_customer",
"rail": "card",
"link_id": null,
"payment_id": "pmt_wTlCzIlsyPJmYDCn",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": 172,
"network_fee": null,
"total": 5072
},
"crypto": {
"asset": "USDC",
"network": "solana",
"amount": "50.72",
"address": "DByH4SLDQ7beybEaCy8t8p1y5fF81Dnfq1EdHyfN7Rhi",
"payment_uri": "solana:DByH4SLDQ7beybEaCy8t8p1y5fF81Dnfq1EdHyfN7Rhi?amount=50.72&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "0.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": null,
"explorer_url": null
},
"card": {
"payment_url": "/checkout/uu5fx45hz6/card/chk_BV9TkjSIsguX6RNU?attempt=att_example_2",
"can_retry": true
},
"confirmations": {
"current": 0,
"required": 2
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-27T18:42:25.311Z",
"sent_at": null,
"created_at": "2026-09-27T18:12:25.108Z",
"updated_at": "2026-09-27T18:12:25.311Z"
}
}
```
# Simulate a transfer
Source: https://developer.402pay.co/api/checkouts/mark-sent
For testing: make a crypto checkout receive its transfer now, exact or not.
`POST https://dash.402pay.co/api/v1/checkouts/{id}/mark-sent`
For testing, on a simulated business only. Nothing watches a chain there, so an open crypto checkout receives its transfer 20 seconds after it opens. This makes the transfer arrive now, the way `simulate` says, and returns the checkout as it left it. Public, like the rest of a checkout. A live business watches the chain for real transfers instead.
On an `underpaid` checkout, the transfer is measured against what's still due, so `exact` sends the rest. A checkout that's already `confirming`, `completed` or in `needs_review` comes back as it is, with no second transfer.
### Path
- `id` (string, required): The checkout's ID, such as `chk_C5yhPQvPqpYgdDgj`.
### Body
- `simulate` (string): How the transfer arrives. Defaults to `exact`, whatever the checkout was created with. `underpaid` sends 90% and `overpaid` 110%. `wrong_network` arrives on another EVM network at the same address, so it needs a coin both carry, such as USDC, and `late` ends the quote first, if it hasn't ended, so the transfer comes after expiry. Both put the checkout in `needs_review`. See [testing](https://developer.402pay.co/testing.md#simulate).
### Errors
- 400 `invalid_request` `simulate` isn't one it takes, or it's `wrong_network` on a network outside the EVM family or in a coin no other EVM network carries.
- 404 `not_found` No checkout has that ID.
- 409 `wrong_rail` The checkout is for a card, which finishes on the secure payment page.
- 409 `checkout_canceled` The checkout was canceled.
- 409 `checkout_failed` The checkout failed.
- 410 `checkout_expired` The quote expired, and `simulate` isn't `late`.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj/mark-sent" \
-H "Content-Type: application/json" \
-d '{
"simulate": "exact"
}'
```
Request, 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: "exact"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/checkouts/chk_C5yhPQvPqpYgdDgj/mark-sent",
json={
"simulate": "exact"
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "chk_C5yhPQvPqpYgdDgj",
"kind": "checkout",
"status": "confirming",
"rail": "crypto",
"link_id": null,
"payment_id": "pmt_QI02vLdJGd48hBbg",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": 2,
"total": 4902
},
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"payment_uri": "ethereum:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359@137/transfer?address=0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe&uint256=49000000",
"rate": {
"amount": 100,
"currency": "USD"
},
"received_amount": "49.00",
"remaining_amount": null,
"remaining_payment_uri": null,
"overpaid_amount": null,
"detected_network": null,
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"card": null,
"confirmations": {
"current": 0,
"required": 4
},
"remainder": false,
"canceled_by": null,
"replaced_by": null,
"failure_code": null,
"review_reason": null,
"success_url": "https://example.com/thanks",
"expires_at": "2026-09-26T21:37:47.047Z",
"sent_at": "2026-09-26T21:23:07.047Z",
"created_at": "2026-09-26T21:22:47.047Z",
"updated_at": "2026-09-26T21:23:07.047Z"
}
}
```
# Retrieve a public link
Source: https://developer.402pay.co/api/checkouts/public-link
Get what a link's hosted checkout shows, without IDs, fees or internal settings.
`GET https://dash.402pay.co/api/v1/public/links/{code}`
Public: what a link's hosted checkout shows, with nothing internal, no IDs and none of your fees except one you pass on. Use it to build your own page for a link, then [create a checkout](https://developer.402pay.co/api/checkouts/create.md) with its `code` when the customer chooses how to pay.
`crypto` lists each coin and network you accept, with an amount at today's rate that a checkout locks when it opens, and `rate`, one coin in minor units of the link's currency. `card` lists card payments, with whether their `total` is within their `min` and `max`. Each option's `breakdown` lays out what the customer pays, as on a [checkout](https://developer.402pay.co/api/checkouts/retrieve.md): the price, the processing fee when you pass it on (which the amount and `total` then include), and for crypto an estimated network fee the customer's wallet pays on top. `quote_expires_at` is when a quote opened now would end, and `collect_email` says whether checkout asks for an email. `request` is always `null` here.
### Path
- `code` (string, required): The code at the end of the link's `url`, such as `v6e4mrczwg`.
### Errors
- 404 `not_found` No active link has this code. Archived and deleted links, and API payments' codes, return this too.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/public/links/v6e4mrczwg"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/public/links/v6e4mrczwg");
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/public/links/v6e4mrczwg",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "public_link",
"code": "v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"business": {
"name": "Acme Studio",
"website": "https://example.com",
"support_email": "billing@example.com"
},
"accepting_payments": true,
"collect_email": true,
"accepted_rails": ["crypto", "card"],
"crypto": [
{
"asset": "USDC",
"network": "polygon",
"amount": "199.00",
"rate": {
"amount": 100,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 2,
"total": 19902
}
},
{
"asset": "USDC",
"network": "ethereum",
"amount": "199.00",
"rate": {
"amount": 100,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 90,
"total": 19990
}
},
{
"asset": "USDC",
"network": "solana",
"amount": "199.00",
"rate": {
"amount": 100,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 1,
"total": 19901
}
},
{
"asset": "USDT",
"network": "tron",
"amount": "199.00",
"rate": {
"amount": 100,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 110,
"total": 20010
}
},
{
"asset": "USDT",
"network": "ethereum",
"amount": "199.00",
"rate": {
"amount": 100,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 90,
"total": 19990
}
},
{
"asset": "USDT",
"network": "solana",
"amount": "199.00",
"rate": {
"amount": 100,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 1,
"total": 19901
}
},
{
"asset": "BTC",
"network": "bitcoin",
"amount": "0.00202236",
"rate": {
"amount": 9840000,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 150,
"total": 20050
}
},
{
"asset": "ETH",
"network": "ethereum",
"amount": "0.054972",
"rate": {
"amount": 362000,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 30,
"total": 19930
}
},
{
"asset": "SOL",
"network": "solana",
"amount": "1.0815",
"rate": {
"amount": 18400,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": 1,
"total": 19901
}
}
],
"card": [
{
"rail": "card",
"available": true,
"min": {
"amount": 500,
"currency": "USD"
},
"max": {
"amount": 1000000,
"currency": "USD"
},
"total": {
"amount": 19900,
"currency": "USD"
},
"breakdown": {
"currency": "USD",
"price": 19900,
"processing_fee": null,
"network_fee": null,
"total": 19900
}
}
],
"quote_minutes": 15,
"quote_expires_at": "2026-09-27T18:32:19.065Z",
"request": null
}
}
```
# Retrieve a receipt
Source: https://developer.402pay.co/api/checkouts/receipt
Get a payment's public receipt, the data behind its hosted receipt page.
`GET https://dash.402pay.co/api/v1/public/receipts/{id}`
Public: the data behind a payment's [hosted receipt](https://developer.402pay.co/guides/receipts.md). Anyone with the payment's ID can read it, so it leaves out the fees you pay, the deposit and the customer's details. Every payment has one, whatever its status.
`amount` and `currency` are the price, and `breakdown` adds any processing fee the customer paid to reach its `total` (its `network_fee` is always `null`, since the wallet paid that). `crypto` is the coin, network and amount quoted, and `null` for a card, whose details are entered on the secure payment page. When the transfer came on another network than quoted, `crypto` names the network it arrived on. `received_amount` stays `null` until something arrives, and `transfers` lists every transfer the customer sent, the first also in `transaction`. A card receipt lists none, since the card-payment deposit isn't the customer's. `paid_at` is set once the payment succeeds or you accept it, and `back_url` is where the receipt's button goes: the success URL, or else your website.
### Path
- `id` (string, required): The payment's ID, such as `pmt_QI02vLdJGd48hBbg`.
### Errors
- 404 `not_found` No payment has that ID.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/public/receipts/pmt_QI02vLdJGd48hBbg"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/public/receipts/pmt_QI02vLdJGd48hBbg");
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/public/receipts/pmt_QI02vLdJGd48hBbg",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "receipt",
"id": "pmt_QI02vLdJGd48hBbg",
"status": "succeeded",
"description": "Pro plan, monthly",
"reference": "order_1042",
"amount": 4900,
"currency": "USD",
"breakdown": {
"currency": "USD",
"price": 4900,
"processing_fee": null,
"network_fee": null,
"total": 4900
},
"rail": "crypto",
"crypto": {
"asset": "USDC",
"network": "polygon",
"amount": "49.00"
},
"received_amount": "49.00",
"overpaid_amount": null,
"failure_code": null,
"transaction": {
"hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"transfers": [
{
"hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"network": "polygon",
"url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"amount": "49.00"
}
],
"business": {
"name": "Acme Studio",
"support_email": "billing@example.com"
},
"back_url": "https://example.com/thanks",
"paid_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z"
}
}
```
# Create a link
Source: https://developer.402pay.co/api/links/create
Create a reusable hosted checkout with a fixed price.
`POST https://dash.402pay.co/api/v1/links`
Returns the link with its hosted checkout at `url`. Every customer who pays it creates a new payment with the link's `link_id`.
### Body
- `amount` (integer, required): The price in minor units of `currency`: `4900` with `USD` is $49.00. From the equivalent of $1.00 up to 1,000,000.00 in the currency.
- `currency` (string): USD, EUR, GBP, CAD, AUD. Defaults to USD.
- `name` (string): What the customer is paying for, up to 80 characters. It becomes the description of each payment made through the link, shown on the receipt.
- `description` (string): More about it, shown in the checkout's details, up to 300 characters.
- `success_url` (string): Where checkout sends the customer after paying, with `payment_id` added to its query. Use `https://`; `http://` works only for `localhost` and `127.0.0.1`. Defaults to the redirect in your checkout settings, then the receipt.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 403 `test_mode_unavailable` A test key can't create links on a live business, so use a live key.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/links" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD"
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Team plan, monthly",
description: "Everything in Pro for up to 10 people.",
amount: 19900,
currency: "USD"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/links",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD"
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"id": "lnk_nt842tPNne3lf0ka",
"kind": "link",
"code": "v6e4mrczwg",
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"success_url": null,
"status": "active",
"disabled_at": null,
"payments_count": 0,
"volume": {
"amount": 0,
"currency": "USD"
},
"created_at": "2026-09-26T21:22:46.992Z",
"updated_at": "2026-09-26T21:22:46.992Z"
}
}
```
# List links
Source: https://developer.402pay.co/api/links/list
List your payment links, newest first, with each one's payment count and volume.
`GET https://dash.402pay.co/api/v1/links`
Returns links newest first.
### Query
- `status` (string): `active` or `archived`.
- `q` (string): Searches the name, description, code and ID.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` A filter has a value it can't take, or the `cursor` belongs to another list.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/links?status=active&limit=10" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links?status=active&limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/links?status=active&limit=10",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "lnk_nt842tPNne3lf0ka",
"kind": "link",
"code": "v6e4mrczwg",
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"success_url": null,
"status": "active",
"disabled_at": null,
"payments_count": 0,
"volume": {
"amount": 0,
"currency": "USD"
},
"created_at": "2026-09-26T21:22:46.992Z",
"updated_at": "2026-09-26T21:22:46.992Z"
}
],
"has_more": true,
"next_cursor": "lnk_nt842tPNne3lf0ka"
}
```
# Retrieve a link
Source: https://developer.402pay.co/api/links/retrieve
Get a payment link with its payment count and volume.
`GET https://dash.402pay.co/api/v1/links/{id}`
`payments_count` and `volume` count the link's succeeded payments.
### Path
- `id` (string, required): The link's ID, such as `lnk_nt842tPNne3lf0ka`.
### Errors
- 404 `not_found` No link with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/links/lnk_nt842tPNne3lf0ka" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links/lnk_nt842tPNne3lf0ka", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/links/lnk_nt842tPNne3lf0ka",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "lnk_nt842tPNne3lf0ka",
"kind": "link",
"code": "v6e4mrczwg",
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"success_url": null,
"status": "active",
"disabled_at": null,
"payments_count": 0,
"volume": {
"amount": 0,
"currency": "USD"
},
"created_at": "2026-09-26T21:22:46.992Z",
"updated_at": "2026-09-26T21:22:46.992Z"
}
}
```
# Update a link
Source: https://developer.402pay.co/api/links/update
Change a link's details or price, or archive it to stop taking payments.
`PATCH https://dash.402pay.co/api/v1/links/{id}`
Send only what changes. Archive a link with `status` to stop taking payments while keeping its history, and set it back to `active` to restore it. A new price applies to checkouts opened after the change.
When 402pay disables a link, for example for breaking the Acceptable Use Policy, it's archived with `disabled_at` set, and it stays archived until 402pay turns it back on.
### Path
- `id` (string, required): The link's ID, such as `lnk_nt842tPNne3lf0ka`.
### Body
- `amount` (integer): The price in minor units of `currency`: `4900` with `USD` is $49.00. From the equivalent of $1.00 up to 1,000,000.00 in the currency.
- `currency` (string): USD, EUR, GBP, CAD, AUD. Defaults to USD.
- `name` (string): What the customer is paying for, up to 80 characters. It becomes the description of each payment made through the link, shown on the receipt.
- `description` (string): More about it, shown in the checkout's details, up to 300 characters.
- `success_url` (string): Where checkout sends the customer after paying, with `payment_id` added to its query. Use `https://`; `http://` works only for `localhost` and `127.0.0.1`. Defaults to the redirect in your checkout settings, then the receipt.
- `status` (string): `active` or `archived`. You receive `link.archived` or `link.restored`.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 404 `not_found` No link with that ID belongs to your business.
- 403 `test_mode_unavailable` A test key can't change, archive or restore links on a live business, so use a live key.
- 409 `link_disabled` 402pay disabled the link, so `status` can't go back to `active`.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/links/lnk_nt842tPNne3lf0ka" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "archived"
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links/lnk_nt842tPNne3lf0ka", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "archived"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/links/lnk_nt842tPNne3lf0ka",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"status": "archived"
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "lnk_nt842tPNne3lf0ka",
"kind": "link",
"code": "v6e4mrczwg",
"url": "https://checkout.402pay.co/checkout/v6e4mrczwg",
"name": "Team plan, monthly",
"description": "Everything in Pro for up to 10 people.",
"amount": 19900,
"currency": "USD",
"success_url": null,
"status": "archived",
"disabled_at": null,
"payments_count": 1,
"volume": {
"amount": 17910,
"currency": "USD"
},
"created_at": "2026-09-26T21:22:46.992Z",
"updated_at": "2026-09-26T21:23:16.755Z"
}
}
```
# Delete a link
Source: https://developer.402pay.co/api/links/delete
Delete a link that has never taken a payment.
`DELETE https://dash.402pay.co/api/v1/links/{id}`
Only a link that has never had a payment can be deleted, so no payment loses its link. Archive any other link instead.
### Path
- `id` (string, required): The link's ID, such as `lnk_tDofQnHNbxq9jsyq`.
### Errors
- 404 `not_found` No link with that ID belongs to your business.
- 403 `test_mode_unavailable` A test key can't delete links on a live business, so use a live key.
- 409 `link_has_payments` The link has payments. Archive it instead.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X DELETE "https://dash.402pay.co/api/v1/links/lnk_tDofQnHNbxq9jsyq" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/links/lnk_tDofQnHNbxq9jsyq", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.delete(
"https://dash.402pay.co/api/v1/links/lnk_tDofQnHNbxq9jsyq",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "lnk_tDofQnHNbxq9jsyq",
"kind": "link",
"deleted": true
}
}
```
# Create a customer
Source: https://developer.402pay.co/api/customers/create
Add a customer before they pay, such as an account from your own system.
`POST https://dash.402pay.co/api/v1/customers`
Customers are added for you when checkout collects an email, or when a payment names one in `customer_email`. Create one yourself to set their details before they pay: later payments with the same email link to them. You receive `customer.created`. Customers can't be deleted.
`blocked` isn't taken here. To stop someone from paying, create them, then [update them](https://developer.402pay.co/api/customers/update.md) with `blocked` set to `true`.
### Body
- `email` (string, required): Unique among your customers.
- `name` (string): Up to 80 characters. Defaults to one made from the email, such as Mateo Garcia for `mateo.garcia@example.com`.
- `note` (string): For your team, up to 500 characters.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 409 `customer_exists` Another customer already has this email.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/customers" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Mateo García",
"email": "mateo.garcia@example.com",
"note": "Invoiced monthly."
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/customers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Mateo García",
email: "mateo.garcia@example.com",
note: "Invoiced monthly."
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/customers",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"name": "Mateo García",
"email": "mateo.garcia@example.com",
"note": "Invoiced monthly."
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"id": "cst_6OOyvqK7aSwN8Ve8",
"kind": "customer",
"name": "Mateo García",
"email": "mateo.garcia@example.com",
"blocked": false,
"note": "Invoiced monthly.",
"stats": {
"payments_count": 0,
"incomplete_count": 0,
"volume": {
"amount": 0,
"currency": "USD"
},
"average": null,
"last_payment_at": null,
"preferred_rail": null
},
"created_at": "2026-09-27T18:31:10.909Z",
"updated_at": "2026-09-27T18:31:10.909Z"
}
}
```
# List customers
Source: https://developer.402pay.co/api/customers/list
List the people who have paid you or started to, with totals for each.
`GET https://dash.402pay.co/api/v1/customers`
A customer is added when checkout collects an email, when a payment names one in `customer_email`, or when you [create one](https://developer.402pay.co/api/customers/create.md), so a customer can have no payments yet. In `stats`, `payments_count` and `volume` count succeeded payments, and `incomplete_count` counts the rest.
### Query
- `q` (string): Matches the name, email or ID.
- `blocked` (boolean): `true` or `false`.
- `sort` (string): `created_at`, `last_payment_at`, `payments_count`, `volume` or `name`, with `-` for descending. Defaults to `-last_payment_at`.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` A filter has a value it can't take, or the `cursor` belongs to another list.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/customers?limit=10" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/customers?limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/customers?limit=10",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "cst_gWFWcc7Ga0Pv7LSC",
"kind": "customer",
"name": "Harper Wilson",
"email": "harper.wilson@example.com",
"blocked": false,
"note": "",
"stats": {
"payments_count": 2,
"incomplete_count": 2,
"volume": {
"amount": 22810,
"currency": "USD"
},
"average": {
"amount": 11405,
"currency": "USD"
},
"last_payment_at": "2026-09-26T21:22:47.082Z",
"preferred_rail": "crypto"
},
"created_at": "2026-09-26T21:01:16.809Z",
"updated_at": "2026-09-26T21:01:16.809Z"
}
],
"has_more": true,
"next_cursor": "cst_gWFWcc7Ga0Pv7LSC"
}
```
# Retrieve a customer
Source: https://developer.402pay.co/api/customers/retrieve
Get a customer with their payment count and totals.
`GET https://dash.402pay.co/api/v1/customers/{id}`
Returns the customer with their totals. List their payments with [`GET /payments?customer_id=…`](https://developer.402pay.co/api/payments/list.md).
### Path
- `id` (string, required): The customer's ID, such as `cst_gWFWcc7Ga0Pv7LSC`.
### Errors
- 404 `not_found` No customer with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/customers/cst_gWFWcc7Ga0Pv7LSC" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/customers/cst_gWFWcc7Ga0Pv7LSC", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/customers/cst_gWFWcc7Ga0Pv7LSC",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "cst_gWFWcc7Ga0Pv7LSC",
"kind": "customer",
"name": "Harper Wilson",
"email": "harper.wilson@example.com",
"blocked": false,
"note": "",
"stats": {
"payments_count": 2,
"incomplete_count": 2,
"volume": {
"amount": 22810,
"currency": "USD"
},
"average": {
"amount": 11405,
"currency": "USD"
},
"last_payment_at": "2026-09-26T21:22:47.082Z",
"preferred_rail": "crypto"
},
"created_at": "2026-09-26T21:01:16.809Z",
"updated_at": "2026-09-26T21:01:16.809Z"
}
}
```
# Update a customer
Source: https://developer.402pay.co/api/customers/update
Change a customer's details or note, or block them from paying.
`PATCH https://dash.402pay.co/api/v1/customers/{id}`
Send only what changes. A blocked customer can't start a checkout until you unblock them, and you receive `customer.blocked` or `customer.unblocked`.
### Path
- `id` (string, required): The customer's ID, such as `cst_gWFWcc7Ga0Pv7LSC`.
### Body
- `name` (string): Up to 80 characters.
- `email` (string): Unique among your customers.
- `note` (string): For your team, up to 500 characters.
- `blocked` (boolean): `true` stops the customer from paying you.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 404 `not_found` No customer with that ID belongs to your business.
- 403 `test_mode_unavailable` A test key sent `blocked` on a live business. Use a live key to block or unblock a customer.
- 409 `customer_exists` Another customer already has this email.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/customers/cst_gWFWcc7Ga0Pv7LSC" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"note": "Prefers USDC on Polygon."
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/customers/cst_gWFWcc7Ga0Pv7LSC", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
note: "Prefers USDC on Polygon."
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/customers/cst_gWFWcc7Ga0Pv7LSC",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"note": "Prefers USDC on Polygon."
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "cst_gWFWcc7Ga0Pv7LSC",
"kind": "customer",
"name": "Harper Wilson",
"email": "harper.wilson@example.com",
"blocked": false,
"note": "Prefers USDC on Polygon.",
"stats": {
"payments_count": 2,
"incomplete_count": 2,
"volume": {
"amount": 22810,
"currency": "USD"
},
"average": {
"amount": 11405,
"currency": "USD"
},
"last_payment_at": "2026-09-26T21:22:47.082Z",
"preferred_rail": "crypto"
},
"created_at": "2026-09-26T21:01:16.809Z",
"updated_at": "2026-09-26T21:23:16.989Z"
}
}
```
# List events
Source: https://developer.402pay.co/api/events/list
List everything that happened in your business, with a snapshot of each subject.
`GET https://dash.402pay.co/api/v1/events`
The same events your [webhooks](https://developer.402pay.co/guides/webhooks.md) receive, newest first, each with a snapshot of its subject in `data`. A restricted key sees only events about resources it can read.
### Query
- `type` (string): Exact types or prefix wildcards, comma separated, such as `payment.*,link.created`.
- `subject_id` (string): Events about this object.
- `created_after` (timestamp): Only objects created at or after this ISO 8601 time.
- `created_before` (timestamp): Only objects created before this ISO 8601 time.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` `type` names no event type, or the `cursor` belongs to another list.
- 403 `permission_denied` `type` names an event a restricted key can't read, or a wildcard that matches only such events.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/events?type=payment.*&limit=10" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/events?type=payment.*&limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/events?type=payment.*&limit=10",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
}
],
"has_more": true,
"next_cursor": "evt_JxmQNXpkZZN8mpqE"
}
```
# Retrieve an event
Source: https://developer.402pay.co/api/events/retrieve
Get one event with the snapshot of its subject.
`GET https://dash.402pay.co/api/v1/events/{id}`
`data` is the subject as it was when the event happened, not as it is now. `mode` is the mode of the key or session that caused it, which is `live` for every event during the beta, and `api_version` is the version of the event's shape. `actor` says who caused it: a person, an API key, a customer at checkout, or `system` when 402pay moved a payment along on its own.
### Path
- `id` (string, required): The event's ID, such as `evt_JxmQNXpkZZN8mpqE`.
### Errors
- 404 `not_found` No event with that ID belongs to your business.
- 403 `permission_denied` The event is about something a restricted key can't read. Events about API keys or the business need a key with full access.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/events/evt_JxmQNXpkZZN8mpqE" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/events/evt_JxmQNXpkZZN8mpqE", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/events/evt_JxmQNXpkZZN8mpqE",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
}
}
```
# List event types
Source: https://developer.402pay.co/api/events/types
Every event type, what it means, and whether webhooks can receive it.
`GET https://dash.402pay.co/api/v1/event-types`
Public, in catalog order and paginated like any list, so pass `limit=100` for all of them at once. `webhooks` says whether an endpoint can subscribe to the type: account events, such as turning on two-step verification, belong to a person and are only listed. `area` groups types the way the dashboard does.
### Query
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` The `cursor` doesn't name an event type.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/event-types?limit=100"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/event-types?limit=100");
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/event-types?limit=100",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"kind": "event_type",
"type": "payment.succeeded",
"title": "Payment succeeded",
"description": "A payment was paid in full, or accepted, and its funds landed in the wallet or went to 402pay to pay the fees this business owes.",
"area": "Payments",
"webhooks": true
}
],
"has_more": false,
"next_cursor": null
}
```
# Create a webhook endpoint
Source: https://developer.402pay.co/api/webhooks/create
Add an endpoint for the events you choose and get its signing secret.
`POST https://dash.402pay.co/api/v1/webhooks`
This is the only response that includes the signing `secret`, so store it now. Every other response shows only `secret_hint`.
### Body
- `url` (string, required): An https:// address on the public internet, up to 2048 characters. Private, loopback and link-local hosts are refused.
- `event_types` (array, required): The types to receive, at least one. See [event types](https://developer.402pay.co/guides/webhook-events.md#event-types).
- `description` (string): A note for your team, up to 120 characters.
- `enabled` (boolean): Defaults to `true`.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/webhooks" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.failed"]
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/webhooks/402pay",
description: "Fulfill orders",
event_types: ["payment.succeeded", "payment.failed"]
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/webhooks",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.failed"]
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"id": "whk_GFAKLrE8wkBwF4WL",
"kind": "webhook",
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.failed"],
"enabled": true,
"secret_hint": "whsec_••••X/LG",
"created_at": "2026-09-26T21:22:47.012Z",
"updated_at": "2026-09-26T21:22:47.012Z",
"secret": "whsec_bM0XCID9vQtL0CuhH+4f/aQMZm7pX/LG"
}
}
```
# List webhook endpoints
Source: https://developer.402pay.co/api/webhooks/list
List your webhook endpoints and the events each one receives.
`GET https://dash.402pay.co/api/v1/webhooks`
Returns your endpoints, newest first, without their secrets.
### Query
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` The `cursor` belongs to another list.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/webhooks" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/webhooks",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "whk_GFAKLrE8wkBwF4WL",
"kind": "webhook",
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.failed"],
"enabled": true,
"secret_hint": "whsec_••••X/LG",
"created_at": "2026-09-26T21:22:47.012Z",
"updated_at": "2026-09-26T21:22:47.012Z"
}
],
"has_more": false,
"next_cursor": null
}
```
# Retrieve a webhook endpoint
Source: https://developer.402pay.co/api/webhooks/retrieve
Get one endpoint with the events it receives.
`GET https://dash.402pay.co/api/v1/webhooks/{id}`
Returns the endpoint with the events it receives. Only the responses that create an endpoint or rotate its secret include the `secret`; here, `secret_hint` shows its last four characters.
### Path
- `id` (string, required): The webhook endpoint's ID, such as `whk_GFAKLrE8wkBwF4WL`.
### Errors
- 404 `not_found` No webhook endpoint with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "whk_GFAKLrE8wkBwF4WL",
"kind": "webhook",
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.failed"],
"enabled": true,
"secret_hint": "whsec_••••X/LG",
"created_at": "2026-09-26T21:22:47.012Z",
"updated_at": "2026-09-26T21:22:47.012Z"
}
}
```
# Update a webhook endpoint
Source: https://developer.402pay.co/api/webhooks/update
Change an endpoint's URL, events or description, or turn it off.
`PATCH https://dash.402pay.co/api/v1/webhooks/{id}`
Send only what changes. `event_types` replaces the whole list. Turning an endpoint off with `enabled` stops new deliveries without losing its settings.
### Path
- `id` (string, required): The webhook endpoint's ID, such as `whk_GFAKLrE8wkBwF4WL`.
### Body
- `url` (string): An https:// address on the public internet, up to 2048 characters. Private, loopback and link-local hosts are refused.
- `event_types` (array): The types to receive, at least one. See [event types](https://developer.402pay.co/guides/webhook-events.md#event-types).
- `description` (string): A note for your team.
- `enabled` (boolean): Whether the endpoint receives deliveries.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 404 `not_found` No webhook endpoint with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_types": ["payment.succeeded", "payment.underpaid", "payment.failed"]
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
event_types: ["payment.succeeded", "payment.underpaid", "payment.failed"]
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"event_types": ["payment.succeeded", "payment.underpaid", "payment.failed"]
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "whk_GFAKLrE8wkBwF4WL",
"kind": "webhook",
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.underpaid", "payment.failed"],
"enabled": true,
"secret_hint": "whsec_••••X/LG",
"created_at": "2026-09-26T21:22:47.012Z",
"updated_at": "2026-09-26T21:23:17.086Z"
}
}
```
# Delete a webhook endpoint
Source: https://developer.402pay.co/api/webhooks/delete
Remove an endpoint and stop its deliveries, including pending retries.
`DELETE https://dash.402pay.co/api/v1/webhooks/{id}`
The endpoint stops receiving events at once, including retries still waiting.
### Path
- `id` (string, required): The webhook endpoint's ID, such as `whk_GFAKLrE8wkBwF4WL`.
### Errors
- 404 `not_found` No webhook endpoint with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X DELETE "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL", {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.delete(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "whk_GFAKLrE8wkBwF4WL",
"kind": "webhook",
"deleted": true
}
}
```
# Send a test event
Source: https://developer.402pay.co/api/webhooks/test
Deliver a signed sample event to an endpoint to check your handler.
`POST https://dash.402pay.co/api/v1/webhooks/{id}/test`
Sends a signed sample event and returns the delivery. The payload has `test: true` and a made-up subject whose ID contains `_test_`, with only that ID in `data`, so your handler can tell it apart and nothing in your business changes. A test delivery goes out once and isn't retried.
### Path
- `id` (string, required): The webhook endpoint's ID, such as `whk_GFAKLrE8wkBwF4WL`.
### Body
- `event_type` (string): A type the endpoint receives. Defaults to its first one.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 404 `not_found` No webhook endpoint with that ID belongs to your business.
- 429 `rate_limited` More than 10 test events and resends in a minute for this business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/test" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "payment.succeeded"
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
event_type: "payment.succeeded"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/test",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"event_type": "payment.succeeded"
},
)
data = response.json()["data"]
```
Response, 201 Created:
```json
{
"data": {
"id": "dlv_bT1dN9zK1zp9H6Re",
"kind": "webhook_delivery",
"webhook_id": "whk_GFAKLrE8wkBwF4WL",
"event_id": "evt_AncdRQ0YlMsNNIQD",
"event_type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_test_wcKbRA2NP8gXhlmr"
},
"status": "succeeded",
"response_status": 200,
"duration_ms": 269,
"attempt": 1,
"attempts": [
{
"at": "2026-09-26T21:23:17.117Z",
"response_status": 200,
"duration_ms": 269,
"trigger": "manual"
}
],
"next_retry_at": null,
"request_headers": {
"content-type": "application/json",
"webhook-id": "evt_AncdRQ0YlMsNNIQD",
"webhook-timestamp": "1790457797",
"webhook-signature": "v1,erz2/ak2MUN/eIaREBRHTG9nfipCw9fZ2VcWbYByqw4="
},
"payload": {
"id": "evt_AncdRQ0YlMsNNIQD",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_test_wcKbRA2NP8gXhlmr"
},
"data": {
"id": "pmt_test_wcKbRA2NP8gXhlmr"
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"test": true,
"api_version": "v1",
"created_at": "2026-09-26T21:23:17.117Z"
},
"created_at": "2026-09-26T21:23:17.117Z"
}
}
```
# Rotate a signing secret
Source: https://developer.402pay.co/api/webhooks/rotate-secret
Replace an endpoint's signing secret, with the old one signing alongside for a day.
`POST https://dash.402pay.co/api/v1/webhooks/{id}/rotate-secret`
Returns the endpoint with its new `secret`, shown only this once. For the next 24 hours, each delivery carries a signature from the old secret and one from the new, so you can switch without dropping a delivery.
### Path
- `id` (string, required): The webhook endpoint's ID, such as `whk_GFAKLrE8wkBwF4WL`.
### Errors
- 404 `not_found` No webhook endpoint with that ID belongs to your business.
- 403 `step_up_required` The session hasn't proven its password or a passkey in the last 15 minutes. Confirm it at `POST /sessions/current/step-up` and send the request again.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/rotate-secret" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/rotate-secret", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/rotate-secret",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "whk_GFAKLrE8wkBwF4WL",
"kind": "webhook",
"url": "https://example.com/webhooks/402pay",
"description": "Fulfill orders",
"event_types": ["payment.succeeded", "payment.underpaid", "payment.failed"],
"enabled": true,
"secret_hint": "whsec_••••6NSj",
"created_at": "2026-09-26T21:22:47.012Z",
"updated_at": "2026-09-26T21:23:17.184Z",
"secret": "whsec_zprG6pS4p8qH/nuG5HUwCYHz0OtK6NSj"
}
}
```
# List deliveries
Source: https://developer.402pay.co/api/webhooks/deliveries
Every delivery attempt with its signed request and your server's response.
`GET https://dash.402pay.co/api/v1/webhook-deliveries`
Returns deliveries newest first, each with every attempt, the signed request headers, the payload, and your server's response status. [`GET /webhook-deliveries/{id}`](https://developer.402pay.co/api/webhooks/deliveries/retrieve.md) returns one, and [`GET /webhooks/{id}/deliveries`](https://developer.402pay.co/api/webhooks/endpoint-deliveries.md) lists one endpoint's.
### Query
- `webhook_id` (string): Deliveries to this endpoint.
- `status` (string): `succeeded`, `retrying` or `failed`.
- `event_type` (string): Deliveries of this event type.
- `event_id` (string): Deliveries of this event.
- `subject_id` (string): Deliveries about this object, such as a payment.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` A filter has a value it can't take, or the `cursor` belongs to another list.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/webhook-deliveries?webhook_id=whk_GFAKLrE8wkBwF4WL&status=failed" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhook-deliveries?webhook_id=whk_GFAKLrE8wkBwF4WL&status=failed", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/webhook-deliveries?webhook_id=whk_GFAKLrE8wkBwF4WL&status=failed",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "dlv_EpCJYUSbN3WObIJp",
"kind": "webhook_delivery",
"webhook_id": "whk_GFAKLrE8wkBwF4WL",
"event_id": "evt_JxmQNXpkZZN8mpqE",
"event_type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"status": "succeeded",
"response_status": 200,
"duration_ms": 375,
"attempt": 1,
"attempts": [
{
"at": "2026-09-26T21:23:15.256Z",
"response_status": 200,
"duration_ms": 375,
"trigger": "automatic"
}
],
"next_retry_at": null,
"request_headers": {
"content-type": "application/json",
"webhook-id": "evt_JxmQNXpkZZN8mpqE",
"webhook-timestamp": "1790457795",
"webhook-signature": "v1,Usi/akbM9PxJChbtzMSCZXf783T+z0FaQRv68dL2fwQ="
},
"payload": {
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
},
"created_at": "2026-09-26T21:23:15.256Z"
}
],
"has_more": true,
"next_cursor": "dlv_EpCJYUSbN3WObIJp"
}
```
# Retrieve a delivery
Source: https://developer.402pay.co/api/webhooks/deliveries/retrieve
Get one delivery with every attempt, its signed request and your server's response.
`GET https://dash.402pay.co/api/v1/webhook-deliveries/{id}`
Returns one delivery with every attempt in `attempts`, oldest first. `status`, `response_status`, `duration_ms` and `request_headers` describe the latest attempt: the delivery is `succeeded` once that attempt got a 2xx, `retrying` while another automatic attempt waits for `next_retry_at`, and `failed` when none is left.
On a simulated business, retries that came due run when deliveries are read, so reading one brings it up to date.
### Path
- `id` (string, required): The delivery's ID, such as `dlv_EpCJYUSbN3WObIJp`.
### Errors
- 404 `not_found` No delivery with that ID belongs to your business.
- 403 `permission_denied` The delivery's event is about something a restricted key can't read.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/webhook-deliveries/dlv_EpCJYUSbN3WObIJp" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhook-deliveries/dlv_EpCJYUSbN3WObIJp", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/webhook-deliveries/dlv_EpCJYUSbN3WObIJp",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "dlv_EpCJYUSbN3WObIJp",
"kind": "webhook_delivery",
"webhook_id": "whk_GFAKLrE8wkBwF4WL",
"event_id": "evt_JxmQNXpkZZN8mpqE",
"event_type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"status": "succeeded",
"response_status": 200,
"duration_ms": 375,
"attempt": 1,
"attempts": [
{
"at": "2026-09-26T21:23:15.256Z",
"response_status": 200,
"duration_ms": 375,
"trigger": "automatic"
}
],
"next_retry_at": null,
"request_headers": {
"content-type": "application/json",
"webhook-id": "evt_JxmQNXpkZZN8mpqE",
"webhook-timestamp": "1790457795",
"webhook-signature": "v1,Usi/akbM9PxJChbtzMSCZXf783T+z0FaQRv68dL2fwQ="
},
"payload": {
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
},
"created_at": "2026-09-26T21:23:15.256Z"
}
}
```
# List an endpoint's deliveries
Source: https://developer.402pay.co/api/webhooks/endpoint-deliveries
List the deliveries to one endpoint, newest first.
`GET https://dash.402pay.co/api/v1/webhooks/{id}/deliveries`
The deliveries to one endpoint, newest first. It's [listing deliveries](https://developer.402pay.co/api/webhooks/deliveries.md) with `webhook_id`, except that an endpoint that doesn't exist, or was deleted, returns 404 rather than an empty list. A restricted key sees only deliveries of events it can read.
### Path
- `id` (string, required): The webhook endpoint's ID, such as `whk_GFAKLrE8wkBwF4WL`.
### Query
- `status` (string): `succeeded`, `retrying` or `failed`.
- `event_type` (string): Deliveries of this event type.
- `event_id` (string): Deliveries of this event.
- `subject_id` (string): Deliveries about this object, such as a payment.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` A filter has a value it can't take, or the `cursor` belongs to another list.
- 404 `not_found` No webhook endpoint with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/deliveries?limit=10" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/deliveries?limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/webhooks/whk_GFAKLrE8wkBwF4WL/deliveries?limit=10",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "dlv_EpCJYUSbN3WObIJp",
"kind": "webhook_delivery",
"webhook_id": "whk_GFAKLrE8wkBwF4WL",
"event_id": "evt_JxmQNXpkZZN8mpqE",
"event_type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"status": "succeeded",
"response_status": 200,
"duration_ms": 375,
"attempt": 1,
"attempts": [
{
"at": "2026-09-26T21:23:15.256Z",
"response_status": 200,
"duration_ms": 375,
"trigger": "automatic"
}
],
"next_retry_at": null,
"request_headers": {
"content-type": "application/json",
"webhook-id": "evt_JxmQNXpkZZN8mpqE",
"webhook-timestamp": "1790457795",
"webhook-signature": "v1,Usi/akbM9PxJChbtzMSCZXf783T+z0FaQRv68dL2fwQ="
},
"payload": {
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
},
"created_at": "2026-09-26T21:23:15.256Z"
}
],
"has_more": true,
"next_cursor": "dlv_EpCJYUSbN3WObIJp"
}
```
# Resend a delivery
Source: https://developer.402pay.co/api/webhooks/resend
Send a delivery again, now, with a fresh timestamp and signature.
`POST https://dash.402pay.co/api/v1/webhook-deliveries/{id}/resend`
Sends the same event again, now, with a fresh timestamp and signature. The attempt is added to the delivery's `attempts` with a `trigger` of `manual`. A resend that succeeds ends any automatic retries still waiting, and one that fails leaves their schedule as it was.
### Path
- `id` (string, required): The delivery's ID, such as `dlv_EpCJYUSbN3WObIJp`.
### Errors
- 429 `rate_limited` More than 10 test events and resends in a minute for this business.
- 404 `not_found` No delivery has that ID, or its endpoint was deleted.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X POST "https://dash.402pay.co/api/v1/webhook-deliveries/dlv_EpCJYUSbN3WObIJp/resend" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/webhook-deliveries/dlv_EpCJYUSbN3WObIJp/resend", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.post(
"https://dash.402pay.co/api/v1/webhook-deliveries/dlv_EpCJYUSbN3WObIJp/resend",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "dlv_EpCJYUSbN3WObIJp",
"kind": "webhook_delivery",
"webhook_id": "whk_GFAKLrE8wkBwF4WL",
"event_id": "evt_JxmQNXpkZZN8mpqE",
"event_type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"status": "succeeded",
"response_status": 200,
"duration_ms": 113,
"attempt": 2,
"attempts": [
{
"at": "2026-09-26T21:23:15.256Z",
"response_status": 200,
"duration_ms": 375,
"trigger": "automatic"
},
{
"at": "2026-09-26T21:23:54.224Z",
"response_status": 200,
"duration_ms": 113,
"trigger": "manual"
}
],
"next_retry_at": null,
"request_headers": {
"content-type": "application/json",
"webhook-id": "evt_JxmQNXpkZZN8mpqE",
"webhook-timestamp": "1790457834",
"webhook-signature": "v1,MoYupCC1kjgKhsahhyC6OTiUKemIz68ygMzCmmwDFOU= v1,Hjopj5DWS/GQsmb1+rSTbrwuMTWGYQ8lPDerEDthHpo="
},
"payload": {
"id": "evt_JxmQNXpkZZN8mpqE",
"kind": "event",
"type": "payment.succeeded",
"subject": {
"kind": "payment",
"id": "pmt_QI02vLdJGd48hBbg"
},
"data": {
"id": "pmt_QI02vLdJGd48hBbg",
"kind": "payment",
"status": "succeeded",
"amount": 4900,
"currency": "USD",
"fee_payer": "business",
"customer_fee": 0,
"amount_received": 4900,
"reporting": {
"currency": "USD",
"amount": 4900,
"fee": 0,
"transaction_fee": 25,
"customer_fee": 0,
"net": 4875
},
"fee_rate_bps": 0,
"method": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"expected_amount": "49.00",
"overpaid_amount": null,
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"settlement": {
"destination": "wallet",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
},
"customer_id": "cst_gWFWcc7Ga0Pv7LSC",
"link_id": null,
"url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
"reference": "order_1042",
"metadata": {
"order_id": "1042"
},
"success_url": "https://example.com/thanks",
"cancel_url": "https://example.com/cart",
"expires_at": "2026-09-27T21:22:47.038Z",
"canceled_at": null,
"checkout_id": "chk_C5yhPQvPqpYgdDgj",
"description": "Pro plan, monthly",
"country": "US",
"failure_code": null,
"failure_message": null,
"confirmed_at": "2026-09-26T21:23:13.447Z",
"created_at": "2026-09-26T21:22:47.038Z",
"updated_at": "2026-09-26T21:23:13.447Z",
"events": [
{
"type": "created",
"created_at": "2026-09-26T21:22:47.038Z",
"data": null
},
{
"type": "method_selected",
"created_at": "2026-09-26T21:22:47.047Z",
"data": {
"rail": "crypto",
"asset": "USDC",
"network": "polygon"
}
},
{
"type": "detected",
"created_at": "2026-09-26T21:23:07.047Z",
"data": {
"amount": "49.00",
"network": "polygon",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:08.647Z",
"data": {
"current": 1,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:10.247Z",
"data": {
"current": 2,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:11.847Z",
"data": {
"current": 3,
"required": 4
}
},
{
"type": "confirmation",
"created_at": "2026-09-26T21:23:13.447Z",
"data": {
"current": 4,
"required": 4
}
},
{
"type": "succeeded",
"created_at": "2026-09-26T21:23:13.447Z",
"data": null
}
]
},
"actor": {
"kind": "system",
"id": null,
"name": null
},
"mode": "live",
"api_version": "v1",
"created_at": "2026-09-26T21:23:15.256Z"
},
"created_at": "2026-09-26T21:23:15.256Z"
}
}
```
# Retrieve the wallet
Source: https://developer.402pay.co/api/wallet/retrieve
Get your wallet's balances per coin and network, and its main addresses.
`GET https://dash.402pay.co/api/v1/wallet`
Only public data comes back: balances with their USD value, and the main address on each network. The recovery phrase never leaves your browser.
`address_counts` gives, for each key family, the highest index checkout has handed out plus one: how far a wallet app restoring your recovery phrase has to look. EVM networks share one count, and Solana's counts the addresses its checkout key derived.
`pending_change` is a [deletion](https://developer.402pay.co/api/wallet/delete.md) waiting out its hold, or null. Until its `effective_at`, the wallet keeps receiving payments.
### Errors
- 404 `not_found` Your business has no wallet yet.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/wallet" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/wallet",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "wal_1fIGZOrILmsCO0jw",
"kind": "wallet",
"name": "Treasury",
"source": "created",
"total_value_usd": 9544239,
"balances": [
{
"asset": "USDC",
"network": "polygon",
"amount": "28814.47",
"price_usd": 100,
"value_usd": 2881447
},
{
"asset": "USDT",
"network": "tron",
"amount": "22995.72",
"price_usd": 100,
"value_usd": 2299572
},
{
"asset": "BTC",
"network": "bitcoin",
"amount": "0.17992045",
"price_usd": 9840000,
"value_usd": 1770417
},
{
"asset": "SOL",
"network": "solana",
"amount": "47.4820",
"price_usd": 18400,
"value_usd": 873669
},
{
"asset": "USDC",
"network": "solana",
"amount": "7099.29",
"price_usd": 100,
"value_usd": 709929
},
{
"asset": "USDC",
"network": "ethereum",
"amount": "6177.58",
"price_usd": 100,
"value_usd": 617758
},
{
"asset": "ETH",
"network": "ethereum",
"amount": "0.836580",
"price_usd": 362000,
"value_usd": 302842
},
{
"asset": "USDT",
"network": "ethereum",
"amount": "886.05",
"price_usd": 100,
"value_usd": 88605
}
],
"main_addresses": {
"ethereum": "0x82DD86f0f1CC201A49EEaf01C1980A6D66AaEb86",
"polygon": "0x82DD86f0f1CC201A49EEaf01C1980A6D66AaEb86",
"solana": "2YJtPr25BcbApPPAXHyAu9USMS3z8KSepTbBx9N3YMgp",
"tron": "TFtTqBsPS39pnxMaHVzKHYuCurEgof2wDG",
"bitcoin": "bc1qp932jy8gqh9zn62ef2azjnh5gpvk0hv40jfmpx"
},
"address_counts": {
"evm": 118,
"tron": 37,
"bitcoin": 24,
"solana": 46
},
"pending_change": null,
"created_at": "2026-05-09T21:22:00.813Z"
}
}
```
# Update the wallet
Source: https://developer.402pay.co/api/wallet/update
Rename your wallet.
`PATCH https://dash.402pay.co/api/v1/wallet`
Only the name changes here. Everything else comes from the wallet's keys, and creating, restoring or deleting a wallet takes a person signed in to the dashboard, where any recovery phrase stays in their browser.
### Body
- `name` (string): From 2 to 40 characters, shown in the dashboard.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 404 `not_found` Your business has no wallet yet.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/wallet" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Treasury"
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Treasury"
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/wallet",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"name": "Treasury"
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "wal_1fIGZOrILmsCO0jw",
"kind": "wallet",
"name": "Treasury",
"source": "created",
"total_value_usd": 9544239,
"balances": [
{
"asset": "USDC",
"network": "polygon",
"amount": "28814.47",
"price_usd": 100,
"value_usd": 2881447
},
{
"asset": "USDT",
"network": "tron",
"amount": "22995.72",
"price_usd": 100,
"value_usd": 2299572
},
{
"asset": "BTC",
"network": "bitcoin",
"amount": "0.17992045",
"price_usd": 9840000,
"value_usd": 1770417
},
{
"asset": "SOL",
"network": "solana",
"amount": "47.4820",
"price_usd": 18400,
"value_usd": 873669
},
{
"asset": "USDC",
"network": "solana",
"amount": "7099.29",
"price_usd": 100,
"value_usd": 709929
},
{
"asset": "USDC",
"network": "ethereum",
"amount": "6177.58",
"price_usd": 100,
"value_usd": 617758
},
{
"asset": "ETH",
"network": "ethereum",
"amount": "0.836580",
"price_usd": 362000,
"value_usd": 302842
},
{
"asset": "USDT",
"network": "ethereum",
"amount": "886.05",
"price_usd": 100,
"value_usd": 88605
}
],
"main_addresses": {
"ethereum": "0x82DD86f0f1CC201A49EEaf01C1980A6D66AaEb86",
"polygon": "0x82DD86f0f1CC201A49EEaf01C1980A6D66AaEb86",
"solana": "2YJtPr25BcbApPPAXHyAu9USMS3z8KSepTbBx9N3YMgp",
"tron": "TFtTqBsPS39pnxMaHVzKHYuCurEgof2wDG",
"bitcoin": "bc1qp932jy8gqh9zn62ef2azjnh5gpvk0hv40jfmpx"
},
"address_counts": {
"evm": 118,
"tron": 37,
"bitcoin": 24,
"solana": 46
},
"pending_change": null,
"created_at": "2026-05-09T21:22:00.813Z"
}
}
```
# List wallet transactions
Source: https://developer.402pay.co/api/wallet/transactions
List money in and out of your wallet, including every payment's deposit.
`GET https://dash.402pay.co/api/v1/wallet/transactions`
Returns transactions newest first. Each payment's deposit is an `in` transaction with its `payment_id`, and each send from the dashboard is an `out` one.
### Query
- `q` (string): Searches the transaction hash, addresses and note.
- `direction` (string): `in` or `out`.
- `asset` (string): A coin, such as `USDC`.
- `network` (string): A network, such as `polygon`.
- `created_after` (timestamp): Only objects created at or after this ISO 8601 time.
- `created_before` (timestamp): Only objects created before this ISO 8601 time.
- `sort` (string): `created_at` or `value_usd`, with `-` for descending. Defaults to `-created_at`.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` A filter has a value it can't take, or the `cursor` belongs to another list.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/wallet/transactions?direction=in&limit=10" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/transactions?direction=in&limit=10", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/wallet/transactions?direction=in&limit=10",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "wtx_nXRP8NNOMtb4LySU",
"kind": "wallet_transaction",
"direction": "in",
"status": "confirmed",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"value_usd": 4900,
"network_fee_usd": null,
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"to_address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"payment_id": "pmt_QI02vLdJGd48hBbg",
"note": "",
"created_at": "2026-09-26T21:23:13.447Z"
}
],
"has_more": true,
"next_cursor": "wtx_nXRP8NNOMtb4LySU"
}
```
# Retrieve a wallet transaction
Source: https://developer.402pay.co/api/wallet/transactions/retrieve
Get one transaction in or out of your wallet.
`GET https://dash.402pay.co/api/v1/wallet/transactions/{id}`
`amount` is in the coin, after fees for a payment's deposit, and `value_usd` is its value in US cents. `network_fee_usd` is set on sends, which pay the network; payments you receive don't.
### Path
- `id` (string, required): The transaction's ID, such as `wtx_nXRP8NNOMtb4LySU`.
### Errors
- 404 `not_found` No wallet transaction with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/wallet/transactions/wtx_nXRP8NNOMtb4LySU" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/transactions/wtx_nXRP8NNOMtb4LySU", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/wallet/transactions/wtx_nXRP8NNOMtb4LySU",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "wtx_nXRP8NNOMtb4LySU",
"kind": "wallet_transaction",
"direction": "in",
"status": "confirmed",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"value_usd": 4900,
"network_fee_usd": null,
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"to_address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"payment_id": "pmt_QI02vLdJGd48hBbg",
"note": "",
"created_at": "2026-09-26T21:23:13.447Z"
}
}
```
# Update a wallet transaction
Source: https://developer.402pay.co/api/wallet/transactions/update
Add or change a transaction's note, such as what a send was for.
`PATCH https://dash.402pay.co/api/v1/wallet/transactions/{id}`
A note is for your team, such as the order a payment was for or why a send went out. It's the only thing that changes: the rest records what happened on the network.
### Path
- `id` (string, required): The transaction's ID, such as `wtx_nXRP8NNOMtb4LySU`.
### Body
- `note` (string): Up to 280 characters. An empty string or `null` clears it.
### Errors
- 400 `invalid_request` A field is missing, unknown or out of range. `field` names it.
- 404 `not_found` No wallet transaction with that ID belongs to your business.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/wallet/transactions/wtx_nXRP8NNOMtb4LySU" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"note": "Order 1042, Pro plan."
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/transactions/wtx_nXRP8NNOMtb4LySU", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
note: "Order 1042, Pro plan."
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/wallet/transactions/wtx_nXRP8NNOMtb4LySU",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"note": "Order 1042, Pro plan."
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"id": "wtx_nXRP8NNOMtb4LySU",
"kind": "wallet_transaction",
"direction": "in",
"status": "confirmed",
"asset": "USDC",
"network": "polygon",
"amount": "49.00",
"value_usd": 4900,
"network_fee_usd": null,
"address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"to_address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
"tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
"payment_id": "pmt_QI02vLdJGd48hBbg",
"note": "Order 1042, Pro plan.",
"created_at": "2026-09-26T21:23:13.447Z"
}
}
```
# Retrieve fee estimates
Source: https://developer.402pay.co/api/wallet/fee-estimates
Get what a send pays the network at each speed, and how long it takes.
`GET https://dash.402pay.co/api/v1/wallet/fee-estimates`
What a send from your wallet would pay the network at each speed, slowest first: `fee_usd` in US cents, and about how many `minutes` it takes. The fee is paid in the network's own coin, such as POL on Polygon, so sending a token needs some of that coin too. Sends take a person signed in to the dashboard, never an API key.
On a simulated business, the estimates are fixed.
### Query
- `network` (string, required): One of `solana`, `polygon`, `ethereum`, `tron`, `bitcoin`.
### Errors
- 400 `invalid_request` `network` is missing or isn't a network.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/wallet/fee-estimates?network=polygon" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/wallet/fee-estimates?network=polygon", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/wallet/fee-estimates?network=polygon",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "fee_estimates",
"network": "polygon",
"speeds": [
{
"speed": "slow",
"fee_usd": 1,
"minutes": 3
},
{
"speed": "standard",
"fee_usd": 2,
"minutes": 2
},
{
"speed": "fast",
"fee_usd": 4,
"minutes": 1
}
]
}
}
```
# Retrieve a send context
Source: https://developer.402pay.co/api/wallet/send-context
Get what the dashboard needs to sign a send: each funded address with its balance and nonce, and the fees.
`GET https://dash.402pay.co/api/v1/wallet/send-context`
What the dashboard reads before it signs a send in the browser. Checkout pays every customer to a fresh address, so a wallet's funds sit across many addresses: this lists each one that holds the coin or the network's own coin, with its balance and next nonce, and what the network charges at each speed. Nothing in it can move funds, and the browser checks every address against the recovery phrase before it signs.
It takes a person signed in to the dashboard, like the [send](https://developer.402pay.co/api/wallet/transactions/create.md) it prepares, so an API key gets 403 `session_required`. Balances and nonces come from the network when it's read, less any sends still pending; on a simulated business they come from its own records, and the fees are fixed. Sends are signed on Solana, Polygon and Ethereum. To move Tron and Bitcoin funds for now, restore the recovery phrase in a wallet app.
### Query
- `network` (string, required): One of `polygon`, `ethereum`, `solana`.
- `asset` (string, required): The coin to send, such as `USDC`.
- `to_address` (string, required): The recipient. On Solana, a token send costs more the first time an address receives the coin.
### Fields to know
- `family` (string): `evm` for Polygon and Ethereum, or `solana`. The rest of the object depends on it.
- `addresses` (array): The main address first, then every address holding the coin or the network's own coin. `index` is the address's place in the wallet (0 is the main address), and `balance` and `native_balance` are in the smallest unit of the coin and of the network's own coin: wei, lamports or the token's base units. EVM addresses also carry the next `nonce`, counting transactions still pending.
- `speeds` (array): Slowest first, each with about how many `minutes` it takes. EVM speeds give `max_fee_per_gas` and `max_priority_fee_per_gas` in wei per gas, and Solana speeds give `compute_unit_price` in micro-lamports per compute unit.
- `gas_limits` (object): EVM only: the gas for a transfer of the network's coin, a token transfer, and a token transfer that another of the wallet's addresses submits under an EIP-3009 authorization. `base_fee_per_gas` gives the expected fee, and `l1_fee_reserve` is what a rollup charges per transaction apart from gas, in wei, `"0"` elsewhere.
- `recent_blockhash` (string): Solana only: the blockhash to sign with. The transaction must land by `last_valid_block_height`, so read a fresh context if the send waits. `recipient_token_account_exists` says whether `to_address` already has an account for the token, or is `null` for SOL.
### Errors
- 400 `invalid_request` A parameter is missing, the coin isn't on the network, or sends on the network aren't signed in the browser yet. `field` names it.
- 400 `invalid_address` `to_address` isn't valid for the network.
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` Your business has no wallet yet.
- 409 `external_wallet` The wallet keeps its own keys, so its sends happen in its own app.
- 503 `network_unavailable` The network couldn't be reached. Try again in a moment.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/wallet/send-context?network=polygon&asset=USDC&to_address=0x136e44a2738dea5217e8d6745463d2a9a84d1421", {
headers: {
"402pay-Business": businessId,
},
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"kind": "send_context",
"family": "evm",
"network": "polygon",
"asset": "USDC",
"chain_id": 137,
"base_fee_per_gas": "857142857143",
"gas_limits": {
"native_transfer": "21000",
"token_transfer": "80000",
"token_transfer_with_authorization": "100000"
},
"l1_fee_reserve": "0",
"speeds": [
{
"speed": "slow",
"minutes": 3,
"max_fee_per_gas": "1904761904762",
"max_priority_fee_per_gas": "190476190476"
},
{
"speed": "standard",
"minutes": 2,
"max_fee_per_gas": "3809523809524",
"max_priority_fee_per_gas": "380952380952"
},
{
"speed": "fast",
"minutes": 1,
"max_fee_per_gas": "7619047619048",
"max_priority_fee_per_gas": "761904761904"
}
],
"addresses": [
{
"address": "0x2A845e4D5e88c78112A719a6B20E71320A88EF86",
"index": 0,
"balance": "0",
"native_balance": "0",
"nonce": 0
},
{
"address": "0x6F00bCaA08D1A6De8Ce7724dd643823c978bc81F",
"index": 1,
"balance": "49000000",
"native_balance": "0",
"nonce": 0
},
{
"address": "0xfb8417FbE62ddD1A7992e40Cb42215f02eAd1b37",
"index": 2,
"balance": "0",
"native_balance": "10000000000000000000",
"nonce": 0
}
]
}
}
```
# Send from the wallet
Source: https://developer.402pay.co/api/wallet/transactions/create
Relay a send the dashboard signed in the browser, or record one made in an external wallet's app.
`POST https://dash.402pay.co/api/v1/wallet/transactions`
Sends coins from your wallet, one chain transaction per request. The dashboard makes these requests when someone sends from Wallet, so it takes a person signed in there, never an API key, which gets 403 `session_required`. A send that draws on several of the wallet's addresses is several requests, each with its own `Idempotency-Key`.
From a wallet created or imported in 402pay, the browser reads the [send context](https://developer.402pay.co/api/wallet/send-context.md), signs each transaction with the recovery phrase, and sends it as `signed_transaction`. 402pay checks that it pays exactly `amount` of `asset` to `to_address` from one of the wallet's addresses, records it under its hash and relays it to the network. The phrase and the encryption password never leave the browser. Before you sign, the dashboard shows the most the network fees can cost; if they rose by the time you sign, it shows the new most and asks again. A USDC send from an address with no gas goes by an EIP-3009 authorization that another of the wallet's addresses submits and pays for, as in the example. Sends are signed on Solana, Polygon and Ethereum.
From an external wallet, send in its own app, then record the send here with its `tx_hash`. The hash starts an unverified record: it does not reserve or deduct funds, and `value_usd` is zero until the chain proves the sender, recipient, asset and amount match. A mismatch fails the record. `network_fee_usd` stays zero because these records have no verified fee amount.
The transaction is `pending` until the network confirms it, then `confirmed`, or `failed` if it never lands. Follow it with [`GET /wallet/transactions/{id}`](https://developer.402pay.co/api/wallet/transactions/retrieve.md). A simulated business broadcasts nothing. Signed sends confirm on a timer; external hash records fail on that timer because the simulation has no chain evidence to verify them.
### Body
- `asset` (string, required): The coin, such as `USDC`.
- `network` (string, required): The network, such as `polygon`.
- `amount` (string, required): What this transaction pays `to_address`, as a decimal string in the coin, such as `"25.00"`, with no more [decimal places](https://developer.402pay.co/api/currencies.md#coin-amounts) than the coin's. An external wallet's record states exactly what its app sent, to as many decimal places as the coin counts on chain: USDC 6, USDT 6, BTC 8, ETH 18 and SOL 9.
- `to_address` (string, required): The recipient's address on the network.
- `speed` (string): `slow`, `standard` or `fast`, as signed. Defaults to `standard`.
- `note` (string): Up to 280 characters for your team, such as what the send was for.
- `signed_transaction` (string): Required from a wallet created or imported in 402pay. EVM: the `0x` hex of the signed EIP-1559 transaction. Solana: the base64 wire transaction.
- `funding_transaction` (string): EVM only: a signed transaction from another of the wallet's addresses that sends the gas `signed_transaction` needs, relayed first. The dashboard adds it for a token with no authorization, such as USDT on Ethereum, at an address without the network's own coin.
- `tx_hash` (string): Required from an external wallet: the hash of the send made in its own app. A hex hash is recorded in lower case.
### Errors
- 400 `idempotency_key_required` The request has no `Idempotency-Key` header.
- 400 `invalid_request` A field is missing or invalid, `signed_transaction` doesn't pay exactly `amount` to `to_address` from one of the wallet's addresses, or a field is for the other kind of wallet, such as `tx_hash` from a wallet created in 402pay. `field` names it.
- 400 `invalid_address` `to_address` isn't valid for the network, or is one of the wallet's own addresses.
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` Your business has no wallet yet.
- 409 `insufficient_funds` The send is for more than the address holds.
- 409 `insufficient_fee_funds` The wallet doesn't hold enough of the network's own coin to pay the fee.
- 409 `duplicate_transaction` That transaction was already sent: its hash is on the wallet's history, or another business recorded it from the same address.
- 409 `transaction_rejected` The network refused it, such as when another transaction from the address got there first or the fee is too low right now. Nothing was sent, so read a fresh [send context](https://developer.402pay.co/api/wallet/send-context.md) and sign again.
- 503 `network_unavailable` The network couldn't be reached. Retry with the same `Idempotency-Key`: the same signed transaction never goes out twice.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/wallet/transactions", {
method: "POST",
headers: {
"402pay-Business": businessId,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
asset: "USDC",
network: "polygon",
amount: "25.00",
to_address: "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
speed: "standard",
note: "Refund for order 1042.",
signed_transaction: "0x"
}),
});
const { data } = await response.json();
```
Response, 201 Created:
```json
{
"data": {
"id": "wtx_XJPPvt18plFGNmuW",
"kind": "wallet_transaction",
"direction": "out",
"status": "pending",
"asset": "USDC",
"network": "polygon",
"amount": "25.00",
"value_usd": 2500,
"network_fee_usd": 2,
"address": "0x6F00bCaA08D1A6De8Ce7724dd643823c978bc81F",
"from_address": "0x6F00bCaA08D1A6De8Ce7724dd643823c978bc81F",
"to_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
"tx_hash": "0xcb6a65a380c1deecb982539aed5ca831acf85b94cfee5dc624cd34451cba0538",
"explorer_url": "https://polygonscan.com/tx/0xcb6a65a380c1deecb982539aed5ca831acf85b94cfee5dc624cd34451cba0538",
"payment_id": null,
"note": "Refund for order 1042.",
"created_at": "2026-09-29T09:37:20.808Z"
}
}
```
# Delete the wallet
Source: https://developer.402pay.co/api/wallet/delete
Remove your wallet, right away during setup or after a 24-hour hold once you've been paid.
`DELETE https://dash.402pay.co/api/v1/wallet`
Removes your wallet, so checkout has nowhere to send payments until you add a new one. It takes a person signed in to the dashboard who confirmed their password or a passkey in the last 15 minutes, never an API key. Funds stay at the wallet's addresses on-chain, and its recovery phrase still controls them.
Once your business has received a payment (one collected as fees, or a referral payout, counts too), the deletion waits 24 hours: the answer is 202 with the pending change, and the wallet keeps receiving payments until `effective_at`. We email the owner a link that cancels it, which works without signing in, and [canceling it](https://developer.402pay.co/api/wallet/changes/cancel.md) from the dashboard works too. `GET /wallet` shows it as `pending_change` until then. A business still being set up loses its wallet right away, with 200.
Changing the wallet's encryption password isn't held: the phrase and its addresses stay the same.
### Errors
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 403 `step_up_required` The session hasn't proven its password or a passkey in the last 15 minutes. Confirm it at `POST /sessions/current/step-up` and send the request again.
- 404 `not_found` Your business has no wallet yet.
- 409 `wallet_change_pending` The wallet is already set to be deleted. Cancel the deletion first, or wait for it.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/wallet", {
method: "DELETE",
headers: {
"402pay-Business": businessId,
},
});
const { data } = await response.json();
```
Response, 202 Accepted:
```json
{
"data": {
"id": "wcr_7QmVx2LpT9sKd4Rz",
"kind": "wallet_change",
"change": "delete",
"status": "pending",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"effective_at": "2026-09-29T14:05:09.123Z",
"canceled_at": null,
"created_at": "2026-09-28T14:05:09.123Z"
}
}
```
Response, 200 OK:
```json
{
"data": {
"id": "wal_1fIGZOrILmsCO0jw",
"kind": "wallet",
"deleted": true
}
}
```
# Cancel a wallet change
Source: https://developer.402pay.co/api/wallet/changes/cancel
Stop a wallet deletion while it's still waiting out the hold.
`POST https://dash.402pay.co/api/v1/wallet/changes/{id}/cancel`
Stops a deletion that's waiting out its hold, so your wallet stays and payments keep going to it. It takes a person signed in to the dashboard, but no step-up, since nothing about where payments go changes. Canceling one that's already canceled returns it as it is.
### Path
- `id` (string, required): The wallet change's ID, such as `wcr_7QmVx2LpT9sKd4Rz`.
### Errors
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` No wallet change of your business has that ID.
- 409 `wallet_change_applied` The hold is over and the change took effect, so there's nothing to cancel.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/wallet/changes/wcr_7QmVx2LpT9sKd4Rz/cancel", {
method: "POST",
headers: {
"402pay-Business": businessId,
},
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"id": "wcr_7QmVx2LpT9sKd4Rz",
"kind": "wallet_change",
"change": "delete",
"status": "canceled",
"wallet_id": "wal_1fIGZOrILmsCO0jw",
"effective_at": "2026-09-29T14:05:09.123Z",
"canceled_at": "2026-09-28T16:41:37.502Z",
"created_at": "2026-09-28T14:05:09.123Z"
}
}
```
# Retrieve a business
Source: https://developer.402pay.co/api/businesses/retrieve
Get your business's profile, and where its email alerts go.
`GET https://dash.402pay.co/api/v1/businesses/{id}`
Your business's profile, and where its email alerts go. It takes a person signed in to the dashboard, never an API key.
### Path
- `id` (string, required): The business's ID, such as `biz_Nq4sT8vWx2Lm6Rb1`.
### Fields to know
- `status` (string): `incomplete` until setup finishes, which takes a wallet, then `active`.
- `suspended_at` (timestamp): Set while 402pay has suspended the business: checkout takes no new payments, though ones already open can finish.
- `alert_email` (string): Where payment alerts and the weekly summary go: your sign-in email, or another address once its code is confirmed. `null` means your sign-in email too.
- `pending_alert_email` (string): An address waiting for its code, or `null`. It gets nothing but the code until someone [confirms it](https://developer.402pay.co/api/businesses/alert-email/confirm.md).
- `email_alerts` (boolean): `false` pauses every alert email for this business.
### Errors
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` You don't own a business with that ID.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/businesses/biz_Nq4sT8vWx2Lm6Rb1", {
headers: {
"402pay-Business": businessId,
},
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"id": "biz_Nq4sT8vWx2Lm6Rb1",
"kind": "business",
"name": "Acme Studio",
"website": "https://example.com",
"support_email": "billing@example.com",
"alert_email": "payments@example.com",
"pending_alert_email": "alerts@example.com",
"status": "active",
"suspended_at": null,
"email_alerts": true,
"product_updates": false,
"created_at": "2026-05-09T21:18:42.216Z",
"updated_at": "2026-09-24T10:02:51.338Z"
}
}
```
# Update a business
Source: https://developer.402pay.co/api/businesses/update
Change your business's name, website and emails, and turn alert emails on or off.
`PATCH https://dash.402pay.co/api/v1/businesses/{id}`
Send only what changes. It takes a person signed in to the dashboard, never an API key. You receive `business.updated` when the profile, as it comes back, changes.
A new `alert_email` doesn't take the alerts right away: it becomes `pending_alert_email` and gets a 6-digit code, and alerts keep going to `alert_email` until the code is [confirmed](https://developer.402pay.co/api/businesses/alert-email/confirm.md). Sending another address replaces the one waiting, with a new code. `null`, your own sign-in email and the address alerts already go to apply at once, and drop any address waiting.
### Path
- `id` (string, required): The business's ID, such as `biz_Nq4sT8vWx2Lm6Rb1`.
### Body
- `name` (string): 2 to 60 characters.
- `website` (string): An `https://` URL, or `null` to clear it.
- `support_email` (string): Shown on receipts so customers can reach you, or `null` to clear it.
- `alert_email` (string): Where payment alerts should go once its code is confirmed. `null` sends them to your sign-in email.
- `email_alerts` (boolean): Whether alert emails go out at all.
- `product_updates` (boolean): Whether 402pay emails you about new features.
### Errors
- 400 `invalid_request` A field is unknown or has a value it can't take, such as an `alert_email` that isn't an email address. `field` names it.
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` You don't own a business with that ID.
- 429 `too_many_emails` You started 3 new alert addresses in the last hour, across all your businesses and counting ones since replaced or canceled, or the new `alert_email` has had as many emails as it can get for now, 5 an hour and 20 a day. Nothing in the request changed. `Retry-After` says when to try again.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/businesses/biz_Nq4sT8vWx2Lm6Rb1", {
method: "PATCH",
headers: {
"402pay-Business": businessId,
"Content-Type": "application/json",
},
body: JSON.stringify({
alert_email: "alerts@example.com"
}),
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"id": "biz_Nq4sT8vWx2Lm6Rb1",
"kind": "business",
"name": "Acme Studio",
"website": "https://example.com",
"support_email": "billing@example.com",
"alert_email": "payments@example.com",
"pending_alert_email": "alerts@example.com",
"status": "active",
"suspended_at": null,
"email_alerts": true,
"product_updates": false,
"created_at": "2026-05-09T21:18:42.216Z",
"updated_at": "2026-09-24T10:02:51.338Z"
}
}
```
# Confirm an alert email
Source: https://developer.402pay.co/api/businesses/alert-email/confirm
Enter the code sent to a new alert address, so payment alerts go there.
`POST https://dash.402pay.co/api/v1/businesses/{id}/alert-email/confirm`
Moves your alerts to `pending_alert_email` with the code emailed there, which shows the address reaches you. Alerts go there from now on, `pending_alert_email` goes back to `null`, and you receive `business.updated`. It takes a person signed in to the dashboard, never an API key.
### Path
- `id` (string, required): The business's ID, such as `biz_Nq4sT8vWx2Lm6Rb1`.
### Body
- `code` (string, required): The 6-digit code from the email. Spaces don't count.
### Errors
- 400 `invalid_code` The code is wrong, or isn't 6 digits. `field` is `code`.
- 400 `code_expired` The code is more than 15 minutes old. Send a new one.
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` You don't own a business with that ID.
- 409 `verification_expired` No alert address is waiting: it was confirmed, canceled or replaced, or it's more than 24 hours old. Enter it again with [`PATCH /businesses/{id}`](https://developer.402pay.co/api/businesses/update.md).
- 429 `too_many_attempts` 5 wrong tries used up the code. Send a new one.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/businesses/biz_Nq4sT8vWx2Lm6Rb1/alert-email/confirm", {
method: "POST",
headers: {
"402pay-Business": businessId,
"Content-Type": "application/json",
},
body: JSON.stringify({
code: "305817"
}),
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"id": "biz_Nq4sT8vWx2Lm6Rb1",
"kind": "business",
"name": "Acme Studio",
"website": "https://example.com",
"support_email": "billing@example.com",
"alert_email": "alerts@example.com",
"pending_alert_email": null,
"status": "active",
"suspended_at": null,
"email_alerts": true,
"product_updates": false,
"created_at": "2026-05-09T21:18:42.216Z",
"updated_at": "2026-09-28T14:07:33.905Z"
}
}
```
# Resend an alert email code
Source: https://developer.402pay.co/api/businesses/alert-email/resend
Send a new code to the alert address waiting for one.
`POST https://dash.402pay.co/api/v1/businesses/{id}/alert-email/resend`
Emails a new code to `pending_alert_email`, and the one before stops working. It takes a person signed in to the dashboard, never an API key.
### Path
- `id` (string, required): The business's ID, such as `biz_Nq4sT8vWx2Lm6Rb1`.
### Errors
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` You don't own a business with that ID.
- 409 `verification_expired` No alert address is waiting: it was confirmed, canceled or replaced, or it's more than 24 hours old. Enter it again with [`PATCH /businesses/{id}`](https://developer.402pay.co/api/businesses/update.md).
- 429 `too_many_emails` A code went out less than 1 minute ago, the address has had 5 codes since it was entered, or it has had as many emails as it can get for now. `Retry-After` says when to try again.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/businesses/biz_Nq4sT8vWx2Lm6Rb1/alert-email/resend", {
method: "POST",
headers: {
"402pay-Business": businessId,
},
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"id": "biz_Nq4sT8vWx2Lm6Rb1",
"kind": "business",
"name": "Acme Studio",
"website": "https://example.com",
"support_email": "billing@example.com",
"alert_email": "payments@example.com",
"pending_alert_email": "alerts@example.com",
"status": "active",
"suspended_at": null,
"email_alerts": true,
"product_updates": false,
"created_at": "2026-05-09T21:18:42.216Z",
"updated_at": "2026-09-24T10:02:51.338Z"
}
}
```
# Cancel an alert email change
Source: https://developer.402pay.co/api/businesses/alert-email/cancel
Drop the alert address waiting for its code, so alerts keep going where they go now.
`POST https://dash.402pay.co/api/v1/businesses/{id}/alert-email/cancel`
Drops `pending_alert_email` and its code, so alerts keep going to `alert_email`. With nothing waiting, it returns the business as it is. It takes a person signed in to the dashboard, never an API key.
### Path
- `id` (string, required): The business's ID, such as `biz_Nq4sT8vWx2Lm6Rb1`.
### Errors
- 403 `session_required` An API key sent the request. Only a person signed in to the dashboard can send.
- 404 `not_found` You don't own a business with that ID.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, Browser:
```js
// On a dashboard page, whose session cookie the browser sends.
const response = await fetch("/api/v1/businesses/biz_Nq4sT8vWx2Lm6Rb1/alert-email/cancel", {
method: "POST",
headers: {
"402pay-Business": businessId,
},
});
const { data } = await response.json();
```
Response, 200 OK:
```json
{
"data": {
"id": "biz_Nq4sT8vWx2Lm6Rb1",
"kind": "business",
"name": "Acme Studio",
"website": "https://example.com",
"support_email": "billing@example.com",
"alert_email": "payments@example.com",
"pending_alert_email": null,
"status": "active",
"suspended_at": null,
"email_alerts": true,
"product_updates": false,
"created_at": "2026-05-09T21:18:42.216Z",
"updated_at": "2026-09-24T10:02:51.338Z"
}
}
```
# Retrieve checkout settings
Source: https://developer.402pay.co/api/settings/retrieve
Get the coins, networks and rails checkout offers, and how it quotes and redirects.
`GET https://dash.402pay.co/api/v1/settings/checkout`
The settings every hosted checkout of your business uses, for links and API payments alike.
### Fields to know
- `accepted_assets` (array): Every coin and network pair, each with `enabled`, your choice, and `offered`, whether checkout can take it right now. Checkout offers the pairs that are turned on, offered, and on a network your wallet can receive on.
- `card_available` (boolean): Whether card payments can be taken for your business. While it's `false`, checkout offers crypto only, even with `card` in `accepted_rails`.
- `accepted_rails` (array): `crypto`, which is always on, and `card` while card payments are on. Card payments arrive in a coin and network your wallet can receive and your business accepts.
- `fee_payer` (string): Who pays 402pay's fees: `business`, billed to you as usual, or `customer`, added to each customer's total at checkout. Payments you create through the API follow it unless they set their own. See [passing fees on](https://developer.402pay.co/guides/reconciliation.md#passing-fees-on).
- `quote_minutes` (integer): How long a crypto checkout holds its address, amount and rate.
- `underpayment_tolerance_pct` (number): How far short, in percent, a transfer can be and still count as paid in full.
- `success_redirect_url` (string): Where checkout sends the customer after paying, while `success_redirect_enabled` is `true`, when the link or payment has no `success_url` of its own. Otherwise they see the receipt.
- `collect_email` (boolean): Whether checkout asks crypto customers for an email. Card payments always need one.
### Errors
Only the errors any request can return. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/settings/checkout" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/settings/checkout", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/settings/checkout",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "checkout_settings",
"accepted_assets": [
{
"asset": "USDC",
"network": "polygon",
"enabled": true,
"offered": true
},
{
"asset": "USDC",
"network": "ethereum",
"enabled": true,
"offered": true
},
{
"asset": "USDC",
"network": "solana",
"enabled": true,
"offered": true
},
{
"asset": "USDT",
"network": "tron",
"enabled": true,
"offered": true
},
{
"asset": "USDT",
"network": "ethereum",
"enabled": true,
"offered": true
},
{
"asset": "USDT",
"network": "solana",
"enabled": true,
"offered": true
},
{
"asset": "BTC",
"network": "bitcoin",
"enabled": true,
"offered": true
},
{
"asset": "ETH",
"network": "ethereum",
"enabled": true,
"offered": true
},
{
"asset": "SOL",
"network": "solana",
"enabled": true,
"offered": true
}
],
"accepted_rails": ["crypto", "card"],
"fee_payer": "business",
"quote_minutes": 15,
"underpayment_tolerance_pct": 0.5,
"success_redirect_enabled": false,
"success_redirect_url": null,
"collect_email": true,
"card_available": true
}
}
```
# Update checkout settings
Source: https://developer.402pay.co/api/settings/update
Turn coins and card payments on or off, and change the quote window, tolerance and redirect.
`PATCH https://dash.402pay.co/api/v1/settings/checkout`
Send only what changes. New settings apply to checkouts opened afterward: a quote that's already open keeps its address, amount and expiry. You receive `checkout_settings.updated`.
### Body
- `accepted_assets` (array): Pairs to turn on or off, each `{ asset, network, enabled }`. Pairs you leave out keep their setting, and at least one must stay on. See [supported networks](https://developer.402pay.co/guides/networks.md).
- `accepted_rails` (array): Must include `crypto`. Add `card` to take card payments, or leave it out to stop.
- `fee_payer` (string): `business` or `customer`: who pays 402pay's fees. With `customer`, checkout adds them to each customer's total.
- `quote_minutes` (integer): 5, 10, 15, 30 or 60.
- `underpayment_tolerance_pct` (number): 0, 0.5, 1, 2 or 5.
- `success_redirect_enabled` (boolean): Whether to send customers to `success_redirect_url` after paying.
- `success_redirect_url` (string): An `https://` URL, or `null` to clear it.
- `collect_email` (boolean): Whether checkout asks crypto customers for an email.
### Errors
- 400 `invalid_request` A field is unknown or has a value it can't take, such as `accepted_rails` without `crypto`. `field` names it.
- 403 `test_mode_unavailable` A test key can't change checkout settings on a live business, so use a live key.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl -X PATCH "https://dash.402pay.co/api/v1/settings/checkout" \
-H "Authorization: Bearer $PAY402_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"accepted_assets": [
{
"asset": "BTC",
"network": "bitcoin",
"enabled": true
}
],
"quote_minutes": 15
}'
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/settings/checkout", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accepted_assets: [
{
asset: "BTC",
network: "bitcoin",
enabled: true
}
],
quote_minutes: 15
}),
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.patch(
"https://dash.402pay.co/api/v1/settings/checkout",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
json={
"accepted_assets": [
{
"asset": "BTC",
"network": "bitcoin",
"enabled": True
}
],
"quote_minutes": 15
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "checkout_settings",
"accepted_assets": [
{
"asset": "USDC",
"network": "polygon",
"enabled": true,
"offered": true
},
{
"asset": "USDC",
"network": "ethereum",
"enabled": true,
"offered": true
},
{
"asset": "USDC",
"network": "solana",
"enabled": true,
"offered": true
},
{
"asset": "USDT",
"network": "tron",
"enabled": true,
"offered": true
},
{
"asset": "USDT",
"network": "ethereum",
"enabled": true,
"offered": true
},
{
"asset": "USDT",
"network": "solana",
"enabled": true,
"offered": true
},
{
"asset": "BTC",
"network": "bitcoin",
"enabled": true,
"offered": true
},
{
"asset": "ETH",
"network": "ethereum",
"enabled": true,
"offered": true
},
{
"asset": "SOL",
"network": "solana",
"enabled": true,
"offered": true
}
],
"accepted_rails": ["crypto", "card"],
"fee_payer": "business",
"quote_minutes": 15,
"underpayment_tolerance_pct": 0.5,
"success_redirect_enabled": false,
"success_redirect_url": null,
"collect_email": true,
"card_available": true
}
}
```
# Retrieve the fee statement
Source: https://developer.402pay.co/api/billing/fees
Get what your business owes 402pay in fees, what's on its way to 402pay, and this month's totals.
`GET https://dash.402pay.co/api/v1/billing/fees`
What your business owes 402pay in fees, in US cents. Fees accrue as payments succeed and as your wallet plan renews each month, and 402pay collects them by sending whole payments to its own address once you owe $25.00. See [fees and billing](https://developer.402pay.co/guides/fees.md).
Reading the statement first charges any wallet-plan month that came due, so it's never behind. A restricted key needs read access to payments.
### Fields
- `owed` (integer): What you owe now: `accrued` less `collected`, plus `adjusted`. Below zero it's credit against future fees, from an overpayment or a credit 402pay gave.
- `reserved` (integer): What's on its way to 402pay's address and counts against what you owe: a checkout's whole total once its payment arrives or its card is approved, and what arrived of an underpaid one. A checkout nobody has paid yet holds nothing.
- `accrued` (integer): Every payment fee and wallet-plan month, all time.
- `collected` (integer): What reached 402pay's addresses, all time.
- `adjusted` (integer): Adjustments 402pay made, signed: positive raised what you owe, negative was a credit.
- `this_month` (object): `period`, the current UTC month as `YYYY-MM`, with what accrued and was collected in it.
- `collection` (object): `enabled` is whether 402pay collects from this business, `threshold` what you have to owe before it does, and `active` whether it's collecting now.
### Errors
- 403 `permission_denied` A restricted key without read access to payments.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/billing/fees" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/billing/fees", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/billing/fees",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "fee_statement",
"currency": "USD",
"owed": 3140,
"reserved": 2500,
"accrued": 10250,
"collected": 7110,
"adjusted": 0,
"this_month": {
"period": "2026-09",
"accrued": 1200,
"collected": 2500
},
"collection": {
"enabled": true,
"threshold": 2500,
"active": true
}
}
}
```
# List fee entries
Source: https://developer.402pay.co/api/billing/fees/entries
List every fee, collection and adjustment behind your fee statement, newest first.
`GET https://dash.402pay.co/api/v1/billing/fees/entries`
Every fee, collection and adjustment behind your statement, newest first. `amount` is signed from your side: a fee adds to what you owe and a collection pays it down. `balance` is what you owed right after the entry.
### Entry types
- `payment_fee` (value): A successful payment's fee, its rate plus the flat fee, whoever paid it. `payment_id` names it.
- `wallet_plan` (value): A month of your wallet plan. `wallet_id` and `plan_month`, from 1, name it.
- `collection` (value): A payment's funds that went to 402pay. `collection` holds the coin, network, amount, 402pay's address and the transfer.
- `adjustment` (value): A change 402pay made, with its `note` for you when there is one.
### Query
- `type` (string): `payment_fee`, `wallet_plan`, `collection` or `adjustment`.
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` `type` has a value it can't take, or the `cursor` belongs to another list.
- 403 `permission_denied` A restricted key without read access to payments.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/billing/fees/entries?type=collection&limit=1" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/billing/fees/entries?type=collection&limit=1", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/billing/fees/entries?type=collection&limit=1",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "fee_Eor0UAv0Ama0Pcnu",
"kind": "fee_entry",
"type": "collection",
"amount": -2500,
"currency": "USD",
"balance": 640,
"payment_id": "pmt_IEizRuSvGtJU0tPQ",
"checkout_id": "chk_Hq9bLRPRqpYqhWv6",
"wallet_id": null,
"plan_month": null,
"collection": {
"asset": "USDC",
"network": "solana",
"amount": "25.00",
"address": "7DmM84gY7ZokTyQcKydWauBdvwithGVG1pgDH44qWTG8",
"tx_hash": "a1ot1F4sVTnAMfnC2UwnMCxBkLGNrfBGuHsx2GAW5qFNTkSXMZdZ5SYjxToTw9ct5JvjzxixQbV18Y4ZcvAe7oE",
"explorer_url": "https://solscan.io/tx/a1ot1F4sVTnAMfnC2UwnMCxBkLGNrfBGuHsx2GAW5qFNTkSXMZdZ5SYjxToTw9ct5JvjzxixQbV18Y4ZcvAe7oE"
},
"note": null,
"created_at": "2026-09-29T12:00:00.000Z"
}
],
"has_more": true,
"next_cursor": "fee_Eor0UAv0Ama0Pcnu"
}
```
# Retrieve metrics
Source: https://developer.402pay.co/api/metrics
Get revenue, payment counts, new customers and approval rate over time, with the period before.
`GET https://dash.402pay.co/api/v1/metrics`
The numbers on the dashboard's home page, for any range. Each metric has a `total`, a `series` with one value per bucket in `buckets`, and the same for the period just before in `previous_total` and `previous_series`. `change_pct` compares the two, and is `null` without a comparison or when the previous total is zero.
Payments count in the bucket they were created in. Money is in US cents, and `range.end` is exclusive. A restricted key needs read access to payments.
### Metrics
- `revenue` (currency): What succeeded payments were worth.
- `net_revenue` (currency): Revenue plus fees customers paid, less 402pay's rate and flat fee on each payment.
- `successful_payments` (count): Payments that succeeded.
- `new_customers` (count): Customers whose first successful payment falls in the bucket.
- `average_payment` (currency): Revenue divided by successful payments.
- `approval_rate` (percent): The share of payments with an outcome that didn't fail, from 0 to 100. `pending` and `expired` payments never reached one, so they don't count.
### Query
- `period` (string): 24h, 3d, 7d, 30d, 4w, 3m, 12m, ytd or all, counted back from today. `all` starts at your first payment. Defaults to `30d`.
- `from` (date): A calendar date such as `2026-09-24`, sent with `to` instead of `period`.
- `to` (date): The last day of the range, included.
- `timezone` (string): An IANA time zone, such as `America/New_York`, that days and buckets follow. Defaults to `UTC`.
- `interval` (string): The bucket size: hour, day, week or month. Defaults to `hour` for `24h` and `day` otherwise.
- `compare` (string): `previous_period`, the default, or `none`, which leaves the previous fields `null`.
### Errors
- 400 `invalid_request` A parameter has a value it can't take, `period` was sent with `from` and `to`, or the range needs more than 1,000 buckets at the interval.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/metrics?from=2026-09-24&to=2026-09-26" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/metrics?from=2026-09-24&to=2026-09-26", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/metrics?from=2026-09-24&to=2026-09-26",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "metrics",
"currency": "USD",
"timezone": "UTC",
"interval": "day",
"period": null,
"range": {
"start": "2026-09-24T00:00:00.000Z",
"end": "2026-09-27T00:00:00.000Z"
},
"compare_range": {
"start": "2026-09-21T00:00:00.000Z",
"end": "2026-09-24T00:00:00.000Z"
},
"buckets": [
"2026-09-24T00:00:00.000Z",
"2026-09-25T00:00:00.000Z",
"2026-09-26T00:00:00.000Z"
],
"metrics": {
"revenue": {
"unit": "currency",
"total": 184900,
"series": [17400, 97500, 70000],
"previous_total": 303000,
"previous_series": [39800, 34500, 228700],
"change_pct": -39
},
"net_revenue": {
"unit": "currency",
"total": 181944,
"series": [17072, 95853, 69019],
"previous_total": 300937,
"previous_series": [39375, 33909, 227653],
"change_pct": -39.5
},
"successful_payments": {
"unit": "count",
"total": 22,
"series": [4, 9, 9],
"previous_total": 17,
"previous_series": [5, 6, 6],
"change_pct": 29.4
},
"new_customers": {
"unit": "count",
"total": 3,
"series": [0, 0, 3],
"previous_total": 4,
"previous_series": [0, 0, 4],
"change_pct": -25
},
"average_payment": {
"unit": "currency",
"total": 8405,
"series": [4350, 10833, 7778],
"previous_total": 17824,
"previous_series": [7960, 5750, 38117],
"change_pct": -52.8
},
"approval_rate": {
"unit": "percent",
"total": 95.7,
"series": [80, 100, 100],
"previous_total": 89.5,
"previous_series": [100, 85.7, 85.7],
"change_pct": 6.9
}
}
}
}
```
# Search
Source: https://developer.402pay.co/api/search
Find payments, customers and links with one query, best matches first.
`GET https://dash.402pay.co/api/v1/search`
One query across payments, customers and links, the way the dashboard's search works. Each result is the object itself, as its own endpoint returns it, so tell them apart by `kind`.
An exact ID, code, email, hash or address ranks first, then a word that starts with the query, then a match anywhere. Ties put links first, then customers, then payments, newest first within each. A restricted key searches only what it can read.
### Query
- `q` (string, required): Up to 100 characters. Payments match on their ID, checkout ID, reference, metadata values, transaction hash, sender address, description, customer name and email, and amount. Customers match on their ID, email and name, and links on their ID, code, name, description and price.
- `kind` (string): `payment`, `customer`, `link` or several, comma separated. Defaults to every kind the key can read.
- `include` (string): `customer` adds each payment's customer, as on [list payments](https://developer.402pay.co/api/payments/list.md).
- `limit` (integer): Page size, from 1 to 100. Defaults to 25.
- `cursor` (string): The `next_cursor` from the previous page. See [pagination](https://developer.402pay.co/api/pagination.md).
### Errors
- 400 `invalid_request` `q` is missing or too long, `kind` or `include` has a value it can't take, or the `cursor` belongs to another list.
- 403 `permission_denied` `kind` names something a restricted key can't read, `include=customer` was sent without read access to customers, or the key can read none of the three.
Any request can also fail on its key or its body. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/search?q=harper&limit=1" \
-H "Authorization: Bearer $PAY402_SECRET_KEY"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/search?q=harper&limit=1", {
headers: {
Authorization: `Bearer ${process.env.PAY402_SECRET_KEY}`,
},
});
const { data } = await response.json();
```
Request, Python:
```python
import os
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/search?q=harper&limit=1",
headers={
"Authorization": f"Bearer {os.environ['PAY402_SECRET_KEY']}",
},
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": [
{
"id": "cst_gWFWcc7Ga0Pv7LSC",
"kind": "customer",
"name": "Harper Wilson",
"email": "harper.wilson@example.com",
"blocked": false,
"note": "",
"stats": {
"payments_count": 2,
"incomplete_count": 2,
"volume": {
"amount": 22810,
"currency": "USD"
},
"average": {
"amount": 11405,
"currency": "USD"
},
"last_payment_at": "2026-09-26T21:22:47.082Z",
"preferred_rail": "crypto"
},
"created_at": "2026-09-26T21:01:16.809Z",
"updated_at": "2026-09-26T21:01:16.809Z"
}
],
"has_more": true,
"next_cursor": "cst_gWFWcc7Ga0Pv7LSC"
}
```
# Retrieve exchange rates
Source: https://developer.402pay.co/api/exchange-rates
Get the coin prices and currency rates checkout quotes with.
`GET https://dash.402pay.co/api/v1/exchange-rates`
Public. Each coin's price is in US cents, and each currency's value is in US dollars.
### Errors
Only the errors any request can return. See [errors](https://developer.402pay.co/api/errors.md).
Request, cURL:
```bash
curl "https://dash.402pay.co/api/v1/exchange-rates"
```
Request, Node.js:
```js
const response = await fetch("https://dash.402pay.co/api/v1/exchange-rates");
const { data } = await response.json();
```
Request, Python:
```python
import requests
response = requests.get(
"https://dash.402pay.co/api/v1/exchange-rates",
)
data = response.json()["data"]
```
Response, 200 OK:
```json
{
"data": {
"kind": "exchange_rates",
"coins": {
"USDC": {
"price_usd": 100
},
"USDT": {
"price_usd": 100
},
"BTC": {
"price_usd": 9840000
},
"ETH": {
"price_usd": 362000
},
"SOL": {
"price_usd": 18400
}
},
"fiat": {
"USD": {
"usd_rate": 1
},
"EUR": {
"usd_rate": 1.08
},
"GBP": {
"usd_rate": 1.27
},
"CAD": {
"usd_rate": 0.73
},
"AUD": {
"usd_rate": 0.66
}
},
"created_at": "2026-09-26T21:23:17.323Z"
}
}
```
# Changelog
Source: https://developer.402pay.co/changelog
What changed in the API, webhooks, checkout and these docs.
The first-release API scope is below. Future changes appear newest first; changes an existing integration must act on are marked Breaking.
September 30, 2026
## API v1 initial release
API Webhooks Checkout Docs
The initial API at `/api/v1` covers payments, hosted checkouts, payment links, customers, signed webhooks, wallets and fee statements. The reference documents the supported networks, authentication, test environments and payment lifecycle. Start with [your first payment](https://developer.402pay.co/quickstart.md) and review the [going-live checklist](https://developer.402pay.co/going-live.md) before accepting real payments.