Orishare
Recipes

Game setup

The configuration every recipe applies: XP and 100 levels, coins, a daily streak with repair, badges with rarity, a starter quest, a weekly XP leaderboard, a weekly league, referrals, notifications and friends. Exact calls, caps, player screens and tests.

Every recipe configures the same reward system with a few calls, all with the agent token and the test environment id from the signup (Start here):

Authorization: Bearer <agent token>
X-Orishare-Environment: <test environment id>
Content-Type: application/json
Idempotency-Key: <new UUID per request; reuse it only to repeat that same request>

What you get

MechanicKeyWhat it does
Currenciesxp, coinsXP drives levels and is not spendable. Coins are spendable.
Levelsplayer_level100 levels on XP: level 2 at 100 XP, 3 at 220, 4 at 360, then +20 more per step. 25 coins per level-up while all of a player's XP is server-reported (see Caps), paid by the player_level program's own reward (reward.on slot level:level_N), not by a rule.
RulesEach match.completed: 20 XP; a win (won: true) adds 30 XP and 10 coins. Each level.completed: 50 XP. These are the only coins rules pay.
Daily streakdaily_playOne session.started a day. 20 coins at 3 days, 50 at 7. Up to 2 freezes (100 coins each, used automatically). A break can be repaired for 50 coins within 3 days, at most twice in 30 days.
BadgeswinsRanks first_win (1 win), ten_wins (10), hundred_wins (100), with rarity.
Questwarm_upThree steps: play 3 matches, win 1, complete 1 level. 100 coins, once. A daily quest needs repeatable: true and a server call per day, see Daily quests.
Leaderboardweekly_xpWeekly, scored by the XP earned this week.
Leagueweekly_leagueBronze, Silver, Gold; groups of 30 scored by weekly XP; top 5 promote from Bronze and Silver, bottom 5 demote from Silver and Gold (Bronze, the lowest tier, has demote_count: 0); ranks 1 to 3 each get 50 coins in Bronze, 100 in Silver, 200 in Gold.
Referralsinvite_friendsA player shares a code; a new player who claims it and plays earns 100 coins, the referrer 200 (50 referrals at most).
Notificationsstreak_at_risk, level_upPush and web push, from Orishare.

Event names: match.completed (score number, won boolean), level.completed (level number), session.started (no properties). For an app that is not a game, keep the names or edit them in the files below before you apply them.

1. Apply the starter

  1. Download /recipes/game-starter.change-set.json and send it unchanged as the body of POST /v1/change_sets. The answer is 201 with status: "pending" and plan.plan_digest.
  2. Apply it: POST /v1/change_set_applications with { "change_set_id": "<id>", "plan_digest": "<plan.plan_digest>" }. The answer is 201 with status: "committed". Everything except the league and notifications now exists.
  3. The league. GET /v1/programs?limit=100 and take the id of the program whose key is weekly_xp. Download /recipes/game-starter.league.json, put that id in body.config.score.program_id, send body to POST /v1/leagues, then send activate.body ({ "expected_revision": 1 }) to POST /v1/leagues/{id}/activate.
  4. Notifications: send each item of message_types in /recipes/game-starter.notifications.json to POST /v1/notification_message_types.

Steps 3 and 4 are direct calls, which the agent token may make in the test environment. In a live environment they become change set commands (Go live).

Notes:

  • The change set is applied once per environment. Sending it again with a new Idempotency-Key is refused because the program keys exist; to start over, use a new project or change the keys.
  • It sets the environment's trust policy (the per-day caps below). Apply it before any other trust policy change of your own.
  • Do not edit the rules inside the file by hand: each rule version carries a digest of its content, and a changed rule without a new digest is refused (422). Change amounts after applying, with a new rule version (Rules).

Caps

Activity the app or browser reports with a player session (POST /v1/customer/events) counts up to these limits per player per day; events your server sends with the secret key are never capped.

WhatLimit per player per day
XP from app events1,000
Coins from app-reported wins20 wins (200 coins)
All coins from app activity400
Badge and quest progress from app events30 each
Streak1 day

