Agent integration guide
REST-first plan for a coding agent: credentials and where they live, the anonymous, Firebase, Supabase and server paths, retry-safe fetch examples, async ingestion and customer reads.
Start here
No project yet? Start here for AI agents signs up with two HTTP calls and no
browser, and returns keys, an agent token and a claim link for your person.
Connect MCP sets up the hosted MCP server, and Autonomy explains
how your change sets are applied: always in a test environment, in a production one by the environment's policy.
Any page here is plain markdown at its URL plus .md.
This page is written for a coding agent wiring an application to Orishare. Everything here is plain HTTP against the REST API; the OpenAPI document is the only list of routes. Do not invent routes, fields or rule actions that are not in it or in the capability manifest.
Base URL. The API origin is https://api.orishare.com and every route lives under /v1.
These pages write full paths (POST /v1/events) and keep the origin, without /v1, in a
constant (API). The OpenAPI document instead declares the server https://api.orishare.com/v1
and lists paths without /v1 (/events), and so does capabilities.json: add /v1 exactly once.
URLs the API returns, such as a receipt's receipt_url (/v1/customer/event_receipts/...), are
origin-relative and already include /v1; resolve them against the origin
(new URL(receipt_url, API)), never against a base that ends in /v1.
Ids are opaque strings: store and compare them, never parse their prefixes (the ids in OpenAPI
examples are illustrative). Timestamps are RFC 3339; most end in Z, a few (a quote's
expires_at) carry an offset such as +00:00, so parse them with a full RFC 3339 parser.
Capability manifest
/capabilities.json (schema orishare.capabilities/v1) is generated from
the OpenAPI document and has four lists:
rest_operations: every REST operation withoperation_id,method,path,scopes,credentials(the credential kinds allowed:publishable_key,customer_session,secret_key,pat,session,staff; empty means a public route),proposal(true when the call only records a change set or preview that a person confirms in the dashboard) andtool: the operator tool name, ornullwith ano_tool_reasonwhen the operation deliberately has no tool.rule_actions: the rule action types the engine executes.tools: the operator tool inventory, generated operation tools and composite tools.change_set_commands: every command typePOST /v1/change_setsaccepts, with itskind(documentoroperation),proposal: trueand therequired_scopesper runtime class (sandboxfor test environments,productionfor production ones).
Check credentials before choosing where a call runs: an operation without publishable_key or
customer_session cannot be called from frontend code. session is a signed-in dashboard (cookie)
session: an operation whose only management credential is session (committing a change set,
POST /v1/change_set_commits) is a person's action in the dashboard and is never open to a PAT
or a key, which the API refuses with 403 confirmation_required. credentials lists the kinds a
route accepts; scopes lists what that credential must also hold.
The manifest is the static list; GET /v1/capabilities is the runtime check of which features
and rule actions one environment has installed (execution_supported, feature_versions). It
says nothing about the caller. What the calling credential may do is GET /v1/principal: its
kind, environment and the scopes it holds there now (for a PAT, the token's scopes narrowed by the
member's role in the environment named by X-Orishare-Environment). GET /v1/auth/tokens lists
the signed-in user's own PATs with their scopes.
The tools list describes operator tools; there is no REST route that runs a tool. They are
served by the local MCP server in the Orishare repository (apps/mcp, stdio, run with your own
secret key or PAT), and each operation tool is exactly the REST operation it names, so an agent
without MCP calls the operations directly. The composite tools are fixed sequences of those
operations; the proposing ones (preview_change_set, preview_playbook, request_confirmation)
return the change set together with its dashboard_url, where a person confirms it. The REST
routes they wrap (POST /v1/change_sets, POST /v1/playbook_previews) return no link: a REST
caller builds it from the change set id (Change sets).
Machine-readable entry points: /llms.txt, /openapi.json,
/capabilities.json, /errors (every error code with its
status, type and retry class). The API reference pages under /docs/api are generated from
/openapi.json when the site is built; a copy of the docs without them loses nothing that
/openapi.json does not have. Ready-made prompts for common stacks:
Firebase web, Supabase web,
Node backend, Python backend.
Credential matrix
Four credentials exist. Pick by where the code runs, never by convenience.
| Credential | Looks like | Who holds it | Where it may live | What it may call |
|---|---|---|---|---|
| Publishable key | opk_... | The application, per environment | Frontend code, mobile apps, public config. It is not a secret. | Only POST /v1/customer_sessions on its own; with a customer session, the /v1/customer/* routes and client-allowed events. Never merchant-only events. |
| Customer session | ocs_... (shown once) | One end user, for minutes | Memory of the user's browser or app only. Not logs, URLs, cookies or browser storage. | /v1/customer/* for that customer, sent with the publishable key, within the session's capabilities. |
| Secret key | osk_test_... osk_live_... | Your backend, per environment | The server's secret store or environment variables. Never in a browser bundle. | Server routes for its environment by scope: POST /v1/events (any event), GET /v1/customers/{id}/state, backend session mint (only with customer_sessions:write), management by scope. |
| Personal access token | opat_... | A person or the operator's agent | The operator's terminal, CLI config or CI secret. Never in an application. | Management across organizations: projects, environments, keys, rules, programs, change sets. Names the environment with X-Orishare-Environment. |
Never put a secret key in frontend code. Anything shipped to a browser or app is public. If
a secret key or PAT ever reaches a bundle, a repository or a log, revoke it
(DELETE /v1/api_keys/{id} or DELETE /v1/auth/tokens/{id}) and mint a new one. A customer
token never authorizes management routes, and a publishable key never gains the merchant
events:write scope.
Scopes and who hands out which key
Keys are created only by an owner or admin of the organization, in the dashboard (Settings,
Environments & keys) or with a PAT, never with a secret key:
POST /v1/environments/{id}/api_keys requires a PAT or dashboard session with
environments:write, and so does DELETE /v1/api_keys/{id}. A backend integrator therefore receives both keys from the operator: the secret key for the server's secret
store and the publishable key (opk_...) for the frontend's public config.
A secret key created without scopes gets these defaults, everything except management:
events:*, customers:*, currencies:*, ledger:*, rules:*, campaigns:*, programs:*,
segments:*, webhooks:*, audit:read, context:*, ai:*, config:*, counters:*,
usage:read, analytics:*, notifications:*. customers:* includes customers:merge, so a default key can merge accounts with
POST /v1/customer_merges (backend account merge). A backend that must not merge accounts gets a
key with an explicit scopes list that names the customer scopes one by one instead of
customers:*: the defaults with customers:* replaced by customers:read, customers:write and
customers:client_token. Such a key is refused 403 insufficient_scope on
POST /v1/customer_merges. customer_sessions:write (backend session mint) is not a default for keys created later
(the secret keys returned by agent signup do include it):
the operator creates the key with POST /v1/environments/{id}/api_keys and an explicit scopes
list, for example the defaults plus customer_sessions:write (the dashboard's form creates keys
with the defaults). Scopes never
change on an existing key; to change them, create a new key and revoke the old one. A publishable key gets
customer_sessions:exchange, customer:read, customer:write and events:write_client once the
environment's customer surface is enabled, otherwise only events:write_client.
An operator's agent works with a PAT. For inspection give it rules:read (GET /v1/capabilities,
GET /v1/programs, GET /v1/rules), config:read (GET /v1/config/export, change sets,
playbooks), events:read (GET /v1/event_definitions), programs:read (the typed mechanic
reads such as GET /v1/streaks) and environments:read (GET /v1/environments/{id}, for
example its timezone); add config:write to propose change sets and playbook installs. For the
hosted widget add portal_configurations:read (GET /v1/portal_configurations/{id} with its
readiness, GET /v1/portal_configurations/{id}/versions) and, to propose portal changes,
portal_configurations:write beside config:write (a portal proposal needs both); add
client_configurations:read to see allowed_origins. None of these can commit anything. Check
what a token holds with GET /v1/principal.
Mint keys with a PAT, test environment first:
const API = 'https://api.orishare.com';
const createKey = crypto.randomUUID(); // one per intent, reused if you repeat this request
const created = await fetch(`${API}/v1/environments/${environmentId}/api_keys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_PAT}`,
'Content-Type': 'application/json',
'Idempotency-Key': createKey,
},
body: JSON.stringify({ kind: 'publishable', name: 'web' }),
});
// The token is in the response once. Store a secret key in the secret store immediately.Stable retry ids
Two different ids make a repeated request safe; create both once per intent, never inside a loop.
- The business id. An event's
idis unique per environment forever (order_42, the same on every send, never a new random value). Events are deduplicated by it; the same id with a different body is409 event_id_conflict. With a secret key, sending a stored event again answersduplicatewith the original event'sevt_...id: nothing is evaluated again, andexecution_idandeffectsare filled only if the original was already evaluated (otherwisenulland[]), so pollGET /v1/events/{evt_id}for its outcome. Customer events (POST /v1/customer/events) never answerduplicate; see Customer events. - The operation key.
Idempotency-Keyidentifies one intent for 24 hours. Every creatingPOSTneeds one, except a secret-keyPOST /v1/events, which deduplicates by event id instead. On routes that replay by key, the same key with the same body returns the stored response (Idempotent-Replayed: true); the same key with a different body is422 idempotency_key_reused. A stored response is the same JSON value as the first answer (the order of keys can differ), so areplayedfield in its body keeps its first value, usuallyfalse: theIdempotent-Replayed: trueheader is the signal. A body saysreplayed: trueonly when the resource itself recognised a repeat sent as a new request (the samematch_id,dedupe_keyor economy operation under another key).
An operation key is any value of 1 to 128 characters from A-Z a-z 0-9 . _ : -;
crypto.randomUUID() called once per intent and kept is enough. With a secret key,
POST /v1/events does not read Idempotency-Key at all (its dedupe is the event id), so
sending one there is harmless and changes nothing; customer events require it.
@orishare/api-client (the typed client) and the orishare CLI are workspace packages of the
Orishare repository (packages/api-client, packages/cli). They are not published to npm, so
outside the repository use plain HTTP as on this page; the examples using them show the same
calls. The client exports retryId(): call it once per intent, keep the value and send the same
value whenever that intent is repeated, as the Idempotency-Key of a write or as an event id
when no business id exists. Its values match RETRY_ID_PATTERN, the pattern above. The client's
default Idempotency-Key is a new retryId() per request, so pass your own key for any write you
might repeat.
When a request may be repeated
There is no blanket retry loop for writes. What is safe depends on the answer you got and on the
operation's retry class, published per operation in
/capabilities.json (rest_operations[].retry) and available in code as
retryClassOf(method, path) and replaysByKey(method, path) from @orishare/api-client (the
same lookup over the manifest).
Not executed: repeat. A 429, or a 409 that carries Retry-After (for example
concurrent_update or idempotency_in_progress), means the request did not run. Wait for
Retry-After and send it again with the same key and body.
Lost answer (timeout, dropped connection, 5xx): the write may or may not have run.
retry | Meaning | What to do after a lost answer |
|---|---|---|
read | A GET; changes nothing | Repeat freely. |
atomic_replay | The API stores the first result under the Idempotency-Key | Repeat with the same key and body; you get that result. |
keyed_resume | The API resumes or replays the work for the same key | Repeat with the same key and body. |
outcome_unknown | No replay by key | Do not repeat blindly. Read the resource first. |
POST /v1/events (secret key) is outcome_unknown, but events are deduplicated by their id:
after a lost answer, resend the same event with the same id. The answer is duplicate with the
stored event's evt_ id, or the first admission. Any other outcome_unknown write (for example
POST /v1/config/apply) needs a read of the target before deciding to send it again.
Everything else: do not repeat. Other 4xx answers mean the request was refused; fix the
cause named by error.code. 409 stale_write means re-read the resource and send its new
If-Match. Error bodies are
{ "error": { "type", "code", "message", "param", "doc_url", "request_id", "details"? } } on
every route, and the codes at /errors never change meaning. The CLI follows exactly
this rule: it repeats a write after a lost answer only for atomic_replay and keyed_resume, always with the
same key, and otherwise reports outcome_unknown.
With the client library:
import { createOrishareClient, replaysByKey, retryId } from '@orishare/api-client';
const client = createOrishareClient({ token: process.env.ORISHARE_SECRET_KEY });
replaysByKey('POST', '/customer_sessions'); // true: atomic_replay
replaysByKey('POST', '/events'); // false: outcome_unknown, deduplicated by event id
const key = retryId(); // one per intent
const mint = () =>
client.POST('/customer_sessions', {
body: { grant_type: 'secret_key', customer_id: customerId },
headers: { 'Idempotency-Key': key },
});
let result;
try {
result = await mint();
} catch {
result = await mint(); // lost answer: same key, so the API replays the first result
}Sending an event with plain fetch. Only "not executed" answers wait and repeat; a lost answer
resends the same event, which is safe only because the event id deduplicates it:
const API = 'https://api.orishare.com';
async function sendOrderPaid(order) {
const body = JSON.stringify({
id: `order_${order.id}`, // business id: the same on every send of this event
name: 'order.paid',
customer_id: order.userId, // your own user id (the external id), never a cust_ id
occurred_at: order.paidAt,
properties: { total_cents: order.totalCents },
});
for (let attempt = 0; attempt < 5; attempt += 1) {
let res;
try {
res = await fetch(`${API}/v1/events`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body,
});
} catch {
continue; // lost answer: resend the same id; a stored event comes back as duplicate
}
if (res.ok) return res.json(); // accepted, evaluated or duplicate
const notExecuted =
res.status === 429 || (res.status === 409 && res.headers.has('Retry-After'));
if (res.status >= 500) continue; // lost outcome: same id again, deduplicated
if (!notExecuted) throw new Error(`Orishare ${res.status}: ${(await res.json()).error?.code}`);
await new Promise((r) => setTimeout(r, Number(res.headers.get('Retry-After') ?? 1) * 1000));
}
// Still unconfirmed: resending the same event later is safe (it answers duplicate with the
// stored evt_ id), or list GET /v1/events?customer_id=...&name=order.paid and match client_event_id.
throw new Error('Orishare: not confirmed; resend the same event later');
}Do not copy this loop to other writes: for an outcome_unknown write without a business id,
read the resource after a lost answer instead of sending again.
Integration paths
Choose one identity path for the browser or app; the server path is used alongside any of them.
Server
The trusted path. Valuable facts (order.paid, refunds, subscriptions) come only from your
backend with the secret key, so they carry merchant_asserted trust. POST /v1/events is
async by default (202); mode: "sync" evaluates inline and returns effects (200), falling
back to 202 with fallback: true under load. Read trusted state with
GET /v1/customers/{id}/state and GET /v1/customers/{id}/balances.
Clock skew: a server clock that runs a little fast is fine. An occurred_at up to 24 hours
ahead of our receipt is stored as sent and evaluated now, at the receipt time, so daily caps and
periods never jump ahead; more than 24 hours ahead is 400 occurred_at_in_future. Omit
occurred_at when the event is happening now. See
event time.
Two customer ids exist and each route says which it takes:
- External id: your own user id (
u-1001).customer_idin an event body is always the external id; an event for an external id nobody holds yet creates that customer. The same value may be sent asexternal_customer_idorcustomer: { "external_id": ... }. A value sent there that is an existing customer's internal id, while no customer has it as its external id, is refused withvalidation_failed(a rejected item in a batch,400for a single event;POST /v1/customersrefuses it asexternal_idtoo), so an internal id sent there by mistake never creates a stray customer. Any other value is an ordinary external id, whatever its spelling, and a resent event that was already recorded still answersduplicate. - Internal id: the
cust_...id Orishare assigns, returned byPOST /v1/customers(an upsert byexternal_id;201created,200updated) and byGET /v1/customers?external_id=.... To address an event by internal id sendcustomer: { "id": "cust_..." }; an unknown internal id is rejected withresource_not_found.GET /v1/customers/{id},GET /v1/customers/{id}/stateandGET /v1/customers/{id}/balancesaccept either id. Session mint takes the internal id.
An upsert advances the customer's version by one when it carries non-empty attributes (even
attributes identical to the stored ones) or a timezone (null included). With attributes
omitted or {} and no timezone it changes nothing on an existing customer and answers 200
with the version it already had; a customer created without them starts at version 1. Always
use the version the response returns (for example as If-Match on PATCH /v1/customers/{id}),
never one you computed.
A backend that already knows who the user is can hand the browser a customer session without any identity provider: mint it with the secret key and return only the session token.
const exchangeKey = crypto.randomUUID(); // one per exchange intent, reused on every retry of it
const res = await fetch(`${API}/v1/customer_sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': exchangeKey,
},
body: JSON.stringify({ grant_type: 'secret_key', customer_id: customerIdFromYourLogin }),
});
const { customer_token, expires_at } = await res.json();
// Return customer_token to the signed-in browser; keep the secret key here.customer_id is the internal cust_... id resolved from your verified login, never a value the
browser sent. Minting needs a secret key with customer_sessions:write, which is not a default
scope (Scopes); without it the answer is
403 insufficient_scope.
Firebase
The browser signs in with Firebase, then exchanges the Firebase ID token (not a custom
token) for a customer session. An operator first creates the identity connection
(POST /v1/identity_connections, profile firebase_id) and allows the site origin with
PATCH /v1/client_configuration. The browser holds only the publishable key and the
connection id.
const exchangeKey = crypto.randomUUID(); // one per exchange intent, reused on every retry of it
const idToken = await auth.currentUser.getIdToken();
const res = await fetch(`${API}/v1/customer_sessions`, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': exchangeKey,
},
body: JSON.stringify({
grant_type: 'identity_token',
connection_id: CONNECTION_ID,
provider_token: idToken,
}),
});
let customerToken = (await res.json()).customer_token; // memory onlySupabase
Identical, with the Supabase access token of the signed-in user and a connection with
profile supabase_access (RS256 or ES256 signing keys; HS256 projects are not supported).
const exchangeKey = crypto.randomUUID(); // one per exchange intent, reused on every retry of it
const { data } = await supabase.auth.getSession();
const res = await fetch(`${API}/v1/customer_sessions`, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': exchangeKey,
},
body: JSON.stringify({
grant_type: 'identity_token',
connection_id: CONNECTION_ID,
provider_token: data.session.access_token,
}),
});A Firebase or Supabase anonymous user stays anonymous assurance even with a valid signature.
Anonymous
Where the operator has enabled anonymous exchange, a visitor gets a session without signing in.
It is anonymous assurance: its events are anonymous_claim, the weakest trust, and renewal is
bounded to 24 hours from the first token. A lost anonymous token cannot be recovered.
const exchangeKey = crypto.randomUUID(); // one per exchange intent, reused on every retry of it
const res = await fetch(`${API}/v1/customer_sessions`, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': exchangeKey,
},
body: JSON.stringify({ grant_type: 'anonymous' }),
});Signing in does not merge anything: an exchange always yields the signed-in customer alone, and
no email or alias links accounts. To keep what the visitor earned anonymously, merge explicitly
while the anonymous token is still live (sessions last 15 minutes by default; a lost or
expired anonymous token cannot be recovered). Send POST /v1/customer/merges with the signed-in
session's headers (the publishable key and the authenticated session token, the target), an
Idempotency-Key, and the anonymous token in the body (the source). This route is the one place a
customer call carries a token in its body:
const mergeKey = crypto.randomUUID(); // one per merge intent, reused on every retry of it
const merged = await fetch(`${API}/v1/customer/merges`, {
method: 'POST',
credentials: 'omit',
headers: {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'X-Orishare-Customer-Token': signedInToken, // target: authenticated session
'Content-Type': 'application/json',
'Idempotency-Key': mergeKey,
},
body: JSON.stringify({ source_customer_token: anonymousToken }), // source: anonymous session
});
// 201 { id, source_customer_id, target_customer_id, membership_generation }History and balances are kept (the anonymous award shows on the signed-in customer) and the
anonymous sessions are revoked (401 invalid_customer_session afterwards), so drop that token. A
backend can merge instead with POST /v1/customer_merges
({ "source_customer_id", "target_customer_id" }, internal ids), which needs a secret key with
customers:merge.
Session lifetime
Sessions last up to an hour (default 15 minutes). Renew before expiry with
POST /v1/customer/session_renewals (both headers, one new Idempotency-Key per renewal, reused
on every retry of that renewal, body {}) and replace the token only when the response has a
non-null one. Renewal needs customer_sessions:exchange on the publishable key that sent it (and
on the key that issued the family), not in the session's own capabilities. A renewed session
belongs to the same family; earlier unexpired tokens of the family stay live until they expire,
and a family holds at most four live tokens, so a renewal while four are live is
409 customer_session_limit (retry after a delay, or revoke a spare token with
POST /v1/customer/session_revocations sent with that token). A family can be renewed until its
ceiling: the provider token's expiry for an identity-token exchange, 24 hours from the first token
for an anonymous or secret-key session (and the earlier of the two for a provider's anonymous
user). After that, exchange again.
On logout call POST /v1/customer/session_family_revocations and drop the token from memory.
It revokes only the family of the token it is sent with.
Keys follow intents. Every exchange intent gets one Idempotency-Key, reused on every retry of
that exchange. A retry that is answered by replay is still 201, with headers
Idempotent-Replayed: true and Idempotent-Replay-Redacted: customer_token, and
customer_token: null (the token is shown only once; an anonymous replay also nulls
customer_id, session_id and family_id). A replay is never a credential. Recovering from it
is a deliberate new exchange, a new intent with a new key and a currently valid provider token
(the same unexpired provider token works); it is not a retry of the old one. Every exchange starts
a new session family, so the family whose token was lost stays live until its tokens expire
and is not reached by a later logout. Exchange again also when the provider refreshes its token
(a Firebase ID token lasts an hour), since the family cannot outlive the provider token.
Customer events
Customer events use the publishable key plus X-Orishare-Customer-Token, a stable event id and an
operation key (Idempotency-Key is required here). Only event names an operator marked
client_allowed are accepted; merchant-only events return 403 event_requires_secret_key.
const headers = {
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
'X-Orishare-Customer-Token': customerToken,
};
const eventId = `lesson_${lessonId}_${userId}`; // stable business id
const key = crypto.randomUUID(); // one per intent, reused on retry
const res = await fetch(`${API}/v1/customer/events`, {
method: 'POST',
credentials: 'omit',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': key },
body: JSON.stringify({
id: eventId,
name: 'lesson.completed',
properties: { lesson_id: lessonId },
}),
});
const [receipt] = (await res.json()).results; // 202, status "pending" or "evaluated"The customer route has only pending and evaluated, never duplicate:
- The same id with the same key (a retry) replays the original admission exactly
(
Idempotent-Replayed: true), usuallypendingwith emptyeffects, even after evaluation. Read the receipt for the outcome. - The same id with a new key admits nothing new and answers with the current receipt of the
original event. Its
effectsare that original event's effects, not a second award, so a UI must not show them again. Remember which event ids it already rendered.
Async ingestion and partial batches
Admission is durable and asynchronous. A 202 means stored, not evaluated:
- Secret key: poll
GET /v1/events/{id}with theevt_...id from the admission result (results[i].id) untilstatusisevaluated(orfailed,poison);GET /v1/events/{id}/executionshows the plan and trace. Your own business id is the event'sclient_event_id;GET /v1/events/{id}does not accept it (404). If you lost theevt_id, resend the same event (it answersduplicatewith theevt_id) or listGET /v1/events?customer_id=...&name=...and matchclient_event_id. - Customer session: poll the receipt (
GET /v1/customer/event_receipts/{id}with your event id, thereceipt_urlabove) with the same two headers. Stop atevaluated: itseffectsare final.pending_followupsistruewhile the event is pending and, after evaluation, while follow-up work it caused (program transitions, rewards) is still queued; refresh state later when it istrue.
const poll = await fetch(`${API}/v1/customer/event_receipts/${encodeURIComponent(eventId)}`, {
credentials: 'omit',
headers,
});
const { status, effects, pending_followups } = await poll.json();A secret-key batch sends up to 100 events as { "events": [...] } (customer batches up to 25)
and answers 207 with one result per item, in request order, matched by index:
status | Fields |
|---|---|
accepted | index, id (the evt_... id), customer_id (internal), late |
evaluated | as accepted, plus execution_id, effects, trace, queued_work, pending_followups (mode: "sync" only) |
duplicate | index, id (the stored evt_... id), execution_id (or null), effects |
rejected | index, error ({ code, message, param? }); no id, nothing was stored |
id is never your business id. Accepted items are stored even when others are rejected. Retry
only the rejected indices, with the same event ids, after fixing the cause in error.code:
const res = await fetch(`${API}/v1/events`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ events }),
});
const { results } = await res.json(); // HTTP 207
const rejected = results.filter((r) => r.status === 'rejected');
for (const r of rejected) console.warn(events[r.index].id, r.error.code, r.error.param);
// Fix each rejected event (error.code and error.param say what), then resend only those,
// with the same ids. Never resend an event_id_conflict item under its old id with a new body.duplicate items were already stored earlier and need nothing. A whole request can still fail
(400 batch_too_large, 401, 429); then nothing in it was stored and the same batch can be
resent. A single event (no events array) answers 202 when accepted, 200 when evaluated or a
duplicate, and a rejected single event is an error response instead of a result.
Event names are dotted snake_case (order.paid); a name that does not match is rejected with
validation_failed on events.<i>.name. A well-formed name with no definition is not an
error: it is accepted and creates a server-only definition (client_allowed: false), so a typo
is stored as a new event. Check names on your side, or list GET /v1/event_definitions.
Customer reads
Every read sends the publishable key and the customer token, never a customer id: identity comes from the session.
| Read | Returns |
|---|---|
GET /v1/customer/state | Summary, state_revision, as_of, mechanic availability |
GET /v1/customer/balances | Currency balances and the next expiry |
GET /v1/customer/activity | Committed changes, newest first |
GET /v1/customer/quests | Quest runs and quests the customer can join |
GET /v1/customer/streaks | Streaks the customer participates in, with freezes |
GET /v1/customer/badges | Earned badges and visible progress |
GET /v1/customer/levels | Levels and XP |
GET /v1/customer/leaderboard_positions | The customer's positions |
GET /v1/customer/referrals | Referral progress; codes at GET /v1/customer/referral_codes |
GET /v1/customer/reward_offers | Issued rewards the customer can redeem |
GET /v1/customer/redemptions | Redemptions of issued rewards |
GET /v1/customer/catalogue_offers | Catalogue offers and prices |
GET /v1/customer/catalogue_redemptions | Catalogue redemptions (points spent on offers) and their fulfilment |
GET /v1/customer/coupon_entitlements | Coupons the customer holds |
let etag = null;
let snapshot = null;
async function refreshState() {
const res = await fetch(`${API}/v1/customer/state`, {
credentials: 'omit',
headers: etag ? { ...headers, 'If-None-Match': etag } : headers,
});
if (res.status === 304) return snapshot; // unchanged, keep the cached body
snapshot = await res.json();
etag = res.headers.get('ETag');
return snapshot;
}On these customer routes, amounts and state_revision are decimal strings: do not convert them
to JavaScript numbers. (The trusted GET /v1/customers/{id}/balances returns numbers.) Render
text with textContent. A balance list has a row only for currencies the customer has ever held:
a customer with no ledger entries gets data: [], which means zero. A mechanic that is off
answers with availability other than available; show nothing for it rather than an empty
earned state. availability: "available" (and mechanics.streaks: "available" in the state)
says the mechanic is switched on, not that any program of it exists.
Mechanic collections list what the customer takes part in. Quests also list quests the customer
can join (kind available); streaks, badges, levels and leaderboards have no "could start" row:
a streak appears in GET /v1/customer/streaks after the customer's first contribution to it
(see Author a mechanic). Items of the quest, streak, badge, level,
leaderboard position and referral collections carry program (with its id) and
program_key, the stable key the operator gave the program: match on program_key, not on
titles.
GET /v1/customer/state_changes is a server-sent event stream of invalidations only; refetch
state when told and keep polling as the fallback.
Engagement reads use the same headers: GET /v1/customer/leagues, GET /v1/customer/friends,
GET /v1/customer/friend_streaks, GET /v1/customer/friend_quests, GET /v1/customer/team and
GET /v1/customer/notification_preferences; see Leagues,
Friends and teams and
Notifications.
Hosted widget
Instead of building the UI from the customer reads above, a page can embed the hosted widget: an iframe on the Orishare widget origin, built only on the customer API, that shows balances, streaks, levels, badges, quests, leaderboards, referrals, the catalogue and activity. The full setup is in the Hosted widget guide. In short:
- An operator creates and publishes a portal in the dashboard Widget area. An agent (or
server-side automation with a scoped secret key) calls the direct routes
(
POST /v1/portal_configurations,PATCH /v1/portal_configurations/{id},POST /v1/portal_configurations/{id}/versions,.../activations,.../disable,.../enable, scopeportal_configurations:write); the production gate runs them directly inlimitstoo. It may instead propose the same changes as change sets (POST /v1/change_setswithportal_configuration.create,save_draft,publish,activate_version,disableorenable), which commit by the environment's autonomy policy. The portal holds the surfaces, layout, brand, locales andallowed_parent_origins; its document is described field by field in the guide. - The page loads
embed.jsand pushesmountwith the portal id, the publishable key and one session path:getSession(your backend mints with the secret key and returns onlycustomer_token) orconnectionIdplusgetIdentityToken(the page origin must be in the client configuration'sallowed_origins). - Never put a secret key in frontend code. The publishable key in the snippet is fine; the secret key stays on the backend that mints the session (credential matrix).
- The widget never signs anyone in and never sends events; your app still sends events as above. It never posts a token, a key or customer data to the page.
Customer data export
GET /v1/customers/{id}/export (customers:read, backend or operator credential, never a
browser) returns everything Orishare holds about one customer, for a data subject request or a
migration. {id} is the customer id or your external_id. A customer of another environment is
404 resource_not_found.
The document has two parts:
- The profile (
customer,member_profiles) and the earlier top-level lists (aliases,events,ledger_entries,reward_issuances,tiers,challenge_progress). They keep their shape for existing callers and are capped; do not rely on them for completeness. sections: one entry per name insection_names, each{ "data": [...], "next_cursor": ... }. This is the complete form. It covers identity (aliases, identity links, sign-ins and session metadata, merges), events and their receipts, balances and every ledger entry, reward issuances, tiers, challenges, program enrolments with every transition and contribution, streaks, badges, quests, levels and XP runs, leaderboard entries, profiles, scores and prizes, referrals on both sides (as referrer and as referred friend: codes, attributions, conversions, settlements), reward commerce (quotes, redemptions, fulfilments, coupons) and experiment assignments.export_versionis2;generated_atis when the document was read.
Each section holds at most limit rows (?limit=, default 200, maximum 1000). When a section's
next_cursor is not null it has more rows: read
GET /v1/customers/{id}/export/{section}?cursor=<next_cursor>&limit=1000 and repeat with each new
next_cursor until it is null. The response is { section, customer_id, data, next_cursor, has_more }. Sections are ordered oldest first by a stable key, so a walk never repeats or skips a
row; events is newest first. A cursor belongs to one customer and one section, and a merge
invalidates it (400 invalid_cursor): start that section again.
const API = 'https://api.orishare.com';
const headers = { Authorization: `Bearer ${process.env.ORISHARE_SECRET_KEY}` };
const base = `${API}/v1/customers/${encodeURIComponent(customerId)}/export`;
const doc = await (await fetch(`${base}?limit=1000`, { headers })).json();
for (const [name, section] of Object.entries(doc.sections)) {
while (section.next_cursor) {
const url = `${base}/${name}?limit=1000&cursor=${encodeURIComponent(section.next_cursor)}`;
const page = await (await fetch(url, { headers })).json();
section.data.push(...page.data);
section.next_cursor = page.next_cursor;
}
}orishare customers export <id> --json does the same walk, and so does Export data on the
customer page of the dashboard.
What to know about the content:
- Merged customers. Exporting the surviving customer includes the history of every account
merged into it; rows keep the
customer_idthey were recorded under, andmember_profileslists those accounts. Asking for a merged account's id returns the surviving customer's export. - Old events. Events older than the hot window (30 days in a test environment, 90 in a production one) come
from the archive.
events_archiveisincludedwhen they are part of theeventssection andunavailablewhen no archive is configured, in which case the section covers the hot window only. - Private reward codes are not included.
reward_code_allocationsandreward_redemptionscarry the state, the timestamps and the ids of a redemption, never the code itself. - Large integers are strings. In
sections, 64-bit integer and decimal fields (amounts, scores, revisions) are decimal strings such as"9007199254740993", so no reader rounds them. Numbers inside stored JSON fields (state,manifest) are passed through as stored. - No credentials or internals. Session rows are metadata only (no token), identity links carry the issuer but not the subject, and hashes, request fingerprints and worker bookkeeping are left out. Rows are otherwise exported under their stored snake_case field names.
Operator tasks an agent may do
- Inspect:
GET /v1/capabilities(rules:read),GET /v1/event_definitions(events:read),GET /v1/programs(rules:read),GET /v1/rules(rules:read),GET /v1/principal(any). - Draft rules with
POST /v1/ai/rule_drafts; it never publishes. Validate withPOST /v1/rules/{id}/simulatebeforePOST /v1/rules/{id}/publish. - Author mechanics and install playbooks as proposals:
POST /v1/change_setsandPOST /v1/playbook_previewsstore a change set. Apply it yourself withPOST /v1/change_set_applicationswhen the environment's autonomy policy allows (always in a test environment; in a production one inauto, or inlimitswhen the plan is inside the limits). Otherwise, and always inconfirmmode, a signed-in owner or admin confirms it in the dashboard. Undo an applied change withPOST /v1/change_set_undos. See Change sets, Author a mechanic and Playbooks. - With an agent credential (the agent token from agent signup, or a key it
created) in a production environment that is not
auto, a direct write meets the production gate for AI callers: inconfirmevery write waits (your game server's events with your key keep running over the REST API); inlimitsruntime, setup, plain configuration and your own keys, origins and webhooks run, while value configuration (rules going live, programs, currencies, segments) answers403 confirmation_requiredwithdetails.reasonai_gate: propose it as a change set. See the agent gate. - Set up the hosted widget with the direct portal routes (open to agent credentials in
production environments, like events and keys), or as proposals when a person should review it: one change
set per portal command (
portal_configuration.create,portal_configuration.save_draft,portal_configuration.publish,portal_configuration.activate_version,portal_configuration.disable,portal_configuration.enable), each naming the portal bykeywith theexpected_revisionread fromGET /v1/portal_configurations/{id}, and read the stored plan back withGET /v1/change_sets/{id}before applying it or handing the id to the person who confirms. Readiness (GET /v1/portal_configurations/{id},portal_configurations:read) lists allowed parent origins missing fromallowed_origins(parent_origins_missing_from_client_configuration). That matters only for the identity path (the backend path never needs it). An agent with portal scopes cannot fix it: there is no proposal command for the client configuration and no dashboard page that editsallowed_origins(the dashboard Widget area only reports the missing origins). Tell the person exactly which origins to add, and that they (or a script holdingclient_configurations:readandclient_configurations:write, with their PAT or a secret key) readGET /v1/client_configuration, merge the origins intoallowed_originsand write the whole list back withPATCH /v1/client_configurationandIf-Matchset to theversionread, as the widget guide's loop does. A rollback (portal_configuration.activate_version) leaves the draft unchanged, so the next publish puts the rolled-back-from content live again unless the draft is saved with the rolled-back version'sdocumentfirst. See Operator agents. - Configure engagement (leagues, friend quests, friend streaks, team quests, notification message
types, templates and settings) as change-set commands such as
league.create,friend_streak_definition.activateandnotification_message_type.create; see Leagues, Friends and teams and Notifications. - Promote with a v2 configuration document; see Promote between environments.
- Webhooks: register with
POST /v1/webhook_endpointsand verifyOrishare-Signatureas the webhooks page shows (HMAC-SHA256 of"<t>.<raw body>").
Change sets
A change set is a stored proposal: the commands and the exact plan that is applied, by the proposer under the environment's autonomy policy or by a person who reviews it. Creating one changes nothing live.
Proposing. POST /v1/change_sets takes commands (or a document for a configuration
apply) and an optional publish, which defaults to true. publish matters for document
commands and for rule.create: true publishes, on commit, the rules the commands or the
document mark published or paused, and the rule a rule.create command creates (a live
environment also needs rules:publish); false leaves them as drafts (publish one later with
rule.publish_draft, whose key is the rule id). Every other operation command
(portal_configuration.*, segment.delete and the other single operations, always one per
change set) ignores it, and its change set is stored with publish: false: a portal is published
by the portal_configuration.publish command, not by this flag.
The plan (plan), every field:
| Field | What it holds |
|---|---|
diff | entries (one per changed target: section, key, op create, update or delete, and fields with path, before, after) and unchanged, the number of configuration entries left as they were |
effects | What the change reaches: customers_affected, liability, stock, codes, webhooks.event_types and plain-text notes |
irreversibility | Steps that cannot be undone by another change, each with the command, target and reason |
flags | Changes that weaken trust (trust_weakening, for example an event becoming client-allowed); a person acknowledges each at confirmation, and a policy apply records them as advisories |
destructive | Operation proposals: the rows the operation removes or changes (by identity, or a count and digest); recomputed at commit, any difference is stale |
dependencies | Portal publish and activation: the programs and catalogues the published document references, as they were at preview; a change to them is stale |
approved_bounds | Economic upper bounds the person approves (liability, stock, codes, customers affected, issuances); the commit recomputes them, and a bound that grows makes the proposal stale |
expected_versions | The versions the plan was built against (the configuration revision, or for a portal { resource: "portal_configuration", key, version }) |
required_scopes | The scopes the proposer and the confirming person need, for example config:write and portal_configurations:write |
plan_digest | The digest of the reviewed plan; the confirmation sends it, so the person confirms exactly what they saw |
commands_digest | The digest of the commands |
snapshot_at | When the plan was computed |
Some proposals add bulk (a bulk operation under review), playbook (a playbook install) or
provenance (where a promoted document came from).
- Applying under the policy. The proposer applies a pending change set with
POST /v1/change_set_applications({ "change_set_id", "plan_digest" }, theplan.plan_digestit proposed or last read). This is allowed always in a test environment, and in a production one whenGET /v1/autonomy_policysaysauto, orlimitswith the plan inside the limits. The answer is the commit withauthority: "policy". A refusal is403 confirmation_requiredwithdetails.reasonsanddetails.review_url; the change set stays pending for a person, so hand over that link and do not retry the apply. Policy commits acknowledge planflagsby policy:limitsdoes not refuse a trust-weakening plan, it records it as an advisory, read only withGET /v1/change_sets/{id}?include_advisories=true(commit.policy.advisories).limitsrefuses only what is over a reward cost cap or has no readable amount, deletes, destructive or irreversible changes, and commands without declared metadata (metadata_missing).POST /v1/change_set_undosundoes an applied change set the same way. See Autonomy. - Who confirms. In
confirmmode, and whenever the policy refuses, a signed-in owner or admin confirms in the dashboard by reviewing and pressing confirm, which sendsPOST /v1/change_set_commitswith the dashboard session. That route is for the dashboard only: a PAT or key calling it is refused with403 confirmation_required; do not retry it. A plan withflags(for exampletrust_weakeningwhen an event becomes client-allowed) is confirmed only with each flag acknowledged. - Where. The review page is
/changes/{id}in the dashboard, for the selected environment. The link the proposing tools return asdashboard_urlis<dashboard>/change-sets/{id}, which finds the environment and opens that page.<dashboard>is the dashboard origin: for the hosted servicehttps://app.orishare.com; for a local or self-run deployment, the operator's own dashboard URL.POST /v1/change_setsreturns nodashboard_url, so a REST caller hands over the change set id and the link built from it, for examplehttps://app.orishare.com/change-sets/chs_.... In the dashboard every proposal is also listed under Operations, Changes. - Waiting for the decision. Read
GET /v1/change_sets/{id}every 10 to 30 seconds untilstatusleavespending, and stop atexpires_at.committedmeans it was confirmed and applied (committed_at,commit_id);stale(stale_reasonsays why) andexpiredmean propose again;cancelledmeans it was withdrawn. Onlycommittedchanged anything. The commit's answer, with what it created, goes only to the dashboard session that confirmed it; read the resource itself afterwards (for a portal created bykey, listGET /v1/portal_configurationsand matchkey; see Operator agents). - Expiry. A change set expires 24 hours after it is created (
expires_at); confirming it later is409 change_set_expired. - Stale. What makes a proposal stale depends on its kind. A document proposal (commands that
edit configuration, and a configuration apply) is bound to the configuration it was previewed
against: if anything in the environment's configuration changes before confirmation, even an
unrelated rule draft, confirming is
409 preview_stale. An operation proposal (one direct operation such assegment.delete) is bound only to what it touches: its target's version, the inventory of rows it removes or changes (destructive) and its plan digest; any change there is409 preview_stale, but an unrelated configuration edit does not stale it and it stays confirmable. Either way the change set becomesstale(stale_reasonsays why). Expired and stale proposals are fixed the same way: propose the same commands again with a newIdempotency-Keyand hand over the new id. The dashboard offers this as "propose again". To withdraw a pending proposal, cancel it withPOST /v1/change_sets/{id}/cancellations; never rely on another edit to invalidate it.
Promote between environments
Promotion uses the portable v2 document (orishare.config/v2). An export without a schema is
v1, which carries no revision protection and is refused with 409 config_format_required in any
environment with native programs, seasons, catalogues, currency capabilities or v2 rules, so
always ask for v2 (--schema v2 in the CLI, ?schema=orishare.config/v2 over HTTP).
# 1. Export from the test environment.
orishare --env $TEST_ENV config pull --schema v2 > config.json
# 2. Preview against the target. The CLI first reads the target's Configuration-Revision and
# previews against it; the output reports that revision.
orishare --json --env $LIVE_ENV config push --file config.json
# 3. Apply exactly what was reviewed: pass the revision the preview reported.
orishare --json --env $LIVE_ENV config push --file config.json --apply --expected-revision $REVIEWED_REVISIONThe apply reuses the reviewed target revision as expected_revision. If the target changed after
the preview, the apply is refused with 409 stale_write and nothing is written: pull or preview
again and review the new diff. Running --apply without --expected-revision reads the target
revision again and previews and applies in one run, so it guards only against changes during that
run, not since your review. The revision in the exported document's headers is the source's and
is provenance only; it is never sent as the target's.
Over HTTP the same flow is GET /v1/config/export?schema=orishare.config/v2 on the target (the
Configuration-Revision response header), then POST /v1/config/apply with
{ "document", "mode": "preview", "expected_revision" } and, after review, the same body with
"mode": "apply". An apply without expected_revision is refused with 409 stale_write. Both
answers carry revision, which is the revision they were checked against, not the new one: read
the new revision from a fresh export. Apply is a merge, not a replace; see
Configuration as code. Rules are published in a production environment only by owners
and admins.
Measure it
To find out what a program is worth, hold it back from a small share of customers and compare.
These calls need a secret key or personal access token with analytics:write (reads need
analytics:read); with a personal access token, add X-Orishare-Environment. Concepts and the
meaning of every field: Experiments and
Analytics.
API=https://api.orishare.com
# 1. Create a holdout as a draft from a template: 10% of customers go without the program, 90%
# receive it. GET /v1/experiment_templates lists the templates (streak_lift, d7_retention,
# purchase_conversion); explicit variants work too (weights in basis points, first = control).
curl -sS -X POST "$API/v1/experiments" \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"key": "streak-holdout",
"name": "Daily streak holdout",
"template": "streak_lift",
"targets": [{ "type": "program", "key": "daily_streak" }],
"goal_event": "order.completed"
}'
# => { "id": "exp_...", "status": "draft", ... }
# 2. Start it. From now on the holdout stops receiving the program. This changes what real
# customers receive, so get the person's agreement first.
curl -sS -X POST "$API/v1/experiments/$EXPERIMENT_ID/start" \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)"
# In a production environment with a key you created by agent signup, the direct routes answer
# confirmation_required unless autonomy is `auto`: propose the same step as a change set
# and apply it under the policy instead (see below).
# 3. Results are rebuilt every hour. To see them now, run the build for this environment.
# If it answers "complete": false, call it again.
curl -sS -X POST "$API/v1/analytics/builds" \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY" \
-H "Idempotency-Key: $(uuidgen)"
# 4. Read the results: per variant, retention on day 1, 7 and 30 and the goal conversion, each
# with a 95% interval, and for the treatment the lift against the holdout with a p value.
curl -sS "$API/v1/experiments/$EXPERIMENT_ID/results" \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY"A customer enters the experiment at their first event after the start, so results need traffic
and time: day 7 retention cannot exist before 8 days have passed. Report the interval of the lift,
not only significant, and mention sample_ratio.mismatch when it is true. End with
POST /v1/experiments/{id}/end and { "winner_variant": "treatment" } to give the program to
everyone, or {} to restore everything as it was. Ending with "holdout" as the winner keeps the
program withheld from everyone until it is paused or deleted.
Through change sets
Every step above also exists as a change set operation: experiment.create (the same body),
experiment.start and experiment.end (key plus an optional winner_variant). This is the way
for an agent key in a production environment, and the review a person sees in the dashboard.
# Propose the start; the plan's effects.notes say, per variant, who stops receiving what.
CS=$(curl -sS -X POST "$API/v1/change_sets" \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "commands": [{ "type": "experiment.start", "key": "streak-holdout" }] }')
# Apply it under the environment's autonomy policy.
curl -sS -X POST "$API/v1/change_set_applications" \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{ \"change_set_id\": \"$(echo "$CS" | jq -r .id)\", \"plan_digest\": \"$(echo "$CS" | jq -r .plan.plan_digest)\" }"Under limits (the default for a project an agent signed up), the create and the start apply at
once (a start that withholds something from some customers is recorded as the advisory
withholds_rewards), but an end answers confirmation_required with irreversible: send the
person the dashboard link and wait. To limit an experiment to one segment's members, add
"audience": { "segment": "vip" } to the create body. POST /v1/change_set_undos undoes a create
(the draft is deleted) or a start (the experiment ends without a winner).