# 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.io/docs/guides/fulfilment/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs

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

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

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

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.

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