Guides
Refunds
Refund a paid order from your server, see what is taken back from the player, and read what a refund could not recover.
On this page
What a refund does
A refund cancels one paid order. In one step, GameCoin:
- gives the money back to the buyer (simulated in the test environment);
- takes back the currency the order delivered, with one
refund_clawbackledger entry per currency; - takes back the items the order delivered, as far as the player still owns them;
- 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
/orders/{orderId}/refundThe only field is an optional reason, up to 500 characters. An empty body means {}.
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"}'const order = await gamecoin.orders.refund("6ac419fbdbe02af6c99c3d47", { reason: "customer request" });
console.log(order.status, order.refundedAt); // refunded 2026-10-05T21:44:07.009Zorder = gamecoin.orders.refund("6ac419fbdbe02af6c99c3d47", reason="customer request")
print(order.status, order.refunded_at) # refunded 2026-10-05 21:44:07.009000+00:00$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:00order, 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 UTCvar 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{
"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:
curl "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"const order = await gamecoin.orders.get("6ac419fbdbe02af6c99c3d47");
if (order.status === "fulfilled") {
await gamecoin.orders.refund(order.id, { reason: "customer request" }); // still to do
}order = gamecoin.orders.get("6ac419fbdbe02af6c99c3d47")
if order.status == "fulfilled":
gamecoin.orders.refund(order.id, reason="customer request") # still to do$order = $gamecoin->orders->get('6ac419fbdbe02af6c99c3d47');
if ($order->status === 'fulfilled') {
$gamecoin->orders->refund($order->id, ['reason' => 'customer request']); // still to do
}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)
}
}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
}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:
{
"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_FUNDSwithavailable: 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 inpaid.
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:
# 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"// 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' ] }# 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']}// 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"]}// 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]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"]}- With allow a debt,
shortfallis always0: everything was taken. Read the debt in the wallet, as a negativetotal. - With stop at zero,
takenis smaller thancredited. 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: the rules of buckets and debts.
- Selling packs: the other end of an order.
- Testing: try a refund in the test environment.