Sandbox. Test keys, Arc testnet and test USDC. No real money moves here.

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

statuspayment_statusWhat happenedWhat to do
openunpaidNothing paid yet.Wait. Show “awaiting payment”.
openpartially_paidLess than the amount has arrived.Wait for the rest; the buyer can top up on the same page.
completepaidThe full amount arrived.Fulfil. If overpayment is above 0, refund the extra.
expiredunpaidTime ran out with nothing paid.Release stock. Offer a new checkout.
expiredpartially_paidTime ran out part-paid.Contact the buyer; refund or take the rest another way.
cancelledanyYou 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 above amount. 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.