Skip to content

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.

Create the payment
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.

Cancel the old payment
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.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.

  1. pending (customer is paying) → succeeded (fulfill the order): paid in full
  2. pending (customer is paying) → underpaid (part arrived)
  3. underpaid (part arrived) → succeeded (fulfill the order)
  4. pending (customer is paying) → needs_review (late or other network)
  5. needs_review (late or other network) → succeeded (fulfill the order)
  6. pending (customer is paying) → expired (nothing arrived)
  7. expired (nothing arrived) → needs_review (late or other network): funds arrive late
  8. pending (customer is paying) → failed (declined or canceled)
  9. failed (declined or canceled) → pending (customer is paying): declined card, tried again
StatusFinalWhat to do with the order
pendingNoHold it while the customer pays.
succeededYesFulfill it.
underpaidNoPart of the amount arrived. Wait for the rest, request it, or accept what arrived.
needs_reviewNoFunds arrived late or on another network. Accept them and fulfill, or return them and release the order.
failedOnly with canceled_atWithout 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.
expiredYes, unless funds arrive lateRelease 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.

webhooks.jsNode.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.jsNode.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 "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