Server SDKs
PHP SDK
Install the GameCoin SDK for PHP and use it from your game backend to manage wallets, the ledger, inventory, checkouts and refunds.
On this page
The PHP SDK is the server SDK for backends written in PHP. It holds your secret key, so it belongs on your server, never in a browser or a game client. For the browser, use the browser SDK. Not sure which SDK you need? Start with Server SDKs.
Install
Note
The SDK is a pre-release (version 0.1.0) and is not published yet: it is not on Packagist. The package name below is the planned one and may change before the first release.
composer require apilow/gamecoinIt needs PHP 8.1 or later with the curl and json extensions, which most hosts enable. It has no other dependency, and no framework is needed. The classes live in the Apilow\GameCoin namespace.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoin;
use Apilow\GameCoin\GameCoinException;
use Apilow\GameCoin\PlayerRef;Create the client
Create one client per game and environment and share it, for instance in your container. The key decides both: gc_sk_test_… talks to the test environment, gc_sk_live_… to the live one. Read it from an environment variable or a secret store, never from your code.
$gamecoin = new GameCoin(getenv('GAMECOIN_SECRET_KEY'), ['base_url' => 'https://gamecoin.apilow.com']);
echo $gamecoin->environment, "\n"; // test or live, read from the keyhttps://gamecoin.apilow.com is the address of this service, which is also the default: you only need base_url to point the SDK at another address. The options are an array, and every option is optional:
| Option | Default | Meaning |
|---|---|---|
base_url | https://gamecoin.apilow.com | Origin of the service. The SDK appends /api/v1, drops trailing slashes and keeps a path prefix. Only http and https are accepted. |
timeout | 30 | Seconds to wait for one attempt, connecting included. |
max_retries | 2 | Retries after the first attempt, from 0 to 10: three attempts by default. 0 turns retries off. |
transport | cURL | An object implementing TransportInterface that sends one HTTP request. See Testing your integration. |
sleep | usleep | A callable(float $seconds): void that replaces the wait between retries, for tests. |
An unknown option is an InvalidArgumentException, so a typo does not pass silently.
$tuned = new GameCoin(getenv('GAMECOIN_SECRET_KEY'), [
'base_url' => 'https://gamecoin.apilow.com',
'timeout' => 10, // give up on an attempt after 10 seconds
'max_retries' => 3, // retry up to three times, four attempts in all
]);
echo $tuned->environment, "\n";The constructor refuses anything that is not a secret key, before any request: a variable that is not set gives getenv() the value false, which fails at once. A publishable key (gc_pk_…) gets a message that points to the browser SDK. The key is trimmed, so a trailing newline from a file does no harm. It never appears in an exception message or in var_dump() and print_r() output, which show its prefix only (gc_sk_test_…), and a client cannot be serialized, so it cannot leak into a session or a cache.
Tip
In production, keep zend.exception_ignore_args = On, which is the default of php.ini-production. It stops PHP from listing function arguments in stack traces.
Players and the PlayerRef helper
A player is a GameCoin player id, or your own id through PlayerRef::ext():
$player = PlayerRef::ext('user-42');
echo $player, "\n"; // ext:user-42Any string works as your id (spaces, slashes, % and accents included), and so does an integer such as a numeric user id: the SDK percent-encodes the whole reference exactly once. With a secret key, a write on an unknown ext: player creates it, so there is no registration step. A read of an unknown player fails with NOT_FOUND.
$profile = $gamecoin->players->upsert($player, ['display_name' => 'Alice', 'country' => 'BE']);
$same = $gamecoin->players->get($player);
echo $profile->id === $same->id ? 'same' : 'different', ' ', $profile->externalId, ' ', $profile->kind, "\n"; // same user-42 external
// A one-hour token for the browser SDK of your game client
$token = $gamecoin->players->createToken($player);
var_dump(str_starts_with($token->token, 'gc_pt_'), $token->expiresAt > new DateTimeImmutable()); // bool(true) bool(true)In players->upsert(), a key you leave out is unchanged and a key set to null clears the field:
$cleared = $gamecoin->players->upsert($player, ['display_name' => null]);
echo $cleared->displayName ?? 'null', ' ', $cleared->country, "\n"; // null BEWherever a player is expected you may also pass a plain string ('ext:user-42' or a GameCoin id), PlayerRef::id($gameCoinId), or the Player object that the SDK returned.
Wallet
A balance has two buckets: paid (bought with real money) and bonus (granted or earned). A grant always credits bonus. A spend takes from bonus first, then from paid, unless your game reverses that order in its settings.
$movement = $gamecoin->wallet->grant($player, ['currency' => 'gems', 'amount' => 100, 'reason' => 'welcome_gift']);
echo $movement->balance->total, ' ', $movement->balance->currency, "\n"; // 100 gems
$spent = $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 30, 'reason' => 'revive']);
echo $spent->balance->total, ' ', $spent->entry->type, "\n"; // 70 spendamount is an integer of at least 1. reason is a short text and metadata an array of strings: both are recorded on the ledger entry.
$reward = $gamecoin->wallet->grant($player, [
'currency' => 'gold',
'amount' => 25,
'reason' => 'daily_reward',
'metadata' => ['day' => '3'],
]);
echo $reward->entry->type, ' ', $reward->entry->bonusDelta, ' ', $reward->balance->total, "\n"; // grant 25 25wallet->get() returns one balance per active currency, zeros included:
$wallet = $gamecoin->wallet->get($player);
foreach ($wallet->balances as $balance) {
echo $balance->currency, ' ', $balance->paid, ' ', $balance->bonus, ' ', $balance->total, "\n";
}
// gems 0 70 70
// gold 0 25 25
echo $wallet->balanceOf('gems')?->total, "\n"; // 70Ledger
The ledger is the immutable history of every change to a wallet, newest first. ledger->list() returns one page and a cursor; limit is the page size, from 1 to 100 (20 by default).
$page = $gamecoin->ledger->list($player, ['currency' => 'gems', 'limit' => 1]);
echo count($page->entries), ' ', var_export($page->nextCursor !== null, true), "\n"; // 1 true
$older = $gamecoin->ledger->list($player, ['currency' => 'gems', 'limit' => 1, 'cursor' => $page->nextCursor]);
echo $older->entries[0]->type, "\n"; // grantnextCursor is null on the last page. To avoid handling cursors yourself, iterate: ledger->iterate() is a generator that fetches each page only when the loop reaches it, so you can stop early.
foreach ($gamecoin->ledger->iterate($player, ['currency' => 'gems', 'limit' => 50]) as $entry) {
echo $entry->createdAt->format('c'), ' ', $entry->type, ' ', $entry->paidDelta + $entry->bonusDelta, ' ', $entry->balanceAfter->total, "\n";
if ($entry->type === 'grant') {
break; // no further page is fetched
}
}Inventory and purchases
Items are consumable (they can be consumed) or durable (they cannot), and may have a maximum per player. catalog->get() lists the active currencies, items and packs of your game.
$catalog = $gamecoin->catalog->get();
echo implode(', ', array_map(static fn ($item) => "{$item->sku} ({$item->type})", $catalog->items)), "\n"; // potion (consumable), fire-sword (durable)$granted = $gamecoin->inventory->grant($player, ['sku' => 'potion', 'quantity' => 3]);
echo $granted->quantity, "\n"; // 3
$used = $gamecoin->inventory->consume($player, ['sku' => 'potion']); // quantity defaults to 1
echo $used->quantity, "\n"; // 2
foreach ($gamecoin->inventory->list($player) as $item) { // owned items only (quantity above 0)
echo $item->sku, ' x', $item->quantity, "\n"; // potion x2
}inventory->consume() returns the item even when it reaches 0. purchases->create() buys an item with its price in currency, atomically: the wallet is debited and the item added, or nothing happens.
$purchase = $gamecoin->purchases->create($player, ['sku' => 'potion']);
echo $purchase->balance->total, ' ', $purchase->item->quantity, ' ', $purchase->entry->ref->itemSku, "\n"; // 50 3 potionAn item can limit how many a player owns. Going beyond fails with LIMIT_REACHED:
$gamecoin->inventory->grant($player, ['sku' => 'fire-sword']);
try {
$gamecoin->inventory->grant($player, ['sku' => 'fire-sword']); // limited to one per player
} catch (GameCoinException $e) {
var_dump($e->errorCode === ErrorCode::LIMIT_REACHED); // bool(true)
}Checkout, orders and refunds
A checkout sells a currency pack for real money through a hosted payment page. You create the order, send the player to $checkout->checkoutUrl, and the coins and items are delivered when the payment succeeds.
$checkout = $gamecoin->checkouts->create($player, [
'pack_sku' => 'gems-500',
'success_url' => 'https://example.com/shop/thanks', // where the player goes after paying
'cancel_url' => 'https://example.com/shop',
'country' => 'BE', // the buyer's country decides the VAT (EU countries only)
]);
echo $checkout->order->status, ' ', var_export(str_starts_with($checkout->checkoutUrl, 'http'), true), "\n"; // pending true
$order = $gamecoin->orders->get($checkout->order->id);
echo $order->status, ' ', $order->amountCents, ' ', $order->vatCents, ' ', $order->netCents, "\n"; // pending 499 87 412The test environment simulates payments: the hosted page offers « Payer » and « Refuser » buttons (it is in French for now), and no money moves. Real payments are not available yet. After a payment the order goes from pending to paid to fulfilled, and the coins and items are delivered. Poll orders->get(), or wait for the player to come back to your success_url, before telling them it worked. The statuses are the constants of Apilow\GameCoin\Model\OrderStatus.
orders->refund() takes the coins and items back from a paid order and returns the order, with status refunded. What a refund does to coins that the player already spent is explained in Refunds. An order that was never paid answers CONFLICT:
try {
$gamecoin->orders->refund($order->id, ['reason' => 'player request']);
} catch (GameCoinException $e) {
if ($e->errorCode === ErrorCode::CONFLICT) {
echo 'not refundable: ', $e->getMessage(), "\n";
} else {
throw $e;
}
}Warning
A refund has no idempotency key, so the SDK retries it on 429 only. After a network error, a timeout or a 5xx the call fails and the outcome is unknown: read the order back with orders->get() before you try again.
Idempotency and retries
Every call that changes something (wallet->grant(), wallet->spend(), inventory->grant(), inventory->consume(), purchases->create(), checkouts->create()) sends an Idempotency-Key. By default the SDK generates a UUID v4 once per call and reuses it for every retry of that call, so a retry can never apply a change twice.
Pass your own key, in the third argument, to make a retry of your code safe too, for instance when a page is reloaded or a job may run twice. Use 1 to 100 printable ASCII characters, derived from the event (here a quest completion). The result tells whether the answer came from an earlier request:
$key = 'quest-17:user-42';
$params = ['currency' => 'gems', 'amount' => 10];
$first = $gamecoin->wallet->grant($player, $params, ['idempotency_key' => $key]);
$again = $gamecoin->wallet->grant($player, $params, ['idempotency_key' => $key]);
echo var_export($first->replayed, true), ' ', var_export($again->replayed, true), ' ', var_export($first->entry->id === $again->entry->id, true), "\n"; // false true trueThe same key with a different request is refused with IDEMPOTENCY_CONFLICT. Keys are kept for 30 days. A replayed checkout returns the order in its current state. The rules behind this are on the Idempotency page.
Automatic retries run at most max_retries times (twice by default):
| Failure | Retried |
|---|---|
Network error, timeout, 5xx | Yes (not for orders->refund()) |
429 | Yes, after the Retry-After delay |
Any other 4xx | Never |
The wait is 0.5 s × 2^attempt, with a random jitter of ±25 %. A Retry-After header wins, up to 30 seconds. Beyond that nothing is retried and the exception carries retryAfter, in seconds, so that you decide. Every read, players->upsert() and players->createToken() follow the same rules as the changes above.
Errors
Anything that goes wrong with a request is a GameCoinException:
| Property | Content |
|---|---|
errorCode | The API code (INSUFFICIENT_FUNDS, NOT_FOUND…), or NETWORK_ERROR or TIMEOUT (status 0), or INVALID_RESPONSE when the body is not the expected JSON. |
status | The HTTP status, 0 when no response was received. getCode() returns the same integer. |
getMessage() | The message of the API, or a short description. Written for developers, not for your players. |
details | The details of the API (required, available, fieldErrors…), [] when absent. |
retryAfter | Seconds, when the API sent Retry-After, otherwise null. |
The API code is in errorCode, not in getCode(), which PHP requires to be an integer.
try {
$gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 1_000_000]);
} catch (GameCoinException $e) {
if ($e->errorCode === ErrorCode::INSUFFICIENT_FUNDS) {
echo "needs {$e->details['required']}, has {$e->details['available']}\n"; // needs 1000000, has 60
} elseif ($e->errorCode === ErrorCode::RATE_LIMITED) {
echo "try again in {$e->retryAfter} seconds\n";
} else {
throw $e;
}
}ErrorCode holds a constant for every code. These are the ones you will meet:
| HTTP | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | A field is invalid (details['fieldErrors']). |
| 401 | UNAUTHENTICATED | The secret key is missing, unknown or revoked. |
| 402 | PAYMENT_FAILED | The payment was refused, or no payment provider exists for this environment. |
| 403 | FORBIDDEN | This kind of key is not allowed here. |
| 403 | PLAYER_BLOCKED | The player is blocked. |
| 403 | ENV_NOT_ENABLED | The live environment is used by a studio that is not verified. |
| 404 | NOT_FOUND | Unknown player, currency, item, pack or order. |
| 409 | INSUFFICIENT_FUNDS | The wallet or the item quantity is too low (details['required'], details['available']). |
| 409 | LIMIT_REACHED | maxOwned, maxPerPlayer or the balance ceiling is exceeded. |
| 409 | IDEMPOTENCY_CONFLICT | The key was reused with another request. |
| 409 | CONFLICT | Invalid state change, such as refunding an unpaid order. |
| 429 | RATE_LIMITED | Too many requests (retryAfter). |
| 500 | INTERNAL | Unexpected error on the GameCoin side. |
| 0 | NETWORK_ERROR, TIMEOUT | No answer. The SDK has already retried. |
| any | INVALID_RESPONSE | The body is not the expected JSON. |
IDEMPOTENCY_KEY_REQUIRED also exists, but the SDK always sends a key. See Errors for what each code means for your game.
Arguments that are wrong on their face never reach the network. They throw an InvalidArgumentException, not a GameCoinException, and the message never contains the offending value. An unknown key in a parameter array, such as 'ammount', is rejected too:
try {
$gamecoin->wallet->grant($player, ['currency' => 'gems', 'amount' => -5]);
} catch (InvalidArgumentException $e) {
echo get_class($e), ' ', var_export($e instanceof GameCoinException, true), "\n"; // InvalidArgumentException false
}Testing your integration
The HTTP layer is an interface, so your own tests need no network and no mocking library. A TransportInterface receives an HttpRequest (method, url, headers, body) and returns an HttpResponse(status, headers, body) for every status, 4xx and 5xx included. It throws Apilow\GameCoin\Http\TransportException when no response was received (timeout: true for a timeout), and it never follows redirects. With sleep, you also skip the waits between retries:
use Apilow\GameCoin\Http\HttpRequest;
use Apilow\GameCoin\Http\HttpResponse;
use Apilow\GameCoin\Http\TransportInterface;
$fake = new class implements TransportInterface {
/** @var list<string> */
public array $keys = [];
public function send(HttpRequest $request): HttpResponse
{
$this->keys[] = $request->headers['Idempotency-Key'];
if (count($this->keys) < 3) {
return new HttpResponse(502, [], 'Bad gateway');
}
return new HttpResponse(200, [], '{"item": {"sku": "potion", "quantity": 1, "updatedAt": "2026-10-05T12:00:00.000Z"}}');
}
};
$offline = new GameCoin('gc_sk_test_' . str_repeat('x', 40), [
'transport' => $fake,
'sleep' => static function (float $seconds): void {},
]);
$gift = $offline->inventory->grant($player, ['sku' => 'potion']);
echo $gift->quantity, ' ', count($fake->keys), ' ', count(array_unique($fake->keys)), "\n"; // 1 3 1Two failures and a success: three attempts, and a single idempotency key sent three times.
To test against the real service without touching your players, use a test secret key (gc_sk_test_…) of your game. The test environment runs the same API with simulated payments, and its data never mixes with the live data: see Keys and environments. The Testing guide says what to try. Use a fresh player id for each run, so that every run starts from a zero balance:
$sandbox = new GameCoin(getenv('GAMECOIN_SECRET_KEY'), ['base_url' => 'https://gamecoin.apilow.com']);
$fresh = PlayerRef::ext('test-' . bin2hex(random_bytes(4)));
$start = $sandbox->wallet->grant($fresh, ['currency' => 'gems', 'amount' => 100]);
echo $start->balance->total, "\n"; // 100Reference
$player is a PlayerRef, a string ('ext:user-42' or a GameCoin id) or a Player returned by the SDK. The parameters are arrays with snake_case keys, and the results are immutable objects with camelCase properties. The calls marked with an asterisk accept ['idempotency_key' => '…'] as a last argument.
| Method | Returns | HTTP request |
|---|---|---|
$gamecoin->catalog->get() | Catalog | GET /catalog |
$gamecoin->players->upsert($player, array $profile = []) | Player | PUT /players/{player} |
$gamecoin->players->get($player) | Player | GET /players/{player} |
$gamecoin->players->createToken($player) | PlayerToken | POST /players/{player}/tokens |
$gamecoin->wallet->get($player) | Wallet | GET /players/{player}/wallet |
$gamecoin->wallet->grant($player, array $params, array $options = []) * | Movement | POST /players/{player}/wallet/grant |
$gamecoin->wallet->spend($player, array $params, array $options = []) * | Movement | POST /players/{player}/wallet/spend |
$gamecoin->ledger->list($player, array $params = []) | LedgerPage | GET /players/{player}/ledger |
$gamecoin->ledger->iterate($player, array $params = []) | Generator of LedgerEntry | GET /players/{player}/ledger, page after page |
$gamecoin->inventory->list($player) | list<InventoryItem> | GET /players/{player}/inventory |
$gamecoin->inventory->grant($player, array $params, array $options = []) * | InventoryItem | POST /players/{player}/inventory/grant |
$gamecoin->inventory->consume($player, array $params, array $options = []) * | InventoryItem | POST /players/{player}/inventory/consume |
$gamecoin->purchases->create($player, array $params, array $options = []) * | Purchase | POST /players/{player}/purchases |
$gamecoin->checkouts->create($player, array $params, array $options = []) * | Checkout | POST /players/{player}/checkouts |
$gamecoin->orders->get(string $orderId) | Order | GET /orders/{orderId} |
$gamecoin->orders->refund(string $orderId, array $params = []) | Order | POST /orders/{orderId}/refund |
A parameter array rejects any key it does not know, so a typo fails at once instead of being dropped.
| Parameters | Keys |
|---|---|
$profile | display_name, email, country, birth_year: a string (an integer for birth_year), or null to clear |
$params of wallet->grant() and wallet->spend() | currency and amount (required), reason, metadata (strings only) |
$params of inventory->grant(), inventory->consume() and purchases->create() | sku (required), quantity (1 by default) |
$params of ledger->list() | currency, limit (1 to 100), cursor |
$params of ledger->iterate() | currency, limit (the page size) |
$params of checkouts->create() | pack_sku (required), success_url, cancel_url, country |
$params of orders->refund() | reason |
The results are in the Apilow\GameCoin\Model namespace, with DateTimeImmutable values for every timestamp and arrays for collections:
| Type | Properties |
|---|---|
Movement | entry (a ledger entry), balance, replayed |
Purchase | entry, balance, item, replayed |
Checkout | order, checkoutUrl, replayed |
InventoryItem | sku, quantity, updatedAt, replayed (meaningful on the result of grant() and consume()) |
LedgerPage | entries, nextCursor (null on the last page) |
PlayerToken | token, expiresAt |
Wallet | playerId, balances, and the method balanceOf($currency) |
The package also provides Apilow\GameCoin\GameCoin (with the constants VERSION and DEFAULT_BASE_URL), GameCoinException, ErrorCode, PlayerRef and a class for every object of the API (Player, Balance, LedgerEntry, Catalog, Order…). The fields of each object match the API reference, where each resource has its page: catalog, players, wallet, ledger, inventory, purchases, checkouts and orders.