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

# Refunding unused card top-up credits: the owner's self-serve walkthrough

> Walk the credit refund loop in the Billing console: confirm a top-up is refundable before you click, read the partial-refund math with worked examples, request a full or partial refund, decode every 422 reason, track the request through Refund history, and understand what settlement does to your card, wallet, and statements.

> [!NOTE]
>
> **Owner-only, card top-ups only.** Every control this guide walks is
> visible to the organization's owner role only, and the flow refunds
> **card** top-ups to the original card. Cryptocurrency payments are final
> from the dashboard and are settled as manual payouts; see
> [Cryptocurrency refunds](https://docs.orbit.devotel.io/billing/crypto-refunds.md) instead.

# Refunding unused card top-up credits: the owner's self-serve walkthrough

When a card top-up still holds unused credits, you can send some or all of
the unused portion back to the card that funded it, yourself, from the
Billing page. No support ticket, no waiting on an operator. This guide walks
the whole loop in the console: checking that a refund will succeed before
you request one, making the request, reading a rejection when it does not
succeed, and tracking the money afterwards.

The model behind this page (lot provenance, the eligibility window, FIFO
attribution, two-leg settlement) is documented on
[Credit refund lifecycle](https://docs.orbit.devotel.io/concepts/credit-refund-lifecycle-model.md); the
endpoint surface map is
[Billing overview, §8 Refunds](https://docs.orbit.devotel.io/billing/overview.md#8-refunds). This guide is
the task-level companion to both: what to click, in what order, and what
each response means.

## 1. Which refund page applies to you

Two refund flows exist in the Billing area, and they never overlap:

| You paid by | Refund path | Who settles it |
| - | - | - |
| Card (Stripe checkout) | This guide. Self-serve from the Billing page or `POST /api/v1/billing/refunds`, money returns to the original card. | Automatic, against the original Stripe charge. |
| Cryptocurrency | [Cryptocurrency refunds](https://docs.orbit.devotel.io/billing/crypto-refunds.md). Crypto payments are final from the dashboard; each refund is a manual payout to a same-chain wallet you provide. | A human operator, per request. |

A third mechanism reverses money without ever touching your card: when a
message fails terminally at the carrier, its usage charge is reversed inside
the wallet automatically. Those are usage-side corrections, not top-up
refunds, and there is nothing for you to request. See
[Channel charge trigger model](https://docs.orbit.devotel.io/concepts/channel-charge-trigger-model.md).

If your top-up was paid by card, continue here.

## 2. Where the controls live

Open **Billing** in the dashboard. Two surfaces carry this flow, and both
render for owners only (admins and other roles see neither, matching the
API, which answers them with 403):

* **Transaction history** lists every ledger row on your wallet. A card
  top-up that is still refundable shows a **Refund** button on its row. If
  the button is absent, the row is not eligible; [§3](#3-eligibility-pre-flight)
  says why.
* **Refund history** lists every refund request you have submitted, with its
  live status. It is empty until your first request ("No refund requests on
  file").

The same two surfaces exist over the API: `GET /api/v1/billing/transactions`
for the lots, `POST /api/v1/billing/refunds` to request, and
`GET /api/v1/billing/refunds` to track.

## 3. Eligibility pre-flight

Before you click **Refund**, confirm three things about the lot in
transaction history. The dashboard applies the same checks when it decides
whether to show the button, so this is also how you read a missing button.

1. **It is a card top-up.** Only `credit_purchase` rows, card top-ups that
   settled through Stripe checkout, are refundable. Bonus credits, trial
   grants, and crypto top-ups carry no original card charge to refund
   against, so they never show the button.
2. **It is within 30 days.** A lot is refundable for 30 days from its
   purchase timestamp, which the row shows in your timezone. The API applies
   a further 24-hour grace past that boundary, and the reason is clock
   agreement, not leniency: the dashboard computes the eligibility badge in
   your browser while the API checks its own clock when the request arrives,
   and the grace keeps the two from disagreeing at the boundary. Treat 30
   days as your deadline; a request past the window fails with a 422
   ([§6](#6-when-a-request-is-refused-reading-422-and-409)).
3. **It still holds unused credits.** The refundable amount is the unused
   portion ([§4](#4-the-partial-refund-math)). A fully spent lot shows
   nothing to refund: the confirm dialog says "Nothing refundable on this
   top-up — the credits have already been spent" and disables the confirm
   button.

One more rule to plan around: a lot settles **at most one refund**. Once a
refund on a lot processes, the lot is stamped as refunded and closes, even
if you left part of it unrefunded. Decide the amount you want in a single
request; a second request on the same lot returns 409.

## 4. The partial-refund math

A lot refunds only its unused portion:

```
refundable = original − used − already claimed by in-flight or settled refunds
```

* **original** is the top-up amount.
* **used** is the usage spend attributed to this lot: every debit posted
  between this top-up and your *next* card top-up counts against it, oldest
  lot first, capped at the lot's own size.
* **already claimed** counts this lot's pending, approved, and processed
  refund requests, so an in-flight request holds those cents and a second
  request cannot double-refund them. Failed and rejected requests count as
  zero, because no money moved.

**Worked example, one lot.** A $100.00 top-up that sent $40.00 of usage
before your next top-up refunds at most $60.00. The confirm dialog states
this as **Refundable now:** $60.00, and requesting \$80.00 over the API fails
with `details.reason: "refund_exceeds_refundable"` and
`refundable_cents: 6000`, the exact figure to retry with.

**Worked example, two lots, oldest first.** A $100.00 top-up on Sep 1 and a
$50.00 top-up on Sep 20, with $90.00 of usage between Sep 1 and Sep 20 and
$20.00 after Sep 20:

| Lot | Usage attributed | Refundable |
| - | - | - |
| Sep 1, \$100.00 | \$90.00 (spend before the next top-up) | \$10.00 |
| Sep 20, \$50.00 | \$20.00 (spend after it became the newest lot) | \$30.00 |

Spend is always attributed to the oldest open lot first, which is why an
older lot's refundable amount shrinks as you use the platform while a newer
lot stays mostly intact. The dialog derives its preview from the rows
visible in transaction history; the API re-validates against the complete
ledger on submit, so if the two ever disagree you get a clear 422 carrying
the authoritative `refundable_cents`, never an over-refund.

## 5. Requesting the refund in the console

1. On **Billing**, find the original top-up in transaction history
   ([§3](#3-eligibility-pre-flight)) and click **Refund**.

2. The confirm dialog shows the lot's original amount and **Refundable
   now:** , the most you can request for this lot right now. Confirming
   refunds exactly that amount. To refund less than the maximum, keep the
   credits in the wallet or issue the partial amount over the API with
   `amount_cents` at or below `refundable_cents`:

   ```bash theme={null}
   curl -X POST "https://orbit.devotel.io/api/v1/billing/refunds" \
     -H "Authorization: Bearer $ORBIT_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
           "credit_transaction_id": "ctx_01HZYEXAMPLE",
           "amount_cents": 6000
         }'
   ```

3. Confirm with **Refund** (or back out with **Keep credits**). The button
   shows **Refunding…** while the request is submitted.

4. The row appears in **Refund history** immediately with status
   **Pending**; your wallet balance updates as soon as the refund processes.

Two behaviors make the request safe to retry: repeating the POST for a lot
that already has an active request returns the existing request with
`"duplicate": true` instead of creating a second one, and the card leg is
keyed per request, so a retried settlement cannot issue two card refunds.
After a network error, resubmitting is the correct move.

The dialog also states what to expect on the card side: the refund processes
through Stripe and can take 5-10 business days to appear on your card
statement, while the wallet side updates immediately.

## 6. When a request is refused: reading 422 and 409

Every refusal names its reason in `error.details.reason`, and each has a fix:

| Response and reason | What it means | Fix |
| - | - | - |
| 422 `refund_window_expired` | The lot is older than the 30-day window. `details.lot_created_at` is the timestamp the API measured from. | None for this lot; the window is final. Refund newer top-ups before their window closes. |
| 422 `refund_exceeds_refundable` | The requested `amount_cents` exceeds the unused portion. `details.refundable_cents` is the current maximum. | Resubmit with `amount_cents` at or below `refundable_cents`. |
| 422 (no reason) | The row is not a `credit_purchase` lot; credits that arrived some other way are not refundable through this flow. | Use the flow that matches the payment: crypto goes through [Cryptocurrency refunds](https://docs.orbit.devotel.io/billing/crypto-refunds.md). |
| 409 | The lot already has a settled refund on it and is closed, even if part was left unrefunded. | Nothing to retry. The remaining cents stay in the wallet as usable credit. |
| 403 | The caller does not hold the owner role. | Have the organization's owner make the request. |
| 404 | The `credit_transaction_id` does not exist in this organization. The API never reveals whether another organization's lot exists. | Copy the lot id from your own transaction history. |

Refusals that happen *after* acceptance, during settlement (missing purchase
provenance on a legacy lot, a stored-currency conversion the API will not
guess, Stripe declining), flip the request to **Failed** instead and record
a plain-language `failure_reason`; [§7](#7-tracking-refund-history-and-the-api)
covers reading it.

## 7. Tracking: Refund history and the API

**Refund history** on the Billing page lists each request as a row: the
amount, a status badge, the lot it refunds (truncated, full id on hover),
the request time in your timezone, and, once terminal, the processed time. A
processed row also links its Stripe refund id so you can cross-check the
disposition and statement timing in your Stripe Dashboard.

| Status | Meaning | Money moved |
| - | - | - |
| Pending | 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. | Yes |
| Failed | Settlement could not complete. The row shows the `failure_reason` text under the badge, for example a Stripe decline; no money moved and the lot is released, so you can retry. | No |
| Rejected | Declined before settlement; no money moved. | No |

Most requests that pass validation settle automatically against Stripe
without human review, typically well inside a day; the card side then
follows Stripe's 5-10 business-day statement timing. If a request shows
**Failed** with a reason you cannot act on, or stays in flight past a day,
contact support with the row at hand; the failure text tells them which leg
stopped.

Over the API, poll the list or a single request:

```bash theme={null}
curl "https://orbit.devotel.io/api/v1/billing/refunds" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

```json theme={null}
{
  "refunds": [
    {
      "id": "ref_01HZYEXAMPLE",
      "credit_transaction_id": "ctx_01HZYEXAMPLE",
      "amount_cents": 6000,
      "status": "processed",
      "stripe_refund_id": "re_1NzEXAMPLE",
      "created_at": "2026-10-05T09:12:44.000Z",
      "processed_at": "2026-10-05T09:12:51.000Z"
    }
  ],
  "count": 1
}
```

`GET /api/v1/billing/refunds/:id` returns the same row plus `failure_reason`
on failed requests.

## 8. What settlement does to your account

Settlement runs as two legs, and each lands on a different surface:

* **The card leg.** The refund is issued against the original charge, in the
  charge's currency. The wallet is USD; if your card was charged in another
  currency, the refund converts back at the exchange rate stored on the lot
  at purchase time, so the card receives the original-currency amount that
  corresponds to the refunded cents. See
  [Wallet currency conversion](https://docs.orbit.devotel.io/billing/wallet-currency-conversion.md) for the
  forward conversion at top-up.
* **The wallet leg.** When Stripe confirms, the request flips to
  **Processed** and the refunded cents are removed from your wallet: the
  balance is debited by exactly the refunded amount and the ledger receives
  a `credit_refunded` adjustment row referencing the request. In transaction
  history that row renders as **Refund**, and on the monthly statement it
  appears as an adjustment line 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 you spent the credits down after requesting, the card refund still
completes and the wallet debit removes whatever remains, never taking the
balance below zero; the ledger keeps the full adjustment row either way.

Plan for the balance drop. A refund reduces your available balance exactly
like spend does, without being spend, and it *can* drop the balance below
your auto-top-up threshold, which then fires a recharge if auto-top-up is
enabled, or leaves sends closer to the pause threshold if it is not. Check
the threshold on the Billing page before refunding a large lot ahead of a
campaign.

## 9. What a refund does not do

* **It does not re-price usage you already ran.** Usage stays billed and on
  your statements; the refund only returns the unused portion of a top-up to
  the card. There is no retroactive credit against invoiced usage.
* **It does not reverse cryptocurrency payments.** Crypto is final from the
  dashboard and follows the operator-run flow in
  [Cryptocurrency refunds](https://docs.orbit.devotel.io/billing/crypto-refunds.md).
* **It does not reopen a settled lot.** One refund per lot; whatever you
  leave unrefunded stays in the wallet as usable credit.
* **It is not silent.** The request and its outcome are written to your
  organization's audit trail, so a refund is always attributable to a person
  and a time.

## Related

* [Credit refund lifecycle](https://docs.orbit.devotel.io/concepts/credit-refund-lifecycle-model.md), the
  model this guide walks.
* [Billing overview, §8 Refunds](https://docs.orbit.devotel.io/billing/overview.md#8-refunds), the endpoint
  surface map, and §9 for the wallet page card by card.
* [Cryptocurrency refunds](https://docs.orbit.devotel.io/billing/crypto-refunds.md), the manual-payout flow
  for crypto payments.
* [Wallet currency conversion](https://docs.orbit.devotel.io/billing/wallet-currency-conversion.md), how
  non-USD charges convert at top-up and convert back at refund.


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