Fulfill orders reliably
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.
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" }'- 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} 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.
curl -X POST "https://dash.402pay.co/api/v1/payments/pmt_cQL3ct8r16YeUw4l/cancel" \ -H "Authorization: Bearer $PAY402_SECRET_KEY"- Canceling closes any checkout the customer has open for it, and you receive
payment.failedwithcanceled_atset. Canceling twice returns the payment as it is. - It returns 409
payment_in_flightwhile a transfer is on its way, and 409payment_not_cancelableonce funds have arrived. Wait for the payment to finish, then decide what to do with the difference. expires_atsets 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 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.
// 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.succeededalso 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_receivedcan be less thanamount, so compare them if a part payment matters to you.- An overpayment sends
payment.succeeded, thenpayment.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.
// 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.
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"A restricted key needs read access to events and to payments for this. See least-privilege keys.
Next steps
- Framework recipes Create payments, handle the return and verify webhooks in Next.js, Express, Django and Laravel, without an SDK.
- Webhook event catalog Every event type, what its data holds, and the order a payment's events arrive in.
- Underpayments and overpayments What happens when a customer sends too little, too much, too late or on the wrong network.
- Refunds, returns and disputes Send refunds from your wallet, return funds you don't accept, and keep disputes rare.