Skip to content
Skip the menu

Server SDKs

Python SDK

Install the GameCoin SDK for Python and use it from your game backend to manage wallets, the ledger, inventory, checkouts and refunds.

View as Markdown

On this page

The Python SDK is the server SDK for backends written in Python. 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 PyPI. The package name below is the planned one and may change before the first release.

Bash
pip install apilow-gamecoin

It needs Python 3.9 or later and has no dependency: it uses the standard library only. The package is typed (py.typed). You install apilow-gamecoin and import gamecoin:

Python
from gamecoin import GameCoin, ErrorCode, GameCoinError, ext

Create the client

Create one client per game and environment, and share it: it is synchronous and thread-safe. 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.

Python
import os

gamecoin = GameCoin(os.environ["GAMECOIN_SECRET_KEY"], base_url="https://gamecoin.apilow.com")
print(gamecoin.environment)  # test or live, read from the key

https://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. Every option is optional and keyword-only:

OptionDefaultMeaning
base_urlhttps://gamecoin.apilow.comOrigin of the service. The SDK appends /api/v1, drops trailing slashes and keeps a path prefix. Only http and https are accepted.
timeout30Seconds, applied to each socket operation (connect, send, read) of an attempt, not to the whole exchange.
max_retries2Retries after the first attempt, from 0 to 10: three attempts by default. 0 turns retries off.
transporturllibA function that sends one HTTP request. See Testing your integration.
sleeptime.sleepYour own wait between retries, for tests.
Python
tuned = GameCoin(
    os.environ["GAMECOIN_SECRET_KEY"],
    base_url="https://gamecoin.apilow.com",
    timeout=10,  # seconds per socket operation
    max_retries=3,  # retry up to three times, four attempts in all
)
print(tuned.environment)

The constructor refuses anything that is not a secret key, before any request. 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 error or in a repr, which shows its prefix only (gc_sk_test_…).

There is no asyncio client in this version. In an asyncio application, hand the call to a worker thread:

Python
import asyncio


async def welcome_gift(user_id: str):
    return await asyncio.to_thread(gamecoin.wallet.grant, ext(user_id), currency="gems", amount=10, reason="welcome_gift")


print(asyncio.run(welcome_gift("user-43")).entry.type)  # grant

Players and the ext helper

A player is a GameCoin player id, or your own id through ext():

Python
player = ext("user-42")
print(player)  # ext:user-42

Any string works as your id (spaces, slashes, % and accents included): 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.

Python
from datetime import datetime, timezone

profile = gamecoin.players.upsert(player, display_name="Alice", country="BE")
same = gamecoin.players.get(player)
print(profile.id == same.id, profile.external_id, profile.kind)  # True user-42 external

# A one-hour token for the browser SDK of your game client
token = gamecoin.players.create_token(player)
print(token.token.startswith("gc_pt_"), token.expires_at > datetime.now(timezone.utc))  # True True

In players.upsert, a field you leave out is unchanged and None clears it:

Python
cleared = gamecoin.players.upsert(player, display_name=None)
print(cleared.display_name, cleared.country)  # None BE

Wherever a player is expected you may also pass 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.

Python
movement = gamecoin.wallet.grant(player, currency="gems", amount=100, reason="welcome_gift")
print(movement.balance.total, movement.balance.currency)  # 100 gems

spent = gamecoin.wallet.spend(player, currency="gems", amount=30, reason="revive")
print(spent.balance.total, spent.entry.type)  # 70 spend

amount is an integer of at least 1. reason is a short text and metadata a dict of str to str: both are recorded on the ledger entry.

Python
reward = gamecoin.wallet.grant(player, currency="gold", amount=25, reason="daily_reward", metadata={"day": "3"})
print(reward.entry.type, reward.entry.bonus_delta, reward.balance.total)  # grant 25 25

wallet.get returns one balance per active currency, zeros included:

Python
wallet = gamecoin.wallet.get(player)
for balance in wallet.balances:
    print(balance.currency, balance.paid, balance.bonus, balance.total)
# gems 0 70 70
# gold 0 25 25
print(wallet.balance_of("gems").total)  # 70

Ledger

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).

Python
page = gamecoin.ledger.list(player, currency="gems", limit=1)
print(len(page.entries), page.next_cursor is not None)  # 1 True

older = gamecoin.ledger.list(player, currency="gems", limit=1, cursor=page.next_cursor)
print(older.entries[0].type)  # grant

next_cursor is None on the last page. To avoid handling cursors yourself, iterate: each page is fetched only when the loop reaches it, so you can stop early.

