# Fulfil orders safely

> Deliver each order exactly once, only for money that really arrived. The checks that keep fulfilment correct through retries, duplicate events, late payments and reviews.

Source: https://railbed.com/docs/guides/fulfilment/ · Updated: 2026-09-27 · Railbed by DeepWork developer docs

For AI assistants: the index of every docs page, with the rules for an integration, is https://railbed.com/docs/llms.txt

## The rule

Fulfil an order only when your server has seen its payment as `paid` from Railbed itself: a webhook whose signature you verified, or an authenticated `GET /v1/payments/:id`. Never fulfil from the buyer's return to your `success_url`, a query parameter, or anything the buyer's browser reports.

## Save before you create

Before calling Railbed, save the order with everything that decides what the buyer gets: the user, the items, the price, the currency and the `Idempotency-Key` you'll send. After the create call, save the returned `pay_…` id on the order. If the call times out, retry it with the same key: you get the same session back, never a second payment. Send the order itself as [`order`](https://railbed.com/docs/api/checkout-sessions.md#orders-and-customers), with your order's id in `order.id`: a later session for the same order then joins it, and one for an order that's already paid is refused with `409 order_paid`.

## Check before you grant

When a payment reports `paid`, look up your order by the payment's `reference` (or its id) and check, in this order:

1. **It's the right payment.** The payment id matches the one saved on the order, and the mode matches (`livemode` in the API, `livemode` on the event).
2. **It's for this order.** `reference`, and any `metadata` you set, match the order.
3. **It's the price you asked.** `amount` and `currency` match the order. They can't change after creation, so a mismatch means you're looking at the wrong payment.
4. **It's paid.** `status` is `paid`. Not `held`, not `pending`.
5. **It isn't done already.** Record the grant with a unique constraint on the payment id (or the order), in the same database transaction as the delivery itself. A second event for the same payment then changes nothing. When you ship goods, key it on the order: a buyer who paid two sessions for one order has two paid payments, and should get one parcel and a refund.

Resolve the customer from your own order, never from the payment's `customer_email`: buyers can type any email at checkout.

Example: Node\.js: one grant per payment, in one transaction

```js
async function fulfilFromPayment(payment) {
  // Held, pending, expired: nothing to deliver yet
  if (payment.status !== 'paid') return;
  const order = await db.orders.findByPaymentId(payment.id);
  // Not ours (another store sharing the endpoint), or not saved yet
  if (!order) return;
  if (payment.livemode !== order.livemode) {
    throw new Error('mode mismatch');
  }
  if (payment.reference !== order.reference) {
    throw new Error('reference mismatch');
  }
  if (payment.amount !== order.price || payment.currency !== order.currency) {
    throw new Error('amount mismatch');
  }

  await db.transaction(async (tx) => {
    // UNIQUE(payment_id): a duplicate event or a second worker fails here
    // and delivers nothing
    const inserted = await tx.grants.insertIfAbsent({
      paymentId: payment.id,
      orderId: order.id,
    });
    if (!inserted) return;
    await tx.inventory.deliver(order);
    await tx.orders.markPaid(order.id, payment.paid_at);
  });
}
```

> [!NOTE]
> The webhook payload uses camelCase names (`reference`, `amount`, `currency`, `mode`, `paidAt` in milliseconds), while the API uses snake_case (`livemode`, `paid_at` in seconds). Many integrations read the webhook only as a signal and then fetch `GET /v1/payments/:id`, so all their checks run on one shape.

## Ship what the order says

