Orishare
Concepts

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:

FieldMeaning
keyStable item key.
item_typedurable (kept until revoked, can be equipped) or consumable (used up by a consumption). Never changes.
max_quantityThe most one player may hold, 1 to 1,000,000 (default 1). A grant or purchase above it is refused (inventory_full).
equip_slotOptional slot name (^[a-z][a-z0-9_]{0,31}$), durable items only. At most one equipped item per slot per player.
effectWhat consuming one unit does (default { "type": "none" }), below.
displaytitle (1 to 120), description (up to 500), image_url (https or null), rarity (a slot-style name or null).
metadataYour own JSON object, at most 4 KB.

Effects:

  • { "type": "none" }: the consumption is recorded and item.consumed is 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):

RouteBody or queryDoes
GET /v1/customer/inventorycursor, limit, localeHoldings with quantity, equipped, equip_slot, effect, display.
GET /v1/customer/shop_offerscursor, limit, localeOffers 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_purchasesoffer_id, offer_revision, quantityBuys an offer.
POST /v1/customer/inventory_consumptionsitem, quantityConsumes units of a consumable, all or nothing, and applies its effect.
POST /v1/customer/inventory_equipsitem, equippedEquips 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. overflow refuse (default) refuses the whole grant past max_quantity; clamp grants what fits and reports not_granted.
  • POST /v1/customers/{id}/inventory_revocations: { "item", "quantity", "reason" }; takes what is held, reports the rest as not_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}/inventory and GET /v1/customers/{id}/inventory_entries (filter item, 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_seconds intervals since the last change add amount, up to maximum. At or above maximum nothing regenerates. A player who never used an energy reads full. Reads return value, next_refill_at and full_at (null at or above the maximum) and unlimited_until.
  • Spend: POST /v1/customer/energy_spends (or POST /v1/customers/{id}/energy_spends) with { "energy": "lives", "amount": 1 } is all or nothing: insufficient_energy when the player has less. The result says spent and free.
  • Refills: consuming an energy_refill item, or POST /v1/customers/{id}/energy_refills with { "energy", "amount" } (amount a number or "full", optional reason). A number adds up to overfill_maximum (or maximum without one); "full" fills to maximum and never takes overfill away. What does not fit is not added (refilled says what was), and a refill item is consumed even when the energy is already full, so read GET /v1/customer/energy before offering one. To sell energy, sell an energy_refill item in the shop.
  • Unlimited: POST /v1/customers/{id}/energy_entitlements with { "energy": "lives", "until": "2026-11-04T00:00:00Z" } (secret key, from your own subscription state) makes spends free (and still recorded) until then; until: null ends it.
  • Reads: GET /v1/customer/energy, GET /v1/customers/{id}/energy, GET /v1/energy_definitions, GET /v1/energy_definitions/{id}. economy_energy.update changes maximum, refill or overfill_maximum (a player above a lowered maximum keeps it); economy_energy.archive stops 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.award into 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_at or occurred_at), so a replay awards the same amount once. The receipt effect records boost_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 liability is 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):

FieldMeaning
rollsRolls per opened chest, 1 to 10 (default 1).
entries1 to 100 entries: key, weight (1 to 1,000,000) and award.
entries[].max_per_customerThe most draws of this entry one player may get from the table (all versions), or null.
entries[].budget.maximumThe most draws of this entry in the whole environment, or null.
guaranteedUp 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) and effective: this player's next roll, with entries exhausted by a budget or their max_per_customer at 0 and a pity roll certain (pity_forced). GET /v1/loot_tables/{id}/odds is 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, reason trust_cap_exhausted) before any roll.
  • economy_loot_table.update publishes a new immutable version of the whole definition; 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_earned leaderboard 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_claims with { "pass", "tier", "track" } (free or premium), or { "pass", "all": true } for every claimable track (up to 200). One claim answers claim_id, tier, track, threshold, progress and awards; claim-all answers claims[] (one such entry per track claimed), skipped (tracks refused on their own) and remaining (what is left). Claims are accepted from the season start until claims_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: false blocks 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 no key field), version, season, status, paused, claims_until, progress, premium, next_tier, and per tier in tiers[] key, threshold, reached, and per track free and premium its awards, claimed and claimable). The read has no claims[] list: a claimed track is tiers[].free.claimed or tiers[].premium.claimed. The server read GET /v1/customers/{id}/battle_passes adds battle_pass_id, version_id and season_version_id. Also GET /v1/battle_passes, GET /v1/battle_passes/{id}. Backends can claim with POST /v1/customers/{id}/battle_pass_claims.
  • economy_battle_pass.update publishes 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.

EventWhendata
item.grantedA grant (backend, rule, chest, pass).item, item_name, quantity, quantity_after, source_type
item.purchasedA shop purchase.same
item.consumedA consumption.same
item.revokedA revocation, a refund or an expiry.same
energy.refilledA refill that added something.energy, amount, value, maximum, source_type
boost.startedA consumption that started a boost window.item, currency, multiplier, active_multiplier, duration_seconds, ends_at
chest.openedA chest consumption.item, loot_table, chests, award_count, awards
battle_pass.tier_claimedA tier track claimed.battle_pass, tier, track, threshold, award_count, awards
battle_pass.premium_activatedA 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.reverse or a refund of the event that granted them leaves the items. Use POST /v1/customers/{id}/inventory_revocations when you must.
  • Merges never fail on caps. A merged player may hold more than max_quantity (a durable reads at most max_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 or max_per_customer draw 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.

1a coins
{ "commands": [{ "type": "currency.create", "code": "coins", "name": "Coins" }] }
1b xp
{
  "commands": [
    {
      "type": "currency.create",
      "code": "xp",
      "name": "XP",
      "capabilities": {
        "schema": "orishare.currency-capabilities/v1",
        "spendable": false,
        "transferable": false,
        "qualification": "xp",
        "expiry": { "type": "none" }
      }
    }
  ]
}
1c the cape
{
  "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"
      }
    }
  ]
}
1d the cape offer
{
  "commands": [
    {
      "type": "economy_offer.create",
      "key": "red_cape_offer",
      "item": "red_cape",
      "quantity": 1,
      "price": { "currency": "coins", "amount_minor": 500 },
      "per_customer_limit": 1
    }
  ]
}
1e lives
{
  "commands": [
    {
      "type": "economy_energy.create",
      "key": "lives",
      "maximum": 5,
      "refill": { "amount": 1, "every_seconds": 1800 }
    }
  ]
}
1f the life refill item
{
  "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" }
    }
  ]
}
1g the life refill offer
{
  "commands": [
    {
      "type": "economy_offer.create",
      "key": "lives_refill_offer",
      "item": "lives_refill",
      "quantity": 1,
      "price": { "currency": "coins", "amount_minor": 100 }
    }
  ]
}
1h the loot table
{
  "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 }
      }
    }
  ]
}
1i the chest item
{
  "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):

season change set
{
  "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.

1j the battle pass
{
  "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):

1k the match profile
{
  "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.create change set (name, definition), then a rule.publish_draft change set with key set to the created rule's id (outcome.result.id of the first commit) and expected_version set to outcome.result.version, as Author a mechanic shows. In a live environment on limits both apply on request: publishing a rule version (also rule.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_document command applies only rule rows copied from an export: each version's digest and the row's head are 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:

rule: match pay
{
  "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 }
    ]
  }
}
rule: chest on a win
{
  "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

premium
{ "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:

match result
{
  "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.

On this page