Packs
Browse your pack catalog, deliver a pack directly to a player, and read the delivery history.
Read your pack catalog, deliver a pack straight to a player (a free grant — no coins charged), and read the delivery history across every source (API grants, claim-code redemptions, shop purchases, admin gives).
Pack references
A pack has two labels: name — the catalog slug (starter, stable, what you script against) — and
display_name — the human-readable title (Starter Pack, what players see).
Wherever a pack appears — the catalog itself, a delivery result, a ledger row, a claim code — it is the
same { id, name, display_name } shape. For a pack that still exists in the catalog, both labels
are populated. id is authoritative; use it against GET /v1/packs/{id} for the full record.
The one exception: a delivery-history row whose pack has since been deleted carries both labels as
null — the ledger keeps the delivery, but the catalog entry it named is gone.
GET /v1/packs
List the pack catalog, newest-configured first.
Scope: packs:read.
| Query | Type | Meaning |
|---|---|---|
enabled | true | false | Only enabled / disabled packs. |
category_id | integer | Only packs in this category. |
search | string | Free-text match on the pack name. |
cursor | string | Opaque pagination cursor from a previous next_cursor. |
limit | integer (1–100) | Page size. Default 50. |
{
"data": [
{
"id": 5,
"name": "starter",
"display_name": "Starter Pack",
"description": "A leg-up for new arrivals.",
"category": { "id": 2, "name": "Bundles" },
"price": 100,
"enabled": true,
"shop_enabled": true,
"buy_limit": { "count": 0, "window_minutes": 0 },
"image_url": "/api/packs/5/image?raw=1",
"created_at": "2026-01-01 12:00:00",
"updated_at": "2026-01-02 09:30:00"
}
],
"has_more": false,
"next_cursor": null,
"total": 1
}buy_limit.count of 0 means unlimited. price is the coin cost when bought through the shop — direct
delivery below ignores it.
GET /v1/packs/{id}
A single pack by its numeric id. 404 not_found when it doesn't exist.
Scope: packs:read.
POST /v1/packs/{id}/deliver
Deliver the pack directly to a player, for free — the pack's contents are granted with no coin
charge. Identify the recipient with exactly one of steam_id or discord_id.
Scope: packs:deliver.
| Field | Type | Meaning |
|---|---|---|
steam_id | string (17 digits) | Recipient Steam64. Provide this or discord_id. |
discord_id | string (17–20 digits) | Recipient Discord id; resolves to the linked player. Provide this or steam_id. |
quantity | integer (1–100) | How many copies. Default 1. |
POST /v1/packs/5/deliver
Content-Type: application/json
{ "steam_id": "76561198000000000", "quantity": 1 }{
"pack": { "id": 5, "name": "starter", "display_name": "Starter Pack" },
"player": { "steam_id": "76561198000000000", "discord_id": "123456789012345678" },
"quantity": 1,
"delivered": true,
"status": "completed",
"purchase_id": 42,
"error_message": null
}delivered is the overall outcome; status is the precise result (completed, partial, failed,
refund_failed, ledger_failed). A delivery is recorded in the history below with source: "api".
Delivery is NOT idempotent — send an Idempotency-Key
Each call spawns the pack's items again, so a network retry could deliver twice. Send an
Idempotency-Key so a retry after a blip replays the first result instead of
delivering again.
The recipient must be a real player
A discord_id that isn't linked to any player returns 404 not_found. A well-formed but never-seen
steam_id is accepted, but the items may not land (the delivery reports status: "failed" /
"partial").
GET /v1/packs/deliveries
The delivery history (the pack-purchase ledger) — every pack that reached a player, whatever the
source: direct API grants (api), claim-code redemptions (claim), admin gives (admin), shop
purchases, and more.
Scope: packs:read.
| Query | Type | Meaning |
|---|---|---|
steam_id | string (digits) | Only this player's deliveries. Provide this or discord_id. |
discord_id | string (digits) | Resolves to the linked player. An unlinked id → empty page. |
pack_id | integer | Only deliveries of this pack. |
source | string | Only this source (api, claim, admin, …). |
status | string | Only this outcome (completed, partial, failed, …). |
cursor | string | Opaque pagination cursor. |
limit | integer (1–100) | Page size. Default 50. |
{
"data": [
{
"id": 7,
"pack": { "id": 5, "name": "starter", "display_name": "Starter Pack" },
"player": { "steam_id": "76561198000000000", "discord_id": "123456789012345678" },
"quantity": 1,
"total_price": 0,
"status": "completed",
"source": "api",
"error_message": null,
"delivered_at": "2026-01-03 18:05:00"
}
],
"has_more": false,
"next_cursor": null,
"total": 1
}player.discord_id is the recipient's current Discord link (Discord travels with Steam — null
when they aren't linked), regardless of what was recorded at delivery time.
Errors
Standard error envelope.
| Code | Status | When |
|---|---|---|
insufficient_scope | 403 | The key lacks packs:read (reads) or packs:deliver (delivery). |
premium_required | 403 | The game server's plan does not include the pack/shop feature. |
not_found | 404 | Unknown pack id; or a discord_id recipient/filter that isn't linked to any player (delivery only — filters return an empty page instead). |
validation_error | 400 | Bad id, both/neither of steam_id & discord_id, or a game-server rejection (its message is in error.details.mod_message). |
conflict | 409 | Delivery refused because the connected game server is too old to honour a free grant — it would charge the recipient. Update the game server and retry. |
idempotency_conflict | 409 | The Idempotency-Key you sent was already used with a different request body. Reuse the key only for a retry of the identical delivery. |
server_unavailable | 503 | The game server is offline or unreachable. |
gateway_timeout | 504 | The server did not respond in time. |