Every payment belongs to an [order](https://railbed.com/docs/api/orders.md) with the items, the addresses and the [customer](https://railbed.com/docs/api/customers.md), as your store sent them in [`order` and `customer`](https://railbed.com/docs/api/checkout-sessions.md#orders-and-customers). If your store keeps its own orders, ship from those, checked as above. If your fulfilment works from Railbed instead (a warehouse connected to your webhooks, for example), take what to ship from the order:

- **In a webhook:** `data.order`, with `items` and `shipTo` (camelCase). See [the order object](https://railbed.com/docs/webhooks/events.md#the-order-object).
- **From the API:** `GET /v1/orders/:id` with the payment's `order_id`, with `items` and `shipping_address`.

The checks above still apply, plus one: **the paid payment's `amount` and `currency` equal the order's `total` and `currency`.** An order's items and totals follow its latest session, so a late payment for an older session can be for a different basket. Hold those for a person to look at.

Ship each order once, keyed on the order's id, then report it with [Update an order](https://railbed.com/docs/api/orders.md#update-an-order) so the dashboard shows it as fulfilled:

Example: Node\.js: ship a paid order once, then report it

```js
// railbed() is the small helper from /docs/api/errors/
async function shipOrder(paymentId) {
  const payment = await railbed(`/payments/${paymentId}`);
  if (payment.status !== 'paid') return;
  const order = await railbed(`/orders/${payment.order_id}`);
  // The order follows its latest session: check this payment covers it
  if (
    payment.amount !== order.total ||
    payment.currency !== order.currency
  ) {
    return flagForReview(order, payment);
  }
  await db.transaction(async (tx) => {
    // UNIQUE(order_id): a second paid payment for this order ships nothing
    const isNew = await tx.shipments.insertIfAbsent({ orderId: order.id });
    if (!isNew) return;
    await tx.shipments.queue({
      to: order.shipping_address,
      lines: order.items.map((i) => ({ sku: i.sku, quantity: i.quantity })),
    });
  });
}

// Later, when the parcel leaves the warehouse
async function markShipped(orderId) {
  await railbed(`/orders/${orderId}`, {
    method: 'PATCH',
    body: JSON.stringify({ fulfillment: 'fulfilled' }),
  });
}
```

`fulfillment` is a record for you and your team; it doesn't touch the payment. Orders you sent with `order.id` get their fulfilment only from your store, so report every change; send `unfulfilled` if a shipment is recalled. The [WooCommerce plugin](https://railbed.com/docs/guides/woocommerce.md#orders-customers-and-fulfilment) does this for you.

## Webhooks, the API, or both

| Signal | Strengths | Watch out for |
|---|---|---|
| `payment.paid` webhook | Arrives the moment the payment settles; retried for about a day | Your endpoint must be reachable, answer 2xx quickly and verify signatures |
| `GET /v1/payments/:id` | Always current; no public endpoint needed | You decide when to ask: poll gently, with backoff |

The most robust integrations use both: fulfil on the webhook, and run a background job that checks orders still waiting. The job catches anything a webhook missed (your server was down for a day, a deploy dropped a request) without anyone watching.

## Check unfinished orders in the background

Run a job every few minutes that checks each order still waiting on a payment, gently:

- Check recent orders often and older ones less often (for example after 1, 5, 15 and 60 minutes, then hourly).
- Include orders whose payment `expired` in the last two days. Money can arrive after the 24-hour window, and the payment then becomes `paid` or `held`.
- Stop checking a payment once it's `paid`, `failed`, or has been `expired` for two days.
- Respect the shared rate limit: 120 requests a minute per account. On a `429`, wait for `Retry-After` seconds.
- Survive restarts: keep the queue in your database, not in memory.

## Held payments

`held` means money arrived but didn't pass a check, most often because the provider delivered less than 90% of the order's value. Don't fulfil. You can review the payment in the dashboard and **Accept as paid** when the shortfall is fine with you; the payment then becomes `paid` and `payment.paid` is sent, so your normal fulfilment path handles it. Payments held because the money may have gone somewhere else can't be accepted; contact support.

Listen for `payment.held` if you want to tell the buyer their payment is being reviewed.

## Late payments

A buyer can open a provider's page, leave, and come back to finish after the session's 24 hours. The payment was `expired`, and then becomes `paid` (or `held`). If your system cancelled the order on `payment.expired`, decide what a late payment means for you: fulfil it, or refund it from your wallet. Either way, don't ignore a `payment.paid` because the order was once marked abandoned.

## Refunds

Railbed can't reverse a settled payment; the money is already in your wallet. To refund, send USDC from your wallet to the buyer (ask them for an address), or refund in another way you both agree. Card disputes are handled by the provider that charged the card.
