Skip to main content

Credit refund lifecycle: lot eligibility, partial refunds, and two-leg settlement

When a card top-up still holds unused credits, the organization’s owner can refund some or all of the unused portion back to the original card, self-serve, from the Billing page or the API. This page is the model behind that flow: which lots are eligible, how the refundable amount is computed, how settlement runs as two legs (the Stripe refund and the wallet debit), and how to read a refund’s status while it settles. The endpoint surface map is Billing overview, §8 Refunds; the ledger this page refers to is Wallets, credits, and charges.

What is refundable

A refund request names one ledger lot by credit_transaction_id, the id of a row in your transaction history (GET /api/v1/billing/transactions). Only lots of type credit_purchase are refundable: card top-ups that settled through a Stripe checkout session. Each such lot carries that provenance as a purchase_id, and the refund is issued against the original card charge resolved from that session, so the money returns to the same card that funded the top-up. Lots without that provenance cannot enter the flow. Bonus credits, trial grants, usage debits, refund offsets, and crypto top-ups are not credit_purchase lots tied to a Stripe charge, so they are not refundable here. Crypto payments have their own operator-processed flow; see Cryptocurrency refunds. Entry rules at POST /api/v1/billing/refunds:

The eligibility window: 30 days, with grace at the boundary

A lot is refundable for 30 days from its created_at. The server applies a 24-hour grace past that boundary, and the reason is clock agreement, not leniency: the dashboard computes the eligibility badge in your browser from the lot’s timestamp, while the API checks its own clock when the request arrives. Without the grace, a refund clicked at the 29-day-23-hour mark could look eligible in the dashboard and still be refused at the API. The grace keeps the two clocks from disagreeing at the boundary. Treat 30 days as the contract in your integration; a request past the window fails with 422 and:
Once a refund on a lot settles, the lot is stamped as refunded and closes: further requests for it return the 409 above, even if part of the lot was left unrefunded. Pick the amount you want in a single request.

Partial-refund math

A lot refunds only its unused portion:
  • original is the lot’s purchase amount in wallet cents.
  • used is the usage spend attributed to this lot: every debit posted between this lot’s created_at and your next card top-up’s created_at counts against it (lots are consumed in purchase order), capped at the lot’s own size so later spend can never be over-attributed to an older lot.
  • already_refunded is the sum of this lot’s pending, approved, and processed refund requests. In-flight claims count, so two concurrent requests cannot refund the same cents; failed and rejected requests count as zero because no money moved.
Example: a $100.00 lot (10000 cents) that sent 2500 cents of usage before the next top-up has refundable_cents: 7500. Requesting 8000 fails with 422 and decodes as:
Read refundable_cents as the most you can request for this lot right now; used_cents and already_refunded_cents tell you exactly why it is lower than the purchase amount. Retry with amount_cents at or below refundable_cents.

Two-leg settlement: Stripe refund, then wallet debit

When a request passes validation, settlement runs as two legs. Leg 1, the card. Orbit resolves the original charge from the lot’s purchase_id and issues a Stripe refund against that charge, denominated in the charge’s currency. The wallet is USD; if your card was charged in another currency, the refund amount is converted back with the exchange rate stored on the lot at purchase time (fx_rate_micros), so the card receives the original-currency amount that corresponds to the refunded wallet cents. The forward conversion at top-up time is described on Wallet currency conversion. Two legacy-lot failures can end leg 1, both terminal for self-serve and both routed to support for a manual refund:
  • refund_no_fx_rate — the lot predates multi-currency refund support and carries no stored rate to convert back with.
  • refund_currency_mismatch — the stored purchase currency disagrees with the charge currency, and the API refuses to guess a conversion.
(A lot whose purchase_id is missing altogether fails the same way, with refund_no_purchase_reference.) Leg 2, the wallet. When Stripe confirms the refund, the request flips to processed and Orbit removes the refunded cents from your wallet: the balance is debited by exactly the refunded amount and the ledger receives a credit_refunded offset row referencing the request (reference: "refund_request:<refund id>"). The card has its money back, so those credits must not keep existing in the wallet; the offset row is what keeps the ledger and the balance reconcilable, the same reversal pattern as every other correction on this ledger. On your statement. The offset row flows into the surfaces that replay your ledger: in GET /api/v1/billing/transactions it is the credit_refunded row, and on the monthly statement and records exports it renders as an adjustment line referencing the refund request, offsetting the original top-up. See Billing documents model for how ledger rows become statements. If the wallet was already spent down. You can request a refund for credits you have since spent. The card refund still completes (leg 1 is already done), and the wallet debit then removes whatever remains, never taking the balance below zero. The ledger keeps the full offset row, so your records show the complete adjustment even though the visible balance floored at $0.00. Idempotency: one active refund per lot. The API keeps at most one active (non-failed) refund request per lot. Repeating POST /api/v1/billing/refunds for a lot that already has one returns the existing request with "duplicate": true instead of creating a second, so a retry after a network error is safe. The Stripe call itself is keyed per request, so a retried settlement cannot issue two card refunds.

Status lifecycle and failure_reason

GET /api/v1/billing/refunds and GET /api/v1/billing/refunds/:id report each request’s status: When a refund fails, the detail endpoints also return failure_reason, a plain-language cause such as “Stripe declined the refund request. Contact support for details.” The dashboard’s Refund history panel shows the same text under the Failed badge, so you can always tell why a refund did not settle and whether to retry or contact support. failure_reason is absent on every non-failed status.

Owner role, the dashboard, and how this differs from other refunds

The whole flow is owner-only. POST /billing/refunds, GET /billing/refunds, and GET /billing/refunds/:id all require the organization’s owner role; admins and every other role receive 403. In the dashboard the same gate applies visually: the Refund action on an eligible card top-up (Billing, transaction history) and the Refund history panel that tracks the request afterwards are shown to owners only. Two other refund mechanisms exist on the platform; this page covers neither:
  • Cryptocurrency refunds. Crypto payments are final from the dashboard; a human operator settles each one as a manual payout to a same-chain wallet. See Cryptocurrency refunds.
  • Terminal-failure charge refunds. When a message fails terminally at the carrier, its usage charge is reversed inside the wallet automatically (RCS conversational tiers work this way). Those are usage-side corrections that never touch your card. See Channel charge trigger model.
The credit refund flow is the only one that returns money to your card, which is exactly why it is owner-gated, bounded by the 30-day window, and limited to the unused portion of the lot.