apilow / gamecoin
Server SDK for Apilow GameCoin: the bank of your game (virtual currency, inventory, sales). No dependencies.
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 16:22:13 UTC
README
The bank of your game. GameCoin keeps the virtual currency, the inventory and the sales of your web or mobile game behind one API. This is the server SDK: it runs in your game's backend, holds a secret key, and is the only place that can credit coins. (The browser SDK uses a publishable key and can never credit anything.) Full documentation: gamecoin.apilow.com/docs/sdk/php.
- PHP 8.1+, no dependency beyond
ext-curlandext-json. No framework needed, and nothing to configure on a shared host that has cURL. - Typed all the way:
declare(strict_types=1),readonlyproperties,DateTimeImmutabledates. - Safe to retry: every mutation carries an idempotency key and the SDK retries network errors,
5xxand429for you. - Synchronous, one request at a time: the right fit for PHP.
Install
composer require apilow/gamecoin
Quickstart
<?php require __DIR__ . '/vendor/autoload.php'; use Apilow\GameCoin\GameCoin; use Apilow\GameCoin\PlayerRef; $gamecoin = new GameCoin(getenv('GAMECOIN_SECRET_KEY')); // gc_sk_test_... while you develop $player = PlayerRef::ext('user-42'); // your own id for the player $gamecoin->wallet->grant($player, ['currency' => 'gems', 'amount' => 100, 'reason' => 'welcome_gift']); $movement = $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 30, 'reason' => 'shop']); echo $movement->balance->total, "\n"; // 70 for a brand new player $checkout = $gamecoin->checkouts->create($player, ['pack_sku' => 'gems-500']); echo $checkout->checkoutUrl, "\n"; // send the player to this page to pay
If GAMECOIN_SECRET_KEY is not set, getenv() returns false and the constructor refuses it. The examples below
reuse $gamecoin and $player from this quickstart. A runnable tour lives in
examples/quickstart.php.
Configuration
use Apilow\GameCoin\GameCoin; $gamecoin = new GameCoin(getenv('GAMECOIN_SECRET_KEY'), [ 'base_url' => 'https://gamecoin.apilow.com', // the default; use your sandbox or proxy address if you have one 'timeout' => 30, // seconds for one request (connect and transfer) 'max_retries' => 2, // extra attempts after a network error, timeout, 5xx or 429 ]); echo $gamecoin->environment, "\n"; // "test" or "live", read from the key prefix
| Option | Default | |
|---|---|---|
base_url |
https://gamecoin.apilow.com |
Origin of the service; the SDK adds /api/v1 itself. A path prefix is kept, trailing slashes are dropped. |
timeout |
30 |
Seconds for one request, connecting included. |
max_retries |
2 |
Extra attempts, so up to three in all; from 0 to 10. 0 turns retries off. |
transport |
cURL | A TransportInterface that sends one HTTP request: see Testing your code. |
An unknown option is an InvalidArgumentException, so a typo does not pass silently.
The constructor refuses anything that is not a secret key (gc_sk_test_... or gc_sk_live_...); whitespace around it,
such as the newline of a key read from a file, is trimmed. A publishable key (gc_pk_...) gets a message pointing to
the browser SDK. The key never appears in an exception message or in var_dump() / print_r() output, which show its
prefix only (gc_sk_test_…), and a client cannot be serialized (it would carry the key into a session or a cache).
Keep it in an environment variable or a secret store, never in your repository.
In production, keep zend.exception_ignore_args = On (the default of php.ini-production): it stops PHP from listing
function arguments in stack traces.
Players and PlayerRef
A player is a GameCoin player id, or your own id wrapped by PlayerRef::ext(): PlayerRef::ext('user-42') is
ext:user-42. With a secret key, the first write for an unknown ext player creates it, so there is no registration
step. Numeric user ids work too: PlayerRef::ext(42).
use Apilow\GameCoin\PlayerRef; $player = PlayerRef::ext('user-42'); $profile = $gamecoin->players->upsert($player, ['display_name' => 'Alice', 'country' => 'BE']); echo $profile->id, ' ', $profile->externalId, ' ', $profile->kind, "\n"; // GameCoin id, "user-42", "external" $gamecoin->players->upsert($player, ['display_name' => null]); // null erases a field; a key you leave out is kept var_dump($gamecoin->players->get($player)->displayName); // NULL $token = $gamecoin->players->createToken($player); // short-lived token for the browser SDK echo $token->expiresAt->format(DATE_ATOM), "\n";
Wherever a player is expected you may also pass a plain string ('ext:user-42' or a GameCoin id), a
PlayerRef::id($gameCoinId), or a Player returned by the SDK. Reading an unknown ext player is a NOT_FOUND
error; any write creates it. Ids may hold anything (spaces, slashes, %, accents): the SDK percent-encodes them exactly
once. The profile keys are display_name, email, country and birth_year.
Wallet and ledger
$movement = $gamecoin->wallet->grant($player, [ 'currency' => 'gems', 'amount' => 100, 'reason' => 'level_up', 'metadata' => ['level' => '7'], ]); echo $movement->balance->total, ' ', $movement->entry->id, ' ', var_export($movement->replayed, true), "\n"; $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 30, 'reason' => 'shop']); $wallet = $gamecoin->wallet->get($player); // one balance per currency, zeros included foreach ($wallet->balances as $balance) { echo "{$balance->currency}: paid {$balance->paid}, bonus {$balance->bonus}, total {$balance->total}\n"; } echo $wallet->balanceOf('gems')?->total, "\n";
grant credits free coins (the bonus part of a balance); paid coins come from packs bought with real money.
amount is a positive integer. reason (a short text) and metadata (an array of string to string) are stored on the
ledger entry.
The ledger is the immutable history of every movement, newest first (limit is the page size, from 1 to 100):
$page = $gamecoin->ledger->list($player, ['currency' => 'gems', 'limit' => 20]); foreach ($page->entries as $entry) { echo $entry->createdAt->format('c'), ' ', $entry->type, ' ', $entry->paidDelta + $entry->bonusDelta, ' ', $entry->balanceAfter->total, "\n"; } if ($page->nextCursor !== null) { $older = $gamecoin->ledger->list($player, ['currency' => 'gems', 'limit' => 20, 'cursor' => $page->nextCursor]); } // Or let the SDK turn the pages. Each page is fetched only when the loop reaches it. foreach ($gamecoin->ledger->iterate($player, ['currency' => 'gems']) as $entry) { echo $entry->id, ' ', $entry->reason, "\n"; }
Inventory and purchases
Items are what players own: consumable items are used up, durable ones are kept (some have a maximum).
$gamecoin->inventory->grant($player, ['sku' => 'potion', 'quantity' => 3]); $gamecoin->inventory->consume($player, ['sku' => 'potion']); // quantity defaults to 1 foreach ($gamecoin->inventory->list($player) as $item) { // owned items only echo $item->sku, ' ', $item->quantity, "\n"; } $purchase = $gamecoin->purchases->create($player, ['sku' => 'potion']); // buys with the item's price in virtual currency echo $purchase->balance->total, ' ', $purchase->item->quantity, "\n";
use Apilow\GameCoin\ErrorCode; use Apilow\GameCoin\GameCoinException; $gamecoin->inventory->grant($player, ['sku' => 'fire-sword']); try { $gamecoin->inventory->grant($player, ['sku' => 'fire-sword']); // this item is limited to one per player } catch (GameCoinException $e) { var_dump($e->errorCode === ErrorCode::LIMIT_REACHED); // bool(true) }
$gamecoin->catalog->get() lists the active currencies, items (with their price) and packs of your game.
A pack can have a sale window and a limited stock. $pack->availability is null for an ordinary pack (always on
sale, no limit); otherwise it holds startsAt and endsAt (DateTimeImmutable or null; startsAt included,
endsAt excluded), remaining (the units left in the environment of your key, null for an unlimited stock) and
founder (a founder pack). The catalog also lists scheduled, ended and sold-out packs: your shop decides what to show.
$catalog->prelaunch and $catalog->launchAt come from the game settings: while prelaunch is true, show pre-orders
with the launch date.
$catalog = $gamecoin->catalog->get(); foreach ($catalog->packs as $pack) { $a = $pack->availability; if ($a !== null && $a->remaining === 0) { continue; // sold out } echo $pack->sku, $a?->founder ? ' (founder pack)' : '', "\n"; } if ($catalog->prelaunch) { echo 'Launch: ', $catalog->launchAt?->format('Y-m-d') ?? 'soon', "\n"; }
Manage the catalog
currencies, items and packs create, change and archive the catalog, the way a payment platform manages its products and
prices. Each has create, get, update, archive and list.
Each environment has its own catalog: the key decides which one you change, and a pack created with a test key exists in
testonly. Create a new pack as a draft (the default) and turn it on with'status' => 'active'when it is ready.
$gamecoin->currencies->create(['code' => 'rubies', 'name' => 'Rubies']); $gamecoin->items->create(['sku' => 'elixir', 'name' => 'Elixir', 'type' => 'consumable', 'price' => ['currency' => 'gems', 'amount' => 15]]); $pack = $gamecoin->packs->create([ 'sku' => 'mega', 'name' => 'Mega pack', 'price_cents' => 999, 'grants' => [['currency' => 'gems', 'amount' => 1200, 'bonus' => 100]], 'items' => [['sku' => 'elixir', 'quantity' => 2]], ]); echo $pack->status, "\n"; // "draft" $gamecoin->packs->update('mega', [ 'price_cents' => 1099, 'availability' => ['starts_at' => new DateTimeImmutable('2026-11-01T00:00:00Z'), 'stock' => 500, 'founder' => true], // replaces the whole availability 'status' => 'active', ]); $gamecoin->items->update('elixir', ['price' => null]); // null clears a description, a price or a limit $gamecoin->packs->archive('mega'); // nothing is deleted $everything = $gamecoin->packs->list(['include_archived' => true]);
A key you leave out of update is unchanged. The identifiers (code, sku) and the type of an item are final, so update
refuses them. A taken identifier is CONFLICT with details['fieldErrors']['sku'] === ['SKU_TAKEN'] (['CODE_TAKEN'] for a
currency); an invalid pack is VALIDATION_FAILED with the faulty lines in details['fieldErrors']; archiving something that
an active pack still uses is CONFLICT with details['blockedBy']. A create has no idempotency key (the identifier is its
natural key), so it is retried on 429 only: after a network error, a timeout or a 5xx, read the resource back with get
before you try again.
Checkout, orders and refunds
A pack is sold for real money. checkouts->create() opens an order and returns the hosted payment page; send the
player there. In the test environment the page offers "Pay (simulated)" and "Decline"; with a live key it takes a real payment.
$checkout = $gamecoin->checkouts->create($player, [ 'pack_sku' => 'gems-500', 'success_url' => 'https://mygame.example/shop/thanks', // where the player lands after paying (optional) 'cancel_url' => 'https://mygame.example/shop', // optional 'country' => 'BE', // tax country, ISO 3166-1 alpha-2 (optional) 'locale' => 'nl', // language of the payment page, receipt and e-mails: fr, en or nl (optional) 'creator_code' => 'ALICE10', // a creator code the buyer typed (optional) ]); echo $checkout->order->status, ' ', $checkout->checkoutUrl, "\n"; // "pending", then the page to open $order = $gamecoin->orders->get($checkout->order->id); echo $order->status, ' ', $order->amountCents, ' ', var_export($order->paidAt, true), "\n"; // pending -> paid -> fulfilled
Coins and items are credited when the order is paid. Poll orders->get() (or wait for the player to come back to your
success_url), or better, react to the webhook, before telling them it worked. Statuses are in
Apilow\GameCoin\Model\OrderStatus.
locale is optional (fr, en or nl; the API ignores any other value) and is not sent when absent. A pending order
that the API reuses takes the language of the latest request.
creator_code (sent as creatorCode) is the code of a creator the buyer wants to support: once the order is delivered
the buyer gets a bonus of free coins and the creator earns a commission. The order keeps it:
$order->creatorCode is null or a CreatorCode with code and creatorName (never the creator's rates); the orders
of the order.* webhooks carry it too. A refused code (unknown, disabled, not valid for this pack...) is a
VALIDATION_FAILED error whose details['fieldErrors']['creatorCode'] is ['CODE_INVALID']. A pack outside its sale
window is a PACK_NOT_AVAILABLE error (details['startsAt'] and details['endsAt']: ISO 8601 text or null), a pack
without stock left a PACK_SOLD_OUT error (details['packSku']); both are HTTP 409 and are never retried.
try { $gamecoin->checkouts->create($player, ['pack_sku' => 'founder', 'creator_code' => 'ALICE10']); } catch (GameCoinException $e) { if ($e->errorCode === ErrorCode::VALIDATION_FAILED && isset($e->details['fieldErrors']['creatorCode'])) { echo "unknown creator code\n"; } elseif ($e->errorCode === ErrorCode::PACK_NOT_AVAILABLE) { echo 'on sale from ', $e->details['startsAt'] ?? 'now', ' until ', $e->details['endsAt'] ?? 'further notice', "\n"; } elseif ($e->errorCode === ErrorCode::PACK_SOLD_OUT) { echo "sold out\n"; } else { throw $e; } }
A refund takes back the coins and items of a paid order. Refusing to refund an unpaid one is a CONFLICT:
try { $gamecoin->orders->refund($checkout->order->id, ['reason' => 'support request']); } catch (GameCoinException $e) { var_dump($e->errorCode === ErrorCode::CONFLICT); // bool(true): nobody paid this order }
A refund has no idempotency key, so the SDK retries it on 429 only. After a network error or a 5xx it throws, and
you check the order with orders->get() before trying again.
Codes
A gift code gives a player free coins (the bonus part of a balance) and items, as the studio configured it in the
dashboard. codes->redeem() redeems one for a player:
try { $redemption = $gamecoin->codes->redeem($player, 'WELCOME-2026'); foreach ($redemption->grants as $grant) { // free coins, with the new balance echo "+{$grant->amount} {$grant->currency}, now {$grant->balance->total}\n"; } foreach ($redemption->items as $item) { // items credited echo "{$item->quantity} x {$item->sku}\n"; } foreach ($redemption->skipped as $item) { // items the player could not receive echo "skipped {$item->quantity} x {$item->sku}\n"; } } catch (GameCoinException $e) { if ($e->errorCode === ErrorCode::VALIDATION_FAILED && isset($e->details['fieldErrors']['code'])) { echo "this code does not work\n"; } else { throw $e; } }
Pass the code as the player typed it: the server ignores case, spaces and dashes, so the SDK does not change it. Every
refusal (unknown, malformed, expired, used up, disabled, already used by this player, or a creator code) is the same
VALIDATION_FAILED error with details['fieldErrors']['code'] === ['CODE_INVALID']: nothing tells a guess from a real
code, and it is never retried. PLAYER_BLOCKED, LIMIT_REACHED (the player already owns the maximum of every item),
CONFLICT (currencies or items archived since) and RATE_LIMITED come through the usual mapping; the code is not used
up by those. Like every mutation, codes->redeem() takes an idempotency_key option (third argument) and its result has
replayed: the same key returns the same Redemption, never a second credit.
Webhooks
GameCoin calls your server when an order is delivered, refunded or disputed. Declare the address in the dashboard
(Webhooks, per environment); it shows the signing secret (whsec_gc_...) once. Webhooks::constructEvent()
checks the signature and returns the event. It needs no client and no API key, and makes no request.
Read the raw body. The signature covers the exact bytes GameCoin sent. Use
file_get_contents('php://input')(or$request->getContent()in Laravel and Symfony), neverjson_decode()d or re-encoded data: one changed character, one reordered key or one different space and the signature no longer matches. Passing an array or an object throws anInvalidArgumentException.
A plain PHP endpoint:
<?php require __DIR__ . '/vendor/autoload.php'; use Apilow\GameCoin\Model\Order; use Apilow\GameCoin\Model\WalletCreditedData; use Apilow\GameCoin\WebhookEventType; use Apilow\GameCoin\Webhooks; use Apilow\GameCoin\WebhookSignatureException; $rawBody = file_get_contents('php://input'); // the RAW body, before any decoding $signature = $_SERVER['HTTP_GAMECOIN_SIGNATURE'] ?? null; // the GameCoin-Signature header try { $event = Webhooks::constructEvent($rawBody, $signature, getenv('GAMECOIN_WEBHOOK_SECRET')); } catch (WebhookSignatureException $e) { error_log('GameCoin webhook refused: ' . $e->reason); // never log the secret http_response_code(400); exit; } // GameCoin can send the same event twice: process each id once (e.g. a unique index on it). if (alreadyProcessed($event->id)) { http_response_code(200); exit; } switch ($event->type) { case WebhookEventType::ORDER_FULFILLED: // $event->data is an Order unlockPurchase($event->data->playerId, $event->data->pack->sku); break; case WebhookEventType::ORDER_REFUNDED: case WebhookEventType::ORDER_CHARGEBACK: revokePurchase($event->data->playerId, $event->data->pack->sku); break; case WebhookEventType::WALLET_CREDITED: // $event->data is a WalletCreditedData foreach ($event->data->credits as $credit) { echo "{$credit->currency}: +{$credit->paid} paid, +{$credit->bonus} bonus\n"; } break; } markProcessed($event->id); http_response_code(200); // any 2xx within 10 seconds means "received"
The same thing in a Laravel controller (declare the route in routes/api.php, which has no CSRF check; if you use
routes/web.php, exclude the path from CSRF validation):
// routes/api.php Route::post('/gamecoin/webhook', \App\Http\Controllers\GameCoinWebhookController::class); // config/services.php: 'gamecoin' => ['webhook_secret' => env('GAMECOIN_WEBHOOK_SECRET')], // app/Http/Controllers/GameCoinWebhookController.php namespace App\Http\Controllers; use Apilow\GameCoin\Model\Order; use Apilow\GameCoin\WebhookEventType; use Apilow\GameCoin\Webhooks; use Apilow\GameCoin\WebhookSignatureException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; class GameCoinWebhookController extends Controller { public function __invoke(Request $request) { try { $event = Webhooks::constructEvent( $request->getContent(), // the raw body, not $request->all() $request->header('GameCoin-Signature'), config('services.gamecoin.webhook_secret'), ); } catch (WebhookSignatureException $e) { report($e); return response('', 400); } // Cache::add() is false when the key exists: this event was already handled. if (!Cache::add('gamecoin-event:' . $event->id, true, now()->addDays(2))) { return response('', 200); } if ($event->type === WebhookEventType::ORDER_FULFILLED && $event->data instanceof Order) { // Unlock what the player bought: $event->data->playerId, $event->data->pack->sku, ... } return response('', 200); } }
constructEvent($rawBody, $signatureHeader, $secret, $options = []) throws a WebhookSignatureException, whose public
reason is one of the WebhookSignatureException constants:
reason |
Meaning |
|---|---|
missing_header |
The GameCoin-Signature header is absent or empty. |
malformed_header |
It is not t=<unix seconds>,v1=<hex>. |
no_matching_signature |
Wrong secret, or the body is not exactly the one received (parsed, re-encoded, trimmed...). |
timestamp_out_of_tolerance |
The signature is right but the call is more than 5 minutes away from your clock (a replay?). |
invalid_payload |
The signature is right but the body is not a GameCoin event. |
The exception does not extend GameCoinException, and its messages never contain the secret, the body or the signature.
Answer 400 and move on. Options: tolerance_seconds (default 300; 0 turns the timestamp check off) and now (a
unix timestamp, a DateTimeInterface or a callable replacing the clock, for tests). Several v1 signatures in the
header are all compared (the one of the current secret is enough), and unknown keys are ignored. The secret is trimmed;
an empty one is an InvalidArgumentException.
The returned WebhookEvent has id, type, createdAt (DateTimeImmutable), env ("test" or "live") and
data, whose type depends on type:
type (constants in WebhookEventType) |
data |
|---|---|
order.fulfilled, order.refunded, order.chargeback |
an Order, as orders->get() returns it (with its creatorCode, null when none) |
wallet.credited |
a WalletCreditedData (playerId, orderId, credits: a list of WalletCredit with currency, paid, bonus) |
webhook.test |
a WebhookTestData (message), sent from the dashboard to check your endpoint |
| any other (a newer type) | the decoded JSON as a plain array: an unknown type does not fail |
Answer with a 2xx within 10 seconds; GameCoin sends an event again after a failure, so keep your handler idempotent
on $event->id. The wallet and the inventory are already updated when the event arrives: an event is a notification,
not a request to credit anything. A runnable demo (it signs a sample event, verifies it, then refuses a tampered one) is
in examples/webhook.php, which doubles as a plain PHP endpoint under php -S. The
webhooks guide describes the delivery and retry rules.
Idempotency and retries
Every call that changes something (wallet->grant, wallet->spend, inventory->grant, inventory->consume,
purchases->create, checkouts->create, codes->redeem) sends an Idempotency-Key. Without your help the SDK generates a UUID v4
once per call and reuses it for each retry of that call, so a retried grant never credits twice.
Give your own key (third argument) when the same action may reach your code twice (a page reloaded, a job restarted):
$key = 'quest-17:user-42'; // 1 to 100 printable ASCII characters $params = ['currency' => 'gems', 'amount' => 50]; $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), "\n"; // false on the first run, true for the repeat var_dump($first->entry->id === $again->entry->id); // bool(true): the original result comes back
replayedistruewhen the API recognised the key and returned the original result; nothing happened twice.- The same key with a different request is an
IDEMPOTENCY_CONFLICTerror. Keys are kept for 30 days. - A replayed
checkouts->create()shows the order in its current state, not the one it had at creation.
Automatic retries: at most max_retries (default 2), on a network error, a timeout, a 5xx or a 429. The delay is
0.5 s x 2^attempt times a random factor from 0.75 to 1.25; a Retry-After header (in seconds or as an HTTP date)
wins, up to 30 seconds included, on a 5xx as on a 429. A longer wait is not worth blocking your request for: the
exception is thrown and its retryAfter tells you how long to wait. 4xx errors other than 429 are never retried.
Reads, players->upsert() and players->createToken() are retried like the mutations; only orders->refund() is
limited to 429.
Errors
Every failure of a call is a GameCoinException:
use Apilow\GameCoin\ErrorCode; use Apilow\GameCoin\GameCoinException; try { $gamecoin->wallet->spend($player, ['currency' => 'gems', 'amount' => 5000]); } catch (GameCoinException $e) { if ($e->errorCode === ErrorCode::INSUFFICIENT_FUNDS) { echo "needs {$e->details['required']}, has {$e->details['available']}\n"; } elseif ($e->errorCode === ErrorCode::RATE_LIMITED) { echo "try again in {$e->retryAfter} seconds\n"; } else { throw $e; } }
| Property | Content |
|---|---|
errorCode |
API code (INSUFFICIENT_FUNDS, NOT_FOUND...), or NETWORK_ERROR / TIMEOUT (status 0), or INVALID_RESPONSE when the body is not the expected JSON. Constants in ErrorCode. |
status |
HTTP status, 0 when no response was received. Also what getCode() returns, so the integer code stays an integer. |
getMessage() |
Message of the API, or a short description. For developers, not for your players. |
details |
details of the API (required and available for INSUFFICIENT_FUNDS, fieldErrors for VALIDATION_FAILED, startsAt and endsAt for PACK_NOT_AVAILABLE, packSku for PACK_SOLD_OUT...), [] when absent. |
retryAfter |
Seconds, when the API sent Retry-After. |
Codes you will meet: VALIDATION_FAILED, UNAUTHENTICATED, FORBIDDEN, PLAYER_BLOCKED, NOT_FOUND,
INSUFFICIENT_FUNDS, LIMIT_REACHED, PACK_NOT_AVAILABLE, PACK_SOLD_OUT, IDEMPOTENCY_CONFLICT, CONFLICT,
RATE_LIMITED, PAYMENT_FAILED, ENV_NOT_ENABLED, INTERNAL. The API reference describes each one.
Arguments that are wrong on their face (an empty player, 'amount' => -5, 'quantity' => 1.5, an unknown key such as
'ammount') never reach the network: they throw InvalidArgumentException at once.
In a browser game
The player's browser never talks to GameCoin with your secret key: it calls an endpoint of yours, which authenticates the player and does the GameCoin call. For example, a "buy a potion" endpoint:
use Apilow\GameCoin\ErrorCode; use Apilow\GameCoin\GameCoin; use Apilow\GameCoin\GameCoinException; use Apilow\GameCoin\PlayerRef; /** What your endpoint does once it knows who the player is. */ function buyPotion(GameCoin $gamecoin, string $userId): array { try { $purchase = $gamecoin->purchases->create(PlayerRef::ext($userId), ['sku' => 'potion']); return ['ok' => true, 'gems' => $purchase->balance->total, 'potions' => $purchase->item->quantity]; } catch (GameCoinException $e) { return match ($e->errorCode) { ErrorCode::INSUFFICIENT_FUNDS => ['ok' => false, 'error' => 'not_enough_gems', 'missing' => $e->details['required'] - $e->details['available']], ErrorCode::LIMIT_REACHED => ['ok' => false, 'error' => 'limit_reached'], default => throw $e, }; } } header('Content-Type: application/json'); echo json_encode(buyPotion($gamecoin, 'user-42')), "\n";
Testing your code
The HTTP layer is an interface, so your own tests need no network and no mock library:
use Apilow\GameCoin\GameCoin; use Apilow\GameCoin\Http\HttpRequest; use Apilow\GameCoin\Http\HttpResponse; use Apilow\GameCoin\Http\TransportInterface; $fake = new class implements TransportInterface { public function send(HttpRequest $request): HttpResponse // $request->method, ->url, ->headers, ->body { return new HttpResponse(200, [], '{"playerId": "p1", "balances": []}'); } }; $testClient = new GameCoin('gc_sk_test_' . str_repeat('x', 43), ['transport' => $fake]); var_dump($testClient->wallet->get('ext:user-42')->balances); // array(0) {}
A transport returns an HttpResponse for every status (4xx and 5xx included), throws
Apilow\GameCoin\Http\TransportException when no response was received (timeout: true for a timeout), and never
follows redirects.
Running the tests
composer install composer test # unit tests: no network, no key needed
The integration tests run against a sandbox game and are skipped unless both variables are set:
GAMECOIN_SECRET_KEY=gc_sk_test_... GAMECOIN_BASE_URL=http://localhost:3400 composer test:integration
Status
Pre-release (0.4.0). The API has a test environment (simulated payments) and a live environment (real payments), reserved for verified studios.
Licence
MIT © 2026 Apilow