Python
for entry in gamecoin.ledger.iterate(player, currency="gems", limit=50):
    print(entry.created_at.isoformat(), entry.type, entry.paid_delta + entry.bonus_delta, entry.balance_after.total)
    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.

Python
catalog = gamecoin.catalog.get()
print([f"{item.sku} ({item.type})" for item in catalog.items])  # ['potion (consumable)', 'fire-sword (durable)']
Python
granted = gamecoin.inventory.grant(player, sku="potion", quantity=3)
print(granted.quantity)  # 3

used = gamecoin.inventory.consume(player, sku="potion")  # quantity defaults to 1
print(used.quantity)  # 2

owned = gamecoin.inventory.list(player)  # owned items only (quantity above 0)
print([f"{item.sku} x{item.quantity}" for item in owned])  # ['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.

Python
purchase = gamecoin.purchases.create(player, sku="potion")
print(purchase.balance.total, purchase.item.quantity, purchase.entry.ref.item_sku)  # 50 3 potion

An item can limit how many a player owns. Going beyond fails with LIMIT_REACHED:

Python
gamecoin.inventory.grant(player, sku="fire-sword")
try:
    gamecoin.inventory.grant(player, sku="fire-sword")  # limited to one per player
except GameCoinError as error:
    print(error.code == ErrorCode.LIMIT_REACHED)  # 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_url, and the coins and items are delivered when the payment succeeds.

Python
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)
)
print(checkout.order.status, checkout.checkout_url.startswith("http"))  # pending True

order = gamecoin.orders.get(checkout.order.id)
print(order.status, order.amount_cents, order.vat_cents, order.net_cents)  # pending 499 87 412

The 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 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:

Python
try:
    gamecoin.orders.refund(order.id, reason="player request")
except GameCoinError as error:
    if error.code == ErrorCode.CONFLICT:
        print("not refundable:", error.message)
    else:
        raise

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 to make a retry of your code safe too, for instance when 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:

Python
key = "quest-17:user-42"
first = gamecoin.wallet.grant(player, currency="gems", amount=10, idempotency_key=key)
again = gamecoin.wallet.grant(player, currency="gems", amount=10, idempotency_key=key)
print(first.replayed, again.replayed, first.entry.id == again.entry.id)  # False True True

The 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):

FailureRetried
Network error, timeout, 5xxYes (not for orders.refund)
429Yes, after the Retry-After delay
Any other 4xxNever

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 error carries retry_after, in seconds, so that you decide. Every read, players.upsert and players.create_token follow the same rules as the changes above.

Errors

Anything that goes wrong with a request is a GameCoinError:

AttributeContent
codeThe API code (INSUFFICIENT_FUNDS, NOT_FOUND…), or NETWORK_ERROR or TIMEOUT (status 0), or INVALID_RESPONSE when the body is not the expected JSON.
statusThe HTTP status, 0 when no response was received.
messageThe message of the API, or a short description. Written for developers, not for your players.
detailsThe details of the API (required, available, fieldErrors…), {} when absent.
retry_afterSeconds, when the API sent Retry-After, otherwise None.
Python
try:
    gamecoin.wallet.spend(player, currency="gems", amount=1_000_000)
except GameCoinError as error:
    if error.code == ErrorCode.INSUFFICIENT_FUNDS:
        print(f"needs {error.details['required']}, has {error.details['available']}")  # needs 1000000, has 60
    elif error.code == ErrorCode.RATE_LIMITED:
        print(f"try again in {error.retry_after} seconds")
    else:
        raise

ErrorCode holds a constant for every code. These are the ones you will meet:

HTTPCodeWhen
400VALIDATION_FAILEDA field is invalid (details["fieldErrors"]).
401UNAUTHENTICATEDThe secret key is missing, unknown or revoked.
402PAYMENT_FAILEDThe payment was refused, or no payment provider exists for this environment.
403FORBIDDENThis kind of key is not allowed here.
403PLAYER_BLOCKEDThe player is blocked.
403ENV_NOT_ENABLEDThe live environment is used by a studio that is not verified.
404NOT_FOUNDUnknown player, currency, item, pack or order.
409INSUFFICIENT_FUNDSThe wallet or the item quantity is too low (details["required"], details["available"]).
409LIMIT_REACHEDmaxOwned, maxPerPlayer or the balance ceiling is exceeded.
409IDEMPOTENCY_CONFLICTThe key was reused with another request.
409CONFLICTInvalid state change, such as refunding an unpaid order.
429RATE_LIMITEDToo many requests (retry_after).
500INTERNALUnexpected error on the GameCoin side.
0NETWORK_ERROR, TIMEOUTNo answer. The SDK has already retried.
anyINVALID_RESPONSEThe 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. A wrong type raises TypeError and an unacceptable value ValueError, not a GameCoinError, and the message never contains the offending value:

