Concepts
Idempotency
How to send the same write twice without it counting twice, which routes need a key, and how to choose one.
On this page
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.
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.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 oneparams = {"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$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 oneparams := 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 onevar 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 oneThe 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
idempotencyKeywhen 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: truein 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.
Where next
- Errors: every code, including the two idempotency errors.
- Wallets and ledger: what a grant and a spend write.