Guides
Selling packs
Sell a pack for euros with a checkout and the hosted payment page, from the VAT to the return URL and the life of an order.
On this page
What you will do
A pack is what a player buys with euros: currency, items, or both. You sell it in four steps:
- Create a checkout. GameCoin makes an order and a payment URL.
- Send the player to the payment page, which is hosted for you.
- The player pays. GameCoin delivers the currency and items.
- The player comes back to your game, and you read the order.
The browser SDK does all four with one call, gc.checkout("gems-500"). This page is for the steps behind it, and for selling from your server. The examples use the pack gems-500: 500 gems and 50 free gems for 4.99 €.
Note
In the test environment, payments are simulated: the payment page offers a button to pay and a button to decline, and no money moves. Real payments are not available yet.
/players/{player}/checkouts| Field | Required | Meaning |
|---|---|---|
packSku | yes | The code of the pack |
successUrl | no | Where to send the player after a successful payment |
cancelUrl | no | Where to send the player after a declined payment |
country | no | The buyer's country, which decides the VAT. Server API only. |
The call needs an idempotency key. It answers 201 with the new order and the URL of the payment page.
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/checkouts" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{"packSku":"gems-500","country":"FR","successUrl":"https://example.com/shop/thanks","cancelUrl":"https://example.com/shop"}'const { order, checkoutUrl } = await gamecoin.checkouts.create(
ext("user-42"),
{
packSku: "gems-500",
country: "FR",
successUrl: "https://example.com/shop/thanks",
cancelUrl: "https://example.com/shop",
},
{ idempotencyKey: "order-1001" },
);
console.log(order.status, order.vatRateBp, checkoutUrl); // pending 2000 https://gamecoin.apilow.com/pay/…?t=…checkout = gamecoin.checkouts.create(
ext("user-42"),
pack_sku="gems-500",
country="FR",
success_url="https://example.com/shop/thanks",
cancel_url="https://example.com/shop",
idempotency_key="order-1001",
)
print(checkout.order.status, checkout.order.vat_rate_bp, checkout.checkout_url) # pending 2000 https://gamecoin.apilow.com/pay/…?t=…$checkout = $gamecoin->checkouts->create(
PlayerRef::ext('user-42'),
[
'pack_sku' => 'gems-500',
'country' => 'FR',
'success_url' => 'https://example.com/shop/thanks',
'cancel_url' => 'https://example.com/shop',
],
['idempotency_key' => 'order-1001'],
);
echo $checkout->order->status, ' ', $checkout->order->vatRateBp, ' ', $checkout->checkoutUrl, "\n"; // pending 2000 https://gamecoin.apilow.com/pay/…?t=…checkout, err := client.Checkouts.Create(
ctx,
gamecoin.Ext("user-42"),
gamecoin.CheckoutParams{
PackSKU: "gems-500",
Country: "FR",
SuccessURL: "https://example.com/shop/thanks",
CancelURL: "https://example.com/shop",
},
gamecoin.WithIdempotencyKey("order-1001"),
)
if err != nil {
log.Fatal(err)
}
fmt.Println(checkout.Order.Status, checkout.Order.VatRateBp, checkout.CheckoutURL) // pending 2000 https://gamecoin.apilow.com/pay/…?t=…var checkout = await gamecoin.Checkouts.CreateAsync(
PlayerRef.Ext("user-42"),
new CheckoutRequest
{
PackSku = "gems-500",
Country = "FR",
SuccessUrl = "https://example.com/shop/thanks",
CancelUrl = "https://example.com/shop",
},
new RequestOptions { IdempotencyKey = "order-1001" });
Console.WriteLine($"{checkout.Order.Status} {checkout.Order.VatRateBp} {checkout.CheckoutUrl}"); // pending 2000 https://gamecoin.apilow.com/pay/…?t=…// The browser needs no key, no country and no URLs: it uses the page it is on.
const order = await gc.checkout("gems-500");
console.log(order.status); // "fulfilled", "failed" or "pending"{
"order": {
"id": "6ac419fbdbe02af6c99c3d47",
"playerId": "6ac419fa60e877541898f38d",
"status": "pending",
"pack": { "sku": "gems-500", "name": "Bag of gems" },
"amountCents": 499,
"currency": "EUR",
"country": "FR",
"vatRateBp": 2000,
"vatCents": 83,
"netCents": 416,
"createdAt": "2026-10-05T21:43:23.065Z",
"paidAt": null,
"fulfilledAt": null,
"refundedAt": null,
"creatorCode": null
},
"checkoutUrl": "https://gamecoin.apilow.com/pay/6ac419fbdbe02af6c99c3d47?t=…"
}Open checkoutUrl in the player's browser: redirect the tab, or open a window. Do not store it or parse it. It works for one order, for one hour.
The payment page
The page is hosted. It shows the pack, the contents, the price with VAT, a country selector, and the buttons. In test the buttons are « Payer » (pay) and « Refuser » (decline). The page is in French for now.
The buyer can change the country on the page. The VAT is recalculated before they pay, and the order is updated with the final country and VAT.
VAT
Pack prices are in euros with VAT included. GameCoin works out the VAT from the buyer's country and writes it on the order, together with the amount without VAT:
| Order field | Meaning | Example (4.99 € in France) |
|---|---|---|
amountCents | What the buyer pays, VAT included | 499 |
vatRateBp | The rate in basis points: 2000 is 20 % | 2000 |
vatCents | The VAT inside the price | 83 |
netCents | The price without VAT | 416 |
The country is chosen in this order: the country of the checkout, then the country of the player's profile, then BE. A browser checkout cannot send a country, so set the player's country on your server, or let the buyer pick it on the payment page.
Packs are sold in the countries of the European Union: AT, BE, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IE, IT, LV, LT, LU, MT, NL, PL, PT, RO, SK, SI, ES and SE. Any other country answers 400 VALIDATION_FAILED with the field error COUNTRY_NOT_SELLABLE.
Return URLs
successUrl and cancelUrl send the player back to your game.
- Use
https://URLs. In the test environment,http://is accepted for the local machine only (localhost,127.0.0.1,[::1]). Any otherhttp://answersURL_SCHEME_NOT_ALLOWED. - A URL with a user name or password is refused (
URL_INVALID). - From a browser, they must have the same origin as the page that asks, so a script on another site cannot redirect your players. Otherwise:
ORIGIN_MISMATCH.
After a successful payment, GameCoin redirects to successUrl and adds the order and its status to the query. After a declined payment it does the same with cancelUrl:
https://example.com/shop/thanks?order=6ac419fbdbe02af6c99c3d47&status=fulfilled
https://example.com/shop?order=6ac41c5a62015d21b714ae0a&status=failedIf you give no URL, the player lands on a confirmation page of GameCoin, which also tells the window that opened it with a postMessage: { type: "gamecoin:order", orderId, status }. The browser SDK listens for that message, so you need no URL at all with gc.checkout.
What to do when the player comes back
Never trust the address. Anyone can type ?status=fulfilled. The truth is the order, read from GameCoin:
curl "https://gamecoin.apilow.com/api/v1/orders/6ac419fbdbe02af6c99c3d47" \
-H "Authorization: Bearer $GAMECOIN_SECRET_KEY"const order = await gamecoin.orders.get("6ac419fbdbe02af6c99c3d47");
console.log(order.status, order.paidAt, order.fulfilledAt); // fulfilled 2026-10-05T21:43:42.641Z 2026-10-05T21:43:42.641Zorder = gamecoin.orders.get("6ac419fbdbe02af6c99c3d47")
print(order.status, order.paid_at, order.fulfilled_at) # fulfilled 2026-10-05 21:43:42.641000+00:00 2026-10-05 21:43:42.641000+00:00$order = $gamecoin->orders->get('6ac419fbdbe02af6c99c3d47');
echo $order->status, ' ', $order->paidAt->format(DATE_RFC3339_EXTENDED), ' ', $order->fulfilledAt->format(DATE_RFC3339_EXTENDED), "\n"; // fulfilled 2026-10-05T21:43:42.641+00:00 2026-10-05T21:43:42.641+00:00order, err := client.Orders.Get(ctx, "6ac419fbdbe02af6c99c3d47")
if err != nil {
log.Fatal(err)
}
fmt.Println(order.Status, *order.PaidAt, *order.FulfilledAt) // fulfilled 2026-10-05 21:43:42.641 +0000 UTC 2026-10-05 21:43:42.641 +0000 UTCvar order = await gamecoin.Orders.GetAsync("6ac419fbdbe02af6c99c3d47");
Console.WriteLine($"{order.Status} {order.PaidAt:O} {order.FulfilledAt:O}"); // fulfilled 2026-10-05T21:43:42.6410000+00:00 2026-10-05T21:43:42.6410000+00:00// After a redirect checkout, init() reads the order for you and cleans the address.
const gc = await GameCoin.init({ publishableKey: "gc_pk_test_…" });
if (gc.returnedOrder) console.log(gc.returnedOrder.status);{
"id": "6ac419fbdbe02af6c99c3d47",
"playerId": "6ac419fa60e877541898f38d",
"status": "fulfilled",
"pack": { "sku": "gems-500", "name": "Bag of gems" },
"amountCents": 499,
"currency": "EUR",
"country": "FR",
"vatRateBp": 2000,
"vatCents": 83,
"netCents": 416,
"createdAt": "2026-10-05T21:43:23.065Z",
"paidAt": "2026-10-05T21:43:42.641Z",
"fulfilledAt": "2026-10-05T21:43:42.641Z",
"refundedAt": null,
"creatorCode": null
}Deliver nothing yourself. When the status is fulfilled, the coins and items are already in the player's account: the payment and the delivery happen together. A server only needs to read the order to update its screen. Read the wallet to see the result, and the ledger to see the entries (purchase_credit, with ref.orderId).
The life of an order
pending ─ paid ─▶ fulfilled ─▶ refunded
│
├────────────▶ failed the payment was declined
└────────────▶ expired nobody paid within one hour| Status | Meaning | Final |
|---|---|---|
pending | Created. Waiting for the payment. | no |
fulfilled | Paid and delivered. | no: it can be refunded |
refunded | Refunded: coins and items were taken back. | yes |
failed | The payment was declined. Nothing was delivered. | yes |
expired | Not paid within the hour. | yes |
(paid is a passing state that you will not see: payment and delivery are one step. canceled and chargeback exist in the API but nothing produces them yet.)
A failed or expired order is final. To let the player try again, create a new checkout with a new idempotency key. The same key would return the old order.
Retry safely
A checkout needs an idempotency key. If your request times out, send it again with the same key: you get the same order and the same payment URL, with status 200 instead of 201. The order is returned in its current state, so a replay after the player paid shows fulfilled.
Build the key from your own order or basket number (order-1001), not from the clock.
Limits per player
A pack can have a maxPerPlayer, such as a welcome pack that each player may buy once. When the limit is reached, a new checkout answers 409 LIMIT_REACHED with maxPerPlayer and bought in details. Only orders that were paid count: a pending, declined, expired or refunded order does not use up the limit.
Limited and timed packs
A pack can be sold only during a window, in limited stock, or as a founder pack. A checkout then answers 409 PACK_NOT_AVAILABLE outside the window and 409 PACK_SOLD_OUT when the stock is used up; the catalog tells you in advance with availability. See Founder packs.
Items inside a pack
A pack can include items. They are delivered with the currency. If one cannot be delivered, because the player already owns the most they can of a durable item, the pack is still delivered: the player gets the currency and the other items, and the item is marked as not delivered on the order. You see it in the order's page in the dashboard (« Objets non livrés »); the API's Order object does not list it. If a refund follows, only what was delivered is taken back.
Where next
- Founder packs: sell before the launch, with a window and a limited stock.
- Refunds: take back a paid order.
- Testing: pay and decline in the test environment.
- Wallets and ledger