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.
| Provider | Supported profile | Required configuration and limits |
|---|---|---|
| Firebase | firebase_id, RS256 | Firebase 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. |
| Supabase | supabase_access, RS256 or ES256 | Exact project Auth issuer and its fixed JWKS endpoint, authenticated audience/role and UUID subject. HS256 is unsupported. A signed anonymous claim retains anonymous assurance. |
| Clerk | clerk_template, RS256 or ES256 | Explicit asymmetric template, configured audience and authorized party policy. Default unconfigured session-token profiles are unsupported. |
| Auth0 | auth0_user_access, RS256 or ES256 | Configured user access-token audience and issuer. Machine-to-machine credentials cannot stand in for customer identity. |
| Generic OIDC | oidc_id, RS256 or ES256 | Explicit 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): firstPOST /v1/customer/redemption_quoteswith{ "offer_version_id": offer.id }(the offer'sidis its version id). The quote pins the price until itsexpires_atand spends nothing. ThenPOST /v1/customer/catalogue_redemptionswith{ "quote_id" }and anIdempotency-Key: this spends the points atomically and answers201withstatus: "pending_fulfillment"(andissuance_id: null) until fulfilment completes. Follow it inGET /v1/customer/catalogue_redemptions; the code of a code offer is read withPOST /v1/customer/catalogue_redemptions/{id}/code_disclosures. - Issued rewards (a rule issued the reward,
GET /v1/customer/reward_offers):POST /v1/customer/redemptionswith{ "reward_issuance_id": issuanceId }. These appear inGET /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:
| Budget | Default |
|---|---|
| Customer reads and commands, per environment | 6,000 requests, 6,000 events, 16 MiB of request bodies |
| Customer reads and commands, per customer | 240 requests, 120 events, 1 MiB |
| Customer reads and commands, per session | 120 requests, 120 events, 512 KiB |
| Customer reads and commands, per IP | 600 requests, 600 events, 4 MiB |
| Events of one name, per customer | 60 events |
| State change streams | the same request and byte numbers as customer calls |
Session exchanges (POST /v1/customer_sessions) | 600 per environment, 30 per IP |
| Anonymous exchanges | 10 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:
| Budget | Default | Maximum |
|---|---|---|
| Secret-key session issuance, per environment | 6,000 requests | 60,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
exchangebudget: 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_configurationand{ "admission_limits": { "backend_exchange": { "environment": { "requests": 20000 } } } }. A value above 60,000 is refused with400 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
429usingRetry-After. If you need sustained rates above a few hundred mints per second, contact us first. - Over the budget, the mint is
429 rate_limitedwithdetails.family: "backend_exchange",Retry-Afterand theRateLimit-*headers; wait and retry with the sameIdempotency-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.tsThe 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.
Reference scenario
The audiobook purchase, progress and completion rules, every edge case and its expected result.
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.