# Idempotency

> How to send the same write twice without it counting twice, which routes need a key, and how to choose one.

## The problem

You call GameCoin to grant 100 gems. The connection drops before the answer arrives. Did the grant happen? If you send it again and it did, the player has 200 gems.

An **idempotency key** removes the doubt. You send a unique key with the write. If GameCoin already saw that key with the same request, it does not repeat the write: it sends back the original answer. You can retry as often as you like.

## How it works

Send the key in the `Idempotency-Key` header: 1 to 100 printable ASCII characters (letters, digits, spaces and punctuation, no accents).

| You send | GameCoin answers |
|---|---|
| A new key | Does the write, remembers the answer |
| The **same key with the same request** | The **original answer**, with the header `Idempotent-Replayed: true`. No second effect. |
| The same key with **another request** | `409 IDEMPOTENCY_CONFLICT`. Nothing changes. |
| No key on a route that needs one | `400 IDEMPOTENCY_KEY_REQUIRED` |
| A key that is not 1 to 100 printable ASCII characters | `400 VALIDATION_FAILED` |

Keys are kept for **30 days**.

<!-- tabs:start -->
```bash tab="curl"
curl -i -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/wallet/grant" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: level-3-reward-user-42" \
  -H "Content-Type: application/json" \
  -d '{"currency":"gems","amount":10,"reason":"level-3"}'
# Run it twice. The second answer is identical and carries "Idempotent-Replayed: true".
# The player received 10 gems once.
```
```javascript tab="Node.js"
const params = { currency: "gems", amount: 10, reason: "level-3" };
const options = { idempotencyKey: "level-3-reward-user-42" };

const first = await gamecoin.wallet.grant(ext("user-42"), params, options);
const again = await gamecoin.wallet.grant(ext("user-42"), params, options);

console.log(first.replayed, again.replayed); // false true
console.log(first.entry.id === again.entry.id); // true: the same entry, not a second one
```
```python tab="Python"
params = {"currency": "gems", "amount": 10, "reason": "level-3"}
key = "level-3-reward-user-42"

first = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)
again = gamecoin.wallet.grant(ext("user-42"), **params, idempotency_key=key)

print(first.replayed, again.replayed)  # False True
print(first.entry.id == again.entry.id)  # True: the same entry, not a second one
```
```php tab="PHP"
$params = ['currency' => 'gems', 'amount' => 10, 'reason' => 'level-3'];
$options = ['idempotency_key' => 'level-3-reward-user-42'];

$first = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);
$again = $gamecoin->wallet->grant(PlayerRef::ext('user-42'), $params, $options);

echo var_export($first->replayed, true), ' ', var_export($again->replayed, true), "\n"; // false true
var_dump($first->entry->id === $again->entry->id); // bool(true): the same entry, not a second one
```
```go tab="Go"
params := gamecoin.GrantParams{Currency: "gems", Amount: 10, Reason: "level-3"}
options := gamecoin.WithIdempotencyKey("level-3-reward-user-42")

first, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
	log.Fatal(err)
}
again, err := client.Wallet.Grant(ctx, gamecoin.Ext("user-42"), params, options)
if err != nil {
	log.Fatal(err)
}

fmt.Println(first.Replayed, again.Replayed)   // false true
fmt.Println(first.Entry.ID == again.Entry.ID) // true: the same entry, not a second one
```
```csharp tab="C#"
var grantRequest = new GrantRequest { Currency = "gems", Amount = 10, Reason = "level-3" };
var options = new RequestOptions { IdempotencyKey = "level-3-reward-user-42" };

var first = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);
var again = await gamecoin.Wallet.GrantAsync(PlayerRef.Ext("user-42"), grantRequest, options);

Console.WriteLine($"{first.Replayed} {again.Replayed}"); // False True
Console.WriteLine(first.Entry.Id == again.Entry.Id); // True: the same entry, not a second one
```
<!-- tabs:end -->

The second request returns the same entry. The balance moved once.

## Which routes need a key

Every `POST` that changes a wallet or an inventory, or that creates a checkout, requires a key. Reads, `PUT`, and the refund do not.

| Route | Key |
|---|---|
| `POST /players/{player}/wallet/grant` | required |
| `POST /players/{player}/wallet/spend` | required |
| `POST /players/{player}/inventory/grant` | required |
| `POST /players/{player}/inventory/consume` | required |
| `POST /players/{player}/purchases` | required |
| `POST /players/{player}/checkouts` | required |
| `POST /client/me/wallet/spend` | required |
| `POST /client/me/purchases` | required |
| `POST /client/me/inventory/consume` | required |
| `POST /client/me/checkouts` | required |
| `PUT /players/{player}`, `POST …/tokens`, every `GET` | not used |
| `POST /orders/{orderId}/refund` | not used: see below |

A replayed checkout is the one exception to "the original answer": it returns the order in its **current** state (it may have been paid since) and the same payment URL, with status `200` instead of `201`. No second order is created.

## Choose a good key

The key must be **the same for the same event, and different for every other event**. Build it from the thing you are paying for, not from a random value or the clock:

| Good | Why |
|---|---|
| `level-3-reward-user-42` | One reward per player per level, however many times the call is retried |
| `daily-user-42-2026-10-05` | One daily reward per player per day |
| `order-1001` | One checkout for your own order number 1001 |

| Bad | Why |
|---|---|
| A random value made at each attempt | A retry gets a new key, so it counts twice |
| `reward` | Every player and every level shares it: the second use is a conflict |

A key belongs to your game and environment, **not to a player**. The same key sent for two different players is the same key with another request, and answers `409 IDEMPOTENCY_CONFLICT`. Put the player in the key.

A request that **fails** does not use up its key. If a spend answers `INSUFFICIENT_FUNDS` and you retry the same key after the player earned gems, the spend goes through.

## What the SDKs do for you

- The Node.js server SDK and the other server SDKs make a UUID for every call and reuse it for every automatic retry of that call. Pass your own with `idempotencyKey` when a retry of **your** code must be safe too, as above.
- The browser SDK does the same: one random key per call, reused for every replay (network error, `5xx`, expired token). To make a call safe across a page reload, pass your own key as the last argument: `gc.spend("gems", 5, "level-3", { idempotencyKey: "level-3-entry" })`.
- A result that comes from a replay has `replayed: true` in the server SDKs.

## The refund has no key

`POST /orders/{orderId}/refund` takes no idempotency key. It is protected by the state of the order instead: a refunded order cannot be refunded again (`409 CONFLICT`). After a network error or a `5xx`, read the order with `GET /orders/{orderId}` before you try again. See [Refunds](/docs/guides/refunds).

## Where next

- [Errors](/docs/concepts/errors): every code, including the two idempotency errors.
- [Wallets and ledger](/docs/concepts/wallets-and-ledger): what a grant and a spend write.
