# Refunds

> Refund a paid order from your server, see what is taken back from the player, and read what a refund could not recover.

## What a refund does

A refund cancels one paid order. In one step, GameCoin:

1. gives the money back to the buyer (simulated in the test environment);
2. takes back the **currency** the order delivered, with one `refund_clawback` ledger entry per currency;
3. takes back the **items** the order delivered, as far as the player still owns them;
4. sets the order to `refunded`.

Only a **server** can refund, with the secret key. A browser cannot, and the SDK has no refund. A studio member can also refund from the order's page in the dashboard (« Rembourser la commande », in French).

## Refund an order

```endpoint
POST /orders/{orderId}/refund
```

The only field is an optional `reason`, up to 500 characters. An empty body means `{}`.

<!-- tabs:start -->
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47/refund" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"reason":"customer request"}'
```
```javascript tab="Node.js"
const order = await gamecoin.orders.refund("6ac419fbdbe02af6c99c3d47", { reason: "customer request" });
console.log(order.status, order.refundedAt); // refunded 2026-10-05T21:44:07.009Z
```
```python tab="Python"
order = gamecoin.orders.refund("6ac419fbdbe02af6c99c3d47", reason="customer request")
print(order.status, order.refunded_at)  # refunded 2026-10-05 21:44:07.009000+00:00
```
```php tab="PHP"
$order = $gamecoin->orders->refund('6ac419fbdbe02af6c99c3d47', ['reason' => 'customer request']);
echo $order->status, ' ', $order->refundedAt->format(DATE_RFC3339_EXTENDED), "\n"; // refunded 2026-10-05T21:44:07.009+00:00
```
```go tab="Go"
order, err := client.Orders.Refund(ctx, "6ac419fbdbe02af6c99c3d47", &gamecoin.RefundParams{Reason: "customer request"})
if err != nil {
	log.Fatal(err)
}
fmt.Println(order.Status, *order.RefundedAt) // refunded 2026-10-05 21:44:07.009 +0000 UTC
```
```csharp tab="C#"
var order = await gamecoin.Orders.RefundAsync("6ac419fbdbe02af6c99c3d47", new RefundRequest { Reason = "customer request" });
Console.WriteLine($"{order.Status} {order.RefundedAt:O}"); // refunded 2026-10-05T21:44:07.0090000+00:00
```
<!-- tabs:end -->

```json title="Answer"
{
  "id": "6ac419fbdbe02af6c99c3d47",
  "playerId": "6ac419fa60e877541898f38d",
  "status": "refunded",
  "pack": { "sku": "gems-500", "name": "Bag of gems" },
  "amountCents": 499,
  "currency": "EUR",
  "country": "FR",
  "vatRateBp": 2000,
  "vatCents": 83,
  "netCents": 416,
  "createdAt": "2026-10-05T21:43:23.065Z",
  "paidAt": "2026-10-05T21:43:42.641Z",
  "fulfilledAt": "2026-10-05T21:43:42.641Z",
  "refundedAt": "2026-10-05T21:44:07.009Z",
  "creatorCode": null
}
```

Only a `fulfilled` order can be refunded. Any other state answers `409 CONFLICT`: an order still `pending` was never paid, and a `refunded` one is already done. A refund cannot be done twice, so a second call changes nothing.

An unknown order id answers `404 NOT_FOUND`. The `reason` is kept with the order and is visible in the dashboard.

### If the call fails halfway

A refund has no idempotency key. It is protected by the state of the order instead. After a network error or a `5xx`, **read the order before you try again**:

<!-- tabs:start -->
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const order = await gamecoin.orders.get("6ac419fbdbe02af6c99c3d47");
if (order.status === "fulfilled") {
  await gamecoin.orders.refund(order.id, { reason: "customer request" }); // still to do
}
```
```python tab="Python"
order = gamecoin.orders.get("6ac419fbdbe02af6c99c3d47")
if order.status == "fulfilled":
    gamecoin.orders.refund(order.id, reason="customer request")  # still to do
```
```php tab="PHP"
$order = $gamecoin->orders->get('6ac419fbdbe02af6c99c3d47');
if ($order->status === 'fulfilled') {
    $gamecoin->orders->refund($order->id, ['reason' => 'customer request']); // still to do
}
```
```go tab="Go"
order, err := client.Orders.Get(ctx, "6ac419fbdbe02af6c99c3d47")
if err != nil {
	log.Fatal(err)
}
if order.Status == gamecoin.OrderFulfilled {
	// still to do
	if _, err := client.Orders.Refund(ctx, order.ID, &gamecoin.RefundParams{Reason: "customer request"}); err != nil {
		log.Fatal(err)
	}
}
```
```csharp tab="C#"
var order = await gamecoin.Orders.GetAsync("6ac419fbdbe02af6c99c3d47");
if (order.Status == "fulfilled")
{
    await gamecoin.Orders.RefundAsync(order.Id, new RefundRequest { Reason = "customer request" }); // still to do
}
```
<!-- tabs:end -->

