Guides
Confirm and fulfil orders
A payment on Arc is final in under a second, but buyers close tabs, cards take a minute, and people sometimes send the wrong amount. These rules make fulfilment correct every time.
The rule
Every state, and what to do
| status | payment_status | What happened | What to do |
|---|---|---|---|
open | unpaid | Nothing paid yet. | Wait. Show “awaiting payment”. |
open | partially_paid | Less than the amount has arrived. | Wait for the rest; the buyer can top up on the same page. |
complete | paid | The full amount arrived. | Fulfil. If overpayment is above 0, refund the extra. |
expired | unpaid | Time ran out with nothing paid. | Release stock. Offer a new checkout. |
expired | partially_paid | Time ran out part-paid. | Contact the buyer; refund or take the rest another way. |
cancelled | any | You cancelled it in the dashboard. | Refund anything received. |
Reading the amounts
amount: what you asked for.amount_received: everything verified on chain so far.overpayment: anything aboveamount. Online checkout takes no tips, so extra money is recorded here for you to refund.- Amounts are decimal strings with up to six decimals, without trailing zeros (
"25","12.5"). Compare them as decimals, never as floats.
Late completion
A session can become complete after it expired, when a card or cross-chain payment that started in time lands late. Treat complete as final whenever you see it; if the order no longer exists, refund the buyer.
Make fulfilment idempotent
You may see the same completed session from a webhook, the return page and your reconcile job. Record the session id when you fulfil and skip it if it is already recorded:
async function fulfil(order, session) {
// A unique index on fulfilled_session_id makes a second call a no-op.
const updated = await db.query(
`UPDATE orders SET status = 'paid', fulfilled_session_id = $1
WHERE id = $2 AND fulfilled_session_id IS NULL`,
[session.id, order.id],
);
if (updated.rowCount === 1) await shipOrder(order);
}Reconcile with a scheduled job
Webhooks tell you the moment a session completes. Keep a short job as a safety net for the times your endpoint was down: hourly is enough with webhooks, every few minutes without. Retrieving a session also prompts SaphraONE to check for card and cross-chain payments, so polling keeps them moving.
// Hourly with webhooks, every 2–5 minutes without
const pending = await db.orders.where({ status: "awaiting_payment" }).createdWithin("26h");
for (const order of pending) {
const session = await saphra(`/checkout/sessions/${order.saphraSessionId}`);
if (session.status === "complete") await fulfil(order, session);
else if (session.status === "expired" && session.amount_received === "0") await release(order);
}Stay within the rate limits: 100 requests a minute per key.
Refunds today
Refunds through the API are on the roadmap. Until then, refund a buyer by sending USDC back from your SaphraONE business account. Each payment in payments links to its transaction, which shows the sending wallet. Card and cross-chain payments arrive from Circle’s systems, not the buyer’s wallet, so ask the buyer for their address.
