Orishare

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 like ptl_....
  • The environment's publishable key (opk_...) with the customer_sessions:exchange scope. 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.

StepWhoCredential and scopes
Create, edit, publish or roll back the portalOperatorDashboard, 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})Operatorportal_configurations:read
Add your page's origin to allowed_originsOperatorclient_configurations:read and client_configurations:write
Create an identity connectionOperatoridentity_connections:write
Mint the backend session (POST /v1/customer_sessions)Your backendSecret key with customer_sessions:write
Exchange and embed (the snippet)Your page and the widgetPublishable key; it has customer_sessions:exchange once the customer surface is enabled
Check what a page will get (GET /v1/public/portals/{portal_id})AnyoneNo 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 portal mount option. data-orishare-portal on 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 (API above: https://api.orishare.com for 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 (for embed.js) and in frame-src (for the iframe);
    • on the identity path, the API origin in connect-src: the loader itself sends POST /v1/customer_sessions from your page (the exchange) and POST /v1/customer/session_family_revocations (on signOut, unmount and 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 frame's allow attribute is clipboard-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, or 409 with Retry-After (for example concurrent_update or idempotency_in_progress; the request did not run), wait for Retry-After; after a lost answer (network error, timeout, 5xx), back off. Keep the number of attempts small.
  • Final: every other 4xx, for example 403 insufficient_scope, 403 customer_surface_inactive, 404 resource_not_found or 422 idempotency_key_reused. Fix the cause named by error.code; do not resend.
  • Replay: a repeat of a mint that already ran is answered with the stored 201 and both headers Idempotent-Replayed: true and Idempotent-Replay-Redacted: customer_token, with customer_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:

  1. Push signOut. The widget retires its own embed sessions.
  2. Wait for the signed_out event (Sign-in and sign-out).
  3. Clear your own login session and revoke, server side, each family you minted for it: one POST /v1/customer/session_family_revocations per stored token, with the headers Authorization: Bearer <publishable key>, X-Orishare-Customer-Token: <that token>, Content-Type: application/json and Idempotency-Key: <a new UUID per revocation>, and the body {}. It answers 204 when 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 signOut the widget retires its embed tokens with their own credentials; signed_out normally 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 target names, 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). The resize event 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, a max-height or overflow: hidden on 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_position bottom_right or bottom_left) that opens the widget as a panel. Omit target when 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:

ControlRole and accessible name
Launcher button (closed)button "Open your rewards" (launcher.open)
Launcher button and close (open)button "Close your rewards" (launcher.close)
Open paneldialog "Your rewards" (summary.title)
Surface tabstab 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.

SurfaceShowsReadsCommands
shopOffers 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 slotGET /v1/customer/shop_offers, GET /v1/customer/inventoryPOST /v1/customer/shop_purchases, POST /v1/customer/inventory_equips
energyEach 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/inventoryPOST /v1/customer/inventory_consumptions
chestsThe 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 paidGET /v1/customer/inventory, GET /v1/customer/loot_oddsPOST /v1/customer/inventory_consumptions
battle_passEach 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_passesPOST /v1/customer/battle_pass_claims
  • Purchases are confirmed first. Buy opens a confirmation with the price; only "Confirm purchase" sends POST /v1/customer/shop_purchases with 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_premium after 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 shows shop, 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 session

The 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:

EventWhen
readyThe widget loaded and connected to your page.
resizeThe content height changed (height, CSS pixels).
navigateThe customer opened another surface (surface).
redeemedThe customer redeemed a reward (redemption_id).
referral_sharedThe customer shared their referral link.
session_neededThe widget's session ended and it asked your page for a new one; the loader calls your session function.
sign_in_requestedThe customer pressed sign in while signed out: show your own sign-in, then push signIn.
signed_outYour signOut push has settled (Sign-in and sign-out). No fields.
errorSomething 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:

  1. The loader switches to signed out at once: it ignores any session your function returns after that moment, until you push signIn again, and tells every mounted widget to sign out.
  2. 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.
  3. 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).
  4. The loader sends signed_out to onEvent once per sign-out, when the steps above have settled; at once when there is no widget or no session to end. A second signOut pushed while one is still settling shares the same event. It carries no data and is never sent for unmount or when a newer session replaces the old one; if you unmount while a sign-out is settling, no signed_out follows, 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:

  1. Read the list back. With client_configurations:read, GET /v1/client_configuration lists allowed_origins; your exact origin must be in it. This is the list the identity path needs.
  2. Read the portal with no credential. GET /v1/public/portals/{portal_id} shows the published allowed_parent_origins (the pages that may embed the widget) and layout. It does not show allowed_origins.
  3. Watch the exchange. On the identity path, the browser's network panel shows POST /v1/customer_sessions from your page answered 201. While the origin is missing, the API refuses the request with 403 origin_not_allowed (the browser reports it as a CORS error, usually on the preflight) and the widget gets no session: it reports session_needed and, after a few tries, an error event.
  4. Whether plain http loopback is allowed. Readiness reports it as loopback_origins_allowed; without it, the write is the check. PATCH /v1/client_configuration accepting your http://localhost:... origin means it is allowed; 400 validation_failed ("HTTP origins require a local sandbox loopback origin") means it is not. A published portal whose allowed_parent_origins (in the public read) already lists an http origin 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 of locale.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, hidden or off. The widget does not show the surface of an off mechanic, and leaves a hidden one out of its tabs.
  • brand.logo_url: the absolute URL of the logo asset, in place of brand.logo_asset_id.
