> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

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

> How owner-initiated card refunds of unused credits work: which top-up lots are refundable, the 30-day eligibility window, how the refundable amount is computed from the used and already-refunded portions, how the Stripe refund and the wallet debit settle as two legs, and what each refund status means.

# 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](https://docs.orbit.devotel.io/billing/overview.md#8-refunds); the ledger this
page refers to is
[Wallets, credits, and charges](https://docs.orbit.devotel.io/concepts/wallets-credits-and-charges.md).

## 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](https://docs.orbit.devotel.io/billing/crypto-refunds.md).

Entry rules at `POST /api/v1/billing/refunds`:

| Response | Meaning |
| - | - |
| 403 | The caller does not hold the organization's owner role. Refunds are owner-only. |
| 404 | The `credit_transaction_id` does not exist or belongs to a different organization. The API never reveals whether another organization's lot exists. |
| 422 | The lot is not a card top-up (`credit_purchase`). Credits that arrived some other way are not refundable through this flow. |
| 409 | The lot already has a settled refund on it. A lot settles at most one refund. |
| 422, `details.reason: "refund_window_expired"` | The lot is older than the eligibility window (below). |
| 422, `details.reason: "refund_exceeds_refundable"` | The requested `amount_cents` exceeds the lot's unused portion (below). |

## 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:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Credits older than 30 days are not refundable",
    "details": {
      "reason": "refund_window_expired",
      "window_days": 30,
      "lot_created_at": "2026-09-01T09:12:44.000Z"
    }
  }
}
```

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:

```
refundable = max(0, original - used - already_refunded)
```

* **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:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Requested 8000c exceeds refundable balance of 7500c (original 10000c, used 2500c, already refunded 0c)",
    "details": {
      "reason": "refund_exceeds_refundable",
      "original_amount_cents": 10000,
      "used_cents": 2500,
      "already_refunded_cents": 0,
      "refundable_cents": 7500,
      "requested_cents": 8000
    }
  }
}
```

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](https://docs.orbit.devotel.io/billing/wallet-currency-conversion.md). 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](https://docs.orbit.devotel.io/concepts/billing-documents-model.md) 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`:

| Status | Meaning | Money moved |
| - | - | - |
| `pending` | The request is recorded; settlement has not started. | No |
| `approved` | Cleared for processing. The lot is claimed, so no second request can race it. | No |
| `processed` | The Stripe refund was issued and the wallet debited. `processed_at` and `stripe_refund_id` are set. | Yes |
| `failed` | Settlement could not complete: missing purchase provenance, an FX conversion failure, or Stripe declined. No money moved, and the lot is released so you can retry. | No |
| `rejected` | The request was declined before settlement. No money moved. | No |

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](https://docs.orbit.devotel.io/billing/crypto-refunds.md).
* **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](https://docs.orbit.devotel.io/concepts/channel-charge-trigger-model.md).

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.

## Related

* [Wallets, credits, and charges](https://docs.orbit.devotel.io/concepts/wallets-credits-and-charges.md), the
  ledger model this page builds on.
* [Billing documents model](https://docs.orbit.devotel.io/concepts/billing-documents-model.md), how ledger
  rows become invoices and statements.
* [Billing overview](https://docs.orbit.devotel.io/billing/overview.md#8-refunds), the refund endpoint
  surface map.
* [Wallet currency conversion](https://docs.orbit.devotel.io/billing/wallet-currency-conversion.md), the
  forward conversion at top-up that `fx_rate_micros` makes reversible here.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.