# Checkouts API

> Sell a currency pack for real money by creating an order and sending the player to the hosted payment page, with the server API.

A checkout is how a player pays real money for a **pack**: a bundle of currency and items that you define in the dashboard (« Packs », in French for now). You create an order, GameCoin returns the address of a hosted payment page, and you send the player there. When the payment succeeds, the coins and items are delivered to the player. You never handle card details. Payments are **simulated in the test environment**: the payment page offers "Pay (simulated)" and "Decline"; real payments are not available yet. The examples on this page continue from the [setup](/docs/api#setup-for-the-examples).

## Order a currency pack

Creates an order for a pack and returns the hosted payment page. Use it when a player taps "Buy" on a pack in your shop. The call returns at once with a `pending` order: the coins are delivered later, when the player pays.

```endpoint
POST /players/{player}/checkouts
```

**Authentication:** secret key. **Idempotency:** required (`Idempotency-Key` header).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `player` | string | A GameCoin player id, or `ext:<your id>` URL-encoded once. An unknown `ext:` player is created. |

**Body**

| Field | Type | Required | Rules |
|---|---|---|---|
| `packSku` | string | yes | The sku of an active pack: 1 to 48 characters. |
| `successUrl` | string | no | Where to send the player after a successful payment: at most 2048 characters, `https` (`http` only on localhost, in the test environment). |
| `cancelUrl` | string | no | Where to send the player when the payment fails or is refused. Same rules as `successUrl`. |
| `country` | string | no | The buyer's country, two letters (ISO 3166-1 alpha-2), which decides the VAT. Only countries of the European Union are sold to. Defaults to the player's country if it is sold to, otherwise `BE`. |
| `locale` | string | no | Language of the hosted payment page, the receipt and the e-mails of this order: `fr`, `en` or `nl`. Any other value is ignored (no error). Without it, the payment page follows the player's browser language (`Accept-Language`), then French. |
| `creatorCode` | string | no | The [creator code](/docs/guides/codes#creator-codes) the buyer typed: 1 to 64 characters, case, spaces and dashes ignored. The buyer gets a bonus of free coins and the creator earns a tracked commission, both when the order is delivered. |

<!-- tabs:start -->
```bash tab="curl"
curl -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/checkouts" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: checkout-gems-500-user-42" \
  -H "Content-Type: application/json" \
  -d '{"packSku": "gems-500", "successUrl": "https://example.com/shop/thanks", "cancelUrl": "https://example.com/shop", "country": "BE"}'
```
```javascript tab="Node.js"
const { order, checkoutUrl } = await gamecoin.checkouts.create(
  player,
  { packSku: "gems-500", successUrl: "https://example.com/shop/thanks", cancelUrl: "https://example.com/shop", country: "BE" },
  { idempotencyKey: "checkout-gems-500-user-42" },
);
console.log(order.id, order.status, order.amountCents, checkoutUrl); // send the player to checkoutUrl
```
```python tab="Python"
checkout = gamecoin.checkouts.create(
    player,
    pack_sku="gems-500",
    success_url="https://example.com/shop/thanks",
    cancel_url="https://example.com/shop",
    country="BE",
    idempotency_key="checkout-gems-500-user-42",
)
print(checkout.order.id, checkout.order.status, checkout.order.amount_cents, checkout.checkout_url)  # send the player to checkout_url
```
```php tab="PHP"
$checkout = $gamecoin->checkouts->create(
    $player,
    [
        'pack_sku' => 'gems-500',
        'success_url' => 'https://example.com/shop/thanks',
        'cancel_url' => 'https://example.com/shop',
        'country' => 'BE',
    ],
    ['idempotency_key' => 'checkout-gems-500-user-42'],
);
echo $checkout->order->id, ' ', $checkout->order->status, ' ', $checkout->order->amountCents, ' ', $checkout->checkoutUrl, "\n"; // send the player to checkoutUrl
```
```go tab="Go"
checkout, err := client.Checkouts.Create(ctx, player,
	gamecoin.CheckoutParams{
		PackSKU:    "gems-500",
		SuccessURL: "https://example.com/shop/thanks",
		CancelURL:  "https://example.com/shop",
		Country:    "BE",
	},
	gamecoin.WithIdempotencyKey("checkout-gems-500-user-42"))
check(err)
fmt.Println(checkout.Order.ID, checkout.Order.Status, checkout.Order.AmountCents, checkout.CheckoutURL) // send the player to CheckoutURL
```
```csharp tab="C#"
var checkout = await gamecoin.Checkouts.CreateAsync(
    player,
    new CheckoutRequest
    {
        PackSku = "gems-500",
        SuccessUrl = "https://example.com/shop/thanks",
        CancelUrl = "https://example.com/shop",
        Country = "BE",
    },
    new RequestOptions { IdempotencyKey = "checkout-gems-500-user-42" });
Console.WriteLine($"{checkout.Order.Id} {checkout.Order.Status} {checkout.Order.AmountCents} {checkout.CheckoutUrl}"); // send the player to CheckoutUrl
```
<!-- tabs:end -->

**Response** `201 Created` for a new order, `200 OK` for a replay.

| Field | Type | Description |
|---|---|---|
| `order` | [Order](/docs/api/objects#order) | The new order, `pending`. The pack, the amount and the VAT are frozen at this moment. |
| `checkoutUrl` | string | The hosted payment page of the order, `/pay/{orderId}?t=…`. Send the player there. |

```json
{
  "order": {
    "id": "665f1c2e8a3b4d5e6f7081b4",
    "playerId": "665f1c2e8a3b4d5e6f708192",
    "status": "pending",
    "pack": { "sku": "gems-500", "name": "Bag of gems" },
    "amountCents": 499,
    "currency": "EUR",
    "country": "BE",
    "vatRateBp": 2100,
    "vatCents": 87,
    "netCents": 412,
    "createdAt": "2026-10-05T22:14:39.356Z",
    "paidAt": null,
    "fulfilledAt": null,
    "refundedAt": null,
    "creatorCode": null
  },
  "checkoutUrl": "https://gamecoin.apilow.com/pay/665f1c2e8a3b4d5e6f7081b4?t=…"
}
```

Send the same request again with the same key and you get the **same order in its current state** and the same `checkoutUrl`, with `200` and `Idempotent-Replayed: true`, and no second order. After the player has paid, the replay shows it:

```bash
curl -i -X POST "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/checkouts" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY" \
  -H "Idempotency-Key: checkout-gems-500-user-42" \
  -H "Content-Type: application/json" \
  -d '{"packSku": "gems-500", "successUrl": "https://example.com/shop/thanks", "cancelUrl": "https://example.com/shop", "country": "BE"}'
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Idempotent-Replayed: true

{
  "order": {
    "id": "665f1c2e8a3b4d5e6f7081b4",
    "playerId": "665f1c2e8a3b4d5e6f708192",
    "status": "fulfilled",
    "pack": { "sku": "gems-500", "name": "Bag of gems" },
    "amountCents": 499,
    "currency": "EUR",
    "country": "BE",
    "vatRateBp": 2100,
    "vatCents": 87,
    "netCents": 412,
    "createdAt": "2026-10-05T22:14:39.356Z",
    "paidAt": "2026-10-05T22:14:39.826Z",
    "fulfilledAt": "2026-10-05T22:14:39.826Z",
    "refundedAt": null,
    "creatorCode": null
  },
  "checkoutUrl": "https://gamecoin.apilow.com/pay/665f1c2e8a3b4d5e6f7081b4?t=…"
}
```

**Errors**

| Status | Code | When | What to do |
|---|---|---|---|
| 400 | `IDEMPOTENCY_KEY_REQUIRED` | The `Idempotency-Key` header is missing or empty | Send one: see [Idempotency](/docs/api#idempotency). |
| 400 | `VALIDATION_FAILED` | `details.fieldErrors.creatorCode` is `["CODE_INVALID"]`: the creator code is unknown, disabled, not valid for this pack, or the creator's own player (always the same answer, and no order is created) | Tell the buyer the code is not valid; let them retry or buy without it. |
| 400 | `VALIDATION_FAILED` | An unknown field; `packSku` empty or longer than 48 characters; `country` not two letters, or a country that is not sold to (`COUNTRY_NOT_SELLABLE`); a return URL that is not a valid URL (`URL_INVALID`) or not `https` (`URL_SCHEME_NOT_ALLOWED`); a key that is not 1 to 100 printable ASCII characters | Read `details.fieldErrors`. |
| 402 | `PAYMENT_FAILED` | No payment provider is available for the environment | Use a `test` key: payments are simulated there. |
| 403 | `PLAYER_BLOCKED` | The player is blocked | No order can be created for this player until the player is unblocked. |
| 404 | `NOT_FOUND` | The pack is not in the catalog (or is archived), or a GameCoin id does not exist | Check the sku against [Get the catalog](/docs/api/catalog#get-the-catalog). |
| 409 | `LIMIT_REACHED` | The player already bought the pack as many times as its `maxPerPlayer` allows: `details.maxPerPlayer` and `details.bought` | Hide the pack for this player. |
| 409 | `PACK_NOT_AVAILABLE` | The pack is outside its sale window: `details.startsAt` and `details.endsAt` (ISO 8601 or `null`) | Show « coming soon » or « ended ». See [Founder packs](/docs/guides/founder-packs). |
| 409 | `PACK_SOLD_OUT` | The total stock of the pack is used up | Show « sold out ». The stock can come back if a pending order expires. |
| 409 | `IDEMPOTENCY_CONFLICT` | The key was already used with a different request | Use a new key for a new order. |

```json
{
  "error": {
    "code": "LIMIT_REACHED",
    "message": "Pack purchase limit reached",
    "details": { "maxPerPlayer": 1, "bought": 1 }
  }
}
```

**Notes**

- **The call creates an order, not a payment.** The coins and items are delivered when the order becomes `fulfilled`, a moment after the player pays. Do not give anything yourself when the call returns.
- After payment the player is redirected to `successUrl` with `?order=<id>&status=fulfilled` added. When they decline, or the order fails or expires, they are redirected to `cancelUrl` with `?order=<id>&status=failed` (or `expired`). Without these URLs the player lands on a built-in confirmation page, which also notifies the window that opened it with `postMessage({ type: "gamecoin:order", orderId, status })`.
- **Never trust the query string**: when the player comes back, read the order with [Get an order](/docs/api/orders#get-an-order) and check its `status` before you tell them it worked.
- If an item of the pack cannot be delivered, because the player already owns the most they can of a durable item, the pack is **still delivered**: the currency and the other items arrive. See [Selling packs](/docs/guides/selling-packs).
- The buyer confirms their country on the payment page, and the VAT is recomputed if they change it. That is why `country` on the order can differ from the one you sent.
- A `pending` order that is not paid within one hour becomes `expired`. The player can open `checkoutUrl` as long as the order is `pending`.
- **Limited packs.** A new order **holds one unit** of the stock of a pack that has one, from the moment it is created. The unit goes back when the order fails, expires or is refunded, and becomes a sale when the order is delivered. A second request for the same pack by the same player returns the pending order and holds nothing more. The sale window is checked when the order is created: a player who started in time can still pay after the window closes, within the hour.
- `maxPerPlayer` counts the orders that are `paid` or `fulfilled`, and it is checked **when the order is created**. An order that was refunded, never paid or declined does not count. Two orders created before the first one is paid are both accepted, and both can then be paid: when a pack is limited, create the next order only after the previous one is no longer `pending`.
- `successUrl` and `cancelUrl` are free on this server route. The browser route only accepts addresses on the origin of the page: see [Order a currency pack](/docs/api/client#order-a-currency-pack) in the client API.
- The `checkoutUrl` is a link for one player: do not log it, and do not share it.
