Search by

apilow / gamecoin

edrcq

Server SDK for Apilow GameCoin: the bank of your game (virtual currency, inventory, sales). No dependencies.

dev-main 2026-10-24 05:07 UTC

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-curl and ext-json. No framework needed, and nothing to configure on a shared host that has cURL.
  • Typed all the way: declare(strict_types=1), readonly properties, DateTimeImmutable dates.
  • Safe to retry: every mutation carries an idempotency key and the SDK retries network errors, 5xx and 429 for 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 test only. 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), never json_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 an InvalidArgumentException.

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
  • replayed is true when the API recognised the key and returned the original result; nothing happened twice.
  • The same key with a different request is an IDEMPOTENCY_CONFLICT error. 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