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.
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:
- 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.
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);
});
}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.