Python
try:
    gamecoin.wallet.grant(player, currency="gems", amount=-5)
except ValueError as error:
    print(type(error).__name__, isinstance(error, GameCoinError))  # ValueError False

Testing your integration

The HTTP layer is a plain function, so your own tests need no network and no mocking library. The transport receives an HttpRequest (method, url, headers, body) and returns an HttpResponse(status, headers, body) for every status, 4xx and 5xx included. It raises TimeoutError on a timeout and OSError (for instance ConnectionError) on any other network failure. With sleep, you also skip the waits between retries:

Python
from gamecoin import HttpResponse

calls = []


def flaky(request):
    calls.append(request.headers["Idempotency-Key"])
    if len(calls) < 3:
        return HttpResponse(502, {}, b"Bad gateway")
    return HttpResponse(200, {}, b'{"item": {"sku": "potion", "quantity": 1, "updatedAt": "2026-10-05T12:00:00.000Z"}}')


offline = GameCoin("gc_sk_test_" + "x" * 40, transport=flaky, sleep=lambda seconds: None)
gift = offline.inventory.grant(player, sku="potion")
print(gift.quantity, len(calls), len(set(calls)))  # 1 3 1

Two 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:

Python
import uuid

sandbox = GameCoin(os.environ["GAMECOIN_SECRET_KEY"], base_url="https://gamecoin.apilow.com")
fresh = ext(f"test-{uuid.uuid4().hex[:8]}")
start = sandbox.wallet.grant(fresh, currency="gems", amount=100)
print(start.balance.total)  # 100

Reference

player is a string, from ext() or a GameCoin player id, or a Player returned by the SDK. All the arguments after player are keyword-only. The calls marked with an asterisk accept an idempotency_key.

MethodReturnsHTTP request
gamecoin.catalog.get()CatalogGET /catalog
gamecoin.players.upsert(player, *, display_name, email, country, birth_year)PlayerPUT /players/{player}
gamecoin.players.get(player)PlayerGET /players/{player}
gamecoin.players.create_token(player)PlayerTokenPOST /players/{player}/tokens
gamecoin.wallet.get(player)WalletGET /players/{player}/wallet
gamecoin.wallet.grant(player, *, currency, amount, reason=None, metadata=None, idempotency_key=None) *MovementPOST /players/{player}/wallet/grant
gamecoin.wallet.spend(player, *, currency, amount, reason=None, metadata=None, idempotency_key=None) *MovementPOST /players/{player}/wallet/spend
gamecoin.ledger.list(player, *, currency=None, limit=None, cursor=None)LedgerPageGET /players/{player}/ledger
gamecoin.ledger.iterate(player, *, currency=None, limit=None)Iterator[LedgerEntry]GET /players/{player}/ledger, page after page
gamecoin.inventory.list(player)List[InventoryItem]GET /players/{player}/inventory
gamecoin.inventory.grant(player, *, sku, quantity=None, idempotency_key=None) *InventoryItemPOST /players/{player}/inventory/grant
gamecoin.inventory.consume(player, *, sku, quantity=None, idempotency_key=None) *InventoryItemPOST /players/{player}/inventory/consume
gamecoin.purchases.create(player, *, sku, quantity=None, idempotency_key=None) *PurchasePOST /players/{player}/purchases
gamecoin.checkouts.create(player, *, pack_sku, success_url=None, cancel_url=None, country=None, idempotency_key=None) *CheckoutPOST /players/{player}/checkouts
gamecoin.orders.get(order_id)OrderGET /orders/{orderId}
gamecoin.orders.refund(order_id, *, reason=None)OrderPOST /orders/{orderId}/refund

In players.upsert, the four profile arguments default to UNSET (importable from gamecoin): the field is left as it is. Pass None to clear it. quantity is 1 when omitted, and limit is the page size of the API (20) when omitted.

The results are frozen dataclasses with snake_case fields, timezone-aware datetime values for every timestamp, and tuples for collections (wallet.balances, page.entries, catalog.items), except inventory.list, which returns a list:

TypeFields
Movemententry (a ledger entry), balance, replayed
Purchaseentry, balance, item, replayed
Checkoutorder, checkout_url, replayed
InventoryItemsku, quantity, updated_at, replayed (meaningful on the result of grant and consume)
LedgerPageentries, next_cursor (None on the last page)
PlayerTokentoken, expires_at
Walletplayer_id, balances, and the method balance_of(currency)

The package also exports GameCoin, GameCoinError, ErrorCode, ext, UNSET, DEFAULT_BASE_URL, __version__, the transport types (HttpRequest, HttpResponse, Transport) 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.