If the status is `refunded`, the refund went through. If it is still `fulfilled`, send it again.

## What is taken back

The refund takes back **what the order delivered**, not what the player has today. The order remembers, for each currency, how many `paid` and `bonus` coins it credited. The pack `gems-500` credited 500 `paid` and 50 `bonus` gems, so a refund takes back 550.

If the pack included items, the refund removes them, **up to what the player still owns**. A player who already drank the potion of a starter pack has none left to remove: the refund removes nothing more, and the quantity never goes below zero.

## When the player already spent the coins

The order credited 550 gems. If the player has spent some, the wallet cannot give 550 back. What happens depends on a setting of your game (« Après un remboursement » in the dashboard, which is in French):

| Setting | Dashboard name | What the refund does |
|---|---|---|
| **Allow a debt** (default) | « Autoriser une dette » | Takes back all 550. The balance goes below zero: the player is in debt. |
| **Stop at zero** | « S'arrêter à zéro » | Takes back only what is left. The balance never goes below zero. The rest is a **shortfall**, and you bear it. |

With the default, a player who bought `gems-500`, spent 400, and was refunded, ends at **-400**:

```json title="The wallet after the refund"
{
  "playerId": "6ac419fa60e877541898f38d",
  "balances": [
    { "currency": "gems", "paid": -400, "bonus": 0, "total": -400 },
    { "currency": "gold", "paid": 0, "bonus": 0, "total": 0 }
  ]
}
```

A debt behaves like this:

- The player **cannot spend** until the debt is paid: a spend answers `INSUFFICIENT_FUNDS` with `available: 0`.
- Every credit **pays the debt first**. If you grant 100 gems to this player, the balance becomes -300, not 100. A pack they buy pays the debt first too.
- The balance is never negative in `bonus`; the debt sits in `paid`.

The two settings are a choice between two risks. A debt protects you from a player who buys, spends, then asks for a refund. Stopping at zero never shows a negative balance, but the coins that player spent are your loss.

## Read what was taken, and what was not

Every refund writes `refund_clawback` entries in the ledger, one per currency, each with the `ref.orderId` of the order. The order's own `purchase_credit` entries have the same `ref.orderId` and tell what was delivered. Compare the two:

