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.
On this page
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, 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:
- It's the right payment. The payment id matches the one saved on the order, and the mode matches (
livemodein the API,livemodeon the event). - It's for this order.
reference, and anymetadatayou set, match the order. - It's the price you asked.
amountandcurrencymatch the order. They can't change after creation, so a mismatch means you're looking at the wrong payment. - It's paid.
statusispaid. Notheld, notpending. - 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.
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);
});
}Ship what the order says
Every payment belongs to an order with the items, the addresses and the customer, as your store sent them in order and customer. 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, withitemsandshipTo(camelCase). See the order object. - From the API:
GET /v1/orders/:idwith the payment'sorder_id, withitemsandshipping_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 so the dashboard shows it as fulfilled:
// 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 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
expiredin the last two days. Money can arrive after the 24-hour window, and the payment then becomespaidorheld. - Stop checking a payment once it's
paid,failed, or has beenexpiredfor two days. - Respect the shared rate limit: 120 requests a minute per account. On a
429, wait forRetry-Afterseconds. - 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.