Game economy
Items, a shop, inventory, energy and lives, timed boosts, chests with published odds and battle passes: the configuration commands, the player and backend routes, the events, the limits, and one complete recipe.
The economy is everything a player owns besides currencies. One rule holds everywhere: every
owned thing that is not a currency is an item holding. Cosmetics, boosts, chests and energy
refills are item definitions with an effect; granting any of them is granting an item.
Currencies still live in the ledger: the shop spends them through it and chests and passes pay
them into it. The economy needs no activation and is not a program kind.
Configuration (items, offers, energy, loot tables, battle passes) is written only through change
sets, so the dashboard, the assistant, MCP and the autonomy policy share one reviewed path (see
Author a mechanic for how a proposal is confirmed). Every economy
command is an operation command (economy_item.create, economy_offer.update,
economy_loot_table.create, economy_battle_pass.pause and so on: the resource, an underscore,
then the verb after a dot): a change set holds exactly one of them, never several and never mixed
with another command (a second command is refused validation_failed), so each command below is
its own POST /v1/change_sets and its own confirmation. The proposing credential needs
config:write and programs:write. In a test environment (always auto) and on auto an economy change
set applies on request. In a live environment on limits, an economy command is priced
per change by the economy cost hook and applies on request when what one grant can pay is within
the cost caps (see Economy changes). It is refused with
403 confirmation_required only when it goes over a cap (award_exceeds_cap, bound_exceeded),
when an amount cannot be read, for example an unknown item or loot table or a chest that pays
itself (award_unbounded), or when it archives (destructive). Then change the proposal so it
fits, or hand details.review_url to your person and wait for them to confirm.
Keys (key, item and entry keys) match ^[a-z][a-z0-9_]{0,63}$ and never change. An update
names its target by key (the key or the id) with expected_version (its revision); a moved
revision makes the proposal preview_stale. Archiving is final for every economy resource.
Items
economy_item.create:
| Field | Meaning |
|---|---|
key | Stable item key. |
item_type | durable (kept until revoked, can be equipped) or consumable (used up by a consumption). Never changes. |
max_quantity | The most one player may hold, 1 to 1,000,000 (default 1). A grant or purchase above it is refused (inventory_full). |
equip_slot | Optional slot name (^[a-z][a-z0-9_]{0,31}$), durable items only. At most one equipped item per slot per player. |
effect | What consuming one unit does (default { "type": "none" }), below. |
display | title (1 to 120), description (up to 500), image_url (https or null), rarity (a slot-style name or null). |
metadata | Your own JSON object, at most 4 KB. |
Effects:
{ "type": "none" }: the consumption is recorded anditem.consumedis emitted; your game applies the effect.{ "type": "boost", "currency": "xp", "multiplier": "2", "duration_seconds": 900, "stacking": "extend" }: starts a timed multiplier (Timed boosts).{ "type": "energy_refill", "energy": "lives", "amount": 1 }: adds energy per unit, or"amount": "full"to fill up (Energy and lives).{ "type": "chest", "loot_table": "bronze_chest_table" }: opens a loot table once per unit (Chests).
economy_item.update changes max_quantity, equip_slot, effect, display or metadata.
economy_item.archive stops new grants and purchases; players keep what they hold and can still
consume it. Reads: GET /v1/items and GET /v1/items/{id} (id or key, scope programs:read).
The shop
An offer sells quantity units of one item for one price. economy_offer.create:
{
"type": "economy_offer.create",
"key": "red_cape_offer",
"item": "red_cape",
"quantity": 1,
"price": { "currency": "coins", "amount_minor": 500 },
"per_customer_limit": 1,
"available_from": null,
"available_until": null
}The price currency must be spendable (otherwise invalid_economy_request, reason
currency_not_spendable). per_customer_limit
counts offer purchases a player made and kept; available_from and available_until bound the
window. Bundles are several offers, or a chest. economy_offer.update changes quantity,
price, the limit and the window (each change is a new revision); economy_offer.archive
takes it out of the shop, and past purchases keep their pinned price. Reads: GET /v1/shop_offers
(filter item, status) and GET /v1/shop_offers/{id}.
A purchase ({ "offer_id", "offer_revision", "quantity" }, quantity 1 to 1,000 offer
purchases) checks the window, the limit, the balance and the holding room before charging, then
spends the price through the ledger and adds quantity * offer.quantity units in one
transaction. It is refused whole with insufficient_balance, inventory_full,
offer_unavailable, purchase_limit_reached, or stale_write when offer_revision is given and
the offer has moved since. The result carries entry_id, cost_entry_id (the ledger spend) and
price.total_minor.
Refunds. Your backend refunds a purchase whole with
POST /v1/customers/{id}/shop_purchase_refunds and { "purchase_id": "<entry_id>", "reason": "..." }.
It is allowed only while none of the item was used after the purchase: once the player consumed,
revoked or lost a unit of that item, the refund is refused (invalid_economy_request, reason
purchase_consumed), and with fewer units held than bought it is insufficient_items. The units
are removed, the spend is restored to the lots it drew on (restored_minor), and a refunded
purchase no longer counts toward the limit. A purchase is refunded once.
The inventory
Every change is an append-only entry (grant, purchase, consume, revoke, refund,
expire) and the holding is updated in the same transaction; a holding never goes below zero.
Merged customers own the union of their holdings.
Player routes (publishable key and customer session, customer:write or customer:read):
| Route | Body or query | Does |
|---|---|---|
GET /v1/customer/inventory | cursor, limit, locale | Holdings with quantity, equipped, equip_slot, effect, display. |
GET /v1/customer/shop_offers | cursor, limit, locale | Offers the player can buy now: id, key, revision, item (key), item_type, item_display (the item's display, there is no nested item object), quantity, price (currency, amount_minor), purchased and remaining_limit. |
POST /v1/customer/shop_purchases | offer_id, offer_revision, quantity | Buys an offer. |
POST /v1/customer/inventory_consumptions | item, quantity | Consumes units of a consumable, all or nothing, and applies its effect. |
POST /v1/customer/inventory_equips | item, equipped | Equips or unequips a held durable with a slot; equipping empties the slot first. |
Backend and operator routes (secret key, programs:write; reads programs:read). {id} is the
Orishare customer id or your external id:
POST /v1/customers/{id}/inventory_grants:{ "items": [{ "item", "quantity" }], "reason", "overflow" }, 1 to 16 lines.overflowrefuse(default) refuses the whole grant pastmax_quantity;clampgrants what fits and reportsnot_granted.POST /v1/customers/{id}/inventory_revocations:{ "item", "quantity", "reason" }; takes what is held, reports the rest asnot_revoked.POST /v1/customers/{id}/inventory_consumptions,POST /v1/customers/{id}/shop_purchases,POST /v1/customers/{id}/inventory_equips: the player commands, for a backend.GET /v1/customers/{id}/inventoryandGET /v1/customers/{id}/inventory_entries(filteritem,kind; newest first).
Idempotency. Every command needs an Idempotency-Key (idempotency_key_required without
one). The same key with the same body replays the first result and never grants, charges, consumes or
draws twice. A retry of the same request is answered with the stored first response (the same JSON
value; the order of keys can differ), so its body still says replayed: false: the header Idempotent-Replayed: true is the
signal. The body says replayed: true only when the economy itself recognised the key, for
example the same key sent again through the other route (player session, then server) for the
same player; the same key with
another body is idempotency_key_reused. Use one key per player intent, reused on every retry.
Errors. item_not_found, item_archived, inventory_full, insufficient_items,
not_equippable (no slot), offer_unavailable, purchase_limit_reached, insufficient_balance,
stale_write, and invalid_economy_request with a reason (for example not_consumable when a
durable item is consumed).
Energy and lives
An energy definition says how much a player holds and how fast it comes back.
economy_energy.create:
{
"type": "economy_energy.create",
"key": "lives",
"maximum": 5,
"refill": { "amount": 1, "every_seconds": 1800 },
"overfill_maximum": null
}- Regeneration is computed from the server clock at every read and command, never by a timer:
whole
every_secondsintervals since the last change addamount, up tomaximum. At or abovemaximumnothing regenerates. A player who never used an energy reads full. Reads returnvalue,next_refill_atandfull_at(null at or above the maximum) andunlimited_until. - Spend:
POST /v1/customer/energy_spends(orPOST /v1/customers/{id}/energy_spends) with{ "energy": "lives", "amount": 1 }is all or nothing:insufficient_energywhen the player has less. The result saysspentandfree. - Refills: consuming an
energy_refillitem, orPOST /v1/customers/{id}/energy_refillswith{ "energy", "amount" }(amounta number or"full", optionalreason). A number adds up tooverfill_maximum(ormaximumwithout one);"full"fills tomaximumand never takes overfill away. What does not fit is not added (refilledsays what was), and a refill item is consumed even when the energy is already full, so readGET /v1/customer/energybefore offering one. To sell energy, sell anenergy_refillitem in the shop. - Unlimited:
POST /v1/customers/{id}/energy_entitlementswith{ "energy": "lives", "until": "2026-11-04T00:00:00Z" }(secret key, from your own subscription state) makes spends free (and still recorded) until then;until: nullends it. - Reads:
GET /v1/customer/energy,GET /v1/customers/{id}/energy,GET /v1/energy_definitions,GET /v1/energy_definitions/{id}.economy_energy.updatechangesmaximum,refilloroverfill_maximum(a player above a lowered maximum keeps it);economy_energy.archivestops every spend and refill (energy_archived).
Timed boosts
A boost item multiplies points.award into one currency while its window runs.
multiplier is a decimal string above 1 and at most 10 with up to two decimals ("1.5", "2");
duration_seconds is 60 to 604,800. stacking decides what consuming another unit does while a
window of the same currency and multiplier runs: extend adds the duration to it, refresh moves
its end to now plus the duration (never earlier), stack starts a second window. Running windows
multiply each other, and the product per currency never passes 10. A consumption starts at most
100 units.
What is boosted and what is not:
- Boosted: every
points.awardinto that currency that a published rule or a program reward slot plans (quest completion, streak milestone, badge, level attainment), judged at the triggering fact's own time (effective_atoroccurred_at), so a replay awards the same amount once. The receipt effect recordsboost_multiplier. - Budgets and caps see the boosted amount: a campaign budget and a trust liability cap reserve what
is actually paid (a reward slot's declared
liabilityis multiplied too, rounded up). When the boosted amount does not fit, the award is treated exactly like an unboosted award that does not fit: a rule is refused (budget_exhausted,trust_cap_exhausted) and a program leaves the award out. It is never paid unboosted instead and never let past the limit. - Not boosted: deductions, other currencies, item grants, leaderboard prizes and referral rewards (settlements of a fixed obligation), chest and battle pass awards (published fixed payouts), and operator adjustments.
Reads: GET /v1/customer/boosts and GET /v1/customers/{id}/boosts (activations and the
product per currency in currencies).
Chests
A chest is a consumable item with { "type": "chest", "loot_table": "<key>" }. Consuming one
opens the table once per unit (at most 10 units per consumption) in the same transaction, and
effect_result returns every roll and what it paid.
A loot table (economy_loot_table.create with key and definition):
| Field | Meaning |
|---|---|
rolls | Rolls per opened chest, 1 to 10 (default 1). |
entries | 1 to 100 entries: key, weight (1 to 1,000,000) and award. |
entries[].max_per_customer | The most draws of this entry one player may get from the table (all versions), or null. |
entries[].budget.maximum | The most draws of this entry in the whole environment, or null. |
guaranteed | Up to 10 awards paid once per opened chest, whatever the rolls drew. |
pity | { "entry_key", "after_rolls" }: after that many rolls without the entry, the next roll yields it. |
An award is { "type": "currency", "currency": "coins", "amount_minor": 50 } or
{ "type": "item", "item": "red_cape", "quantity": 1 }. In economy commands and responses (offer
prices, loot table and battle pass awards) amount_minor is a JSON integer of at least 1, not a
string; program definitions (leaderboard prizes, referral rewards) write it as a string of digits. A chest cannot award itself. Item awards
clamp at max_quantity (the rest is reported as not_granted).
- Odds.
GET /v1/customer/loot_odds?item=<chest key>returns the published odds (weight / total_weight, six decimals) andeffective: this player's next roll, with entries exhausted by a budget or theirmax_per_customerat 0 and a pity roll certain (pity_forced).GET /v1/loot_tables/{id}/oddsis the operator view. - Deterministic draws. Each roll is an HMAC of a secret environment seed (created with the first loot table, never returned), the player, the table and how many chests of it the player opened. A retry with the same key replays the receipt; a retry with another key draws the same outcome, so a draw cannot be rerolled. Every roll is stored as evidence.
- Refusal. A chest with nothing left to draw and nothing guaranteed is refused and kept
(
loot_table_unavailable, also for an archived table). Currency awards carry the consumption's trust and reserve against the environment's trust liability caps; past a cap the whole consumption is refused (invalid_economy_request, reasontrust_cap_exhausted) before any roll. economy_loot_table.updatepublishes a new immutable version of the wholedefinition; past draws keep the version they used. Reads:GET /v1/loot_tables,GET /v1/loot_tables/{id}.
Battle passes
A pass pins a season (its active version when the pass is published) and a non-spendable XP
currency. economy_battle_pass.create with key and definition: season (the season key),
xp_currency and 1 to 200 tiers, each with key, threshold (XP, strictly increasing) and up
to 8 free and 8 premium awards (same award shape as chests; the XP currency itself cannot be
an award).
- Progress is the XP of that currency earned inside the season window, read on demand: the
number a
currency_earnedleaderboard shows. XP earned before the season starts does not count. - Claims: tiers are claimed one track at a time, each once per player:
POST /v1/customer/battle_pass_claimswith{ "pass", "tier", "track" }(freeorpremium), or{ "pass", "all": true }for every claimable track (up to 200). One claim answersclaim_id,tier,track,threshold,progressandawards; claim-all answersclaims[](one such entry per track claimed),skipped(tracks refused on their own) andremaining(what is left). Claims are accepted from the season start untilclaims_until(the season's late-evidence deadline). Errors:tier_not_reached,premium_required,tier_already_claimed,battle_pass_unavailable,inventory_full. - Premium is set by your backend after a purchase it verified, through its one route
POST /v1/customers/{id}/battle_pass_premium(secret key) with{ "pass", "premium": true, "source": { "type", "id" } }. It is retroactive: every premium track of a tier already reached becomes claimable (nothing is paid automatically).premium: falseblocks further premium claims and takes nothing back. - A currency award carries the weaker trust of the claim and the XP that reached the tier and reserves against the trust liability caps like chests.
- Reads:
GET /v1/customer/battle_passes(per pass:battle_pass(its key; there is nokeyfield),version,season,status,paused,claims_until,progress,premium,next_tier, and per tier intiers[]key,threshold,reached, and per trackfreeandpremiumitsawards,claimedandclaimable). The read has noclaims[]list: a claimed track istiers[].free.claimedortiers[].premium.claimed. The server readGET /v1/customers/{id}/battle_passesaddsbattle_pass_id,version_idandseason_version_id. AlsoGET /v1/battle_passes,GET /v1/battle_passes/{id}. Backends can claim withPOST /v1/customers/{id}/battle_pass_claims. economy_battle_pass.updatepublishes a new version of the tiers; claims and premium stay with the season, so a claimed tier stays claimed.
Pausing
An energy, a loot table or a battle pass can be paused and resumed through a change set
(economy_energy.pause and economy_energy.resume, economy_loot_table.pause and
economy_loot_table.resume, economy_battle_pass.pause and economy_battle_pass.resume, each
with key and expected_version). Paused means frozen and nothing is lost. A paused energy does
not regenerate, refuses spends, refills and entitlement changes (energy_paused) and reads its
value at pause time with paused: true; on resume the paused time is excused, so every player
continues from exactly what they had. A paused loot table refuses opening its chests
(loot_table_unavailable, reason paused) and the chest is kept; its odds read paused: true
and can still be updated. A paused battle pass refuses claims and premium changes
(battle_pass_paused) while progress keeps counting, so every tier reached is claimable after
resume; the season does not move, so if its claim deadline passes while paused, those claims are
refused like any late claim. Boosts have no pause: a boost is a timed purchase the player owns,
and its timer keeps running.
item.grant and reward slots
A rule grants items with the v2 action { "type": "item.grant", "item": "bronze_chest", "quantity": 1 }
(quantity 1 to 1,000). Program reward slots accept it too, so quest completions, streak
milestones, badge ranks and levels can reward items. The grant is made once per rule execution or
reward slot; a receipt reports item.granted (item, quantity, entry_id) or, when nothing
fits or the item was archived, item.not_granted with a reason. See
Rules.
Events
Each change writes one orishare.domain_event/v1 row on the webhooks topic, which webhook
endpoints can subscribe to by name and notifications can react to. The economy sends no messages.
| Event | When | data |
|---|---|---|
item.granted | A grant (backend, rule, chest, pass). | item, item_name, quantity, quantity_after, source_type |
item.purchased | A shop purchase. | same |
item.consumed | A consumption. | same |
item.revoked | A revocation, a refund or an expiry. | same |
energy.refilled | A refill that added something. | energy, amount, value, maximum, source_type |
boost.started | A consumption that started a boost window. | item, currency, multiplier, active_multiplier, duration_seconds, ends_at |
chest.opened | A chest consumption. | item, loot_table, chests, award_count, awards |
battle_pass.tier_claimed | A tier track claimed. | battle_pass, tier, track, threshold, award_count, awards |
battle_pass.premium_activated | A player gains premium. | battle_pass, season, ends_at |
Equipping emits nothing. There is no energy.full event: use full_at from the energy read.
Usage
In a live environment, each of these commands counts as one billable operation, from the player or your backend: a shop purchase, a consumption (a chest opening and a boost start included), an energy spend, a backend inventory grant, and a battle pass claim (one track or claim-all). What a command does inside itself (a chest's loot, a claim's item awards, the tracks of a claim-all) adds nothing. A replay or a refused command counts nothing. Reads, equips, revocations, refunds, energy refills and entitlements, and premium changes are not billed.
Known limits
- Leaderboard prizes and referral rewards can be items (see Leaderboards and Referrals). They are taken back by the leaderboard correction or referral refund that lowers them, never below what the player still holds.
- Items are not clawed back by reversals:
points.reverseor a refund of the event that granted them leaves the items. UsePOST /v1/customers/{id}/inventory_revocationswhen you must. - Merges never fail on caps. A merged player may hold more than
max_quantity(a durable reads at mostmax_quantity, a consumable reads and uses the true union), and no grant or purchase succeeds until the union is under the cap. Two equipped items in one slot read as the most recently equipped. A tier ormax_per_customerdraw that both accounts already had stays paid twice; further claims and draws past the limit are refused.
Recipe: a skin shop, energy, chests and a battle pass
One complete setup for a game with a backend server: coins buy a cape and life refills, players
have 5 lives that refill 1 per 30 minutes, matches pay XP and coins, a win pays a chest, and a
season pass has 3 tiers. Paths are under https://api.orishare.com.
1. Configuration, one change set per command
Each body below is one POST /v1/change_sets with its own Idempotency-Key, from a PAT or key
with config:write, programs:write and currencies:write. Commit each one before proposing
the next, since later commands name what earlier ones create, and a commit in between can make a
pending proposal stale (propose it again). How it commits follows the environment's
autonomy policy: in a test environment (and in a live environment on auto) you apply it yourself
with POST /v1/change_set_applications { "change_set_id", "plan_digest" }, which answers
committed. In a live environment on limits (where an agent-created project starts) you apply them
yourself too: currency.create and match_profile.create apply, and the economy commands
(economy_item.*, economy_offer.*, economy_energy.*, economy_loot_table.*,
economy_battle_pass.*) apply when what one grant can pay is within the cost caps. A change over a
cap, an amount that cannot be read (award_unbounded) or an archive (destructive) is refused
with 403 confirmation_required; a signed-in owner or admin then confirms it in the dashboard
(always so in confirm), and you read GET /v1/change_sets/{id} until status is
committed. Order matters: an offer needs its
item and currency, a refill item its energy, a loot table the items it awards, a chest its loot
table, and a pass its season and every award.
The two currencies come first. coins is an ordinary spendable currency. xp is classified
non-spendable XP, which a battle pass requires (a classified currency is never deleted). Both can
also come from a playbook (Playbooks) or POST /v1/currencies;
skip any that already exist.
{ "commands": [{ "type": "currency.create", "code": "coins", "name": "Coins" }] }{
"commands": [
{
"type": "currency.create",
"code": "xp",
"name": "XP",
"capabilities": {
"schema": "orishare.currency-capabilities/v1",
"spendable": false,
"transferable": false,
"qualification": "xp",
"expiry": { "type": "none" }
}
}
]
}{
"commands": [
{
"type": "economy_item.create",
"key": "red_cape",
"item_type": "durable",
"max_quantity": 1,
"equip_slot": "back",
"display": {
"title": "Red cape",
"description": "A cape for your back slot.",
"rarity": "rare"
}
}
]
}{
"commands": [
{
"type": "economy_offer.create",
"key": "red_cape_offer",
"item": "red_cape",
"quantity": 1,
"price": { "currency": "coins", "amount_minor": 500 },
"per_customer_limit": 1
}
]
}{
"commands": [
{
"type": "economy_energy.create",
"key": "lives",
"maximum": 5,
"refill": { "amount": 1, "every_seconds": 1800 }
}
]
}{
"commands": [
{
"type": "economy_item.create",
"key": "lives_refill",
"item_type": "consumable",
"max_quantity": 20,
"effect": { "type": "energy_refill", "energy": "lives", "amount": "full" },
"display": { "title": "Full lives" }
}
]
}{
"commands": [
{
"type": "economy_offer.create",
"key": "lives_refill_offer",
"item": "lives_refill",
"quantity": 1,
"price": { "currency": "coins", "amount_minor": 100 }
}
]
}{
"commands": [
{
"type": "economy_loot_table.create",
"key": "bronze_chest_table",
"definition": {
"rolls": 1,
"entries": [
{
"key": "coins_small",
"weight": 80,
"award": { "type": "currency", "currency": "coins", "amount_minor": 50 }
},
{
"key": "coins_big",
"weight": 18,
"award": { "type": "currency", "currency": "coins", "amount_minor": 250 }
},
{
"key": "cape",
"weight": 2,
"award": { "type": "item", "item": "red_cape", "quantity": 1 },
"max_per_customer": 1,
"budget": { "maximum": 10000 }
}
],
"guaranteed": [{ "type": "item", "item": "lives_refill", "quantity": 1 }],
"pity": { "entry_key": "cape", "after_rolls": 40 }
}
}
]
}{
"commands": [
{
"type": "economy_item.create",
"key": "bronze_chest",
"item_type": "consumable",
"max_quantity": 100,
"effect": { "type": "chest", "loot_table": "bronze_chest_table" },
"display": { "title": "Bronze chest" }
}
]
}The season is configuration too. Propose it as one change set with two commands, in the same
way as the economy (agent token or a PAT with config:write and programs:write):
{
"commands": [
{ "type": "season.create", "key": "season_1", "name": "Season 1" },
{
"type": "season.publish_definition",
"key": "season_1",
"definition": {
"schema": "orishare.season/v1",
"starts_at": "2026-10-01T00:00:00.000Z",
"ends_at": "2026-12-31T00:00:00.000Z",
"display": { "title": "Season 1" },
"eligibility": { "audience": [] },
"admission": { "grace_seconds": 3600, "max_lateness_seconds": 1800 },
"settlement": { "delay_seconds": 60 }
}
}
]
}Like the economy commands, season commands are held to the limits cost caps, so in a live environment
on limits an agent can apply this one itself when nothing in it is outside the limits. The direct routes (POST /v1/seasons, then
POST /v1/seasons/{id}/versions with If-Match: "<version>" and { "definition": ... }) work
with a secret key or PAT, and in a test environment; from an agent credential in a live environment they answer
403 confirmation_required with details.reason ai_gate.
starts_at and ends_at are compared with Orishare's server clock, not with your machine's. On a
local Orishare stack the server clock can be ahead of (or behind) local time, so a season that
should already be open can still be upcoming there: set starts_at comfortably in the past for a
test season.
{
"commands": [
{
"type": "economy_battle_pass.create",
"key": "season_1_pass",
"definition": {
"season": "season_1",
"xp_currency": "xp",
"tiers": [
{
"key": "tier_1",
"threshold": 100,
"free": [{ "type": "currency", "currency": "coins", "amount_minor": 100 }],
"premium": [{ "type": "item", "item": "bronze_chest", "quantity": 1 }]
},
{
"key": "tier_2",
"threshold": 300,
"free": [{ "type": "item", "item": "lives_refill", "quantity": 1 }],
"premium": [{ "type": "currency", "currency": "coins", "amount_minor": 500 }]
},
{
"key": "tier_3",
"threshold": 600,
"free": [{ "type": "item", "item": "bronze_chest", "quantity": 1 }],
"premium": [{ "type": "item", "item": "bronze_chest", "quantity": 3 }]
}
]
}
}
]
}The match profile that checks reported matches (Match results):
{
"commands": [
{
"type": "match_profile.create",
"key": "arena",
"metrics": [
{ "key": "score", "minimum": 0, "maximum": 50000 },
{ "key": "placement", "minimum": 1, "maximum": 20 }
],
"duration": { "minimum_seconds": 60, "maximum_seconds": 1800 },
"players": { "minimum": 2, "maximum": 20 }
}
]
}2. The rules
Every admitted match pays 50 XP and 10 coins; first place also gets a chest. Propose the rules as a change set, like the configuration above:
- For each rule, a
rule.createchange set (name,definition), then arule.publish_draftchange set withkeyset to the created rule's id (outcome.result.idof the first commit) andexpected_versionset tooutcome.result.version, as Author a mechanic shows. In a live environment onlimitsboth apply on request: publishing a rule version (alsorule.rollback) is not counted as irreversible (the old version stays in history and the head can move back), and its awards are held to the cost caps. - A
config.apply_documentcommand applies only rule rows copied from an export: each version'sdigestand the row'sheadare computed by the server and checked on apply, so do not write new rule rows by hand (Configuration as code).
The direct routes (POST /v1/rules with scope rules:write, then POST /v1/rules/{id}/publish
with If-Match: "<version>"): for an agent credential the publish runs only in a test environment or a
live environment in auto (a draft also in limits); elsewhere it answers 403 confirmation_required (details.reason: "ai_gate"). A verified person's own credential over
the REST API is not held to that gate. The two rule definitions:
{
"name": "Pay XP and coins per match",
"definition": {
"schema": "orishare.rule/v2",
"trigger": { "kind": "customer_event", "event": "match.completed" },
"trust": { "minimum": "merchant_asserted" },
"actions": [
{ "type": "points.award", "currency": "xp", "amount": 50 },
{ "type": "points.award", "currency": "coins", "amount": 10 }
]
}
}{
"name": "A chest for first place",
"definition": {
"schema": "orishare.rule/v2",
"trigger": { "kind": "customer_event", "event": "match.completed" },
"trust": { "minimum": "merchant_asserted" },
"conditions": { "all": [{ "path": "event.properties.placement", "op": "eq", "value": 1 }] },
"actions": [{ "type": "item.grant", "item": "bronze_chest", "quantity": 1 }]
}
}3. The player, from the browser
The browser holds only the publishable key and the customer session token
(Agent guide). Every write carries a new Idempotency-Key per player
intent, reused on retries.
const API = 'https://api.orishare.com';
const headers = {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'X-Orishare-Customer-Token': customerToken,
};
const write = (key) => ({ ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': key });
// What can I buy?
const offers = await (await fetch(`${API}/v1/customer/shop_offers`, { headers })).json();
const cape = offers.data.find((o) => o.key === 'red_cape_offer'); // cape.item_display.title
// Buy the cape (refused whole on insufficient_balance, purchase_limit_reached, ...).
await fetch(`${API}/v1/customer/shop_purchases`, {
method: 'POST',
credentials: 'omit',
headers: write(buyKey),
body: JSON.stringify({ offer_id: cape.id, offer_revision: cape.revision, quantity: 1 }),
});
// Equip it in the back slot.
await fetch(`${API}/v1/customer/inventory_equips`, {
method: 'POST',
credentials: 'omit',
headers: write(equipKey),
body: JSON.stringify({ item: 'red_cape', equipped: true }),
});
// Spend a life to start a match (409 insufficient_energy when none is left).
await fetch(`${API}/v1/customer/energy_spends`, {
method: 'POST',
credentials: 'omit',
headers: write(matchKey),
body: JSON.stringify({ energy: 'lives', amount: 1 }),
});
const lives = await (await fetch(`${API}/v1/customer/energy`, { headers })).json();
// Show the odds, then open a chest: effect_result.draws says what it paid.
const odds = await (
await fetch(`${API}/v1/customer/loot_odds?item=bronze_chest`, { headers })
).json();
const opened = await (
await fetch(`${API}/v1/customer/inventory_consumptions`, {
method: 'POST',
credentials: 'omit',
headers: write(openKey),
body: JSON.stringify({ item: 'bronze_chest', quantity: 1 }),
})
).json();
// Read the pass and claim every claimable track.
const passes = await (await fetch(`${API}/v1/customer/battle_passes`, { headers })).json();
const pass = passes.data.find((p) => p.battle_pass === 'season_1_pass');
await fetch(`${API}/v1/customer/battle_pass_claims`, {
method: 'POST',
credentials: 'omit',
headers: write(claimKey),
body: JSON.stringify({ pass: 'season_1_pass', all: true }),
});GET /v1/customer/inventory lists what the player holds (with equipped), and every read
supports If-None-Match with the returned ETag.
4. Your backend
After your server verifies an in-app purchase of the premium pass, it sets premium with the
secret key, keyed by the store transaction so a retry never applies twice:
POST /v1/customers/{id}/battle_pass_premium with Idempotency-Key: premium-<transaction id> and
{ "pass": "season_1_pass", "premium": true, "source": { "type": "app_store", "id": "txn_123" } }Premium tracks of tiers the player already reached are now claimable. When a match ends, your game
server reports it with POST /v1/match_results (secret key, events:write); match_id is the
idempotency key, and won (optional, true or false) becomes properties.won of the player's
match.completed event, for rules that pay wins:
{
"profile": "arena",
"match_id": "arena-2026-10-04-0001",
"started_at": "2026-10-04T11:52:00Z",
"ended_at": "2026-10-04T12:00:00Z",
"players": [
{
"customer": { "external_id": "player_8812" },
"metrics": { "score": 12400, "placement": 1 },
"won": true
},
{
"customer": { "external_id": "player_4410" },
"metrics": { "score": 9100, "placement": 2 },
"won": false
}
]
}Each admitted player becomes a trusted match.completed event: both earn 50 XP (multiplied while an
XP boost item they consumed is running) and 10 coins, player_8812 also gets a bronze chest, and the XP moves both along
the battle pass.
Match results
Report finished matches from an authoritative game server: plausibility bounds, per-player rate windows, held results and review, and the trusted match.completed event rules turn into XP, scores and items.
Store credit at checkout
Give customers a money valued wallet and apply it to orders with quote, apply, capture, void and refund.