Hosted widget
Embed the hosted rewards widget in your product with one script tag, hand it a customer session from your backend or your identity provider, and theme, localise and troubleshoot it.
The hosted widget shows a customer their rewards inside your product: balances, streaks, levels, badges, quests, leaderboards, referrals, the catalogue, recent activity and, for games, the shop and inventory, energy and boosts, chests and the battle pass. It is an iframe served from the Orishare widget origin, not from your site, and it is built only on the public customer API: everything it shows and every button it offers is a customer route you could call yourself. Your page adds one script tag, mounts the widget and hands it a customer session; the widget does the rest in its own frame.
The hosted widget is available when it is enabled for your account. Until then the widget shows
its unavailable state and GET /v1/portal_configurations/{id} reports
readiness.portal_origin_configured: false.
Base URL. The examples keep the API origin, without /v1, in one constant and write full
/v1 paths, as the agent guide does:
const API = 'https://api.orishare.com';. Use the API origin your keys were issued for: it is
https://api.orishare.com for the hosted API, and on a local runtime (an Orishare API you run
yourself) it is the origin the operator gives you with the keys. The same origin goes in
connect-src below.
What you need
- A portal: the widget's configuration (surfaces, layout, brand, locales, allowed parent
origins), created and published in the dashboard Widget area, by an operator's agent as a
proposal, or through
POST /v1/portal_configurations(Configure, publish and roll back). Its id looks likeptl_.... - The environment's publishable key (
opk_...) with thecustomer_sessions:exchangescope. It is not a secret and belongs in the snippet. - A way to give the widget a customer session for the signed-in user: from your backend (the backend path) or from your identity provider (the identity path). Pick one per page.
- The customer surface activated for the environment (the same precondition as every customer route; see Customer sessions).
Keys and scopes for each step
A secret key created without scopes gets the defaults listed in the
agent guide. The defaults include no
portal_configurations:*, client_configurations:* or customer_sessions:write scope, so a
default key is refused 403 insufficient_scope on every step below except the publishable-key
ones. An owner or admin creates a key with an explicit scopes list with
POST /v1/environments/{id}/api_keys (a PAT or the dashboard, never a secret key), for example
{ "kind": "secret", "name": "Widget setup", "scopes": ["portal_configurations:read", "portal_configurations:write", "client_configurations:read", "client_configurations:write"] }.
Scopes never change on an existing key; create a new one and revoke the old one.
| Step | Who | Credential and scopes |
|---|---|---|
| Create, edit, publish or roll back the portal | Operator | Dashboard, or a proposal (config:write and portal_configurations:write), or a secret key with portal_configurations:read and portal_configurations:write |
Read readiness (GET /v1/portal_configurations/{id}) | Operator | portal_configurations:read |
Add your page's origin to allowed_origins | Operator | client_configurations:read and client_configurations:write |
| Create an identity connection | Operator | identity_connections:write |
Mint the backend session (POST /v1/customer_sessions) | Your backend | Secret key with customer_sessions:write |
| Exchange and embed (the snippet) | Your page and the widget | Publishable key; it has customer_sessions:exchange once the customer surface is enabled |
Check what a page will get (GET /v1/public/portals/{portal_id}) | Anyone | No credential |
An integrator without portal scopes does not need them: readiness is an operator task (ask the operator for it), and you can confirm your own setup without it (Confirm your setup without portal scopes).
Never put a secret key in frontend code. The widget never needs one. On the backend path the secret key stays on your server and only the minted session token reaches the page; see the credential matrix.
The script tag
Copy the snippet from the dashboard Widget area: it has your widget origin and portal id filled
in. Integrating from the API alone works the same way: the script is always /v1/embed.js on
the widget origin, which is the same for every portal of an Orishare deployment (it is not
the API origin and not your site). Read it as widget_origin from
GET /v1/public/portals/{portal_id} (no credential needed) or from the readiness of
GET /v1/portal_configurations/{id}; it is null while the hosted widget is not enabled. Its
shape:
<div id="rewards"></div>
<script src="https://YOUR-WIDGET-ORIGIN/v1/embed.js" data-orishare-portal="ptl_..." async></script>
<script>
window.orishare = window.orishare || [];
orishare.push([
'mount',
{
portal: 'ptl_...',
target: '#rewards', // omit for the launcher layout
publishableKey: 'opk_...',
getSession: getOrishareSession, // backend path, below
// identity path instead: connectionId and getIdentityToken, below
locale: document.documentElement.lang, // ignored unless the portal's locale.follow_parent is true
surface: 'quests', // optional: the surface to open first
theme: 'auto', // optional: light, dark or auto, within the portal's brand
title: 'Rewards', // optional: the iframe's accessible title
onEvent: (event) => console.debug('orishare widget', event.type),
},
]);
</script>orishare starts as a plain array, so orishare.push([...]) works before and after embed.js
has loaded; the loader runs queued commands in order once it is ready. mount needs portal
(ptl_...), publishableKey (opk_...) and exactly one session path; otherwise nothing mounts.
How the pieces find each other:
-
The portal shown is the
portalmount option.data-orishare-portalon the script tag is only a marker the loader uses to find its own<script>(and so the widget origin) when the browser does not report the running script; its value is not read. Keep both the same. -
You never pass the API origin. The loader learns it from the widget frame after the frame connects. It is the API origin your keys were issued for (
APIabove:https://api.orishare.comfor the hosted API, the operator's origin on a local runtime); the public portal read does not return it. -
The iframe is served from
/embed/v1/{portal_id}on the widget origin. -
Content-Security-Policy. If your page sends one, allow:- the widget origin in
script-src(forembed.js) and inframe-src(for the iframe); - on the identity path, the API origin in
connect-src: the loader itself sendsPOST /v1/customer_sessionsfrom your page (the exchange) andPOST /v1/customer/session_family_revocations(onsignOut,unmountand when a newer exchange replaces the session); - on the backend path, only your own session endpoint in
connect-src(usually'self'): the loader sends no request from your page.
Your page never connects to the widget origin. The widget's own API calls run inside its frame under the frame's own policy, so your policy does not list them.
- the widget origin in
-
The frame's
allowattribute isclipboard-write; web-share(copy and share buttons). Some browsers log a console warning that a feature name is unrecognized or unsupported; it is expected and harmless (where a browser refuses to copy, the widget asks the customer to copy by hand).
Backend path
Your backend already knows who is signed in. It mints a customer session with the secret key and
returns only the token to its own page; the widget asks for one whenever it needs a session. The
first time is on page load, as soon as the widget's frame has connected after mount, even while
a launcher is closed (as on the identity path).
On your backend, behind your own authenticated endpoint:
// Server side. ORISHARE_SECRET_KEY comes from your secret store and never reaches a browser.
const API = 'https://api.orishare.com'; // the API origin, without /v1
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// One mint intent: one Idempotency-Key, repeated with the SAME key and body only when the
// request did not run (429, or 409 with Retry-After) or its answer was lost (network error, 5xx).
async function mintOnce(customerId) {
const mintKey = crypto.randomUUID();
const body = JSON.stringify({ grant_type: 'secret_key', customer_id: customerId });
for (let attempt = 0; attempt < 4; attempt += 1) {
let res;
try {
res = await fetch(`${API}/v1/customer_sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': mintKey,
},
body,
});
} catch {
await sleep(500 * 2 ** attempt); // lost answer: same key, the API replays a stored result
continue;
}
if (res.ok) return res.json(); // 201: the first answer, or a replay of it
const notExecuted =
res.status === 429 || (res.status === 409 && res.headers.has('Retry-After'));
if (notExecuted) await sleep(Number(res.headers.get('Retry-After') ?? 1) * 1000);
else if (res.status >= 500) await sleep(500 * 2 ** attempt);
else throw new Error(`session mint failed: ${(await res.json()).error?.code}`); // final
}
throw new Error('session mint not confirmed');
}
export async function mintWidgetSession(signedInUser) {
// The internal cust_ id resolved from your verified login, never a value the browser sent.
for (let intent = 0; intent < 2; intent += 1) {
const { customer_token } = await mintOnce(signedInUser.orishareCustomerId);
// A replay answers customer_token: null (the token is shown once): start a new intent with
// a new key, never resend the old one.
if (customer_token) return { customer_token }; // the only thing your endpoint returns
}
throw new Error('session mint replayed twice');
}Your endpoint returns exactly { customer_token }; the loader reads nothing else.
POST /v1/customer_sessions is atomic_replay
(When a request may be repeated):
- Same key and body again: after
429, or409withRetry-After(for exampleconcurrent_updateoridempotency_in_progress; the request did not run), wait forRetry-After; after a lost answer (network error, timeout,5xx), back off. Keep the number of attempts small. - Final: every other
4xx, for example403 insufficient_scope,403 customer_surface_inactive,404 resource_not_foundor422 idempotency_key_reused. Fix the cause named byerror.code; do not resend. - Replay: a repeat of a mint that already ran is answered with the stored
201and both headersIdempotent-Replayed: trueandIdempotent-Replay-Redacted: customer_token, withcustomer_token: null(the token is shown once). Start a new mint with a new key, as above.
The secret key needs customer_sessions:write, which is not a default scope; without it the mint
is 403 insufficient_scope. Resolve the internal id once with POST /v1/customers (an upsert by
your external id) or GET /v1/customers?external_id=.... Do not narrow capabilities for the
widget: it needs customer:read, and customer:write for its buttons.
On your page, getSession calls that endpoint and returns its JSON:
const SESSION_ENDPOINT = '/your-app/orishare-session'; // your own route, served by your backend
async function getOrishareSession() {
const res = await fetch(SESSION_ENDPOINT, { method: 'POST', credentials: 'same-origin' });
if (res.status === 401) return null; // nobody signed in: the widget shows its signed-out state
if (!res.ok) throw new Error('session endpoint failed');
return res.json(); // { customer_token }
}The session endpoint is expected to be on your page's origin, so credentials: 'same-origin'
sends your own login cookie. If your backend serves it from another origin, that is between your
page and your backend, not an Orishare setting (allowed_origins plays no part in it): allow the
page's exact origin with CORS on that endpoint (with credentials, never *), fetch it with
credentials: 'include', and make sure your login cookie is sent cross-site
(SameSite=None; Secure), or authenticate the call with your own token instead. For example:
// Page on https://app.example.com, session endpoint on https://backend.example.com.
async function getOrishareSession() {
const res = await fetch('https://backend.example.com/orishare-session', {
method: 'POST',
credentials: 'include', // sends your login cookie (SameSite=None; Secure)
// or, instead of a cookie: headers: { Authorization: `Bearer ${yourOwnLoginToken}` },
});
if (res.status === 401) return null;
if (!res.ok) throw new Error('session endpoint failed');
return res.json(); // { customer_token }
}
// On https://backend.example.com, for that route (Express shown):
const SESSION_ROUTE = '/orishare-session';
app.options(SESSION_ROUTE, corsForApp);
app.post(SESSION_ROUTE, corsForApp, requireLogin, async (req, res) => {
res.json(await mintWidgetSession(req.user));
});
function corsForApp(req, res, next) {
if (req.get('Origin') === 'https://app.example.com') {
res.set('Access-Control-Allow-Origin', 'https://app.example.com'); // exact, never *
res.set('Access-Control-Allow-Credentials', 'true'); // only with credentials: 'include'
res.set('Access-Control-Allow-Methods', 'POST');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.set('Vary', 'Origin');
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
}Return a freshly minted token on every call and do not keep it in the browser: the loader hands
it to the widget, which exchanges it for a session bound to the widget
(POST /v1/customer/embed_sessions) and drops it. The minted ocs_ session lasts 15 minutes by
default (lifetime_seconds up to 3600) and its family can be renewed for up to 24 hours from the
first token; the widget renews its own session within that and asks your function again
(session_needed) when the family ends. Leave lifetime_seconds at the default.
The minted session is yours: the loader and the widget never revoke it. Every call of your
session function mints a new session family, so one login of your user can hold several (the
first load, each session_needed, each signIn, each reload of the frame). If you want them
ended at sign-out rather than at expiry, keep every token you minted for that login server side,
with your login session. On sign-out:
- Push
signOut. The widget retires its own embed sessions. - Wait for the
signed_outevent (Sign-in and sign-out). - Clear your own login session and revoke, server side, each family you minted for it: one
POST /v1/customer/session_family_revocationsper stored token, with the headersAuthorization: Bearer <publishable key>,X-Orishare-Customer-Token: <that token>,Content-Type: application/jsonandIdempotency-Key: <a new UUID per revocation>, and the body{}. It answers204when the family is revoked. Then drop the tokens.
Token expiry is not family expiry. Each token you mint lives 15 minutes by default
(lifetime_seconds, at most 1 hour). Its family lives longer: it can be renewed until 24 hours
after the mint. The widget's embed tokens (each at most 15 minutes) are renewed inside that family,
so the family can still hold a live embed token after the token you minted has expired.
Revocation authenticates with the token you send, so a 401 means only that this token can no
longer revoke (it expired, or its family was already revoked). It does not prove the family has
ended. What is left then:
- The widget's own retirement. On
signOutthe widget retires its embed tokens with their own credentials;signed_outnormally confirms that this has settled. - Expiry. An embed token the widget could not retire is no longer renewed once the widget has signed out, so it expires within 15 minutes of its last renewal; nothing in the family outlives the family's 24-hour bound.
So revoke promptly, right after signed_out, while the tokens you stored are fresh. If you keep
tokens longer than their lifetime, renew them (POST /v1/customer/session_renewals, and keep the
new token) or, where ending every session of that customer is right, use the customer-wide
revocation below.
// Server side, at sign-out, after the page reported signed_out.
for (const token of login.orishareTokens) {
await fetch(`${API}/v1/customer/session_family_revocations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'X-Orishare-Customer-Token': token,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: '{}',
}); // 204 revoked; 401 only means this token can no longer revoke (expired or already revoked)
}
login.orishareTokens = [];POST /v1/customers/{id}/session_revocations (secret key with customer_sessions:write) is
different: it ends every session of that customer on every device and app, yours and any
other integration's, the widget's and your own customer-API sessions included. Use it for "sign
out everywhere", a compromised account or account deletion, not for an ordinary sign-out on one
device.
A backend that revokes the family before the widget has finished makes the widget's own
retirement answer 401. That is harmless: the widget's embed sessions are minted in the same
session family as the token you handed it, and revoking a family ends every token in it, embed
sessions included, so those tokens are dead either way.
Identity path
Your page signs the user in with an identity provider (Firebase, Supabase, Clerk, Auth0 or OIDC)
and Orishare verifies its token through an identity connection. Give the loader the connection id
and a function that returns a current provider token; the loader exchanges it with
POST /v1/customer_sessions (grant_type: "identity_token") from your page.
orishare.push([
'mount',
{
portal: 'ptl_...',
target: '#rewards', // omit for a launcher
publishableKey: 'opk_...',
connectionId: 'ic_...',
// Firebase: wait for the initial auth state, then the ID token (not a custom token) or null.
getIdentityToken: async () => {
await auth.authStateReady();
return auth.currentUser ? auth.currentUser.getIdToken() : null;
},
// Supabase instead:
// getIdentityToken: async () => (await supabase.auth.getSession()).data.session?.access_token ?? null,
},
]);getIdentityToken must return null when nobody is signed in (the widget then shows its
signed-out state); never let it throw for a signed-out user. Firebase restores the signed-in user
asynchronously, so auth.currentUser is null right after page load until the initial auth
state is known: wait for it (auth.authStateReady(), or the first onAuthStateChanged callback)
before answering, or the widget starts signed out. The loader calls the function as soon as the
widget's frame has loaded and connected after mount (right after the ready event), because the
widget asks for a session at once: the exchange happens on page load, even while a launcher is
closed and nobody has opened it. Only a signOut pushed before that keeps it from being called.
It is called again whenever the widget needs a session: after signIn, after session_needed
(the widget's session family ended, for example when the provider token it was exchanged from
expired) and after a reload of the frame. You do not refresh provider tokens for
the widget: each call returns a current token (Firebase's getIdToken() and Supabase's
getSession() refresh it when needed), and the loader exchanges that.
The loader exchanges the provider token in your page's context: for a moment the page's
JavaScript context holds the minted ocs_ customer token, inside the loader's closure. The loader
hands it to the widget over a private message channel; it is never posted to your page as a
message or an event, never passed to onEvent and never stored. That parent session family
belongs to the loader: on signOut, on unmount and when a newer exchange replaces it, the loader
revokes it with POST /v1/customer/session_family_revocations, after the widget has retired the
embed sessions it started under it.
Because that exchange runs from your page, your page's origin must be in the client
configuration's allowed_origins (PATCH /v1/client_configuration), as for any browser
integration. Otherwise the exchange is refused with 403 origin_not_allowed. The readiness of
GET /v1/portal_configurations/{id} lists the allowed parent origins that are missing there
(parent_origins_missing_from_client_configuration); without portal scopes, see
Confirm your setup without portal scopes. The
identity connection itself is created by an operator with POST /v1/identity_connections.
Inline and launcher
The portal's layout.mode decides how the widget appears:
- Inline renders inside the element
targetnames, as part of your page. The loader sizes the iframe for you: full width of the target, and the height of the widget's content, which the frame reports whenever it changes (120 px until the first report). Theresizeevent tells your page the same height, for your own layout (for example to scroll it into view); you do not need to act on it. Do give the target a width, and do not set a height, amax-heightoroverflow: hiddenon the target or the iframe, or style the iframe at all: the widget would be cut off or scroll inside itself. - Launcher shows a floating button in a corner of the page (
launcher_positionbottom_rightorbottom_left) that opens the widget as a panel. Omittargetwhen you mount a launcher.
The portal's layout wins over the snippet: a portal whose mode is inline mounted without a
target (or with a selector that matches nothing) mounts nothing and sends an error event with
code: "target_not_found". The launcher button is part of the widget, inside the iframe (a 72 px
frame at the corner while closed), so your page cannot style or click it; the customer opens and
closes the panel, and Escape or the close button returns focus to where it was on your page.
End-to-end tests. A browser test reaches the controls inside the frame (for example with
Playwright's frameLocator). The stable hook on your side is the iframe's title, which you set
with the title mount option (default Rewards). The names inside the frame are display text,
not stable test hooks: they follow the active locale, the portal's locale.strings overrides
and the environment's terminology. With the built-in English strings and no overrides:
| Control | Role and accessible name |
|---|---|
| Launcher button (closed) | button "Open your rewards" (launcher.open) |
| Launcher button and close (open) | button "Close your rewards" (launcher.close) |
| Open panel | dialog "Your rewards" (summary.title) |
| Surface tabs | tab in a tablist "Your rewards", shown when more than one surface is visible; catalogue is "Rewards" (catalogue.title), activity is "Recent activity" (activity.title), shop is "Shop", energy is "Energy", chests is "Chests" and battle_pass is "Battle pass" (their *.title keys), every other surface is the plural of its mechanic in the environment's terminology (for example "Streaks", "Badges", "Leaderboards") |
const widget = page.frameLocator('iframe[title="Rewards"]');
await widget.getByRole('button', { name: 'Open your rewards' }).click();
await widget.getByRole('tab', { name: 'Recent activity' }).click();Pin a locale with locale (when the portal follows the page language) and read the names your
portal really shows before relying on them.
Game economy surfaces
Four surfaces show a player's game economy. They need no mechanic
switched on: each shows what the environment has configured, and an empty state when there is
nothing yet. Each one has command buttons, so a portal that lists any of them asks for
customer:write in its sessions. Every purchase, opening and claim goes through the customer
route below with an Idempotency-Key, so a retry after a lost answer never charges, opens or
claims twice.
| Surface | Shows | Reads | Commands |
|---|---|---|---|
shop | Offers on sale now with their price in the offer's currency, what each contains, the player's remaining limit and end date; then the player's items, with Equip or Unequip for an item that has an equip slot | GET /v1/customer/shop_offers, GET /v1/customer/inventory | POST /v1/customer/shop_purchases, POST /v1/customer/inventory_equips |
energy | Each energy now, the next refill and when it is full again, an unlimited window, refill items the player holds with "Use refill", running boosts with their end and held boosts with "Start boost" | GET /v1/customer/energy, GET /v1/customer/boosts, GET /v1/customer/inventory | POST /v1/customer/inventory_consumptions |
chests | The chest items the player holds, each with its drop odds (the published odds per roll, with the player's own where an entry is used up or a pity roll is due) before "Open chest", then what the opening paid | GET /v1/customer/inventory, GET /v1/customer/loot_odds | POST /v1/customer/inventory_consumptions |
battle_pass | Each active pass: season dates, progress to the next tier, every tier's free and premium rewards with Claim where claimable, and "Claim all" | GET /v1/customer/battle_passes | POST /v1/customer/battle_pass_claims |
- Purchases are confirmed first. Buy opens a confirmation with the price; only "Confirm
purchase" sends
POST /v1/customer/shop_purchaseswith the offer revision the player saw. If the offer changed meanwhile the player is told to check the new price (stale_write), never charged a different one. - A purchase with a lost answer is recovered. Like streak freezes and catalogue redemptions, a shop purchase is stored in the frame before it is sent. After a lost answer the offer shows "Still working. Try again." and its retry reuses the same key, in the same frame, after a reload or in another tab; the widget also replays it on load.
- Refusals in plain words. Not enough balance, an item already at its limit, an offer no longer
on sale or at its purchase limit, a paused energy, too many boosts, a paused or empty chest, a
tier not reached or already claimed, premium needed and a paused pass each have their own
message (the
economy.refusal.*string keys). - Paused means frozen. A paused energy, loot table or battle pass says so and offers no command for it; nothing is lost and it continues when it resumes.
- Premium is yours to sell. The widget shows whether the battle pass premium is active but
never sells it: your backend sets it with
POST /v1/customers/{id}/battle_pass_premiumafter your own purchase flow. - Energy is read, not counted. The value comes from the server at each read; the widget reads
again when the next refill or the end of a boost is due. Spending energy is your game's call
(
POST /v1/customer/energy_spends), not a widget button. When the portal also showsshop, the energy surface links to it for more refills.
Parent API
Every call goes through orishare.push:
orishare.push(['signIn']); // after your sign-in: the widget asks for a session again
orishare.push(['signOut']); // your user signed out: the widget ends its session
orishare.push(['setLocale', 'fr']); // one of the portal's supported locales
orishare.push(['setTheme', 'dark']); // light, dark or auto, within the portal's brand
orishare.push(['navigate', 'catalogue']); // select a surface the portal shows
orishare.push(['refresh']); // reload what the widget shows, for example after your own update
orishare.push(['unmount']); // remove the widget and end its sessionThe widget opens on the first surface of the portal's surfaces order that is shown (inline and
launcher alike), unless the surface mount option names one of the portal's surfaces; an unknown
name falls back to that first surface.
navigate selects a surface; name one the portal shows. With a launcher it does
not open the panel: a closed launcher stays closed, and the customer sees that surface when they
open it. setLocale and setTheme apply only within what the portal allows: when the portal's
locale.follow_parent is false, the locale mount option and setLocale are ignored and the
widget shows locale.default.
onEvent receives plain objects with a type:
| Event | When |
|---|---|
ready | The widget loaded and connected to your page. |
resize | The content height changed (height, CSS pixels). |
navigate | The customer opened another surface (surface). |
redeemed | The customer redeemed a reward (redemption_id). |
referral_shared | The customer shared their referral link. |
session_needed | The widget's session ended and it asked your page for a new one; the loader calls your session function. |
sign_in_requested | The customer pressed sign in while signed out: show your own sign-in, then push signIn. |
signed_out | Your signOut push has settled (Sign-in and sign-out). No fields. |
error | Something failed (code, an error code from the table below). |
The widget never posts a token, a key or customer data to your page: events carry only their type
and the ids listed above. signed_out comes from the loader on your page, never from the frame. Read balances and progress yourself from the customer routes if your
page needs them.
Sign-in and sign-out
The widget never signs anyone in. When nobody is signed in, your session function returns null
(or you push signOut): the widget shows its signed-out state and sends sign_in_requested when the customer asks to sign
in. After your own sign-in completes, push signIn; the widget asks your session function again.
When your user signs out, push signOut, wait for the signed_out event, then clear your own
session (and, on the backend path, revoke the parent session server side if you want it ended
now). signOut does this, in this order:
- The loader switches to signed out at once: it ignores any session your function returns after
that moment, until you push
signInagain, and tells every mounted widget to sign out. - The widget clears everything it cached, shows its signed-out state and retires every embed session this page's frames started for the portal (its own and those of frames that are gone); retiring its own also ends its own token, so it never sends a request the API would refuse. The loader waits for that retirement to be answered, or 5 seconds.
- What happens to the parent session depends on the path:
- Identity path: the loader minted the parent session family, so it revokes that family
(
POST /v1/customer/session_family_revocations) once the widget has retired its ids. The revocation settles when it is answered, when its few attempts are used up, or at once (nothing is sent) when that parent session has already expired. - Backend path: the parent session belongs to your backend; the loader never revokes it. End it yourself if you need to (Backend path).
- Identity path: the loader minted the parent session family, so it revokes that family
(
- The loader sends
signed_outtoonEventonce per sign-out, when the steps above have settled; at once when there is no widget or no session to end. A secondsignOutpushed while one is still settling shares the same event. It carries no data and is never sent forunmountor when a newer session replaces the old one; if you unmount while a sign-out is settling, nosigned_outfollows, so do not wait for it after an unmount.
How long it can take. Usually well under a second. The worst case is bounded: the widget's
retirement settles within 5 seconds, and on the identity path the loader's revocation then makes
at most 3 attempts of at most 10 seconds each, with 1 and 2 seconds between them, so signed_out
comes at most about 38 seconds (5 + 3 × 10 + 1 + 2) after signOut. On the backend path it is
at most about 5 seconds. Give your own wait a timeout: about 5 seconds on the backend path, and
about 10 seconds on the identity path if your sign-out should not hang, then go ahead without the
event (nothing unsafe follows; see below).
If your sign-out cannot wait (the page navigates away at once, for example), go ahead: whatever was not ended expires on its own, embed sessions within 15 minutes.
A different customer signing in on the same page resets the widget completely before it shows anything.
Allow your page's origin (identity path)
The identity path calls the API from your page, so the page's exact origin must be in the client
configuration's allowed_origins. PATCH /v1/client_configuration replaces the whole list,
and every app of the environment (your web app, a marketing site, an admin tool) shares that one
list. Read it, merge your origin in, and write it back with If-Match set to the version you
read; a write against an older version is 409 stale_write: read again and merge again, as the
loop below does. These calls need client_configurations:read and
client_configurations:write, an operator's scopes.
// Server side or an operator's script, with a key that holds client_configurations:read and :write.
const API = 'https://api.orishare.com'; // the API origin, without /v1
const headers = { Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}` };
async function addAllowedOrigin(origin) {
// origin is exact: scheme, host and port, no path ('https://app.example.com')
for (let attempt = 0; attempt < 3; attempt += 1) {
const read = await fetch(`${API}/v1/client_configuration`, { headers });
if (!read.ok) throw new Error(`read failed: ${(await read.json()).error?.code}`);
const current = await read.json();
const origins = new Set(current.allowed_origins);
if (origins.has(origin)) return current; // already there (also after a lost answer)
origins.add(origin);
let res;
try {
res = await fetch(`${API}/v1/client_configuration`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(), // a new merged list is a new intent
'If-Match': `"${current.version}"`,
},
body: JSON.stringify({ allowed_origins: [...origins] }),
});
} catch {
continue; // lost answer: the next read shows whether the write landed
}
if (res.ok) return res.json();
if (res.status === 409) {
// stale_write (someone changed the list meanwhile) or a busy answer: read, merge, write again
await new Promise((r) => setTimeout(r, Number(res.headers.get('Retry-After') ?? 1) * 1000));
continue;
}
throw new Error(`update failed: ${(await res.json()).error?.code}`); // final, for example 400
}
throw new Error('the client configuration kept changing; try again later');
}
await addAllowedOrigin('https://app.example.com');Plain http and loopback origins
Use https origins, also for local development. Plain http is accepted only for a loopback
origin (host localhost, 127.x.x.x or [::1], any port, such as http://localhost:5173) in a
sandbox environment of a local runtime. A local runtime is an Orishare API you run
yourself on your own machine for development; the hosted API at https://api.orishare.com is
never one, in a sandbox environment or a production one. How to tell: an operator's readiness
read says loopback_origins_allowed: true only there, and anywhere else a write with an http
origin is refused with 400 validation_failed ("HTTP origins require a local sandbox loopback
origin"; a portal draft names the field instead). Against the hosted API, serve your local page
over https (a local HTTPS dev server, https://localhost:5173, or an HTTPS tunnel) and add
that origin.
Origins are compared exactly, as browsers do: http://localhost:5173,
http://127.0.0.1:5173 and http://localhost:3000 are three different origins. Add the one in
your browser's address bar, or each one you use; the same holds for allowed_parent_origins.
Check readiness
An operator reads GET /v1/portal_configurations/{id} (portal_configurations:read): its
readiness says whether the hosted widget is enabled for the account
(portal_origin_configured), whether the customer surface is active, which allowed parent
origins are missing from allowed_origins
(parent_origins_missing_from_client_configuration), which surfaces customers would not see now
(surfaces_not_shown) and whether http loopback origins are accepted
(loopback_origins_allowed). widget_origin is the widget origin itself, for the script tag and
your Content-Security-Policy.
An integrator without portal scopes can check what a page will get with no credential at all:
GET /v1/public/portals/{portal_id} returns the active version's document, including
allowed_parent_origins and layout (mode and launcher position), with widget_origin beside
it, and answers 404 resource_not_found while the portal is unknown, disabled or has no
published version. It shows the active version only, never the draft.
Confirm your setup without portal scopes
Readiness needs portal_configurations:read, which an integrator's key usually lacks. Ask the
operator for the readiness read where you need it (whether the hosted widget is enabled, which
origins are missing). Without it you can still confirm that your page's origin was added:
- Read the list back. With
client_configurations:read,GET /v1/client_configurationlistsallowed_origins; your exact origin must be in it. This is the list the identity path needs. - Read the portal with no credential.
GET /v1/public/portals/{portal_id}shows the publishedallowed_parent_origins(the pages that may embed the widget) andlayout. It does not showallowed_origins. - Watch the exchange. On the identity path, the browser's network panel shows
POST /v1/customer_sessionsfrom your page answered201. While the origin is missing, the API refuses the request with403 origin_not_allowed(the browser reports it as a CORS error, usually on the preflight) and the widget gets no session: it reportssession_neededand, after a few tries, anerrorevent. - Whether plain
httploopback is allowed. Readiness reports it asloopback_origins_allowed; without it, the write is the check.PATCH /v1/client_configurationaccepting yourhttp://localhost:...origin means it is allowed;400 validation_failed("HTTP origins require a local sandbox loopback origin") means it is not. A published portal whoseallowed_parent_origins(in the public read) already lists anhttporigin also shows that the API accepts them there.
The portal document
A portal's draft and every version hold one portal/v1 document (at most 32 KB). Unknown fields
are refused. The OpenAPI document describes it as the PortalDocument schema.
The public read (GET /v1/public/portals/{portal_id}) returns the active version's document with
fields the server adds when it answers. They are read-only projections of the environment, not
part of the stored document: never send them back in a draft (they would be refused as unknown).
terminology: for each locale oflocale.supported, the environment's mechanic labels (points,quests,levels,badges,streaks,leaderboards,referrals) as{ singular, plural }, resolved for that locale. The widget uses them for its labels and tabs.mechanics: the environment's mode for each mechanic,enabled,hiddenoroff. The widget does not show the surface of anoffmechanic, and leaves ahiddenone out of its tabs.brand.logo_url: the absolute URL of the logo asset, in place ofbrand.logo_asset_id.
| Field | Required | Allowed values |
|---|---|---|
schema | yes | portal/v1 |
allowed_parent_origins | yes | 1 to 20 exact origins, unique (Allowed parent origins) |
surfaces | yes | 1 to 16 entries in display order, each { surface, programs? }, each surface at most once |
surfaces[].surface | yes | streak, level, badges, quests, leaderboard, referral, catalogue, activity, league, friends, team, notification_preferences, shop, energy, chests, battle_pass |
surfaces[].programs | no | 1 to 20 program keys (catalogue keys for catalogue) to show; absent shows every visible one. Unknown keys are refused at publish. The four engagement surfaces take none. |
layout.mode | yes | inline or launcher |
layout.launcher_position | no | bottom_right (default) or bottom_left |
brand.name | no | Plain text, 1 to 60 characters |
brand.logo_asset_id | no | An ast_... asset of this environment |
brand.theme | yes | light, dark or auto (follows the visitor's system setting) |
brand.light | yes | { accent, accent_ink, canvas, ink }, each a lowercase #rrggbb colour |
brand.dark | when theme is dark or auto | The same four colours |
brand.radius | yes | none, small, medium or large |
brand.font | yes | system or archivo |
locale.default | yes | A BCP 47 tag in canonical form (en, fr, pt-BR), one of supported |
locale.supported | yes | 1 to 20 unique BCP 47 tags, including the default |
locale.follow_parent | yes | true to use the locale the page passes (or setLocale) when it is supported, otherwise the default |
locale.strings | no | Per supported locale, built-in UI string key to plain text (at most 200 characters; no line breaks, <, > or em dashes) |
referral.share_url_template | when surfaces has referral | An https URL on one of the allowed parent origins containing {code} exactly once, outside the origin; refused when referral is not a surface |
Engagement surfaces. Four surfaces show the social and messaging features
(Leagues, Friends and teams,
Notifications). They belong to no mechanic, so they take no
programs filter and follow no mechanic mode.
| Surface | Shows | The customer can |
|---|---|---|
league | The player's tier, time left in the period and the cohort standings by display name, with the promotion and demotion zones (/customer/leagues, /customer/leagues/{id}/standings) | Only look |
friends | The player's friend code, incoming and outgoing requests, friends and blocked players (/customer/friend_code, /customer/friends, /customer/blocks) | Add a friend by code, accept or decline, remove, nudge, high-five, block and unblock (by the block handle) |
team | The player's team, its members by handle and the team quest with the team board (/customer/team, /customer/team/quests, /customer/team_quest_boards/{definition}) | Join with an invite code, leave |
notification_preferences | Consent per channel and opt-outs per message type (/customer/notification_preferences) | Opt in or out per channel and type, or unsubscribe from everything |
Anonymous sessions see a sign-in prompt on friends and team: social features need a
signed-in customer.
Contrast. Each palette (light, and dark when present) must pass three WCAG 2.1 contrast
ratios: ink on canvas at least 4.5, accent_ink on accent at least 4.5, and accent
on canvas at least 3. A failure is 400 validation_failed with one entry in
error.details per failing pair: path names the theme and the pair and message gives the
measured ratio, for example body.draft.brand.dark.accent_ink_on_accent with "Contrast of accent
ink on accent in the dark palette is 2.10; it must be at least 4.5". The pair names are
ink_on_canvas, accent_ink_on_accent and accent_on_canvas. In a proposal the path starts with
commands.0.draft. instead of body.draft., and a publish that finds the stored draft invalid
names draft. paths. Every other document error has the same shape, with the path of the field.
- Labels. Mechanic names (points, quests, levels and the rest) come from your environment's terminology, so the widget uses the same words as the rest of your product.
- Theme at runtime.
setThemeand thethememount option choose only within what the brand allows.
Configure, publish and roll back
The dashboard Widget area edits the draft with a live preview in light and dark, and it saves,
publishes and rolls back through change sets that a person confirms. An agent working for an
operator does the same (next section). Server-side
automation that you run yourself (a deploy script, CI) may call the direct routes with a secret
key created with portal_configurations:read and portal_configurations:write:
// 1. Read the portal: draft, active version, readiness and its revision (also the ETag).
const current = await fetch(`${API}/v1/portal_configurations/${portalId}`, {
headers: { Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}` },
});
const portal = await current.json(); // portal.revision, portal.draft, portal.readiness
// 2. Save a new draft. If-Match is the revision you read.
const saveKey = crypto.randomUUID(); // one per save intent, reused on every retry of it
const saved = await fetch(`${API}/v1/portal_configurations/${portalId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': saveKey,
'If-Match': `"${portal.revision}"`,
},
body: JSON.stringify({
draft: {
schema: 'portal/v1',
allowed_parent_origins: ['https://app.example.com'],
surfaces: [{ surface: 'streak' }, { surface: 'quests' }, { surface: 'catalogue' }],
layout: { mode: 'inline' },
brand: {
name: 'Rewards',
theme: 'auto',
light: { accent: '#1d4ed8', accent_ink: '#ffffff', canvas: '#ffffff', ink: '#111111' },
dark: { accent: '#93c5fd', accent_ink: '#0b1220', canvas: '#0b1220', ink: '#f3f4f6' },
radius: 'medium',
font: 'system',
},
locale: { default: 'en', supported: ['en', 'fr'], follow_parent: true },
},
}),
});
const draft = await saved.json(); // 200, the portal with its new revision
// 3. Publish the draft as the next version and make it live.
const publishKey = crypto.randomUUID(); // one per publish intent, reused on every retry of it
const published = await fetch(`${API}/v1/portal_configurations/${portalId}/versions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': publishKey,
},
body: JSON.stringify({ expected_revision: draft.revision }),
});
const live = await published.json(); // 201, the portal: live.active_version, live.revision- Responses. Create, publish (
POST /v1/portal_configurations/{id}/versions), activation (POST /v1/portal_configurations/{id}/activations), disable and enable answer 201 with the portal (not the version):active_versionis the new or activated version andrevisionthe new revision. A draft save answers 200 with the portal. Every answer carriesETag, which is the same revision, quoted; use it as the nextIf-Matchorexpected_revision. - Revisions. Every change (save, publish, activation, disable, enable) consumes the revision.
A save or transition against an older revision is
409 stale_write: read again and reapply. - Rollback. List versions with
GET /v1/portal_configurations/{id}/versions(every version is kept and immutable) and activate one withPOST /v1/portal_configurations/{id}/activations(version_id,expected_revision). An activation changes only the active version; it does not touch the draft. The next publish publishes whatever the draft holds then, which may be the newer content you rolled back from. To continue from the rolled-back content, save that version'sdocumentas the draft first. - Disable and enable.
POST /v1/portal_configurations/{id}/disabletakes the widget offline and ends its embed sessions at once;enablebrings it back without reviving them. - A published or activated change reaches new widget loads within about 15 seconds; open widgets
reload themselves (
portal_version_stale).
Operator agents: propose, a person confirms
An AI agent acting for an operator (with the operator's PAT, in a terminal, CI or an MCP client) may call the direct write routes: the agent gate leaves portal writes direct, in live environments too (none of them is destructive). When a person should review the change, it proposes each portal change as a change set instead, and a signed-in owner or admin reviews the stored plan and confirms it in the dashboard, the same way the dashboard's own Widget area works.
Direct routes are for server-side automation you run yourself with a scoped secret key, or for an agent whose change needs no review; proposals are for changes a person should see first.
Where the environment's autonomy policy allows it (always in a test environment;
in a live one when the owner chose auto-apply, or auto-apply within limits and the plan is inside
them), the agent applies its own proposal with POST /v1/change_set_applications instead of
waiting for a person. Everything below about proposing is unchanged.
One change set holds exactly one portal command. The portal is named by its key (the immutable
key given at create), and expected_revision is the revision the agent read:
| Command | Fields |
|---|---|
portal_configuration.create | key, name, optional draft |
portal_configuration.save_draft | key, expected_revision, optional name and draft |
portal_configuration.publish | key, expected_revision |
portal_configuration.activate_version | key, expected_revision, version_id (rollback) |
portal_configuration.disable | key, expected_revision |
portal_configuration.enable | key, expected_revision |
// An operator's agent. The PAT holds config:write and portal_configurations:write
// (and the member is an owner or admin of the environment's organization).
const auth = {
Authorization: `Bearer ${process.env.ORISHARE_PAT}`,
'X-Orishare-Environment': environmentId,
};
const proposeKey = crypto.randomUUID(); // one per proposal intent, reused on every retry of it
const proposed = await fetch(`${API}/v1/change_sets`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json', 'Idempotency-Key': proposeKey },
body: JSON.stringify({
commands: [
{
type: 'portal_configuration.save_draft',
key: 'rewards',
expected_revision: portal.revision,
draft: newDraft,
},
],
}),
});
const changeSet = await proposed.json(); // 201: id, status, plan, expires_at
// Read back the stored plan: plan.diff (what changes in the portal), plan.effects, plan.flags.
const stored = await (
await fetch(`${API}/v1/change_sets/${changeSet.id}`, { headers: auth })
).json();POST /v1/change_sets returns no link. Hand the person the change set id and the review link
/change-sets/{id} on the dashboard origin (https://app.orishare.com for the hosted service,
the operator's own dashboard URL for a local or self-run deployment), which finds the environment and opens the
review (/changes/{id} is the same page once that environment is selected; every proposal is
also listed under Operations, Changes). The operator tools of the MCP server return this link as
dashboard_url. Only a signed-in owner or admin confirms, which sends
POST /v1/change_set_commits with the dashboard session; the API refuses a PAT's or key's own
commit with 403 confirmation_required (do not retry it). Nothing changes until the person
confirms.
Wait for the decision by reading GET /v1/change_sets/{id} every 10 to 30 seconds, and stop
at expires_at. Its status is one of:
status | Meaning |
|---|---|
pending | Not decided yet. Keep polling. |
committed | Confirmed and applied (committed_at, commit_id). Read the portal for its new revision. |
stale | The portal (or what the plan bound) changed first (stale_reason). Propose again. |
expired | Nobody confirmed within 24 hours. Propose again. |
cancelled | Withdrawn with POST /v1/change_sets/{id}/cancellations. Nothing changed. |
To publish after the draft is confirmed, read the portal again for its new revision and propose
portal_configuration.publish. A proposal expires after 24 hours and becomes stale
(409 preview_stale at confirmation) when the portal's revision, or a program or catalogue the
published document references, changes first: propose again with a new Idempotency-Key. See
Change sets.
Find the portal id after a confirmed create. Proposals name the portal by key, but the
direct routes and the snippet need its ptl_... id. The commit's answer (with the created
portal) goes only to the dashboard session that confirmed it, and the change set you read after
the commit has no portal id. There is no lookup by key: list GET /v1/portal_configurations
(portal_configurations:read; cursor pages of up to 100 with limit and cursor) and take the
item whose key is the one you proposed. Keys are unique in an environment.
Rollback leaves the draft as it was. A confirmed portal_configuration.activate_version
changes only the active version, exactly as the direct activation route does. The draft still
holds what it held, so the next portal_configuration.publish puts that content live again,
which may be the content you rolled back from. To continue from the rolled-back content, first
propose portal_configuration.save_draft with that version's document (from
GET /v1/portal_configurations/{id}/versions), then publish.
Allowed parent origins
allowed_parent_origins lists the exact origins of the pages that may embed the widget: scheme,
host and port, no path, no trailing slash and no wildcard (https://app.example.com, not
https://*.example.com or https://app.example.com/rewards). https://example.com and
https://www.example.com are different origins. Plain http is accepted only for a loopback
origin in a sandbox environment of a local runtime
(Plain http and loopback origins).
The widget page is served with a Content-Security-Policy whose frame-ancestors is exactly
this list, so a browser refuses to show it on any other page, and the widget also refuses to
connect to a page outside the list. A change takes effect within about 15 seconds of publishing.
For the identity path, the same origins must also be in the client configuration's
allowed_origins (above).
What the widget never does
- It never signs anyone in or shows a sign-in form; it asks your page with
sign_in_requested. - It never sends events. Its session cannot write client events; send events from your backend
with
POST /v1/eventsor from your page withPOST /v1/customer/eventsand your own session. - It never posts a token, a key, an identity token or customer data to your page.
- It never puts a token in a URL, a cookie or browser storage, and never needs a secret key.
- It never spends or redeems without a click inside the widget and a confirmation that shows the price.
Troubleshooting
The widget reports problems as an error event with code, and the same codes appear on the
API calls you can see in the browser's network panel. The last four rows are codes of the setup
calls (keys, portal changes, origins, proposals), not widget events.
| Code | What it means | What to do |
|---|---|---|
portal_unavailable | The hosted widget is not enabled for your account yet, or the portal is disabled, unknown in this environment or has no published version. | Check readiness and status in GET /v1/portal_configurations/{id}; publish a version or enable the portal. Check the publishable key's environment. |
portal_version_stale | A new version was published or activated while the widget was open. | Nothing: the widget reloads itself with the active version. |
invalid_customer_session | The session handed to the widget is expired, revoked or malformed, or the widget's own session ended (portal disabled, customer signed out elsewhere). | Return a newly minted token on every call of your session function; never cache or reuse one. |
customer_session_limit | The session family already holds four live tokens, so the widget's exchange (or a renewal) is refused. | For the widget: hand it a freshly minted token that nothing else uses (a new family); the widget asks again by itself. For your own renewals: see below the table. |
insufficient_customer_scope | The session has no customer:read, or the publishable key cannot exchange sessions. | Mint without narrowing capabilities; use a publishable key that has the customer_sessions:exchange scope. |
origin_not_allowed | Identity path: your page's origin is not in the client configuration's allowed_origins. | Add the exact origin with PATCH /v1/client_configuration; readiness lists the missing ones. |
customer_surface_inactive | The environment's customer surface is not activated. | An operator activates it; until then no customer session can be created. |
insufficient_scope | A secret key or PAT lacks the scope of a setup call (portal_configurations:*, client_configurations:* and customer_sessions:write are not defaults). | Create a key with an explicit scopes list (Keys and scopes). |
validation_failed | The portal document breaks a rule: error.details[] names each path (for example body.draft.brand.light.ink_on_canvas) with a message. | Fix the named field; contrast and the other rules are in The portal document. |
stale_write | The portal or the client configuration changed after you read it. | Read again, reapply your change, and send the new revision or version. |
confirmation_required | A PAT or key tried to commit a change set. | Only a person confirms, in the dashboard; hand them the change set id. |
customer_session_limit for different callers. A session family holds at most four live
tokens, and every embed exchange and every renewal adds one (a new mint or exchange starts a new
family and is never limited). The widget's exchange is refused when the token you handed it
belongs to a family that is already full, typically because your page also uses and renews that
token: give the widget its own freshly minted token, as above. Your own customer-API code that
gets it on a renewal waits for a token of that family to expire, or revokes one it no longer
needs, and renews again (the retry class is after_delay), or exchanges again for a new family.
Problems without a code:
- The frame is blank or the browser says it refused to connect. The page's origin is not in
allowed_parent_origins, or differs in scheme,wwwor port. Fix the list and publish. - No
readyevent. Check thatembed.jsloaded from the widget origin, thatportalis theptl_...id (not the key) and that the page is not itself in a sandboxed frame. errorwithcode: "target_not_found". The portal's layout isinlineandtargetis missing or matches no element whenmountruns. Mount after the element exists, or switch the portal tolauncher.- Nothing happens on
mount.portal,publishableKeyor the session path is missing or malformed (both paths, or neither, given); the loader ignores such a mount. - The widget stays signed out. Your session function returned
nullor threw, orsignOutwas pushed last; pushsignInafter your sign-in. - Cancelled or
304requests in the network panel. Normal. The widget revalidates its reads (balances, state, streaks and the rest) withIf-None-Match, so many answer304 Not Modified. It keeps aGET /v1/customer/state_changesstream open, which the server ends after at most 60 seconds and the widget reopens, and it cancels that stream and any read in flight when the page is hidden, on sign-out and when a request runs too long. Look at theerrorevents instead.
See Error codes for the full table.
Customer sessions and browser integration
Connect a verified customer identity to the customer API, use safe receipts and polling, and migrate a trusted backend.
Pricing and billing
Launch pricing: 10,000 actions free per organization each month, then $0.25 per 1,000. What counts as an action, how small charges accrue until $5, and how billing works in the dashboard.