Orishare

Customer sessions and browser integration

Connect a verified customer identity to the customer API, use safe receipts and polling, and migrate a trusted backend.

The customer surface uses an environment publishable key plus a short-lived customer session. The environments of a project created by signup (a person's or an agent's) are active from birth: the customer surface is switched on right after provisioning, and one whose activation was interrupted is activated before its next request runs. An environment created before that stays inactive until a release operator activates it; until then POST /v1/customer_sessions answers 403 customer_surface_inactive, which is the way to check from an application. An operator sees the state as enabled in GET /v1/client_configuration. The publishable key comes from the operator (dashboard Settings, Environments & keys); it cannot be created with a secret key.

Paths are written with /v1 on the API origin. apiBaseUrl in the examples is the origin without /v1 (https://api.orishare.com); the OpenAPI server URL already ends in /v1 and its paths do not, so add /v1 exactly once.

Choose an identity source

A trusted operator creates an identity connection through POST /v1/identity_connections. The connection fixes the provider, issuer, audience, verification URL, algorithms and token profile. Never derive those values from an unverified token. A browser receives only the connection ID and publishable key, never a secret API key.

ProviderSupported profileRequired configuration and limits
Firebasefirebase_id, RS256Firebase ID token with the exact project audience, https://securetoken.google.com/PROJECT issuer and Google's fixed Secure Token certificate URL. Custom tokens are not ID tokens.
Supabasesupabase_access, RS256 or ES256Exact project Auth issuer and its fixed JWKS endpoint, authenticated audience/role and UUID subject. HS256 is unsupported. A signed anonymous claim retains anonymous assurance.
Clerkclerk_template, RS256 or ES256Explicit asymmetric template, configured audience and authorized party policy. Default unconfigured session-token profiles are unsupported.
Auth0auth0_user_access, RS256 or ES256Configured user access-token audience and issuer. Machine-to-machine credentials cannot stand in for customer identity.
Generic OIDCoidc_id, RS256 or ES256Explicit ID-token profile, fixed issuer/audience/JWKS and authorized-party policy.

The verifier bounds document fetches and validates signatures and claims. It does not promise immediate detection of provider logout, deletion or provider-side revocation. Orishare binding suspension, connection changes, customer epoch changes, key revocation and session revocation are checked locally. No email or alias automatically links accounts.

Exchange, call, renew and revoke

Exchange a currently valid provider token with one operation key per exchange intent, reused on every retry of that exchange:

const exchangeKey = crypto.randomUUID(); // one per exchange intent
const exchange = await fetch(`${apiBaseUrl}/v1/customer_sessions`, {
  method: 'POST',
  credentials: 'omit',
  headers: {
    Authorization: `Bearer ${publishableKey}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': exchangeKey,
  },
  body: JSON.stringify({
    grant_type: 'identity_token',
    connection_id: identityConnectionId,
    provider_token: providerToken,
  }),
});
if (!exchange.ok) throw new Error('Customer exchange failed');
const session = await exchange.json();
let customerToken = session.customer_token;
if (!customerToken) throw new Error('A replay is not a credential: start a new exchange');

Exchange and renewal return 201 with this response shape:

{
  "customer_token": "<shown-once-token>",
  "customer_id": "cust_example",
  "session_id": "cs_example",
  "family_id": "csf_example",
  "assurance": "authenticated_customer",
  "capabilities": ["customer:read", "customer:write", "events:write_client"],
  "expires_at": "2026-09-23T12:00:00.000Z"
}

assurance is exactly anonymous or authenticated_customer. A signed Firebase or Supabase anonymous claim returns anonymous, even though the provider signature is valid. It does not become authenticated when the session is renewed. A new exchange or renewal has a non-null token. Family revocation returns 204, with no body. Ordinary state and receipt reads return 200; an unchanged conditional read returns 304. Invalid provider proofs, including a wrong audience, return 400 invalid_session_grant. A revoked or invalid session returns 401 invalid_customer_session. Capability refusals are 403; durable capacity refusal is 429 rate_limited with Retry-After. Errors use the one envelope of every route, { "error": { "type", "code", "message", "param", "doc_url", "request_id", "details"? } }; branch on code (error codes). See the generated API reference for full schemas.

The response token is shown once. Repeating an exchange with the same Idempotency-Key and body (a retry) is answered by a replay: still 201, with Idempotent-Replayed: true, Idempotent-Replay-Redacted: customer_token and customer_token: null. An authenticated replay keeps the other identifiers; an anonymous replay also returns customer_id, session_id and family_id as null. A replay is never a credential. After a lost response, start a new exchange with a new key and a currently valid provider token (the same unexpired token works). Each exchange opens a new session family; the family whose token was lost stays live until its tokens expire. Tokens stay in memory, not logs, URLs, browser storage or cookies.

Responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, and a 429 also Retry-After. Browser calls need the site origin in the environment's allowlist; the API echoes it in Access-Control-Allow-Origin and exposes ETag, Location, X-Request-Id, Retry-After, the RateLimit-* headers, Idempotent-Replayed and Idempotent-Replay-Redacted. It never allows credentials, so send credentials: 'omit'.

Every customer request includes both credentials:

const headers = {
  Authorization: `Bearer ${publishableKey}`,
  'X-Orishare-Customer-Token': customerToken,
};
const state = await fetch(`${apiBaseUrl}/v1/customer/state`, {
  credentials: 'omit',
  headers,
});
if (!state.ok) throw new Error('Customer state unavailable');
const snapshot = await state.json();

Customer identity comes from the session. Do not send customer IDs, anonymous IDs, aliases or trust claims to /customer/*, and no tokens in bodies, with one exception: the merge below carries the anonymous source token in its body. Configure browser origins explicitly through the backend PATCH /v1/client_configuration API, using its current If-Match. An Origin is required to match that allowlist when the browser supplies one; cookies do not authorize customer requests. The publishable key and session must each permit the requested capability. Read requires customer:read, commands customer:write, and event admission events:write_client. Exchange and renewal are checked against the publishable key only: both need customer_sessions:exchange on it (a renewal of a publishable-key family also on the key that issued the family), which is why a session's own capabilities never list it.

A session your backend minted with a secret key is renewable from the browser too: the renewal needs customer_sessions:exchange on the publishable key presented with the session, not on the secret key. The secret key that minted it must still be unrevoked and hold customer_sessions:write (or customer_sessions:*); otherwise the renewal answers 401 invalid_customer_session. The family's 24-hour ceiling below still applies.

Renew with POST /v1/customer/session_renewals, both headers, one new Idempotency-Key per renewal (reused on its retries) and {}. Replace the in-memory token only after receiving a non-null new token. Earlier unexpired family tokens remain live. A family has at most four live tokens; a renewal while four are live is 409 customer_session_limit. Its ceiling is the provider token's expiry for an identity-token exchange and 24 hours for an anonymous or secret-key session; after it, exchange again. Revoke the family with POST /v1/customer/session_family_revocations, both headers, one operation key and {}; it revokes only that family. Clear local state on logout or authentication failure.

Anonymous visitors and merge

An anonymous grant uses { "grant_type": "anonymous" } at the exchange endpoint. There is no separate anonymous switch: it is enabled exactly where identity-token exchange is, by a publishable key holding customer_sessions:exchange in an environment whose customer surface is active (a publishable key created there gets that scope by default). To keep visitors from getting anonymous sessions, the operator creates the publishable key with scopes that omit customer_sessions:exchange, which also stops identity-token exchange and renewal with that key. The session's capabilities are what you request, limited by the key's scopes and the client configuration's customer_grant_policy. Anonymous exchange also has its own budget of 10 requests per minute per IP (see Rate limits). Anonymous possession does not become authenticated assurance. Signing in and exchanging the provider token does not merge: the signed-in customer starts without the anonymous history. Merge explicitly, while the anonymous token is still live, with POST /v1/customer/merges: the headers carry the publishable key and the authenticated session (the target), the body carries the anonymous token (the source), and an Idempotency-Key is required:

{ "source_customer_token": "<anonymous session token>" }

The answer is 201 { "id", "source_customer_id", "target_customer_id", "membership_generation" }. Merge revokes the consumed source sessions and preserves physical accounting history, so balances and progress earned anonymously appear on the signed-in customer. A trusted backend can merge with POST /v1/customer_merges and { "source_customer_id", "target_customer_id" }, using a secret key with customers:merge. Both routes merge an anonymous customer into a known one: the source must be a live anonymous customer that has not absorbed others and the target a live customer that is not anonymous, otherwise the answer is 409 merge_conflict (also when the two accounts' progress conflicts). These routes never merge two customers that are both known.

Send an event and poll its receipt

const eventId = `${eventName}:${lessonId}:${userId}`; // the business action's stable id
const operationKey = crypto.randomUUID(); // one per send intent, reused on its retries
const response = await fetch(`${apiBaseUrl}/v1/customer/events`, {
  method: 'POST',
  credentials: 'omit',
  headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': operationKey },
  body: JSON.stringify({ id: eventId, name: eventName, properties: {} }),
});
if (response.status !== 202) throw new Error('Event admission failed');
const receipt = (await response.json()).results[0];
const poll = await fetch(new URL(receipt.receipt_url, apiBaseUrl), {
  credentials: 'omit',
  headers,
});

The admission envelope is { "results": [receipt] }. Each receipt has id (your event id), status (pending or evaluated), receipt_url, effects and pending_followups. effects always has currency_deltas ({ currency_id, amount }) and quest_progress (one-step quests: { program_id, participation_id, progress, target, completed }), and adds, only when not empty: graph_quest_progress (multi-step quests: { program_id, participation_id, run_id, status, steps: [{ key, progress, completed }] }), mechanic_changes ({ type, program_id }, for example streak.progressed or badge.granted), coupons and discounts. Read all of them; a multi-step quest reports only in graph_quest_progress. The receipt GET adds exact decimal state_revision and observation timestamp as_of; it returns the same status vocabulary. pending_followups is true while the event is pending and, after evaluation, while follow-up work it caused is still queued; the effects of an evaluated receipt are final either way. Keep receipt requests on the same API origin. receipt_url already includes /v1: resolve it against the origin.

The backend must allow eventName for client use. Keep the event ID and operation key stable when retrying the same request. A changed body with the same key is rejected. Admission is durable and initially pending; it does not promise inline evaluation. Poll receipt_url until evaluated. Replaying the original operation (same key) retains its original safe response even after evaluation. A new operation with the same stable event ID admits no second event and projects the original event's current receipt; there is no duplicate status on this route, so those effects describe the first send, not a new award. Event IDs are per customer: another customer's identical ID is a separate event, so the client needs no customer prefix, and an ID one customer sends never blocks another customer's event.

After activation, publishable POST /v1/events also requires session proof and accepts the same selector-free customer event body, operation key and safe receipt envelope. Secret-key /events keeps its backend contract. Old HMAC minting at /customers/{id}/client_token and old publishable admission fail with 410 customer_credential_retired after activation.

Render state safely

Use GET /v1/customer/state, /v1/customer/balances, /v1/customer/quests, /v1/customer/streaks, /v1/customer/badges, /v1/customer/levels, /v1/customer/activity, /v1/customer/catalogue_offers, /v1/customer/reward_offers and the other reads in the agent guide with the paired headers. Values and state_revision are exact decimal strings; do not turn large revisions or amounts into JavaScript numbers. Render text with textContent. A customer with no balance gets data: [] from /v1/customer/balances; show that as zero.

Polling is sufficient. Save an ETag alongside the exact response it represents, send If-None-Match on the next identical read, and preserve the body on 304. Authorization is checked again before a 304. A changed locale/query needs its own cached representation. SSE at /customer/state_changes sends safe invalidations, not balances or effects; refresh state when instructed, reconnect within documented bounds, and retain polling fallback.

GET /v1/customer/state_changes needs customer:read and the same two headers as every customer call. A browser EventSource cannot send headers, so read the stream with fetch and a stream reader instead. Roughly once a second the stream writes one frame, either a comment (: heartbeat) or a change:

id: <opaque cursor>
event: state.changed
data: {"state_revision":"42","refresh":true}

On state.changed, refetch the reads you show; state_revision is a decimal string. A change frame also comes at least every 4 seconds while nothing changed. The server closes the stream after at most 60 seconds, and quietly when the session stops being authorized. Reconnect with the last id you received in a Last-Event-ID header, so an unchanged state sends no change frame straight away.

const res = await fetch(`${apiBaseUrl}/v1/customer/state_changes`, {
  credentials: 'omit',
  headers: {
    Authorization: `Bearer ${publishableKey}`,
    'X-Orishare-Customer-Token': customerToken,
    ...(lastEventId ? { 'Last-Event-ID': lastEventId } : {}),
  },
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
  const { value, done } = await reader.read();
  if (done) break; // reconnect
  buffer += value;
  let end;
  while ((end = buffer.indexOf('\n\n')) >= 0) {
    const frame = buffer.slice(0, end);
    buffer = buffer.slice(end + 2);
    const id = frame.match(/^id: (.*)$/m)?.[1];
    if (id) lastEventId = id;
    if (/^event: state\.changed$/m.test(frame)) refreshCustomerState();
  }
}

Enroll in a quest through POST /v1/customer/program_enrollments with { "program_id": programId }, one operation key and If-Match set to the quest's program_revision from GET /v1/customer/quests, quoted as an entity tag: If-Match: "2". (A bare 2 is accepted too; a weak W/ tag is refused.) The response's ETag uses the same form. Find your app's quest or streak by its program_key, the stable key the operator gave it, rather than by title.

There are two redemption paths; use the one that matches the offer:

  • Catalogue offers (points buy an offer, GET /v1/customer/catalogue_offers): first POST /v1/customer/redemption_quotes with { "offer_version_id": offer.id } (the offer's id is its version id). The quote pins the price until its expires_at and spends nothing. Then POST /v1/customer/catalogue_redemptions with { "quote_id" } and an Idempotency-Key: this spends the points atomically and answers 201 with status: "pending_fulfillment" (and issuance_id: null) until fulfilment completes. Follow it in GET /v1/customer/catalogue_redemptions; the code of a code offer is read with POST /v1/customer/catalogue_redemptions/{id}/code_disclosures.
  • Issued rewards (a rule issued the reward, GET /v1/customer/reward_offers): POST /v1/customer/redemptions with { "reward_issuance_id": issuanceId }. These appear in GET /v1/customer/redemptions; catalogue redemptions do not.

Streaks, badges and levels list only what the customer takes part in: a streak row appears after the customer's first contribution to it. A mechanic whose collection reports an availability other than available must not be presented as earned state. To show all of this without building the UI, embed the hosted widget.

Leagues, friends, friend streaks and quests, teams and notification preferences use the same session; social routes refuse anonymous sessions. See Leagues, Friends and teams and Notifications.

Rate limits

Every limit counts requests in fixed 60-second windows. Over a limit, the answer is 429 with error code rate_limited and a Retry-After header in seconds; wait that long, then retry with the same Idempotency-Key. Responses also carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.

  • Backend and management calls (secret keys, PATs, the agent token): 600 requests per minute per credential, the API's default (RATE_LIMIT_PER_MINUTE). Unauthenticated requests, and requests whose credential is rejected, count per client IP against the same number.
  • Customer calls (publishable key and customer session) do not use the per-credential limit. Each environment has admission budgets instead, per 60-second window:
BudgetDefault
Customer reads and commands, per environment6,000 requests, 6,000 events, 16 MiB of request bodies
Customer reads and commands, per customer240 requests, 120 events, 1 MiB
Customer reads and commands, per session120 requests, 120 events, 512 KiB
Customer reads and commands, per IP600 requests, 600 events, 4 MiB
Events of one name, per customer60 events
State change streamsthe same request and byte numbers as customer calls
Session exchanges (POST /v1/customer_sessions)600 per environment, 30 per IP
Anonymous exchanges10 per IP

Before any of those, the API also admits at most 6,000 customer requests per minute across all environments and 600 per IP. Except for backend issuance (below), these defaults are also the maximums: an operator can lower an environment's budgets with admission_limits in PATCH /v1/client_configuration (same shape as the table, for example { "exchange": { "ip": { "requests": 10 } } }), and a value above the default is refused with 400 validation_failed. A refused customer call names its budget in details.family (customer, stream, exchange, anonymous_exchange or backend_exchange).

Backend session issuance

Game servers and serverless functions (Firebase, Supabase) mint sessions for many players from a few egress IPs. A POST /v1/customer_sessions authenticated by a secret key is therefore charged to its own budget, backend_exchange, instead of the browser exchange budgets above:

BudgetDefaultMaximum
Secret-key session issuance, per environment6,000 requests60,000
  • It has no per-IP budget and does not count toward the 6,000-per-minute and 600-per-IP customer ingress, and it never uses (or is limited by) the browser exchange budget: a burst of server mints leaves your browser exchanges untouched, and the reverse.
  • Before the key is checked, secret-key issuance passes its own pre-authentication bound instead: 60,000 requests per minute per client IP (one server IP can use a whole environment's maximum) and 120,000 per minute across the platform. A request whose secret key then fails to authenticate is also counted against the 6,000 and 600 customer ingress.
  • Only a secret key that the API verifies gets this budget. A publishable key, a customer session, an anonymous exchange, or a bearer that fails to authenticate (unknown, revoked, or a secret key sent with a customer token) is charged exactly as a browser, with the limits above.
  • Raise or lower it with PATCH /v1/client_configuration and { "admission_limits": { "backend_exchange": { "environment": { "requests": 20000 } } } }. A value above 60,000 is refused with 400 validation_failed.
  • Capacity: 60,000 per minute is a ceiling for one environment's bursts, about 1,000 mints per second, not a guarantee. Every mint is a database write (the session, plus its admission charge, which serializes on the environment's budget row), so sustained rates near the maximum are bound by the database's write capacity, shared with your other traffic. Size the budget to your real peak (players joining per minute, with headroom) rather than the maximum, and let servers back off on 429 using Retry-After. If you need sustained rates above a few hundred mints per second, contact us first.
  • Over the budget, the mint is 429 rate_limited with details.family: "backend_exchange", Retry-After and the RateLimit-* headers; wait and retry with the same Idempotency-Key.
  • Renewals (POST /v1/customer/session_renewals) and embed sessions are customer-session calls: they keep the customer budgets (120 per session, 240 per customer, 600 per IP per minute). A server renewing for many players from one IP should mint a fresh session with its secret key instead.

Migrate a trusted backend

A backend that already authenticates the customer can exchange its secret key for a session using { "grant_type": "secret_key", "customer_id": internalCustomerId }. Resolve that internal ID (cust_..., from POST /v1/customers or GET /v1/customers?external_id=...) from the backend's verified login. A browser query or body field is not proof of identity. Keep the secret key on the backend and return only the session. The secret key needs customer_sessions:write, which is not one of its default scopes: the operator creates a key that lists it (see the credential matrix). Without it the mint is 403 insufficient_scope. The browser then calls the customer routes with the publishable key, which the operator also provides.

Local examples in the Orishare repository

The rest of this page applies only inside the Orishare repository; an integrator outside it can skip it. examples/audiobook-store/src/customer-session.ts provides the backend mint boundary through an injected resolvePrincipal(headers) function returning { customerId } or null. The sample has no default login provider and refuses exchange without that resolver. Its existing trusted 350-point backend walkthrough remains separate from browser login.

Use Node 22 and the repository's existing local test services. These commands use only isolated local databases and signed fixtures; do not point them at a live environment. Run database suites sequentially when using the same suffix.

node --test examples/customer-browser/test/client.test.mjs
node --test scripts/scenarios/test/customer-issuer.test.mjs scripts/scenarios/test/customer-surface.test.mjs
TEST_DATABASE_SUFFIX=_b pnpm --filter @orishare/api exec vitest run test/phase2-customer-example-scenario.test.ts -t Firebase
TEST_DATABASE_SUFFIX=_b pnpm --filter @orishare/api exec vitest run test/phase2-customer-example-scenario.test.ts -t Supabase
TEST_DATABASE_SUFFIX=_b pnpm --filter @orishare/api exec vitest run test/phase2-customer-example-scenario.test.ts -t 'backend mapped-session'
TEST_DATABASE_SUFFIX=_b pnpm --filter @orishare/api exec vitest run test/phase2-docs-only-integrations.test.ts

The HTTP scenario is scripts/scenarios/customer-surface.mjs. Its input is { baseUrl, publishableKey, identityConnectionId, providerToken, eventName, origin, program }. baseUrl here includes /v1, unlike apiBaseUrl in the raw fetch examples above. program is optional. It exports runCustomerSurfaceScenario(input). Its requests use public HTTP only; activation and provisioning belong to the isolated fixture wrapper. The minimal browser example is examples/customer-browser/.

Firebase-shaped fixtures use issuer https://securetoken.google.com/orishare-local-fixture, audience orishare-local-fixture, RS256 and firebase_id. Their verification URL is https://www.googleapis.com/robot/v1/metadata/x509/[email protected]. Supabase-shaped fixtures use issuer https://local-fixture.supabase.co/auth/v1, audience authenticated, RS256 and supabase_access, with the issuer's /.well-known/jwks.json endpoint. Test transport supplies locally signed documents at those exact configured URLs. This is process-owned test injection, not a production local-issuer bypass. Fixture keys are public test material, never production credentials.

These runs prove the documented profiles and local integration boundary. They do not verify real Firebase/Supabase SDK behavior, account configuration, provider revocation, DNS/edge deployment or cloud operation. Those checks require a separately authorized release.

On this page