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

# 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:

```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.
