Orishare

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 with operation_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) and tool: the operator tool name, or null with a no_tool_reason when 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 type POST /v1/change_sets accepts, with its kind (document or operation), proposal: true and the required_scopes per runtime class (sandbox for test environments, production for 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.

CredentialLooks likeWho holds itWhere it may liveWhat it may call
Publishable keyopk_...The application, per environmentFrontend 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 sessionocs_... (shown once)One end user, for minutesMemory 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 keyosk_test_... osk_live_...Your backend, per environmentThe 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 tokenopat_...A person or the operator's agentThe 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 id is 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 is 409 event_id_conflict. With a secret key, sending a stored event again answers duplicate with the original event's evt_... id: nothing is evaluated again, and execution_id and effects are filled only if the original was already evaluated (otherwise null and []), so poll GET /v1/events/{evt_id} for its outcome. Customer events (POST /v1/customer/events) never answer duplicate; see Customer events.
  • The operation key. Idempotency-Key identifies one intent for 24 hours. Every creating POST needs one, except a secret-key POST /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 is 422 idempotency_key_reused. A stored response is the same JSON value as the first answer (the order of keys can differ), so a replayed field in its body keeps its first value, usually false: the Idempotent-Replayed: true header is the signal. A body says replayed: true only when the resource itself recognised a repeat sent as a new request (the same match_id, dedupe_key or 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.

retryMeaningWhat to do after a lost answer
readA GET; changes nothingRepeat freely.
atomic_replayThe API stores the first result under the Idempotency-KeyRepeat with the same key and body; you get that result.
keyed_resumeThe API resumes or replays the work for the same keyRepeat with the same key and body.
outcome_unknownNo replay by keyDo 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_id in 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 as external_customer_id or customer: { "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 with validation_failed (a rejected item in a batch, 400 for a single event; POST /v1/customers refuses it as external_id too), 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 answers duplicate.
  • Internal id: the cust_... id Orishare assigns, returned by POST /v1/customers (an upsert by external_id; 201 created, 200 updated) and by GET /v1/customers?external_id=.... To address an event by internal id send customer: { "id": "cust_..." }; an unknown internal id is rejected with resource_not_found. GET /v1/customers/{id}, GET /v1/customers/{id}/state and GET /v1/customers/{id}/balances accept 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 only

Supabase

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), usually pending with empty effects, 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 effects are 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 the evt_... id from the admission result (results[i].id) until status is evaluated (or failed, poison); GET /v1/events/{id}/execution shows the plan and trace. Your own business id is the event's client_event_id; GET /v1/events/{id} does not accept it (404). If you lost the evt_ id, resend the same event (it answers duplicate with the evt_ id) or list GET /v1/events?customer_id=...&name=... and match client_event_id.
  • Customer session: poll the receipt (GET /v1/customer/event_receipts/{id} with your event id, the receipt_url above) with the same two headers. Stop at evaluated: its effects are final. pending_followups is true while the event is pending and, after evaluation, while follow-up work it caused (program transitions, rewards) is still queued; refresh state later when it is true.
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:

statusFields
acceptedindex, id (the evt_... id), customer_id (internal), late
evaluatedas accepted, plus execution_id, effects, trace, queued_work, pending_followups (mode: "sync" only)
duplicateindex, id (the stored evt_... id), execution_id (or null), effects
rejectedindex, 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.

ReadReturns
GET /v1/customer/stateSummary, state_revision, as_of, mechanic availability
GET /v1/customer/balancesCurrency balances and the next expiry
GET /v1/customer/activityCommitted changes, newest first
GET /v1/customer/questsQuest runs and quests the customer can join
GET /v1/customer/streaksStreaks the customer participates in, with freezes
GET /v1/customer/badgesEarned badges and visible progress
GET /v1/customer/levelsLevels and XP
GET /v1/customer/leaderboard_positionsThe customer's positions
GET /v1/customer/referralsReferral progress; codes at GET /v1/customer/referral_codes
GET /v1/customer/reward_offersIssued rewards the customer can redeem
GET /v1/customer/redemptionsRedemptions of issued rewards
GET /v1/customer/catalogue_offersCatalogue offers and prices
GET /v1/customer/catalogue_redemptionsCatalogue redemptions (points spent on offers) and their fulfilment
GET /v1/customer/coupon_entitlementsCoupons 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, scope portal_configurations:write); the production gate runs them directly in limits too. It may instead propose the same changes as change sets (POST /v1/change_sets with portal_configuration.create, save_draft, publish, activate_version, disable or enable), which commit by the environment's autonomy policy. The portal holds the surfaces, layout, brand, locales and allowed_parent_origins; its document is described field by field in the guide.
  • The page loads embed.js and pushes mount with the portal id, the publishable key and one session path: getSession (your backend mints with the secret key and returns only customer_token) or connectionId plus getIdentityToken (the page origin must be in the client configuration's allowed_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 in section_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_version is 2; generated_at is 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_id they were recorded under, and member_profiles lists 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_archive is included when they are part of the events section and unavailable when no archive is configured, in which case the section covers the hot window only.
  • Private reward codes are not included. reward_code_allocations and reward_redemptions carry 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 with POST /v1/rules/{id}/simulate before POST /v1/rules/{id}/publish.
  • Author mechanics and install playbooks as proposals: POST /v1/change_sets and POST /v1/playbook_previews store a change set. Apply it yourself with POST /v1/change_set_applications when the environment's autonomy policy allows (always in a test environment; in a production one in auto, or in limits when the plan is inside the limits). Otherwise, and always in confirm mode, a signed-in owner or admin confirms it in the dashboard. Undo an applied change with POST /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: in confirm every write waits (your game server's events with your key keep running over the REST API); in limits runtime, setup, plain configuration and your own keys, origins and webhooks run, while value configuration (rules going live, programs, currencies, segments) answers 403 confirmation_required with details.reason ai_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 by key with the expected_revision read from GET /v1/portal_configurations/{id}, and read the stored plan back with GET /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 from allowed_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 edits allowed_origins (the dashboard Widget area only reports the missing origins). Tell the person exactly which origins to add, and that they (or a script holding client_configurations:read and client_configurations:write, with their PAT or a secret key) read GET /v1/client_configuration, merge the origins into allowed_origins and write the whole list back with PATCH /v1/client_configuration and If-Match set to the version read, 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's document first. 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.activate and notification_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_endpoints and verify Orishare-Signature as 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:

FieldWhat it holds
diffentries (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
effectsWhat the change reaches: customers_affected, liability, stock, codes, webhooks.event_types and plain-text notes
irreversibilitySteps that cannot be undone by another change, each with the command, target and reason
flagsChanges 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
destructiveOperation proposals: the rows the operation removes or changes (by identity, or a count and digest); recomputed at commit, any difference is stale
dependenciesPortal publish and activation: the programs and catalogues the published document references, as they were at preview; a change to them is stale
approved_boundsEconomic 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_versionsThe versions the plan was built against (the configuration revision, or for a portal { resource: "portal_configuration", key, version })
required_scopesThe scopes the proposer and the confirming person need, for example config:write and portal_configurations:write
plan_digestThe digest of the reviewed plan; the confirmation sends it, so the person confirms exactly what they saw
commands_digestThe digest of the commands
snapshot_atWhen 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" }, the plan.plan_digest it proposed or last read). This is allowed always in a test environment, and in a production one when GET /v1/autonomy_policy says auto, or limits with the plan inside the limits. The answer is the commit with authority: "policy". A refusal is 403 confirmation_required with details.reasons and details.review_url; the change set stays pending for a person, so hand over that link and do not retry the apply. Policy commits acknowledge plan flags by policy: limits does not refuse a trust-weakening plan, it records it as an advisory, read only with GET /v1/change_sets/{id}?include_advisories=true (commit.policy.advisories). limits refuses 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_undos undoes an applied change set the same way. See Autonomy.
  • Who confirms. In confirm mode, and whenever the policy refuses, a signed-in owner or admin confirms in the dashboard by reviewing and pressing confirm, which sends POST /v1/change_set_commits with the dashboard session. That route is for the dashboard only: a PAT or key calling it is refused with 403 confirmation_required; do not retry it. A plan with flags (for example trust_weakening when 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 as dashboard_url is <dashboard>/change-sets/{id}, which finds the environment and opens that page. <dashboard> is the dashboard origin: for the hosted service https://app.orishare.com; for a local or self-run deployment, the operator's own dashboard URL. POST /v1/change_sets returns no dashboard_url, so a REST caller hands over the change set id and the link built from it, for example https://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 until status leaves pending, and stop at expires_at. committed means it was confirmed and applied (committed_at, commit_id); stale (stale_reason says why) and expired mean propose again; cancelled means it was withdrawn. Only committed changed 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 by key, list GET /v1/portal_configurations and match key; see Operator agents).
  • Expiry. A change set expires 24 hours after it is created (expires_at); confirming it later is 409 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 as segment.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 is 409 preview_stale, but an unrelated configuration edit does not stale it and it stays confirmable. Either way the change set becomes stale (stale_reason says why). Expired and stale proposals are fixed the same way: propose the same commands again with a new Idempotency-Key and hand over the new id. The dashboard offers this as "propose again". To withdraw a pending proposal, cancel it with POST /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_REVISION

The 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).

On this page