<!-- tabs:start -->
```bash tab="curl"
# The ledger entries of the player: keep those whose ref.orderId is the order,
# then add up purchase_credit (delivered) and refund_clawback (taken back) for each currency.
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?limit=100" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
// What did this refund take back, and what is missing?
async function readRefund(player, order) {
  const currencies = {};
  for await (const entry of gamecoin.ledger.iterate(player, { limit: 100 })) {
    if (entry.ref.orderId !== order.id) continue;
    const line = (currencies[entry.currency] ??= { credited: 0, taken: 0 });
    const change = entry.paidDelta + entry.bonusDelta;
    if (entry.type === "purchase_credit") line.credited += change;
    if (entry.type === "refund_clawback") line.taken -= change;
  }
  for (const line of Object.values(currencies)) line.shortfall = line.credited - line.taken;

  const wallet = await gamecoin.wallet.get(player);
  const debts = wallet.balances.filter((b) => b.total < 0).map((b) => `${b.currency}: ${-b.total}`);
  return { currencies, debts };
}

console.log(await readRefund(ext("user-42"), order));
// { currencies: { gems: { credited: 550, taken: 550, shortfall: 0 } }, debts: [ 'gems: 400' ] }
```
```python tab="Python"
# What did this refund take back, and what is missing?
def read_refund(player, order):
    currencies = {}
    for entry in gamecoin.ledger.iterate(player, limit=100):
        if entry.ref.order_id != order.id:
            continue
        line = currencies.setdefault(entry.currency, {"credited": 0, "taken": 0})
        change = entry.paid_delta + entry.bonus_delta
        if entry.type == "purchase_credit":
            line["credited"] += change
        if entry.type == "refund_clawback":
            line["taken"] -= change
    for line in currencies.values():
        line["shortfall"] = line["credited"] - line["taken"]

    wallet = gamecoin.wallet.get(player)
    debts = [f"{b.currency}: {-b.total}" for b in wallet.balances if b.total < 0]
    return {"currencies": currencies, "debts": debts}


print(read_refund(ext("user-42"), order))
# {'currencies': {'gems': {'credited': 550, 'taken': 550, 'shortfall': 0}}, 'debts': ['gems: 400']}
```
```php tab="PHP"
// What did this refund take back, and what is missing?
$readRefund = function ($player, $order) use ($gamecoin): array {
    $currencies = [];
    foreach ($gamecoin->ledger->iterate($player, ['limit' => 100]) as $entry) {
        if ($entry->ref->orderId !== $order->id) {
            continue;
        }
        $currencies[$entry->currency] ??= ['credited' => 0, 'taken' => 0];
        $change = $entry->paidDelta + $entry->bonusDelta;
        if ($entry->type === 'purchase_credit') {
            $currencies[$entry->currency]['credited'] += $change;
        }
        if ($entry->type === 'refund_clawback') {
            $currencies[$entry->currency]['taken'] -= $change;
        }
    }
    foreach ($currencies as &$line) {
        $line['shortfall'] = $line['credited'] - $line['taken'];
    }
    unset($line);

    $wallet = $gamecoin->wallet->get($player);
    $debts = [];
    foreach ($wallet->balances as $balance) {
        if ($balance->total < 0) {
            $debts[] = "{$balance->currency}: " . -$balance->total;
        }
    }

    return ['currencies' => $currencies, 'debts' => $debts];
};

echo json_encode($readRefund(PlayerRef::ext('user-42'), $order)), "\n";
// {"currencies":{"gems":{"credited":550,"taken":550,"shortfall":0}},"debts":["gems: 400"]}
```
```go tab="Go"
// What did this refund take back, and what is missing?
type refundLine struct{ Credited, Taken, Shortfall int64 }

readRefund := func(player gamecoin.PlayerRef, order *gamecoin.Order) (map[string]*refundLine, []string, error) {
	currencies := map[string]*refundLine{}
	it := client.Ledger.Iterate(ctx, player, &gamecoin.LedgerListParams{Limit: 100})
	for it.Next() {
		entry := it.Entry()
		if entry.Ref.OrderID == nil || *entry.Ref.OrderID != order.ID {
			continue
		}
		line, ok := currencies[entry.Currency]
		if !ok {
			line = &refundLine{}
			currencies[entry.Currency] = line
		}
		change := entry.PaidDelta + entry.BonusDelta
		if entry.Type == gamecoin.LedgerPurchaseCredit {
			line.Credited += change
		}
		if entry.Type == gamecoin.LedgerRefundClawback {
			line.Taken -= change
		}
	}
	if err := it.Err(); err != nil {
		return nil, nil, err
	}
	for _, line := range currencies {
		line.Shortfall = line.Credited - line.Taken
	}

	wallet, err := client.Wallet.Get(ctx, player)
	if err != nil {
		return nil, nil, err
	}
	var debts []string
	for _, b := range wallet.Balances {
		if b.Total < 0 {
			debts = append(debts, fmt.Sprintf("%s: %d", b.Currency, -b.Total))
		}
	}
	return currencies, debts, nil
}

currencies, debts, err := readRefund(gamecoin.Ext("user-42"), order)
if err != nil {
	log.Fatal(err)
}
fmt.Println(*currencies["gems"], debts)
// {550 550 0} [gems: 400]
```
```csharp tab="C#"
using System.Text.Json;

// What did this refund take back, and what is missing?
async Task<object> ReadRefundAsync(PlayerRef player, Order order)
{
    var currencies = new Dictionary<string, Dictionary<string, long>>();
    await foreach (var entry in gamecoin.Ledger.IterateAsync(player, new LedgerQuery { Limit = 100 }))
    {
        if (entry.Ref.OrderId != order.Id) continue;
        if (!currencies.TryGetValue(entry.Currency, out var line))
        {
            currencies[entry.Currency] = line = new Dictionary<string, long> { ["credited"] = 0, ["taken"] = 0 };
        }
        var change = entry.PaidDelta + entry.BonusDelta;
        if (entry.Type == LedgerEntryType.PurchaseCredit) line["credited"] += change;
        if (entry.Type == LedgerEntryType.RefundClawback) line["taken"] -= change;
    }
    foreach (var line in currencies.Values) line["shortfall"] = line["credited"] - line["taken"];

    var wallet = await gamecoin.Wallet.GetAsync(player);
    var debts = wallet.Balances.Where(b => b.Total < 0).Select(b => $"{b.Currency}: {-b.Total}");
    return new { currencies, debts };
}

Console.WriteLine(JsonSerializer.Serialize(await ReadRefundAsync(PlayerRef.Ext("user-42"), order)));
// {"currencies":{"gems":{"credited":550,"taken":550,"shortfall":0}},"debts":["gems: 400"]}
```
<!-- tabs:end -->

- With **allow a debt**, `shortfall` is always `0`: everything was taken. Read the debt in the wallet, as a negative `total`.
- With **stop at zero**, `taken` is smaller than `credited`. The difference is the shortfall.

In the dashboard, the order's page shows the shortfall (« Manque à la reprise »), the debt, and the entries the order caused (« Écritures liées »). The API does not return a `shortfall` field: use the comparison above.

## Refund and the ledger together

Nothing is deleted. After a refund, the ledger of the player holds the purchase, the spends, and the clawback. Their sum is the balance, debt included.

| Entry | `paidDelta` | `bonusDelta` | `balanceAfter.total` |
|---|---|---|---|
| `purchase_credit` | +500 | +50 | 550 |
| `spend` (400 gems) | −350 | −50 | 150 |
| `refund_clawback` | −550 | 0 | −400 |

## Where next

- [Wallets and ledger](/docs/concepts/wallets-and-ledger): the rules of buckets and debts.
- [Selling packs](/docs/guides/selling-packs): the other end of an order.
- [Testing](/docs/guides/testing): try a refund in the test environment.
