Guides
Testing
Try your whole integration without spending money, with the test environment, simulated payments, test players and the demo game.
On this page
The test environment
Every game has a test environment, picked by the test keys (gc_sk_test_…, gc_pk_test_…). It behaves like the real thing, with two differences:
- Payments are simulated. The payment page shows « Payer » and « Refuser » buttons. No money moves.
- Data is disposable. Players, balances, the ledger, the inventory and orders of
testare separate from those oflive, and you can leave them behind.
Your catalog (currencies, items, packs) is shared between the two environments. You define it once and test it as it will be sold.
Note
test is the only environment you can use today. Real payments are not available yet, so nothing you test here costs money, and nothing you test here is real.
Four ways to try it
| Way | You write code? | Good for |
|---|---|---|
| The dashboard's test players | No | Seeing balances, orders and refunds without any code |
| The demo game | No | Seeing the browser SDK sell your own catalog |
curl or a server SDK | A little | Checking a call, a header or an error |
| Your own game | Yes | The real integration |
Test players in the dashboard
The dashboard is in French for now. Open your game, go to « Joueurs », and check that the environment (« Environnement ») is Test. Then:
- « Nouveau joueur de test »: give it an id such as
joueur-test-1. This is anext:player: its API name isext:joueur-test-1. - On the player's page, « Donner de la monnaie » grants currency, and « Simuler un achat » opens a simulated payment for a pack of your choice, in a new tab.
- Pay in that tab. Back on the player's page, the balance, the inventory and the order are updated.
- In « Commandes », open the order, and use « Rembourser » to try a refund.
« Bloquer » on the player's page blocks the player: all their client calls answer PLAYER_BLOCKED.
The demo game
The demo is a small clicker built on the browser SDK. It reads your catalog and shows the balance live, sells your packs and items, and lets you consume them. From your game's overview in the dashboard, use « Essayer dans le jeu de démonstration »: it opens the demo with your test key. You can also open the demo game and paste a publishable test key on its start screen.
The demo is in French. Its code is a good example of the SDK in a real page.
The simulated payment
When you create a checkout in test, its payment page offers two buttons:
| Button | What happens | Order status | Where the player goes |
|---|---|---|---|
| « Payer » | The payment succeeds. The pack is delivered at once. | fulfilled | successUrl, with ?order=<id>&status=fulfilled |
| « Refuser » | The payment fails. Nothing is delivered. | failed | cancelUrl, with ?order=<id>&status=failed |
If the player closes the page without choosing, the order stays pending and expires after one hour.
The page also lets the buyer change the country, so you can see how the VAT changes. See Selling packs.
A test plan
Try each of these before you call your integration done. Every one is a real case your players will meet.
| Case | How to try it | Expect |
|---|---|---|
| A new player | Open the game in a fresh browser profile or a private window | A new anonymous player, with balances at 0 |
| The same player returns | Reload the page | Same player, same balance |
| A paid pack | Pay in the simulated page | Status fulfilled, balance up by the pack, change event fired |
| A declined payment | Press « Refuser » | Status failed, balance unchanged, a clear message |
| A closed payment window | Close it without paying | Status pending, balance unchanged |
| Not enough currency | Spend more than the balance | INSUFFICIENT_FUNDS with required and available; your shop opens |
| An item at its limit | Buy a durable item twice | LIMIT_REACHED; your game shows "already owned" |
| A double click | Click "buy" twice quickly | Two purchases: each call has its own key. Disable the button while a call runs. A second gc.checkout() during the first fails with CONFLICT. |
| A retry | Send the same write twice with one idempotency key | The same answer, Idempotent-Replayed: true |
| An expired token | Wait an hour, or use a token you altered | The SDK renews it once; your onTokenExpired runs |
| A blocked player | « Bloquer » in the dashboard | PLAYER_BLOCKED on every call |
| A refund | « Rembourser » on an order, after spending some of it | A debt, or a shortfall: see Refunds |
| A popup blocker | Turn it on in the browser | The SDK falls back to a redirect, and gc.returnedOrder has the order on return |
Test with a script
For automated tests, give each run its own players. The ledger never forgets, so a player you reuse carries the balance of the last run. Make the id from the run:
PLAYER="ext%3Aci-$(date +%s)"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/$PLAYER/wallet/grant" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: seed-$PLAYER" \
-H "Content-Type: application/json" \
-d '{"currency":"gems","amount":100}'import assert from "node:assert/strict";
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";
const player = ext(`ci-${Date.now()}`);
await gamecoin.wallet.grant(player, { currency: "gems", amount: 100 });
await gamecoin.wallet.spend(player, { currency: "gems", amount: 30 });
assert.equal((await gamecoin.wallet.get(player)).balances.find((b) => b.currency === "gems").total, 70);
await assert.rejects(
gamecoin.wallet.spend(player, { currency: "gems", amount: 1000 }),
(error) => error instanceof GameCoinError && error.code === ErrorCode.INSUFFICIENT_FUNDS && error.details.available === 70,
);
console.log("ok");import time
from gamecoin import ErrorCode, GameCoinError
player = ext(f"ci-{int(time.time() * 1000)}")
gamecoin.wallet.grant(player, currency="gems", amount=100)
gamecoin.wallet.spend(player, currency="gems", amount=30)
assert gamecoin.wallet.get(player).balance_of("gems").total == 70
try:
gamecoin.wallet.spend(player, currency="gems", amount=1000)
except GameCoinError as error:
assert error.code == ErrorCode.INSUFFICIENT_FUNDS and error.details["available"] == 70
else:
raise AssertionError("the spend should have been refused")
print("ok")use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;
$player = PlayerRef::ext('ci-' . (int) (microtime(true) * 1000));
$gamecoin->wallet->grant($player, ['currency' => 'gems', 'amount' => 100]);
$gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 30]);
if ($gamecoin->wallet->get($player)->balanceOf('gems')?->total !== 70) {
throw new RuntimeException('expected 70 gems');
}
try {
$gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 1000]);
throw new RuntimeException('the spend should have been refused');
} catch (GameCoinException $e) {
if ($e->errorCode !== ErrorCode::INSUFFICIENT_FUNDS || $e->details['available'] !== 70) {
throw $e;
}
}
echo "ok\n";player := gamecoin.Ext(fmt.Sprintf("ci-%d", time.Now().UnixMilli()))
if _, err := client.Wallet.Grant(ctx, player, gamecoin.GrantParams{Currency: "gems", Amount: 100}); err != nil {
log.Fatal(err)
}
if _, err := client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 30}); err != nil {
log.Fatal(err)
}
wallet, err := client.Wallet.Get(ctx, player)
if err != nil {
log.Fatal(err)
}
for _, b := range wallet.Balances {
if b.Currency == "gems" && b.Total != 70 {
log.Fatalf("expected 70 gems, got %d", b.Total)
}
}
_, err = client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 1000})
var gcErr *gamecoin.Error
if !errors.As(err, &gcErr) || gcErr.Code != gamecoin.CodeInsufficientFunds {
log.Fatalf("the spend should have been refused, got %v", err)
}
if available, _ := gcErr.DetailInt64("available"); available != 70 {
log.Fatalf("expected 70 available, got %d", available)
}
fmt.Println("ok")using Apilow.GameCoin;
var player = PlayerRef.Ext($"ci-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}");
await gamecoin.Wallet.GrantAsync(player, new GrantRequest { Currency = "gems", Amount = 100 });
await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 30 });
var wallet = await gamecoin.Wallet.GetAsync(player);
if (wallet.Balances.Single(b => b.Currency == "gems").Total != 70) throw new InvalidOperationException("expected 70 gems");
try
{
await gamecoin.Wallet.SpendAsync(player, new SpendRequest { Currency = "gems", Amount = 1000 });
throw new InvalidOperationException("the spend should have been refused");
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds && ex.Details["available"].GetInt64() == 70)
{
Console.WriteLine("ok");
}Keep a game for testing, with its own test keys, and put its secret key in the secret store of your CI, never in the repository.
The server SDKs generate an idempotency key for every call, so the script above needs none. With curl you give one.
To test a payment from a script, create the checkout through the API, then open checkoutUrl and press « Payer » once by hand, or drive the page with a browser test tool. Then read the order with GET /orders/{orderId}.
Go live
The live environment is for verified studios and real payments, and neither is available yet. Because the key picks the environment, going live later means changing the keys that your game and your server read, and nothing else. Until then, this page is how to be ready.