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
| Mechanic | Key | What it does |
|---|---|---|
| Currencies | xp, coins | XP drives levels and is not spendable. Coins are spendable. |
| Levels | player_level | 100 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. |
| Rules | Each 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 streak | daily_play | One 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. |
| Badges | wins | Ranks first_win (1 win), ten_wins (10), hundred_wins (100), with rarity. |
| Quest | warm_up | Three 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. |
| Leaderboard | weekly_xp | Weekly, scored by the XP earned this week. |
| League | weekly_league | Bronze, 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. |
| Referrals | invite_friends | A player shares a code; a new player who claims it and plays earns 100 coins, the referrer 200 (50 referrals at most). |
| Notifications | streak_at_risk, level_up | Push 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
- Download
/recipes/game-starter.change-set.jsonand send it unchanged as the body ofPOST /v1/change_sets. The answer is201withstatus: "pending"andplan.plan_digest. - Apply it:
POST /v1/change_set_applicationswith{ "change_set_id": "<id>", "plan_digest": "<plan.plan_digest>" }. The answer is201withstatus: "committed". Everything except the league and notifications now exists. - The league.
GET /v1/programs?limit=100and take theidof the program whosekeyisweekly_xp. Download/recipes/game-starter.league.json, put that id inbody.config.score.program_id, sendbodytoPOST /v1/leagues, then sendactivate.body({ "expected_revision": 1 }) toPOST /v1/leagues/{id}/activate. - Notifications: send each item of
message_typesin/recipes/game-starter.notifications.jsontoPOST /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-Keyis 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.
| What | Limit per player per day |
|---|---|
| XP from app events | 1,000 |
| Coins from app-reported wins | 20 wins (200 coins) |
| All coins from app activity | 400 |
| Badge and quest progress from app events | 30 each |
| Streak | 1 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.
| Screen | Call | Fields to show |
|---|---|---|
| Coins and XP | GET /v1/customer/balances | data[].currency.code, available, lifetime_earned (strings) |
| Level bar | GET /v1/customer/levels | data[0].current_level.display.title, basis (XP), next_threshold, remaining |
| Streak | GET /v1/customer/streaks | current, 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) |
| Badges | GET /v1/customer/badges | highest_rank, progress, criteria.ranks[], rarity (null until 20 players take part) |
| Quest | GET /v1/customer/quests | status, 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 |
| Leaderboard | GET /v1/customer/leaderboard_positions | score, ordinal; full board at GET /v1/customer/leaderboards/{program.id}/boards/{board_id}/standings (players show only after they opt in, below) |
| League | GET /v1/customer/leagues | tier.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) |
| Referrals | GET /v1/customer/referral_codes, GET /v1/customer/referrals | code; status, offered_rewards |
Player actions:
- Event ids are per player: the
ida player's session sends names that player's event only, so every player can use the same scheme, for examplesession-<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 is409 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/eventswith{ "id": "<unique>", "name": "session.started", "properties": {} }(or{ "events": [ ... ] }, up to 25). The answer is202withresults[].status: "pending": rewards are evaluated within seconds, so read the screens again a moment later. - Repair a broken streak: when
repair.availableis true,POST /v1/customer/streaks/repairswith{ "program_id": "<item.program.id>", "program_version_id": "<item.program_version_id>" }. It costsrepair.pricecoins (409 insufficient_balancewithout them;409 streak_repair_unavailablewhen 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 usesPOST /v1/streaks/{id}/repairsinstead (below);/v1/customer/streaks/repairsrefuses a secret key (401 invalid_customer_session). Use one or the other per player action, not both. - Referral code:
POST /v1/customer/referral_codeswith{}returns the player'scode(the same one every time). A new player enters a friend's code:POST /v1/customer/referral_claimswith{ "code": "..." }. - Names others see:
PATCH /v1/customer/social_profilewith{ "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_profileswith{ "entry_id", "profile_revision", "pseudonym": "Ace", "visible": true }, takingentry_idandprofile_revisionfrom the player's item inGET /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" }(tokenis the FCM or APNs device token, or forweb_pushthe browser'sPushSubscriptionas JSON text;platformis a free label such asios,androidorweb) and record the player's consent withPATCH /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_...¤cy=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/eventswith"customer": { "id": "cust_..." }instead ofcustomer_id(customer_idis always your external id; acust_value there is400). - Streak repair:
POST /v1/streaks/{id}/repairs(secret key withprograms:write, anIdempotency-Key) where{id}is thedaily_playprogram id (GET /v1/programs?limit=100), body{ "customer_id": "cust_...", "charge": true }.charge: truetakesrepair.pricecoins from the player as the player route does;charge: falserepairs 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 usesPOST /v1/customer/streaks/repairs(player actions). - Referral code:
POST /v1/referral_codeswith{ "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_claimswith it.
Referrals
- Player A gets a code (
POST /v1/customer/referral_codes), or your server creates one withPOST /v1/referral_codes. - New player B claims it (
POST /v1/customer/referral_claims,{ "code" }) within 7 days of their account. - Once B has really played after claiming the code, your server sends
POST /v1/eventswithname: "referral.qualified", B as the customer (customer_id: "<B's player id>", orcustomer: { "id": "cust_..." }when you only have the Orishare id, as with Firebase or Supabase) andproperties: { "player_id": "<the same id>", "amount_minor": "1", "currency": "coins" }.player_idmakes the fact unique per player, so B never qualifies twice. Use an event id such asreferral-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 answersduplicate. - Referrals approve themselves. A settlement goes
qualifying(just qualified), thenpendingthrough a one-day cooling-off (within minutes, by a background job), thensettledon the first background pass after the cooling-off ends: A gets 200 coins and B 100, with no review. While the settlement isqualifyingnothing is booked yet: the balances show no referral coins and the operator list showsreview: "none". Frompendingon, the rewards show aspendingin the server balances read (notavailable), andvests_atsays when the cooling-off ends. While it cools off, the operator list showsreview: "none"and the player'ssettlement.review_pendingisfalse: no person needs to look at it. A refund or a deleted account inside the cooling-off cancels the referral. The recipe'sapprovalis"mode": "automatic"with"period_seconds": 2592000,"max_per_referrer_per_period": 10and"max_per_period": 500: at most 10 automatic approvals per referrer and 500 for the whole program in any trailing 30 days. - Outliers wait for review. A referral over either limit stays
pendingafter its cooling-off (the operator list then showsreview: "pending", and the player'sGET /v1/customer/referralsshowssettlement.review_pending: true). List them withGET /v1/referrals/{id}/settlements({id}is theinvite_friendsprogram id): a settlement whosereviewis"pending"is an outlier waiting for a person. Right after itsvests_atan ordinary settlement can read"pending"briefly, until the next background pass approves it. Approve or reject it withPOST /v1/referral_settlements/{id}/reviews{ "expected_revision": "<revision>", "decision": "approve", "reason": "verified_review" }(therevisionstring of the settlement; agent token, or a key withreferrals: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, setapproval: { "mode": "review" }(or leaveapprovalout) 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/nudgesandPOST /v1/customer/high_fives{ "code", "context": "friend_streak" },GET /v1/customer/friend_streaks, pair for the week withPOST /v1/customer/friend_quests{ "definition": "friend_weekly", "friend_code" },GET /v1/customer/friend_quests, friend rankings atGET /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 keyfriend_daily){ "customer_id" }once per day played, andPOST /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
201withrecorded: 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 answers201withquest: nullandapplied: false, and is not kept for later (after pairing, send it again with a newIdempotency-Key: the old key replays the no-op). Checkrecordedandappliedin 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 forbiddenwithdetails.reason: "social_unavailable_for_anonymous").
Test it
In the test environment, with the secret key, for a test player test-1:
POST /v1/events{ "id": "t1", "name": "match.completed", "customer_id": "test-1", "properties": { "score": 900, "won": true }, "mode": "sync" }.- Then
level.completed({ "level": 1 }) andsession.started, each with its ownid. GET /v1/customers/{id}/balanceswith{id}=test-1: 100 XP (20 + 30 + 50) and 10 coins (plus the level-up coins: 100 XP is level 2, 25 coins).- 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. - With the session, send
session.startedfrom 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 advisorytrust_weakening), and creating currencies is not counted as irreversible. Read the advisories withGET /v1/change_sets/{id}?include_advisories=true(incommit.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_requiredwith reasonai_gate; propose them as change set commands instead, each as its own change set (they are operation commands), and apply each one:league.createwith the fields ofbodyingame-starter.league.jsonat the top level of the command ({ "type": "league.create", "key": "weekly_league", "name": "Weekly league", "config": { ... } }, not nested underbody), thenleague.activate(key,expected_version: the league'srevision,1after the create);notification_message_type.createwith each item ofmessage_typesandnotification_template.putfor your copy (they pay nothing); and for the social filefriend_streak_definition.create/friend_quest_definition.create/team_quest_definition.createwith 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_requiredand areview_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 thereview_urlto your person with the claim link; after claiming they confirm it, or set the live environment's autonomy toautoin 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.
Push through your own OneSignal
Send Orishare engagement messages (streak at risk, league promotion, friend nudges) as push notifications through your own OneSignal app.
Web game
One prompt: XP, levels, coins, streaks, badges, quests, leaderboards, leagues and referrals in a browser game with a game server. What runs where, the code, and how to test it.