# Ledger API

> Read the immutable history of every change to a player's balances, page by page, with the server API.

The ledger is the history of a wallet: one entry for every grant, spend, pack delivery and refund, never edited and never deleted. Read it to show a player their history, to answer a support request, or to reconcile your own records. The examples on this page continue from the [setup](/docs/api#setup-for-the-examples).

## List ledger entries

Returns a page of a player's ledger entries, newest first. Use `currency` to keep one currency, and `limit` and `cursor` to walk back in time.

```endpoint
GET /players/{player}/ledger
```

**Authentication:** secret key. **Idempotency:** not needed.

**Path parameters**

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

**Query parameters**

| Parameter | Type | Required | Rules |
|---|---|---|---|
| `currency` | string | no | Only the entries of this currency (2 to 24 lowercase characters). A well-formed code that has no entries gives an empty list. |
| `limit` | integer | no | Page size, from 1 to 100. Default 20. |
| `cursor` | string | no | The `nextCursor` of the previous page. Opaque: never build one. |

<!-- tabs:start -->
```bash tab="curl"
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?currency=gems&limit=2" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```
```javascript tab="Node.js"
const page = await gamecoin.ledger.list(player, { currency: "gems", limit: 2 });
for (const entry of page.entries) {
  console.log(entry.createdAt.toISOString(), entry.type, entry.paidDelta + entry.bonusDelta, entry.balanceAfter.total);
}
console.log(page.nextCursor); // null on the last page
```
```python tab="Python"
page = gamecoin.ledger.list(player, currency="gems", limit=2)
for entry in page.entries:
    print(entry.created_at.isoformat(), entry.type, entry.paid_delta + entry.bonus_delta, entry.balance_after.total)
print(page.next_cursor)  # None on the last page
```
```php tab="PHP"
$page = $gamecoin->ledger->list($player, ['currency' => 'gems', 'limit' => 2]);
foreach ($page->entries as $entry) {
    echo $entry->createdAt->format(DATE_ATOM), ' ', $entry->type, ' ', $entry->paidDelta + $entry->bonusDelta, ' ', $entry->balanceAfter->total, "\n";
}
echo var_export($page->nextCursor, true), "\n"; // NULL on the last page
```
```go tab="Go"
page, err := client.Ledger.List(ctx, player, &gamecoin.LedgerListParams{Currency: "gems", Limit: 2})
check(err)
for _, entry := range page.Entries {
	fmt.Println(entry.CreatedAt, entry.Type, entry.PaidDelta+entry.BonusDelta, entry.BalanceAfter.Total)
}
fmt.Println(page.NextCursor != nil) // false on the last page
```
```csharp tab="C#"
var page = await gamecoin.Ledger.ListAsync(player, new LedgerQuery { Currency = "gems", Limit = 2 });
foreach (var entry in page.Entries)
{
    Console.WriteLine($"{entry.CreatedAt:u} {entry.Type} {entry.PaidDelta + entry.BonusDelta} {entry.BalanceAfter.Total}");
}
Console.WriteLine(page.NextCursor ?? "last page");
```
<!-- tabs:end -->

**Response** `200 OK`: a page of [LedgerEntry](/docs/api/objects#ledgerentry) objects and the cursor of the next page.

| Field | Type | Description |
|---|---|---|
| `entries` | array | The entries of the page, newest first. |
| `nextCursor` | string or `null` | Pass it as `cursor` to get the next page. `null` on the last page. |

```json
{
  "entries": [
    {
      "id": "6ac4214e255a2e692cd84196",
      "type": "spend",
      "currency": "gems",
      "paidDelta": 0,
      "bonusDelta": -20,
      "balanceAfter": { "paid": 0, "bonus": 50, "total": 50 },
      "reason": null,
      "metadata": {},
      "ref": { "itemSku": "potion" },
      "createdAt": "2026-10-05T22:14:38.350Z"
    },
    {
      "id": "6ac4214ceab6d4425553aff6",
      "type": "spend",
      "currency": "gems",
      "paidDelta": 0,
      "bonusDelta": -30,
      "balanceAfter": { "paid": 0, "bonus": 70, "total": 70 },
      "reason": "revive",
      "metadata": {},
      "ref": {},
      "createdAt": "2026-10-05T22:14:36.491Z"
    }
  ],
  "nextCursor": "MTc5MTIzODQ3NjQ5MTo2YWM0MjE0Y2VhYjZkNDQyNTU1M2FmZjY"
}
```

To get the next page, pass the cursor back; the other parameters stay the same:

```bash
curl "https://gamecoin.apilow.com/api/v1/players/ext%3Auser-42/ledger?currency=gems&limit=2&cursor=<nextCursor>" \
  -H "Authorization: Bearer $GAMECOIN_SECRET_KEY"
```

```json
{
  "entries": [
    {
      "id": "6ac4214bae33663917ec0ca6",
      "type": "grant",
      "currency": "gems",
      "paidDelta": 0,
      "bonusDelta": 100,
      "balanceAfter": { "paid": 0, "bonus": 100, "total": 100 },
      "reason": "level_up",
      "metadata": { "level": "5" },
      "ref": {},
      "createdAt": "2026-10-05T22:14:35.965Z"
    }
  ],
  "nextCursor": null
}
```

The SDKs can follow the cursors for you. `iterate` fetches a page only when your loop reaches it, so you can stop whenever you want; `limit` is the page size:

<!-- tabs:start -->
```javascript tab="Node.js"
for await (const entry of gamecoin.ledger.iterate(player, { currency: "gems", limit: 50 })) {
  console.log(entry.id, entry.type, entry.reason);
}
```
```python tab="Python"
for entry in gamecoin.ledger.iterate(player, currency="gems", limit=50):
    print(entry.id, entry.type, entry.reason)
```
```php tab="PHP"
foreach ($gamecoin->ledger->iterate($player, ['currency' => 'gems', 'limit' => 50]) as $entry) {
    echo $entry->id, ' ', $entry->type, ' ', $entry->reason ?? '-', "\n";
}
```
```go tab="Go"
it := client.Ledger.Iterate(ctx, player, &gamecoin.LedgerListParams{Currency: "gems", Limit: 50})
for it.Next() {
	entry := it.Entry()
	fmt.Println(entry.ID, entry.Type)
}
check(it.Err())
```
```csharp tab="C#"
await foreach (var entry in gamecoin.Ledger.IterateAsync(player, new LedgerQuery { Currency = "gems", Limit = 50 }))
{
    Console.WriteLine($"{entry.Id} {entry.Type} {entry.Reason}");
}
```
<!-- tabs:end -->

**Errors**

| Status | Code | When | What to do |
|---|---|---|---|
| 400 | `VALIDATION_FAILED` | `limit` is not a whole number from 1 to 100 (`details.fieldErrors.limit`); `currency` is malformed; `cursor` is not one this API issued (`CURSOR_INVALID`); the player reference is malformed (`PLAYER_REF_INVALID`) | Fix the parameter. Never edit a cursor: pass back the one you received. |
| 404 | `NOT_FOUND` | The player does not exist | An `ext:` player exists after its first write; before that it has no history. |

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Invalid cursor",
    "details": { "fieldErrors": { "cursor": ["CURSOR_INVALID"] } }
  }
}
```

**Notes**

- Every entry carries `balanceAfter`, the balance right after it, so you can display a running balance without adding anything up. `paidDelta` and `bonusDelta` are negative for a spend.
- `ref.itemSku` is set on a purchase of an item and `ref.orderId` on an entry caused by an order (pack delivery, refund). The entry types are listed in [Objects](/docs/api/objects#ledger-entry-types).
- Entries are never edited: an error is corrected by a new entry, not by changing an old one.
- A parameter this API does not know is ignored, not refused. A `currency` that is well-formed but unknown to the game is not an error either: you get an empty page.
- Ids sort by creation time. Paging goes back in time from the newest entry, so entries written while you page do not shift the pages behind you.