App-reported XP raises the level but pays no level-up coins; server-reported XP does, but only while all of the player's XP came from your server. A level's trust is the weakest trust of the whole XP total that reaches it (basis lifetime_earned), so once a player has any app-reported XP, every later level-up pays nothing, even when server XP crosses the threshold. A game that wants level-up coins reports XP only from its server (POST /v1/events). This is on purpose: client-reported XP never pays spendable currency (anti-cheat). An app with no backend rewards levels with something that is not spendable instead: a badge (a badge program fed by the same client events, like the starter's wins badge) or a cosmetic item (item.grant from a rule on level.completed that has a per-customer limit; see Game economy). A session from grant_type: "anonymous" can read but its events are refused (403 trust_insufficient): players must be signed in to earn.

A key that mints customer sessions

The test and live secret keys from the agent signup can mint customer sessions (customer_sessions:write). A key created later without scopes cannot: create it with an explicit list, for example POST /v1/environments/{id}/api_keys with { "kind": "secret", "name": "game server", "scopes": ["events:*", "customers:*", "customer_sessions:write", "programs:*", "ledger:*"] }.

Mint a session on your server for a signed-in player:

POST /v1/customer_sessions   (secret key, Idempotency-Key)
{ "grant_type": "secret_key", "customer_id": "cust_..." }

customer_id is the internal id: POST /v1/customers with { "external_id": "<your player id>" } returns it (201 new, 200 existing). Return only customer_token to the client.

Sign in with Firebase or Supabase

The default for a Firebase or Supabase app is server first: the Cloud Functions kit @orishare/firebase-functions (Firebase app) or the Orishare Edge Function template (Supabase app) verifies the signed-in user, mints the session as above with the provider's user id as the customer's external_id, and reports events with the secret key. The app needs only the publishable key, and no identity connection.

Without functions, the app exchanges the provider's token itself (client trust, capped). With the test secret key, create an identity connection (POST /v1/identity_connections; bodies in Firebase app and Supabase app) and give the client its id (ic_...) and the publishable key. The client exchanges the provider's token itself:

POST /v1/customer_sessions   (publishable key, Idempotency-Key)
{ "grant_type": "identity_token", "connection_id": "ic_...", "provider_token": "<Firebase ID token or Supabase access token>" }

The answer's customer_id (cust_...) is the player's Orishare id, and assurance says who it is: authenticated_customer for a signed-in user, anonymous for an anonymous provider user (their events and social calls are refused). It is not the provider's user id: Orishare keeps only a hash of the provider subject, and the customer it creates has a generated external id. So pick one path per app: on this one, a backend function that acts for the player learns the cust_ id by exchanging the caller's verified provider token with the publishable key and reading customer_id from the answer, and never sends the provider uid as customer_id to POST /v1/events (that creates a second, empty customer). On the server-first path the provider uid is the external_id and the exchange is never used. Browsers must also have their origin allowed. The origins given as allowed_origins at signup are already set (in the test environment all of them, in a live environment the https:// ones), so check first with GET /v1/client_configuration; only to add an origin, PATCH /v1/client_configuration with If-Match: <ETag of that read> and the full list { "allowed_origins": ["https://game.example.com", ...] } (the list replaces the old one; http://localhost:<port> is allowed in the test environment). Native apps and game clients send no Origin header and need nothing here.

Player screens

Each item names its program by program_key (player_level, daily_play, ...) and carries program (program.id is the prg_ id; quest and streak items have no top-level program_id). Numbers in these reads are decimal strings except league score and rank. League group standings answer { "standings": null } until the league job has placed the player, then { "standings": { tier, cohort, rows: [{ rank, score, display_name, is_self, zone }] } }; leaderboard standings answer { data: [{ entry_id, pseudonym, ordinal, score }], own_rank }. Every call: Authorization: Bearer <publishable key>, X-Orishare-Customer-Token: <token>, and an Idempotency-Key on writes.

ScreenCallFields to show
Coins and XPGET /v1/customer/balancesdata[].currency.code, available, lifetime_earned (strings)
Level barGET /v1/customer/levelsdata[0].current_level.display.title, basis (XP), next_threshold, remaining
StreakGET /v1/customer/streakscurrent, best (decimal strings, "1"), freezes (a number, 0), at_risk_at, repair (null, or { available, price: { currency, amount_minor }, expires_at, broken_length } after a break)
BadgesGET /v1/customer/badgeshighest_rank, progress, criteria.ranks[], rarity (null until 20 players take part)
QuestGET /v1/customer/questsstatus, steps[].display.title, progress, target, remaining; the starter quest pays by itself (claimable false), so it needs no claim call. For a daily quest see Daily quests
LeaderboardGET /v1/customer/leaderboard_positionsscore, ordinal; full board at GET /v1/customer/leaderboards/{program.id}/boards/{board_id}/standings (players show only after they opt in, below)
LeagueGET /v1/customer/leaguestier.name, seconds_left, entry (score, rank, zone), last_result; group at GET /v1/customer/leagues/{id}/standings with {id} = the item's league.id (other players show by their display name, or a generated Player 1234 until they set one)
ReferralsGET /v1/customer/referral_codes, GET /v1/customer/referralscode; status, offered_rewards

Player actions:

  • Event ids are per player: the id a player's session sends names that player's event only, so every player can use the same scheme, for example session-<UTC day>, and no player can use up another player's id. Resend the same id to retry the same fact; the same id with a different body is 409 event_id_conflict. Ids your server sends with the secret key (POST /v1/events) are a separate namespace, unique across the environment.
  • Report activity: POST /v1/customer/events with { "id": "<unique>", "name": "session.started", "properties": {} } (or { "events": [ ... ] }, up to 25). The answer is 202 with results[].status: "pending": rewards are evaluated within seconds, so read the screens again a moment later.
  • Repair a broken streak: when repair.available is true, POST /v1/customer/streaks/repairs with { "program_id": "<item.program.id>", "program_version_id": "<item.program_version_id>" }. It costs repair.price coins (409 insufficient_balance without them; 409 streak_repair_unavailable when there is no break to repair or it was already repaired). This is the route for a player session (publishable key and customer token, the player pays). A server holding the secret key uses POST /v1/streaks/{id}/repairs instead (below); /v1/customer/streaks/repairs refuses a secret key (401 invalid_customer_session). Use one or the other per player action, not both.
  • Referral code: POST /v1/customer/referral_codes with {} returns the player's code (the same one every time). A new player enters a friend's code: POST /v1/customer/referral_claims with { "code": "..." }.
  • Names others see: PATCH /v1/customer/social_profile with { "display_name": "..." } (league and friend screens).
  • Show me on the leaderboard: the weekly board is pseudonymous and opt-in. Standings list no one until players opt in: POST /v1/customer/leaderboard_profiles with { "entry_id", "profile_revision", "pseudonym": "Ace", "visible": true }, taking entry_id and profile_revision from the player's item in GET /v1/customer/leaderboard_positions. Ask the player once, for example on their first visit to the board.
  • Push: register the device with POST /v1/customer/notification_devices { "kind": "fcm" | "apns" | "web_push", "token": "...", "platform": "ios" } (token is the FCM or APNs device token, or for web_push the browser's PushSubscription as JSON text; platform is a free label such as ios, android or web) and record the player's consent with PATCH /v1/customer/notification_preferences { "push_consent": "opted_in" } (it starts unknown, and nothing is pushed without it).

The league has no join call: a player enters it with their first XP of the week, once the league job has run (within minutes). Until then entry is null. A server event in mode: "sync" updates XP, level and the leaderboard score before it answers; app events are evaluated in the background and show within about a minute.

Server reads

A server with the secret key (Roblox, a game server) reads a player's balances with GET /v1/customers/{id}/balances ({id} is your player id or the cust_ id; amounts are numbers here, strings in the customer reads). Other server routes take only the cust_ id (GET /v1/customers/{id}/leagues, repairs, referral codes, social): resolve it once with POST /v1/customers { "external_id": "<player id>" } and keep it. GET /v1/customers/{id}/state does not carry levels (its tiers is for legacy tier programs). A player's coin history is GET /v1/ledger_entries?customer_id=cust_...&currency=coins (secret key with ledger:read, newest first, optional type earn, spend, expire, reverse or adjust); there is no /v1/ledger/entries.

For the level, the streak, badges, quests and the leaderboard, the server mints a customer session for the player and calls the player screens with the publishable key and that token (a secret key on /v1/customer/* answers 401 invalid_customer_session). Roblox included: the server holds the token and passes only the results to the client.

Acting for a player from the server:

  • Event by internal id: POST /v1/events with "customer": { "id": "cust_..." } instead of customer_id (customer_id is always your external id; a cust_ value there is 400).
  • Streak repair: POST /v1/streaks/{id}/repairs (secret key with programs:write, an Idempotency-Key) where {id} is the daily_play program id (GET /v1/programs?limit=100), body { "customer_id": "cust_...", "charge": true }. charge: true takes repair.price coins from the player as the player route does; charge: false repairs for free (a support gesture). This is the server-held-key route (Roblox, a game server, a Cloud Function); a client with a player session uses POST /v1/customer/streaks/repairs (player actions).
  • Referral code: POST /v1/referral_codes with { "program_id": "<invite_friends program id>", "customer_id": "cust_..." }.
  • Referral claim has no server route: mint the new player's session and call POST /v1/customer/referral_claims with it.

Referrals

  1. Player A gets a code (POST /v1/customer/referral_codes), or your server creates one with POST /v1/referral_codes.
  2. New player B claims it (POST /v1/customer/referral_claims, { "code" }) within 7 days of their account.
  3. Once B has really played after claiming the code, your server sends POST /v1/events with name: "referral.qualified", B as the customer (customer_id: "<B's player id>", or customer: { "id": "cust_..." } when you only have the Orishare id, as with Firebase or Supabase) and properties: { "player_id": "<the same id>", "amount_minor": "1", "currency": "coins" }. player_id makes the fact unique per player, so B never qualifies twice. Use an event id such as referral-qualified-<player id>-<UTC day>: a send before B claimed a code does nothing, and the next day's send still qualifies a code claimed later. A repeat the same day answers duplicate.
  4. Referrals approve themselves. A settlement goes qualifying (just qualified), then pending through a one-day cooling-off (within minutes, by a background job), then settled on the first background pass after the cooling-off ends: A gets 200 coins and B 100, with no review. While the settlement is qualifying nothing is booked yet: the balances show no referral coins and the operator list shows review: "none". From pending on, the rewards show as pending in the server balances read (not available), and vests_at says when the cooling-off ends. While it cools off, the operator list shows review: "none" and the player's settlement.review_pending is false: no person needs to look at it. A refund or a deleted account inside the cooling-off cancels the referral. The recipe's approval is "mode": "automatic" with "period_seconds": 2592000, "max_per_referrer_per_period": 10 and "max_per_period": 500: at most 10 automatic approvals per referrer and 500 for the whole program in any trailing 30 days.
  5. Outliers wait for review. A referral over either limit stays pending after its cooling-off (the operator list then shows review: "pending", and the player's GET /v1/customer/referrals shows settlement.review_pending: true). List them with GET /v1/referrals/{id}/settlements ({id} is the invite_friends program id): a settlement whose review is "pending" is an outlier waiting for a person. Right after its vests_at an ordinary settlement can read "pending" briefly, until the next background pass approves it. Approve or reject it with POST /v1/referral_settlements/{id}/reviews { "expected_revision": "<revision>", "decision": "approve", "reason": "verified_review" } (the revision string of the settlement; agent token, or a key with referrals:review: the game's own secret key cannot approve). An approved outlier settles on the next background pass. In a live environment, tell your person where the review queue is and that only referrals over the limits wait for it. To review every referral instead, set approval: { "mode": "review" } (or leave approval out) in a new version of the program.

Friends and social

Friends, friend streaks and friend quests are in /recipes/social.json: a friend streak friend_daily (both friends play the same day; 10, 30 and 100 coins at 3, 7 and 30 days), a weekly friend quest friend_weekly (10 levels together, 50 coins each) and an optional team quest. Apply its definitions in order with the agent token: each POST and then its activate call.

  • Players (session): GET /v1/customer/friend_code (share it), POST /v1/customer/friend_requests { "code" }, POST /v1/customer/friend_requests/{code}/accept, GET /v1/customer/friends, POST /v1/customer/nudges and POST /v1/customer/high_fives { "code", "context": "friend_streak" }, GET /v1/customer/friend_streaks, pair for the week with POST /v1/customer/friend_quests { "definition": "friend_weekly", "friend_code" }, GET /v1/customer/friend_quests, friend rankings at GET /v1/customer/leagues/{id}/friend_standings.
  • Friend streak days and friend quest progress are recorded by a server call with the secret key and the cust_ id: POST /v1/friend_streak_definitions/{id}/activities ({id} may be the key friend_daily) { "customer_id" } once per day played, and POST /v1/friend_quest_definitions/{id}/contributions (friend_weekly) { "customer_id", "amount": 1, "dedupe_key": "level:<player>:<level>" } per level. An app with no backend needs one small function for these (Cloud Functions, Edge Functions); app events do not feed them yet.
  • Order matters. A friend streak day counts for a pair only when the second of the two players records it after they are friends: each player's day is stored once, so if both recorded the day before the friend request was accepted, that day does not count for the pair, and recording it again answers 201 with recorded: false. Record friend streak days after the friendship exists (and on later days as usual). Likewise a friend quest contribution counts only for a paired week: pair first (POST /v1/customer/friend_quests); a contribution without a pair answers 201 with quest: null and applied: false, and is not kept for later (after pairing, send it again with a new Idempotency-Key: the old key replays the no-op). Check recorded and applied in the answers.
  • Each of these server calls needs its own Idempotency-Key (one per player and day, or per contribution): the same key again replays the first answer, and the same key with another player's body is refused (idempotency_key_reused).
  • Social features need a signed-in player; anonymous sessions are refused (403 forbidden with details.reason: "social_unavailable_for_anonymous").

Test it

In the test environment, with the secret key, for a test player test-1:

  1. POST /v1/events { "id": "t1", "name": "match.completed", "customer_id": "test-1", "properties": { "score": 900, "won": true }, "mode": "sync" }.
  2. Then level.completed ({ "level": 1 }) and session.started, each with its own id.
  3. GET /v1/customers/{id}/balances with {id} = test-1: 100 XP (20 + 30 + 50) and 10 coins (plus the level-up coins: 100 XP is level 2, 25 coins).
  4. Mint a session for the player and read the player screens: level 2, streak 1, badge first_win, quest steps 1/3, 1/1, 1/1, the leaderboard score 100.
  5. With the session, send session.started from the client path and make a referral code.

Use a new event id for every new fact. On POST /v1/events the same id again answers 200 with status: "duplicate"; on POST /v1/customer/events it answers 202 again and counts once. Right after a player's first app event the level, leaderboard and league reads can be empty for up to a minute while background work runs.

Notifications

The two message types send a push or web push when a streak is at risk and on a level-up, to players with a registered device and push consent (Roblox players have no device: use your own in-game messages there). Webhooks to your own servers work from the start. In a test environment every message goes through a test provider: GET /v1/notification_messages lists each one with status (sent, suppressed with a reason such as no_device) so you can check your setup without sending anything. Orishare sends no real notification email or push for the project until a person claims it: built-in messages are suppressed as project_unclaimed and are not replayed after the claim. In a claimed live environment a built-in route answers failed with provider_not_configured until Orishare's providers are connected for it (Notifications). On a local Orishare stack every environment, live ones included, uses the test provider, so messages show sent with provider fake and nothing leaves the machine. Message types, templates and quiet hours: GET /v1/notification_catalog, POST /v1/notification_message_types/{id}/templates.

Each message type creates one message per route. A player with no registered mobile device gets a suppressed row with reason no_device for the mobile_push route on every level-up and streak warning; that is expected and costs nothing. A web-only game can drop the route: update the message type with only { "channel": "web_push", "path": "builtin" } in routes (PATCH /v1/notification_message_types/{id} with If-Match: "<revision>" in the test environment, or a notification_message_type.update change set with key, expected_version (the type's revision) and routes in a live environment). A route list sent this way replaces the old one.

Go live

When the test environment works, apply the same files in a live environment: the same calls with the live environment id (and the live keys in the app). The live environment follows its autonomy policy; an agent-created project starts in limits, which refuses only what goes over the reward cost caps or cannot be undone. You apply everything below yourself with POST /v1/change_set_applications:

  • The starter change set commits under limits: its awards are within the default caps, client-allowed events are never refused (an event whose app-reported activity is not capped is only recorded as the advisory trust_weakening), and creating currencies is not counted as irreversible. Read the advisories with GET /v1/change_sets/{id}?include_advisories=true (in commit.policy.advisories).
  • The league, the notification types and the social definitions are direct calls in the test environment. In a live environment those direct calls from the agent token answer 403 confirmation_required with reason ai_gate; propose them as change set commands instead, each as its own change set (they are operation commands), and apply each one: league.create with the fields of body in game-starter.league.json at the top level of the command ({ "type": "league.create", "key": "weekly_league", "name": "Weekly league", "config": { ... } }, not nested under body), then league.activate (key, expected_version: the league's revision, 1 after the create); notification_message_type.create with each item of message_types and notification_template.put for your copy (they pay nothing); and for the social file friend_streak_definition.create / friend_quest_definition.create / team_quest_definition.create with the create body, then .activate (key, expected_version). Their rewards are held to the cost caps. See Leagues, Notifications and Friends and teams.
  • Economy commands (items, offers, energy, loot tables, battle passes) are priced per change and commit when what one grant can pay is within the caps (Economy changes).
  • What is still refused with 403 confirmation_required and a review_url: an award above the per-grant cap or a bound above its cap (award_exceeds_cap, bound_exceeded), an amount that is computed or cannot be read, such as an item without a readable value (award_unbounded), activating an older version whose rewards the plan does not show (activates_existing_version), any deletion, archive or other irreversible change (delete, destructive, irreversible, bulk_not_reversible), and a command without declared metadata (metadata_missing). Make the change fit (a smaller award), or hand the review_url to your person with the claim link; after claiming they confirm it, or set the live environment's autonomy to auto in the dashboard.
  • Events, customers, sessions, repairs, referral codes and league scores are never held: the app works in a live environment as soon as the configuration is there.

On this page