Server SDKs
Go SDK
Install the GameCoin SDK for Go and use it from your game backend to manage wallets, the ledger, inventory, checkouts and refunds.
On this page
The Go SDK is the server SDK for backends written in Go. 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: the module is not available through go get today. The module path below is the planned one and may change before the first release.
go get github.com/apilow/gamecoin-goIt needs Go 1.21 or later and uses the standard library only (net/http, encoding/json, crypto/rand). The package is named gamecoin:
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
gamecoin "github.com/apilow/gamecoin-go"
)The examples on this page use these imports, a ctx and a client, and a tiny helper that stops the program on an error:
func check(err error) {
if err != nil {
log.Fatal(err)
}
}Create the client
Create one client per game and environment, keep it, and share it: it is safe for concurrent use and reuses its connections. 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 manager, never from your code.
ctx := context.Background()
client, err := gamecoin.New(os.Getenv("GAMECOIN_SECRET_KEY"), gamecoin.WithBaseURL("https://gamecoin.apilow.com"))
check(err)
fmt.Println(client.Environment()) // test or live, read from the keyhttps://gamecoin.apilow.com is the address of this service, which is also the default (gamecoin.DefaultBaseURL): you only need WithBaseURL to point the SDK at another address. Every option is optional:
| Option | Default | Meaning |
|---|---|---|
WithBaseURL(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. |
WithTimeout(d) | 30 seconds | A time.Duration for each HTTP attempt: a retry gets a fresh one. |
WithMaxRetries(n) | 2 | Retries after the first attempt, from 0 to 10: three attempts by default. 0 turns retries off. |
WithHTTPClient(c) | a client of its own | Your own *http.Client: a proxy, a custom transport, instrumentation. The SDK never modifies it. See Testing your integration. |
tuned, err := gamecoin.New(
os.Getenv("GAMECOIN_SECRET_KEY"),
gamecoin.WithBaseURL("https://gamecoin.apilow.com"),
gamecoin.WithTimeout(10*time.Second), // give up on an attempt after 10 seconds
gamecoin.WithMaxRetries(3), // retry up to three times, four attempts in all
)
check(err)
fmt.Println(tuned.Environment())gamecoin.New refuses anything that is not a secret key, before any request, with an error that wraps gamecoin.ErrInvalidArgument. 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, and the string form of the client (%v, %+v, %#v) shows its prefix only (gc_sk_test_…).
Players and the Ext helper
A player is a GameCoin player id, or your own id through gamecoin.Ext():
player := gamecoin.Ext("user-42")
fmt.Println(player) // ext:user-42Any 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. To name a player by its GameCoin id, use gamecoin.PlayerID(id).
name, country := "Alice", "BE"
profile, err := client.Players.Upsert(ctx, player, &gamecoin.PlayerProfile{DisplayName: &name, Country: &country})
check(err)
same, err := client.Players.Get(ctx, player)
check(err)
fmt.Println(profile.ID == same.ID, *profile.ExternalID, profile.Kind) // true user-42 external
// A one-hour token for the browser SDK of your game client
token, err := client.Players.CreateToken(ctx, player)
check(err)
fmt.Println(strings.HasPrefix(token.Token, "gc_pt_"), token.ExpiresAt.After(time.Now())) // true trueIn a PlayerProfile, a nil field is left unchanged. To clear a field, list it in Clear:
cleared, err := client.Players.Upsert(ctx, player, &gamecoin.PlayerProfile{Clear: []gamecoin.ProfileField{gamecoin.FieldDisplayName}})
check(err)
fmt.Println(cleared.DisplayName == nil, *cleared.Country) // true BEWallet
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.
granted, err := client.Wallet.Grant(ctx, player, gamecoin.GrantParams{Currency: "gems", Amount: 100, Reason: "welcome_gift"})
check(err)
fmt.Println(granted.Balance.Total, granted.Balance.Currency) // 100 gems
spent, err := client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 30, Reason: "revive"})
check(err)
fmt.Println(spent.Balance.Total, spent.Entry.Type) // 70 spendAmount is an int64 of at least 1. Reason is a short text and Metadata a map[string]string: both are recorded on the ledger entry.
reward, err := client.Wallet.Grant(ctx, player, gamecoin.GrantParams{
Currency: "gold",
Amount: 25,
Reason: "daily_reward",
Metadata: map[string]string{"day": "3"},
})
check(err)
fmt.Println(reward.Entry.Type, reward.Entry.BonusDelta, reward.Balance.Total) // grant 25 25Wallet.Get returns one balance per active currency, zeros included:
wallet, err := client.Wallet.Get(ctx, player)
check(err)
for _, b := range wallet.Balances {
fmt.Println(b.Currency, b.Paid, b.Bonus, b.Total)
}
// gems 0 70 70
// gold 0 25 25Ledger
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 (0 means the default of 20). A nil *LedgerListParams lists the first page.
page, err := client.Ledger.List(ctx, player, &gamecoin.LedgerListParams{Currency: "gems", Limit: 1})
check(err)
fmt.Println(len(page.Entries), page.NextCursor != nil) // 1 true
older, err := client.Ledger.List(ctx, player, &gamecoin.LedgerListParams{Currency: "gems", Limit: 1, Cursor: *page.NextCursor})
check(err)
fmt.Println(older.Entries[0].Type) // grantNextCursor is nil on the last page. To avoid handling cursors yourself, iterate. Ledger.Iterate returns an iterator that fetches each page only when the loop needs it, so you can stop early. Requests start with the first call to Next, and Err tells the end of the ledger from a failure:
it := client.Ledger.Iterate(ctx, player, &gamecoin.LedgerListParams{Currency: "gems", Limit: 50})
for it.Next() {
entry := it.Entry()
fmt.Println(entry.CreatedAt.Format(time.RFC3339), entry.Type, entry.PaidDelta+entry.BonusDelta, entry.BalanceAfter.Total)
if entry.Type == gamecoin.LedgerGrant {
break // no further page is fetched
}
}
check(it.Err())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, err := client.Catalog.Get(ctx)
check(err)
for _, item := range catalog.Items {
fmt.Println(item.SKU, item.Type)
}
// potion consumable
// fire-sword durablegiven, err := client.Inventory.Grant(ctx, player, gamecoin.InventoryParams{SKU: "potion", Quantity: 3})
check(err)
fmt.Println(given.Quantity) // 3
used, err := client.Inventory.Consume(ctx, player, gamecoin.InventoryParams{SKU: "potion"}) // Quantity 0 means 1
check(err)
fmt.Println(used.Quantity) // 2
owned, err := client.Inventory.List(ctx, player) // owned items only (quantity above 0)
check(err)
for _, item := range owned {
fmt.Printf("%s x%d\n", item.SKU, item.Quantity) // 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, err := client.Purchases.Create(ctx, player, gamecoin.PurchaseParams{SKU: "potion"})
check(err)
fmt.Println(purchase.Balance.Total, purchase.Item.Quantity, *purchase.Entry.Ref.ItemSKU) // 50 3 potionAn item can limit how many a player owns. Going beyond fails with LIMIT_REACHED:
_, err = client.Inventory.Grant(ctx, player, gamecoin.InventoryParams{SKU: "fire-sword"})
check(err)
// The sword is limited to one per player
_, err = client.Inventory.Grant(ctx, player, gamecoin.InventoryParams{SKU: "fire-sword"})
fmt.Println(gamecoin.IsCode(err, gamecoin.CodeLimitReached)) // trueCheckout, 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 CheckoutURL, and the coins and items are delivered when the payment succeeds.
checkout, err := client.Checkouts.Create(ctx, player, gamecoin.CheckoutParams{
PackSKU: "gems-500",
SuccessURL: "https://example.com/shop/thanks", // where the player goes after paying
CancelURL: "https://example.com/shop",
Country: "BE", // the buyer's country decides the VAT (EU countries only)
})
check(err)
fmt.Println(checkout.Order.Status, strings.HasPrefix(checkout.CheckoutURL, "http")) // pending true
order, err := client.Orders.Get(ctx, checkout.Order.ID)
check(err)
fmt.Println(order.Status, order.AmountCents, order.VatCents, order.NetCents) // 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 SuccessURL, before telling them it worked. The statuses are the constants gamecoin.OrderPending, OrderPaid, OrderFulfilled and so on.
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:
_, err = client.Orders.Refund(ctx, order.ID, &gamecoin.RefundParams{Reason: "player request"})
switch {
case gamecoin.IsCode(err, gamecoin.CodeConflict):
fmt.Println("not refundable:", err)
case err != nil:
check(err)
}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 with the gamecoin.WithIdempotencyKey call option 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:
key := gamecoin.WithIdempotencyKey("quest-17:user-42")
params := gamecoin.GrantParams{Currency: "gems", Amount: 10}
first, err := client.Wallet.Grant(ctx, player, params, key)
check(err)
again, err := client.Wallet.Grant(ctx, player, params, key)
check(err)
fmt.Println(first.Replayed, again.Replayed, first.Entry.ID == again.Entry.ID) // 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 as many times as WithMaxRetries says (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 context was cancelled or expired | Never: the call returns the error of the context |
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 RetryAfter, so that you decide. Every read, Players.Upsert and Players.CreateToken follow the same rules as the changes above.
Every call takes a context.Context. Cancelling it, or letting its deadline pass, cancels the call and any retry in progress. Use a deadline to bound a whole call, retries included, whereas WithTimeout bounds one attempt:
quick, cancel := context.WithTimeout(ctx, 5*time.Second)
latest, err := client.Wallet.Get(quick, player)
cancel()
check(err)
fmt.Println(len(latest.Balances)) // 2Errors
A call that fails after a request was attempted returns a *gamecoin.Error:
| Field | Content |
|---|---|
Code | 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. |
Message | The message of the API, or a short description. Written for developers, not for your players. |
Details | The details of the API as a map[string]any (required, available, fieldErrors…), empty when absent. Numbers are json.Number: read them with DetailInt64. |
RetryAfter | A time.Duration (the header is in seconds), set when the API sent Retry-After. |
Get the error with errors.As, or test a code with gamecoin.IsCode(err, code):
_, err = client.Wallet.Spend(ctx, player, gamecoin.SpendParams{Currency: "gems", Amount: 1_000_000})
var gcErr *gamecoin.Error
switch {
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeInsufficientFunds:
required, _ := gcErr.DetailInt64("required")
available, _ := gcErr.DetailInt64("available")
fmt.Printf("needs %d, has %d\n", required, available) // needs 1000000, has 60
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeRateLimited:
fmt.Println("try again in", gcErr.RetryAfter)
case err != nil:
check(err)
}The codes are constants: CodeValidationFailed, CodeUnauthenticated, CodeNotFound and so on. These are the ones you will meet:
| HTTP | Code | When |
|---|---|---|
| 400 | CodeValidationFailed | A field is invalid (Details["fieldErrors"]). |
| 401 | CodeUnauthenticated | The secret key is missing, unknown or revoked. |
| 402 | CodePaymentFailed | The payment was refused, or no payment provider exists for this environment. |
| 403 | CodeForbidden | This kind of key is not allowed here. |
| 403 | CodePlayerBlocked | The player is blocked. |
| 403 | CodeEnvNotEnabled | The live environment is used by a studio that is not verified. |
| 404 | CodeNotFound | Unknown player, currency, item, pack or order. |
| 409 | CodeInsufficientFunds | The wallet or the item quantity is too low (required and available in Details). |
| 409 | CodeLimitReached | maxOwned, maxPerPlayer or the balance ceiling is exceeded. |
| 409 | CodeIdempotencyConflict | The key was reused with another request. |
| 409 | CodeConflict | Invalid state change, such as refunding an unpaid order. |
| 429 | CodeRateLimited | Too many requests (RetryAfter). |
| 500 | CodeInternal | Unexpected error on the GameCoin side. |
| 0 | CodeNetworkError, CodeTimeout | No answer. The SDK has already retried. |
| any | CodeInvalidResponse | The body is not the expected JSON. |
CodeIdempotencyKeyRequired 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. The call returns an error that wraps gamecoin.ErrInvalidArgument, which is not a *gamecoin.Error, and the message never contains the offending value. When the context ends, the call returns the error of the context:
_, err = client.Wallet.Grant(ctx, player, gamecoin.GrantParams{Currency: "gems", Amount: -5})
fmt.Println(errors.Is(err, gamecoin.ErrInvalidArgument), gamecoin.IsCode(err, gamecoin.CodeValidationFailed)) // true falseTesting your integration
The HTTP layer is yours to replace: give WithHTTPClient a client whose Transport is a function, and your own tests need no network and no mocking library. A response that carries Retry-After: 0 is retried at once, so this example does not wait:
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }var keys []string
fake := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
keys = append(keys, r.Header.Get("Idempotency-Key"))
response := &http.Response{StatusCode: http.StatusBadGateway, Header: http.Header{"Retry-After": {"0"}}, Request: r}
response.Body = io.NopCloser(strings.NewReader("Bad gateway"))
if len(keys) == 3 {
response.StatusCode = http.StatusOK
response.Body = io.NopCloser(strings.NewReader(`{"item": {"sku": "potion", "quantity": 1, "updatedAt": "2026-10-05T12:00:00.000Z"}}`))
}
return response, nil
})}
offline, err := gamecoin.New("gc_sk_test_"+strings.Repeat("x", 40), gamecoin.WithHTTPClient(fake))
check(err)
gift, err := offline.Inventory.Grant(ctx, player, gamecoin.InventoryParams{SKU: "potion"})
check(err)
fmt.Println(gift.Quantity, len(keys), keys[0] == keys[2]) // 1 3 trueTwo failures and a success: three attempts, and a single idempotency key sent three times. To turn the retries off in a test, use gamecoin.WithMaxRetries(0).
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, err := gamecoin.New(os.Getenv("GAMECOIN_SECRET_KEY"), gamecoin.WithBaseURL("https://gamecoin.apilow.com"))
check(err)
fresh := gamecoin.Ext(fmt.Sprintf("test-%d", time.Now().UnixNano()))
start, err := sandbox.Wallet.Grant(ctx, fresh, gamecoin.GrantParams{Currency: "gems", Amount: 100})
check(err)
fmt.Println(start.Balance.Total) // 100Reference
player is a gamecoin.PlayerRef, from gamecoin.Ext() or gamecoin.PlayerID(). It is a string type, so an untyped string constant converts to it. Every call takes a context.Context first, and returns an error last. The calls marked with an asterisk take ...gamecoin.CallOption, which can hold gamecoin.WithIdempotencyKey(key).
| Method | Returns | HTTP request |
|---|---|---|
client.Catalog.Get(ctx) | (*Catalog, error) | GET /catalog |
client.Players.Upsert(ctx, player, profile *PlayerProfile) | (*Player, error) | PUT /players/{player} |
client.Players.Get(ctx, player) | (*Player, error) | GET /players/{player} |
client.Players.CreateToken(ctx, player) | (*PlayerToken, error) | POST /players/{player}/tokens |
client.Wallet.Get(ctx, player) | (*Wallet, error) | GET /players/{player}/wallet |
client.Wallet.Grant(ctx, player, params GrantParams, opts ...CallOption) * | (*Movement, error) | POST /players/{player}/wallet/grant |
client.Wallet.Spend(ctx, player, params SpendParams, opts ...CallOption) * | (*Movement, error) | POST /players/{player}/wallet/spend |
client.Ledger.List(ctx, player, params *LedgerListParams) | (*LedgerPage, error) | GET /players/{player}/ledger |
client.Ledger.Iterate(ctx, player, params *LedgerListParams) | *LedgerIterator (Next, Entry, Err) | GET /players/{player}/ledger, page after page |
client.Inventory.List(ctx, player) | ([]InventoryItem, error) | GET /players/{player}/inventory |
client.Inventory.Grant(ctx, player, params InventoryParams, opts ...CallOption) * | (*InventoryItem, error) | POST /players/{player}/inventory/grant |
client.Inventory.Consume(ctx, player, params InventoryParams, opts ...CallOption) * | (*InventoryItem, error) | POST /players/{player}/inventory/consume |
client.Purchases.Create(ctx, player, params PurchaseParams, opts ...CallOption) * | (*Purchase, error) | POST /players/{player}/purchases |
client.Checkouts.Create(ctx, player, params CheckoutParams, opts ...CallOption) * | (*Checkout, error) | POST /players/{player}/checkouts |
client.Orders.Get(ctx, orderID string) | (*Order, error) | GET /orders/{orderId} |
client.Orders.Refund(ctx, orderID string, params *RefundParams) | (*Order, error) | POST /orders/{orderId}/refund |
A nil pointer for profile, LedgerListParams or RefundParams sends the defaults. In the parameter structs, an empty string or a zero value means "not set":
| Struct | Fields |
|---|---|
PlayerProfile | DisplayName, Email, Country (*string), BirthYear (*int), and Clear ([]ProfileField: FieldDisplayName, FieldEmail, FieldCountry, FieldBirthYear) |
GrantParams, SpendParams | Currency and Amount (required, int64), Reason, Metadata (map[string]string) |
InventoryParams, PurchaseParams | SKU (required), Quantity (int64, 0 means 1) |
LedgerListParams | Currency, Limit (1 to 100, 0 means the default), Cursor |
CheckoutParams | PackSKU (required), SuccessURL, CancelURL, Country |
RefundParams | Reason |
The results are structs with time.Time values for every timestamp and pointers for nullable fields:
| Type | Fields |
|---|---|
Movement | Entry (a LedgerEntry), 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 (nil on the last page) |
PlayerToken | Token, ExpiresAt |
The package also exports New, the With… options, Ext, PlayerID, Error, IsCode, ErrInvalidArgument, Version, DefaultBaseURL, and a type for every object of the API (Player, Wallet, Balance, Catalog, Order…) with constants for their string values (OrderFulfilled, LedgerGrant, ItemConsumable…). The fields of each object match the API reference, where each resource has its page: catalog, players, wallet, ledger, inventory, purchases, checkouts and orders.