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

The player's 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"
200 OK
[
  {
    "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.

FieldWhat it carries
marketplaceItemKeyThe key a purchase names the listing by.
imagesA 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.
contentsEverything 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 }.
statusOne of Purchasable, NotEligible, OutOfStock, PurchaseLimitReached, InsufficientBalance.
isPurchasabletrue exactly when status is Purchasable.
occurrenceNumberThe round that stock and per-player limits are counted against. Counts from 0, so a listing that does not repeat is always 0.
remainingStockUnits left this round, or null for unlimited.
remainingPurchasesWhat this player has left this round, or null when there is no per-player limit.
availableUntilUtcWhen 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

One section
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

Buy one
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"
  }'
200 OK
{
  "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. 200 with the original purchase. Nothing is charged twice. remainingBalance is 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.

HTTPcodeWhat happenedWhat to show the player
400player.external_id_missingplayerId was empty.Nothing. Fix the caller.
400player.external_id_too_longOver 200 characters.Nothing. Fix the caller.
400marketplace_purchase.marketplace_item_key_missingThe key in the path was empty.Nothing. Fix the caller.
400marketplace_purchase.marketplace_item_key_too_longOver 100 characters.Nothing. Fix the caller.
400marketplace_purchase.idempotency_key_too_longOver 200 characters.Nothing. Fix the caller.
403marketplace_item.not_eligibleThe player cannot buy this.The requirement, in your own words. The API does not say which part was missed.
404player.not_foundThis environment has never seen that ID.Nothing. Create the player first.
404marketplace_item.not_foundThe key names nothing here, or is malformed."No longer available", and refresh.
404project_environment.not_foundThe environment behind the key did not load.Nothing. Not the player's problem.
409marketplace_item.inactiveSwitched off since the shop was read."No longer available", and refresh.
409marketplace_item.not_availableNot on sale at this instant."Not on sale right now." availableUntilUtc is where a countdown comes from.
409marketplace_item.out_of_stockThis round's stock is gone."Sold out", and refresh.
409marketplace_item.purchase_limit_reachedThey have bought as many as they may this round.That they have had their share this round.
409points.insufficient_balanceThe balance is below priceInPoints.The shortfall. It may have moved since the shop was read.
409marketplace_purchase.idempotency_key_conflictThe key was already used for something else.Nothing. Fix the caller.
409points.concurrent_updateAnother 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.

What they have bought
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.

Next

On this page