Selling with points
Four calls: read the shop for one player, read a collection if you have one, buy something, and read their purchases back. Listings are defined in the dashboard on the Marketplace screen, and what each one hands over on the Catalog screen. No public endpoint creates, edits or lists a definition.
Every read is for one player. The price is the same for everybody, but who can buy, what is left, what that player has already had and what they can afford are not.
The player has to exist first
Neither read creates a player, and neither does the purchase. Buying answers 404 player.not_found
when this environment has never seen that ID.
A brand new player gets a perfectly normal shop and a balance of zero, then player.not_found on
their first purchase. Call POST /v1/players at sign-up,
or send their first event, before you show them a shop.
Read the shop
curl -G https://api.gemifier.io/v1/marketplace-items \
-H "Authorization: Bearer gem_sbox_..." \
--data-urlencode "PlayerId=player-42" \
--data-urlencode "LanguageCode=en" \
--data-urlencode "Take=20"[
{
"marketplaceItemKey": "starter_pack",
"name": "Starter pack",
"description": "Ten spins and a week of premium.",
"images": {},
"priceInPoints": 500,
"contents": [
{ "catalogItemKey": "free_spins", "value": { "type": "number", "value": 10 } },
{ "catalogItemKey": "premium_days", "value": { "type": "number", "value": 7 } }
],
"status": "Purchasable",
"isPurchasable": true,
"occurrenceNumber": 3,
"remainingStock": 42,
"remainingPurchases": 1,
"availableUntilUtc": "2026-09-01T00:00:00+00:00"
}
]Query parameters on both reads are PascalCase. The bodies are camelCase, and the player is
playerId everywhere.
| Field | What it carries |
|---|---|
marketplaceItemKey | The key a purchase names the listing by. |
images | A map from a slot you named in the dashboard to an absolute URL. It is {}, never absent, when there are none. A slot whose image no longer resolves is left out rather than sent as null. |
contents | Everything one purchase hands over. One entry for a single item, several for a bundle. value carries the quantity: ten spins is { "type": "number", "value": 10 }. |
status | One of Purchasable, NotEligible, OutOfStock, PurchaseLimitReached, InsufficientBalance. |
isPurchasable | true exactly when status is Purchasable. |
occurrenceNumber | The round that stock and per-player limits are counted against. Counts from 0, so a listing that does not repeat is always 0. |
remainingStock | Units left this round, or null for unlimited. |
remainingPurchases | What this player has left this round, or null when there is no per-player limit. |
availableUntilUtc | When it stops being on sale, or null when it has no end. |
What the shop leaves out
Anything it returns is on sale, so there is no status for "switched off". Four kinds of listing are left out entirely rather than returned with a status:
- listings that are not active,
- listings that are not on sale at this instant,
- listings that live only in a collection,
- listings the player cannot buy, when you set their visibility to Hidden. Set it to Locked instead
and the same listing comes back with
status: "NotEligible", which is how you show somebody something to work toward.
They come back in the order they were created, and Take caps how many after that filtering. There
is no cursor, no offset and no total.
Only one reason is reported
status names the first thing in the way, in this order: who can buy, stock, the per-player limit,
then the balance. A listing that is both sold out and unaffordable reports OutOfStock. To tell a
player everything between them and it, compare priceInPoints against
GET /v1/points yourself.
status is read at one instant and is stale straight after. The purchase is the authority, and it
can refuse something the shop just called Purchasable.
Read a collection
curl -G https://api.gemifier.io/v1/marketplace-collections/summer_sale \
-H "Authorization: Bearer gem_sbox_..." \
--data-urlencode "PlayerId=player-42" \
--data-urlencode "LanguageCode=en"The response has key, name, description, images and items, where each entry in items is
the shape above. Three differences:
- Listings that live only in a collection are included. That is what a collection is for.
- The order is the one you set, not creation order.
- There is no
Take. A collection returns everything in it.
Members that no longer resolve are skipped silently, so a collection can return fewer than it lists.
404 marketplace_collection.not_found covers three cases the API does not separate: the key is
malformed, it names nothing here, or the collection is switched off.
Buy something
curl -X POST https://api.gemifier.io/v1/marketplace-items/starter_pack/purchases \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{
"playerId": "player-42",
"idempotencyKey": "order-8891"
}'{
"purchaseId": "019f2a7c-9b1e-7a44-8f0d-2c5d9a13b8e1",
"marketplaceItemKey": "starter_pack",
"contents": [
{ "catalogItemKey": "free_spins", "value": { "type": "number", "value": 10 } },
{ "catalogItemKey": "premium_days", "value": { "type": "number", "value": 7 } }
],
"occurrenceNumber": 3,
"pricePaidInPoints": 500,
"remainingBalance": 1250
}One call buys one unit. There is no quantity, so selling three of something is three calls with three idempotency keys.
contents is what this purchase handed over, copied at the moment of sale, so editing the listing
later does not change what somebody already bought. pricePaidInPoints is the price then, and
remainingBalance is the balance after.
Idempotency
idempotencyKey is a body field, never a header. It is optional, at most 200 characters, and blank
counts as absent. It belongs to one player.
- Same key, same listing.
200with the original purchase. Nothing is charged twice.remainingBalanceis read as it stands now, so a replay can return a different balance. - Same key, a different listing.
409 marketplace_purchase.idempotency_key_conflict.
Purchases and adjustments share one set of keys per player, so give them separate keys. Reusing a
purchase's key on POST /v1/points/adjustments is refused
with 409 points_adjustment.idempotency_key_conflict.
Every way a purchase can be refused
Switch on code. The message is prose and may change. See Errors.
| HTTP | code | What happened | What to show the player |
|---|---|---|---|
| 400 | player.external_id_missing | playerId was empty. | Nothing. Fix the caller. |
| 400 | player.external_id_too_long | Over 200 characters. | Nothing. Fix the caller. |
| 400 | marketplace_purchase.marketplace_item_key_missing | The key in the path was empty. | Nothing. Fix the caller. |
| 400 | marketplace_purchase.marketplace_item_key_too_long | Over 100 characters. | Nothing. Fix the caller. |
| 400 | marketplace_purchase.idempotency_key_too_long | Over 200 characters. | Nothing. Fix the caller. |
| 403 | marketplace_item.not_eligible | The player cannot buy this. | The requirement, in your own words. The API does not say which part was missed. |
| 404 | player.not_found | This environment has never seen that ID. | Nothing. Create the player first. |
| 404 | marketplace_item.not_found | The key names nothing here, or is malformed. | "No longer available", and refresh. |
| 404 | project_environment.not_found | The environment behind the key did not load. | Nothing. Not the player's problem. |
| 409 | marketplace_item.inactive | Switched off since the shop was read. | "No longer available", and refresh. |
| 409 | marketplace_item.not_available | Not on sale at this instant. | "Not on sale right now." availableUntilUtc is where a countdown comes from. |
| 409 | marketplace_item.out_of_stock | This round's stock is gone. | "Sold out", and refresh. |
| 409 | marketplace_item.purchase_limit_reached | They have bought as many as they may this round. | That they have had their share this round. |
| 409 | points.insufficient_balance | The balance is below priceInPoints. | The shortfall. It may have moved since the shop was read. |
| 409 | marketplace_purchase.idempotency_key_conflict | The key was already used for something else. | Nothing. Fix the caller. |
| 409 | points.concurrent_update | Another change to this player's points or the stock got in the way. | Nothing. Retry. |
Only points.concurrent_update is worth retrying on its own. Nothing was charged, so the retry is a
first attempt rather than a double charge, and it may then answer out_of_stock. Every other 409
is settled and will say the same next time.
A 200 is not a delivery
The points move at once. The catalog items do not. A purchase produces one
catalog_item.fulfillment_requested webhook per entry in contents, each
carrying sourceType: "MarketplacePurchase" and a sourceId equal to the purchaseId you were
just given. That is how you join a delivery back to the sale, and a bundle of two means two
deliveries sharing one id.
Nothing in the API reports whether a delivery succeeded. The webhook you accepted is the record,
not the 200.
Read purchases back
GET /v1/marketplace-purchases returns what a player
has bought, newest first, with the contents as they stood at the time.
curl -G https://api.gemifier.io/v1/marketplace-purchases \
-H "Authorization: Bearer gem_sbox_..." \
--data-urlencode "PlayerId=player-42" \
--data-urlencode "Take=20"The points side is on
GET /v1/points/transactions, where the spend has
type: "Spend", sourceType: "MarketplacePurchase" and sourceId set to the purchase id. It takes
PlayerId and Take, so filtering is left to you, and leaving Take out returns everything.
What this API does not do
- Undo a purchase. There is no public endpoint for it. A purchase can be reversed in the
dashboard, which gives back the price paid, returns the unit to stock and frees the player's
allowance. Anything already handed over stays handed over. Do not build a refund out of
POST /v1/points/adjustments: that moves the balance and nothing else. - Read a listing on its own. Nothing lists keys or prices without a player. The per-player read is the only view.
- Hold stock. A unit is taken at the moment of purchase, so a checkout with a confirmation screen can still lose the last one between two screens.