FieldRequiredAllowed values
schemayesportal/v1
allowed_parent_originsyes1 to 20 exact origins, unique (Allowed parent origins)
surfacesyes1 to 16 entries in display order, each { surface, programs? }, each surface at most once
surfaces[].surfaceyesstreak, level, badges, quests, leaderboard, referral, catalogue, activity, league, friends, team, notification_preferences, shop, energy, chests, battle_pass
surfaces[].programsno1 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.modeyesinline or launcher
layout.launcher_positionnobottom_right (default) or bottom_left
brand.namenoPlain text, 1 to 60 characters
brand.logo_asset_idnoAn ast_... asset of this environment
brand.themeyeslight, dark or auto (follows the visitor's system setting)
brand.lightyes{ accent, accent_ink, canvas, ink }, each a lowercase #rrggbb colour
brand.darkwhen theme is dark or autoThe same four colours
brand.radiusyesnone, small, medium or large
brand.fontyessystem or archivo
locale.defaultyesA BCP 47 tag in canonical form (en, fr, pt-BR), one of supported
locale.supportedyes1 to 20 unique BCP 47 tags, including the default
locale.follow_parentyestrue to use the locale the page passes (or setLocale) when it is supported, otherwise the default
locale.stringsnoPer supported locale, built-in UI string key to plain text (at most 200 characters; no line breaks, <, > or em dashes)
referral.share_url_templatewhen surfaces has referralAn 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.

SurfaceShowsThe customer can
leagueThe 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
friendsThe 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)
teamThe 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_preferencesConsent 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. setTheme and the theme mount 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_version is the new or activated version and revision the new revision. A draft save answers 200 with the portal. Every answer carries ETag, which is the same revision, quoted; use it as the next If-Match or expected_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 with POST /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's document as the draft first.
  • Disable and enable. POST /v1/portal_configurations/{id}/disable takes the widget offline and ends its embed sessions at once; enable brings 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:

CommandFields
portal_configuration.createkey, name, optional draft
portal_configuration.save_draftkey, expected_revision, optional name and draft
portal_configuration.publishkey, expected_revision
portal_configuration.activate_versionkey, expected_revision, version_id (rollback)
portal_configuration.disablekey, expected_revision
portal_configuration.enablekey, 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:

statusMeaning
pendingNot decided yet. Keep polling.
committedConfirmed and applied (committed_at, commit_id). Read the portal for its new revision.
staleThe portal (or what the plan bound) changed first (stale_reason). Propose again.
expiredNobody confirmed within 24 hours. Propose again.
cancelledWithdrawn 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/events or from your page with POST /v1/customer/events and 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.

CodeWhat it meansWhat to do
portal_unavailableThe 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_staleA new version was published or activated while the widget was open.Nothing: the widget reloads itself with the active version.
invalid_customer_sessionThe 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_limitThe 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_scopeThe 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_allowedIdentity 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_inactiveThe environment's customer surface is not activated.An operator activates it; until then no customer session can be created.
insufficient_scopeA 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_failedThe 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_writeThe portal or the client configuration changed after you read it.Read again, reapply your change, and send the new revision or version.
confirmation_requiredA 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, www or port. Fix the list and publish.
  • No ready event. Check that embed.js loaded from the widget origin, that portal is the ptl_... id (not the key) and that the page is not itself in a sandboxed frame.
  • error with code: "target_not_found". The portal's layout is inline and target is missing or matches no element when mount runs. Mount after the element exists, or switch the portal to launcher.
  • Nothing happens on mount. portal, publishableKey or 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 null or threw, or signOut was pushed last; push signIn after your sign-in.
  • Cancelled or 304 requests in the network panel. Normal. The widget revalidates its reads (balances, state, streaks and the rest) with If-None-Match, so many answer 304 Not Modified. It keeps a GET /v1/customer/state_changes stream 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 the error events instead.

See Error codes for the full table.

On this page