# rails Full Documentation > Concatenated Markdown bundle for agent retrieval. # Overview Source: ../docs/overview.md URL: https://docs.rails.sh/ Markdown: https://docs.rails.sh/markdown/overview.md # Overview Status: current. Orientation page — see the linked pages for verified detail. ## What rails is rails is agentic-first incentives infrastructure. Every customer signal comes in as an event, rules resolve what happens next, and the resulting points, tiers, and campaigns are issued through one backend. It's the same event backbone whether the person driving it is an operator talking to the rails agent or a developer calling the API directly. ## The Two Ways To Work **Converse with the agent.** Operators build and change programs, rules, and campaigns by talking to the rails agent instead of hand-filling forms. Ask it to design a program, add an earn rule, launch a campaign, or explain why a member's balance looks the way it does — it proposes the change, shows you what it will do, and waits for approval before anything mutates. See the [Agent Guide](./agent-guide.md). **Integrate via API, CLI, or SDK.** Developers connect an existing app to rails with server-side API calls, the `loyaltyrails` CLI, or the React SDK components — sending events, reading balances, and wiring rules-driven rewards into checkout, onboarding, or any other flow. See the [Quick Start](./quick-start.md), [Authentication](./authentication.md), and [CLI](./cli.md). Both paths operate on the same programs, rules, and events — a program the agent creates is the program your integration sends events to, and a rule your API key exercises is the rule an operator can ask the agent to explain or adjust. ## Platform Map | Concept | What it does | Learn more | |---------|---------------|------------| | Programs | The loyalty program shell — name, symbol, issuer, chain, and lifecycle (pause/resume/deprecate) that everything else is scoped to | [Programs API](./api/programs.md) | | Members | Identity resolution from your own external IDs, balances, wallet identifiers, and member lifecycle | [Members API](./api/members.md) | | Events | Ingestion of behavioral signals (`transaction.completed` and others) with idempotency and async processing | [Events API](./api/events.md) | | Rules | The engine that evaluates events against program conditions and resolves earn, tier, and outcome decisions — with simulation before you commit a change | [Rules API](./api/rules.md) | | Campaigns | Audience targeting, scheduling, and budget for time-boxed promotions layered on top of programs | [Campaigns API](./api/campaigns.md) | | Gamification | Experiences, mechanics, sessions, and prize pools — spin wheels, quizzes, and onboarding challenges connected to rewards and campaigns | [Gamification](./gamification.md) | | Stablecoin rewards | Reward assets and settlement as a first-class outcome alongside points — **coming soon**, not available in production today | [Stablecoin Rewards](./stablecoin-rewards.md) | ## Where To Go Next - [Quick Start](./quick-start.md) — the current onboarding path: sign up onto the free Developer tier, create a program, mint an API key, send an event, and read a member's balance, with a CLI alternative for the same steps. - [Authentication](./authentication.md) — every credential surface (integration API keys, operator sessions, end-user JWTs, webhook signatures) and which route family expects which one. - [Agent Guide](./agent-guide.md) — what the rails agent can do on its own versus what it needs approval for, and how that approval flow works. - [CLI](./cli.md) — the full `loyaltyrails` command surface for auth, program setup, rules, events, members, games, and generated integration recipes. --- # Quick Start Source: ../docs/quick-start.md URL: https://docs.rails.sh/quick-start Markdown: https://docs.rails.sh/markdown/quick-start.md # Quick Start Status: current. This walks through today's real onboarding path — sign up, create a program, mint an API key, send an event, and read a member's balance — with every request verified against the current backend. One program, one member, and one event carry through every example below. ## 1. Sign Up Register at [`https://app.loyaltyrails.com/register`](https://app.loyaltyrails.com/register). Registration lands your organization on the free **Developer** tier: - 250 active members a month - 1 loyalty program - Production API keys and CLI - The rails agent, included - Unlimited seats - No card required, no time limit Developer has no trial expiry — it works on every surface in this guide, subject to its active-member cap. When you outgrow the cap or need more than one program, see [rails.sh/pricing](https://rails.sh/pricing) for the paid tiers. ## 2. Create A Program From the dashboard, go to Programs and select **Create your first program** (`https://app.loyaltyrails.com/admin/programs/new`). Give it a name, symbol, and issuer: ```json { "name": "Acme Rewards", "symbol": "ACME", "issuer": "Acme Inc." } ``` The response — visible on the program's detail page — includes the program's ID: ```json { "id": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "name": "Acme Rewards", "symbol": "ACME", "issuer": "Acme Inc.", "chain": "base", "contractAddress": null, "reserveRatio": 8000, "reserveStatus": { "currentReserve": 0, "requiredReserve": 0, "adequate": true } } ``` Copy that program ID — every call below is scoped to it. This is also something you can hand to the rails agent directly ("create a loyalty program called Acme Rewards") instead of using the form; see the [Agent Guide](./agent-guide.md). Full field reference and lifecycle (pause/resume/deprecate) are in the [Programs API](./api/programs.md). ## 3. Mint An API Key Server-side integrations authenticate with an integration API key in the `X-API-Key` header — never `Authorization: Bearer`. Mint one from the dashboard (Settings → API Keys), or call the same endpoint directly with an authenticated session: ```http POST /admin/v1/api-keys Cookie: Content-Type: application/json ``` ```json { "name": "storefront-prod", "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "scopes": ["read", "write"], "environment": "live" } ``` The response includes the raw key exactly once — store it server-side (for example as `LOYALTYRAILS_API_KEY`), never in browser code or committed config: ```json { "apiKey": { "id": "...", "keyPrefix": "lr_live_...", "scopes": ["read", "write"] }, "rawKey": "lr_live_...", "warning": "Store this key securely. It will not be shown again!" } ``` Full auth model, scopes, and every credential surface are in [Authentication](./authentication.md). ## 4. Send Your First Event Send a `transaction.completed` event for a member, identified by your own customer ID (`externalId` + `externalIdType`) — rails creates the member on first sight: ```bash curl -X POST "https://api.loyaltyrails.com/internal/v1/programs/0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd/events" \ -H "Content-Type: application/json" \ -H "X-API-Key: lr_live_..." \ -d '{ "eventType": "transaction.completed", "idempotencyKey": "checkout_order_100045", "externalId": "customer_123", "externalIdType": "customer_id", "occurredAt": "2026-05-02T15:04:05Z", "payload": { "amount": 2500, "transactionAmountMinor": 2500, "currency": "USD", "channel": "online", "store_id": "web" } }' ``` A `202` means the event was accepted for asynchronous processing — not that points have been awarded yet: ```json { "eventId": "0de0ff5b-4f44-4e3f-9a27-dbeae4cb9e52", "status": "pending", "statusUrl": "/internal/v1/programs/0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd/events/0de0ff5b-4f44-4e3f-9a27-dbeae4cb9e52/status" } ``` Use a stable, merchant-owned `idempotencyKey` — an order ID, checkout ID, or webhook delivery ID — so retries don't double-count. See [Events API](./api/events.md) for batch ingestion, status polling, and the full idempotency contract. ## 5. Read The Member's Balance The worker processes the event asynchronously, resolving or creating the member and evaluating your program's rules. Look up the balance by the same external ID you sent the event with: ```bash curl -X POST "https://api.loyaltyrails.com/internal/v1/programs/0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd/members/balance/lookup" \ -H "Content-Type: application/json" \ -H "X-API-Key: lr_live_..." \ -d '{ "externalId": "customer_123", "externalIdType": "customer_id" }' ``` ```json { "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "balance": 1250, "settledBalance": 1000, "pendingSettlement": 250, "totalEarned": 1750, "totalRedeemed": 500 } ``` If the member has no rules-driven earn yet, this returns zero balances once the member mapping exists — poll the event's `statusUrl` from step 4 to confirm processing finished before assuming a rule didn't fire. Full member shapes, lifecycle, and the browser-safe host-route pattern (never call this from a browser directly) are in [Members API](./api/members.md). For your own signed-in end users to read their own balance client-side, mint a short-lived member JWT server-side and use `/public/v1/members/me/*` — see [Authentication § End-User Access](./authentication.md#end-user-access). ## 6. Or Do It All With The CLI The `loyaltyrails` CLI wraps steps 2–5 for both operator setup and integration smoke tests. Authenticate with an operator CLI token (minted from an active dashboard session via `POST /admin/v1/auth/cli-tokens`) to create the program: ```bash loyaltyrails auth login --operator-token lr_cli_... --api-url https://api.loyaltyrails.com loyaltyrails program create \ --name "Acme Rewards" \ --symbol ACME \ --issuer "Acme Inc." \ --reserve-ratio 8000 ``` Then switch to an integration API-key profile — the one from step 3 — to smoke-test ingestion and balance reads: ```bash loyaltyrails auth login --api-key lr_live_... --api-url https://api.loyaltyrails.com loyaltyrails init --program 0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd --external-id-type customer_id --identity-strategy custom loyaltyrails events send \ --type transaction.completed \ --external-id customer_123 \ --external-id-type customer_id \ --idempotency-key checkout_order_100045 \ --payload '{"amount":2500,"currency":"USD"}' loyaltyrails members balance \ --external-id customer_123 \ --external-id-type customer_id loyaltyrails doctor ``` `loyaltyrails init` writes non-secret project metadata (`.loyaltyrails/config.json`, `.loyaltyrails/manifest.json`) and unlocks the `add` recipes (`award-route`, `balance-widget`, and others) that generate server-side integration code for an existing app. See the [CLI Guide](./cli.md) for the full command set, including rule, experience, and campaign management, and the Shopify Hydrogen integration recipe. ## Next Steps - [Authentication](./authentication.md) — every credential surface, header, and the tier/trial gating that applies to mutating requests. - [Events API](./api/events.md) and [Members API](./api/members.md) — full request/response shapes, idempotency, and error handling. - [Agent Guide](./agent-guide.md) — driving program, rule, and campaign setup conversationally instead of by hand. - [CLI Guide](./cli.md) — the full `loyaltyrails` command surface, including generated integration recipes for existing apps. --- # Authentication Source: ../docs/authentication.md URL: https://docs.rails.sh/authentication Markdown: https://docs.rails.sh/markdown/authentication.md # Authentication Source-derived status: verified against `backend/src/api/mod.rs` and the `api_key`/`agent_principal`/`admin_permissions`/`trial_gate` middleware — every route, header, and mount below reflects the current backend. rails has separate authentication surfaces for integrations, operators, end users, and webhooks. Pick the credential that matches the route family; do not reuse one credential type across surfaces. ## Agent Summary Use this when asking an agent to add or review rails auth code: ```text Use program-scoped, non-admin `X-API-Key` credentials only on server-side `/internal/v1/*` integration routes — never `Authorization: Bearer` there. Use `/admin/v1/*` only with a browser session cookie or an `Authorization: Bearer lr_cli_...` operator CLI token. Use `/public/v1/members/me/*` with a short-lived member JWT (`Authorization: Bearer`) minted server-side via `/api/v1/member-tokens/issue`. Verify webhook signatures before reading vendor or Stripe payloads. Never put raw API keys, operator tokens, or member JWTs in browser code, generated client bundles, or committed config. ``` ## Surface Map | Surface | Header / mechanism | Primary caller | |---------|---------------------|----------------| | `/internal/v1/*` | `X-API-Key: lr_live_...` / `lr_test_...` | Server-to-server integrations (ingest, mint, award, members) | | `/admin/v1/*` | Browser session cookie (`lr_session`), or `Authorization: Bearer lr_cli_...` operator CLI token | Admin dashboard and operator CLI | | `/public/v1/members/me/*` | `Authorization: Bearer ` | Browser SDK — the current end-user surface | | `/alp/v1/*` | `Authorization: Bearer ` | Legacy end-user/wallet surface (deprecated — see below) | | `/alp/v1/integrations/:id/webhook` | Per-connection webhook secret, verified in the handler | Vendor webhook delivery (e.g. Shopify) | | `/webhooks/stripe` | `Stripe-Signature` header, verified in the handler | Stripe billing events | | `/internal/v1/admin/*` | `X-API-Key` with `admin` scope | Deprecated machine-to-machine mirror of `/admin/v1/*` | `/internal/v1/admin/*` and `/admin/v1/*` are two mounts of the same handler set with different auth regimes — see [Deprecated: `/internal/v1/admin/*`](#deprecated-internalv1admin) below. A request that presents both a session cookie and `X-API-Key` is rejected outright on either mount. ## Integration API Keys Server-side integrations call `/internal/v1/*` with a program-scoped, non-admin API key in `X-API-Key`. This is never a Bearer token: ```bash curl https://api.loyaltyrails.com/internal/v1/auth/introspect \ -H "X-API-Key: lr_live_..." ``` Introspection returns the key's scopes, reach (`program_scoped` or `all_program`), and the programs it can access — useful for confirming a key before wiring it into a new integration. Keys are minted from the dashboard (Settings → API Keys) or by an authenticated operator calling `POST /admin/v1/api-keys`: ```json { "name": "storefront-prod", "programId": "b6c1...", "scopes": ["read", "write"], "environment": "live" } ``` The response includes the raw key exactly once — store it in a server-side environment variable such as `LOYALTYRAILS_API_KEY`. Project metadata files such as `.loyaltyrails/config.json` and `.loyaltyrails/manifest.json` should contain only non-secret metadata. Server-to-server callers also use `X-API-Key` to mint short-lived member JWTs (`POST /api/v1/member-tokens/issue`) — see [End-User Access](#end-user-access) below. ## Operator Access Admin/control-plane routes live under `/admin/v1/*` and accept either: - A browser session cookie (`lr_session`), set by the standard login flow under `/auth/login`, `/auth/register`, and MFA verification (`/auth/mfa/verify`); or - An `Authorization: Bearer lr_cli_...` operator CLI token, minted from an active session via `POST /admin/v1/auth/cli-tokens` for CLI-driven setup and automation. Both credential types carry the same organization-scoped permission set — `/admin/v1/*` routes enforce a permission check (e.g. `programs:write`, `billing:manage`) independent of which credential presented it. Account-security routes — session listing/revocation, TOTP enrollment, backup codes, passkey registration, password/email change, and step-up re-authentication, all under `/admin/v1/auth/*` and `/auth/*` — accept only the session cookie. An operator CLI token cannot manage MFA, passkeys, or another session; keep those in the browser. Do not write operator CLI tokens into generated host-app env files — they are for setup and management commands (programs, API keys, rules, experiences, campaigns), not for storefront traffic. ## End-User Access `/public/v1/members/me/*` is the current browser-facing member surface — balance, tier, active experiences, redemptions, and consent/receipt reads for the signed-in member. It accepts only a short-lived member JWT in `Authorization: Bearer`; it never accepts `X-API-Key` or a session cookie. Your server mints that JWT on the member's behalf, using its own API key: ```bash curl https://api.loyaltyrails.com/api/v1/member-tokens/issue \ -H "X-API-Key: lr_live_..." \ -H "Content-Type: application/json" \ -d '{"externalId": "member-42", "externalIdType": "your_user_id"}' ``` The response is `{ "token": "...", "exp": ..., "jti": "..." }`. Tokens are short-lived by design (minutes, not hours); refresh with `POST /api/v1/member-tokens/refresh` before expiry rather than minting a new token on every page load. Never let a member's browser see your API key — only the mint/refresh calls use it, and those run server-side. `/alp/v1/*` (`/alp/v1/programs`, `/alp/v1/programs/:id`, `/alp/v1/balance`, `/alp/v1/redeem`) is an older end-user surface authenticated by a separate wallet-context JWT. It predates the member-token model above and uses a different signing secret. New integrations should use `/public/v1/members/me/*`; treat `/alp/v1/*` as legacy. ## Agent Auth The rails conversational agent acts on an operator's behalf: the agent service authenticates to the backend with a shared HMAC secret (`X-Agent-Shared-Secret`) plus acting-user headers that identify the human whose session the agent is proxying. This path exists only between the agent service and the backend — it is not a credential type available to integrators, and there is no external how-to for it. ## Tier & Trial Gating Every mutating request against `/admin/v1/*`, `/internal/v1/*`, and `/internal/v1/admin/*` is checked against the calling organization's subscription tier. An org on an expired trial (or in a billing-lapsed read-only state) gets mutations rejected with `402 Payment Required`; reads still work. The free **Developer** tier has no such expiry — it works for every surface documented on this page, subject to its active-member cap. See [rails.sh/pricing](https://rails.sh/pricing) for tier limits and upgrade paths. ## Webhooks Both webhook receivers verify a signature in the handler before trusting the request body: - `/alp/v1/integrations/:id/webhook` checks the per-connection secret configured for that vendor integration (e.g. a Shopify webhook secret). An unconfigured connection fails closed rather than accepting an unsigned payload. - `/webhooks/stripe` checks the `Stripe-Signature` header against your configured Stripe webhook signing secret. Keep webhook secrets server-side (for example `SHOPIFY_WEBHOOK_SECRET` in a generated integration). Fail closed on missing, invalid, stale, or mismatched signatures — do not accept unsigned webhooks outside a local-only fixture explicitly marked as such. ## Deprecated: `/internal/v1/admin/*` `/internal/v1/admin/*` mirrors every route under `/admin/v1/*` but is authenticated with `X-API-Key` (requiring explicit `admin` scope) instead of a session or operator token. It exists only for machine-to-machine callers migrating off the API-key admin model and is deprecated — do not build new integrations against it. New control-plane work should use `/admin/v1/*` with a browser session or an operator CLI token, or a program-scoped API key against `/internal/v1/*` for ordinary integration traffic. ## Security Do And Do Not Do: - Use the narrowest credential for the route family. - Keep `LOYALTYRAILS_API_KEY`, operator CLI tokens, and webhook secrets server-side. - Use program-scoped, non-admin keys for generated app integrations. - Rotate or revoke credentials that appear in logs, screenshots, commits, or browser bundles. - Refresh member JWTs before expiry instead of widening their TTL. Do not: - Send `X-API-Key` from browser-executed code. - Use an operator CLI token for generated storefront or app integration routes. - Use an integration key for `/admin/v1/*` control-plane operations. - Build new integrations against `/internal/v1/admin/*`. - Trust client-submitted member IDs for awards, balance lookup, or game sessions. - Log raw credentials, webhook signatures, JWTs, or session cookies. --- # Programs API Source: ../docs/api/programs.md URL: https://docs.rails.sh/programs-api Markdown: https://docs.rails.sh/markdown/programs-api.md # Programs API Source-derived status: program creation, listing, detail, and lifecycle (pause / resume / deprecate) are implemented, dual-mounted on `/admin/v1` (session/operator, primary) and the deprecated `/internal/v1/admin` API-key mirror. A separate, narrower `/internal/v1/programs` mount exists for machine-to-machine callers (create + reserve deposit only). Program reward-mode settings and tier ladders are also implemented, admin-only. This page covers the merchant-facing program management surface: creating a program, reading its status, pausing/resuming/deprecating it, adjusting its reserve ratio, and its reward-mode and tier-ladder configuration. For event ingestion and member management see [Events API](./events.md) and [Members API](./members.md). ## Route Summary | Method | Path | Status | Auth | | --- | --- | --- | --- | | `POST` | `/admin/v1/programs` | Implemented | Browser session or operator token with `programs:create`; deprecated API-key mirror also works | | `GET` | `/admin/v1/programs` | Implemented | Browser session or operator token with `programs:read`; deprecated API-key mirror also works | | `GET` | `/admin/v1/programs/{programId}/status` | Implemented | Browser session or operator token with `programs:read`; deprecated API-key mirror also works | | `POST` | `/admin/v1/programs/{programId}/pause` | Implemented | Browser session or operator token with `programs:update`; deprecated API-key mirror also works | | `POST` | `/admin/v1/programs/{programId}/resume` | Implemented | Browser session or operator token with `programs:update`; deprecated API-key mirror also works | | `PATCH` | `/admin/v1/programs/{programId}/reserve-ratio` | Implemented | Browser session or operator token with `programs:update`; deprecated API-key mirror also works | | `POST` | `/admin/v1/programs/{programId}/deprecate` | Implemented | Browser session or operator token with `programs:archive`; deprecated API-key mirror also works | | `POST` | `/admin/v1/programs/{programId}/contract` | Implemented, dev/test only | Browser session or operator token with `programs:update`; deprecated API-key mirror also works | | `GET` | `/admin/v1/programs/{programId}/reward-settings` | Implemented | Browser session or operator token with `programs:read`; deprecated API-key mirror also works | | `PATCH` | `/admin/v1/programs/{programId}/reward-settings` | Implemented | Browser session or operator token with `stablecoin_reward_settings:manage`; **not** available on the deprecated API-key mirror | | `GET` | `/admin/v1/programs/{programId}/tier-ladder` | Implemented | Browser session or operator token with `rules:read`; deprecated API-key mirror also works | | `PUT` | `/admin/v1/programs/{programId}/tier-ladder` | Implemented | Browser session or operator token with `rules:update`; deprecated API-key mirror also works | | `GET` | `/admin/v1/programs/{programId}/tier-ladder/versions` | Implemented | Browser session or operator token with `rules:read`; deprecated API-key mirror also works | | `POST` | `/internal/v1/programs` | Implemented | `X-API-Key` with `admin` scope, or agent-proxied | | `POST` | `/internal/v1/programs/{id}/reserve` | Implemented | `X-API-Key` with `write` or `admin` scope, program-scoped | Every `/admin/v1/programs...` route above is also mounted, deprecated, under `/internal/v1/admin/...` (e.g. `/internal/v1/admin/programs`) for machine-to-machine callers still on the legacy admin API-key path, using an `admin`-scoped `X-API-Key` — with one exception: `PATCH .../reward-settings` explicitly rejects any API-key caller regardless of scope, on both mounts, so it can only be changed by a session or operator-token caller. There is also a legacy, JWT-authed pair of routes at `GET /alp/v1/programs` and `GET /alp/v1/programs/{id}` (see [Legacy JWT Program Reads](#legacy-jwt-program-reads) below) — it predates the admin program-management surface above and returns a different response shape. Prefer `/admin/v1/programs` for new integrations. ## Auth Model Admin program routes use the admin auth stack: browser session cookies or operator CLI tokens on `/admin/v1`, and a deprecated admin API-key mount under `/internal/v1/admin`. Route permissions are feature permissions (`programs:read`, `programs:create`, `programs:update`, `programs:archive`, `rules:read`, `rules:update`, `stablecoin_reward_settings:manage`), not API-key scopes — see the Route Summary table above for which permission each route requires. For the `/internal/v1/programs` M2M mount, a direct (non-agent-proxied) `X-API-Key` must carry `admin` scope to create a program; the reserve-deposit route requires `write` or `admin` scope scoped to that program. An agent-proxied API key (one carrying an acting user/org) creates the program in its acting organization instead of the legacy platform-default organization — see [Program Ownership](#program-ownership) below. Program routes are tenant-scoped: a session or operator token can only see and mutate programs owned by its organization. Platform-scope (cross-org) access requires an explicit `X-Org-Scope-Reason` header and is restricted to the platform-admin account; it is audit-logged on every use. ## Program Ownership ```http POST /admin/v1/programs Cookie: Content-Type: application/json ``` ```json { "name": "Acme Rewards", "symbol": "ACME", "issuer": "Acme Inc.", "reserveRatio": 8000, "chainId": 8453 } ``` `reserveRatio` is basis points (0–10000; defaults to `8000` = 80% if omitted) and `chainId` defaults to `8453` (Base mainnet) if omitted. `contractAddress` is optional — set later via the contract-address route, below. `name`, `symbol`, and `issuer` are required. Response (`ProgramDto`): ```json { "id": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "name": "Acme Rewards", "symbol": "ACME", "issuer": "Acme Inc.", "chain": "base", "contractAddress": null, "reserveRatio": 8000, "reserveStatus": { "currentReserve": 0, "requiredReserve": 0, "adequate": true } } ``` Ownership rule (the create endpoint): a session-authed or operator-token caller owns the new program in their own organization. A direct `X-API-Key` caller (not agent-proxied) must carry `admin` scope and the program is assigned to the platform-default organization — a legacy behavior kept for backward compatibility, planned to be replaced by DB-backed M2M keys in a later phase. An agent-proxied `X-API-Key` (one carrying an acting user and organization) owns the program in that acting organization instead. Two additional guards apply before creation: a rate limit of 5 program creations per hour per organization, and a subscription-tier quota (`max_programs`) checked against the organization's current program count. Both return `429`/quota errors — see [Errors](#errors). ## List And Get Program ```http GET /admin/v1/programs Cookie: ``` Response (`ListProgramsResponse`) — scoped to the caller's organization (or every organization, for an audited platform-scope call): ```json { "programs": [ { "id": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "name": "Acme Rewards", "symbol": "ACME", "issuer": "Acme Inc.", "status": "active", "chain": "Base", "totalSupply": 125000, "totalReserve": 100000, "createdAt": "2026-05-01T12:00:00Z" } ], "total": 1 } ``` ```http GET /admin/v1/programs/{programId}/status Cookie: ``` Response (`ProgramStatusResponse`) — richer than the list entry above, including chain, supply, reserve health, contract deployment, and timestamps: ```json { "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "name": "Acme Rewards", "symbol": "ACME", "issuer": "Acme Inc.", "status": "active", "isOperational": true, "chain": { "chainId": 8453, "chainName": "Base" }, "supply": { "totalSupply": 125000, "mintableAmount": 0 }, "reserve": { "currentReserve": 100000, "requiredReserve": 100000, "excessReserve": 0, "reserveRatioBps": 8000, "reserveRatioPct": "80%", "isAdequate": true, "healthPct": "100.00%" }, "contract": { "deployed": false }, "timestamps": { "createdAt": "2026-05-01T12:00:00Z", "updatedAt": "2026-05-01T12:00:00Z" } } ``` `contract.address` is omitted entirely when no contract is deployed yet; it only appears once a contract address has been set. `isOperational` is `true` only when the program is `active` **and** has a deployed contract address. `mintableAmount` reflects how much more can be minted before reserves become inadequate at the current ratio. ## Program Lifecycle (Pause / Resume / Deprecate) ```http POST /admin/v1/programs/{programId}/pause Cookie: Content-Type: application/json ``` ```json { "reason": "Investigating a reserve discrepancy" } ``` ```http POST /admin/v1/programs/{programId}/resume Cookie: ``` ```http POST /admin/v1/programs/{programId}/deprecate Cookie: ``` Response (all three; `SimpleStatusResponse`): ```json { "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "status": "paused", "message": "Program paused: Investigating a reserve discrepancy" } ``` Status is one of `active`, `paused`, or `deprecated` (`ProgramStatus`). Confirmed transitions, from the API's behavior: - **Pause** (`active` → `paused`): rejected with `400` if the program is already `paused` or already `deprecated`. Requires a `reason` string in the request body (no length validation is applied server-side, unlike the member-suspend `reason` field). - **Resume** (`paused` → `active`): rejected with `400` if the program is already `active`, already `deprecated`, or does not have adequate reserves for its configured ratio. No request body. - **Deprecate** (`active` or `paused` → `deprecated`): rejected with `400` if the program is already `deprecated`. No request body, and **this transition cannot be undone** — there is no un-deprecate route. Unlike pause/resume, deprecate does not append a domain event to the event store; it only updates the read-model status directly. ## Reserve Ratio And Contract Address ```http PATCH /admin/v1/programs/{programId}/reserve-ratio Cookie: Content-Type: application/json ``` ```json { "newRatioBps": 9000, "adjustedBy": "ops@acme.example" } ``` `newRatioBps` must be `0`–`10000`. Increasing the ratio is rejected with `400` if current reserves would be inadequate under the new, higher requirement. Response is the same `ProgramStatusResponse` shown above, reflecting the new ratio. ```http POST /admin/v1/programs/{programId}/contract Cookie: Content-Type: application/json ``` ```json { "contractAddress": "0x1234567890123456789012345678901234567890" } ``` Sets the on-chain contract address for a program that doesn't have one yet (rejected with `400` if already set, or if the address isn't `0x`-prefixed 40 hex characters). `txHash` is optional; a placeholder hash is generated if omitted. This is a development/testing convenience for exercising the award/redeem flow without an actual contract deployment — it does not deploy anything. Response is the updated `ProgramStatusResponse`. ## Reserve Deposit (M2M) ```http POST /internal/v1/programs/{id}/reserve X-API-Key: lr_test_... Content-Type: application/json ``` ```json { "amount": 50000, "depositor": "treasury-wallet" } ``` Response is the `ProgramDto` shape shown in [Program Ownership](#program-ownership) above, with `reserveStatus` reflecting the new total. Requires the API key to hold `write` or `admin` scope for the specific program. ## Reward Settings ```http GET /admin/v1/programs/{programId}/reward-settings Cookie: ``` ```http PATCH /admin/v1/programs/{programId}/reward-settings Cookie: Content-Type: application/json ``` ```json { "rewardMode": "points_only" } ``` Response (`ProgramRewardSettingsResponse`) when nothing has been configured yet (`isPersisted: false`, defaults apply): ```json { "organizationId": "b1f6...", "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "rewardMode": "points_only", "defaultRewardAssetCode": null, "stablecoinDisplayLabel": null, "settlementAvailabilityPolicy": "not_available", "status": "active", "metadata": {}, "defaultRewardAsset": null, "availableStablecoinAssets": [], "isPersisted": false, "createdAt": null, "updatedAt": null } ``` `rewardMode` is `points_only` (default) or `stablecoin_primary`. Switching to `stablecoin_primary` requires `defaultRewardAssetCode` to reference an active, program-scoped stablecoin provider asset mapping, and `settlementAvailabilityPolicy` must be `setup_required` or `testnet_only` (never `not_available`) — the API validates both server-side and returns `400` otherwise. Switching back to `points_only` clears all stablecoin-related fields. This endpoint only manages the reward-mode *setting*; it does not itself move value. See [Stablecoin Rewards](../stablecoin-rewards.md) for the reward-asset and settlement model this setting selects between. ## Tier Ladders ```http GET /admin/v1/programs/{programId}/tier-ladder Cookie: ``` ```http PUT /admin/v1/programs/{programId}/tier-ladder Cookie: Content-Type: application/json ``` ```json { "rungs": [ { "slug": "bronze", "minBalance": 0, "multiplier": 1.0 }, { "slug": "silver", "minBalance": 500, "multiplier": 1.25 }, { "slug": "gold", "minBalance": 2000, "multiplier": 1.5 } ], "expirationDaysInactive": 180 } ``` `rungs` must be strictly ascending by `minBalance` (rejected with `400` otherwise). `expirationDaysInactive` is optional (`1`–`3650`); when set it describes an inactivity-based demotion policy that is **persisted but not yet enforced** — no background job currently acts on it. `PUT` appends a new version and marks it active; `GET .../tier-ladder/versions` lists the full history. An agent-proxied `PUT` (a request carrying `x-acting-user-id`) additionally requires a valid `X-Agent-Plan-Approval` envelope covering the `tier_ladder.upsert` intent. Response (`TierLadderResponse`): ```json { "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "organizationId": "b1f6...", "version": 1, "status": "active", "rungs": [ { "slug": "bronze", "minBalance": 0, "multiplier": 1.0 }, { "slug": "silver", "minBalance": 500, "multiplier": 1.25 }, { "slug": "gold", "minBalance": 2000, "multiplier": 1.5 } ], "expirationDaysInactive": 180, "createdBy": "operator@acme.example", "createdAt": "2026-05-01T12:00:00Z", "updatedAt": "2026-05-01T12:00:00Z" } ``` `GET .../tier-ladder` returns `null` (not `404`) when no ladder has been configured yet. ## Legacy JWT Program Reads `GET /alp/v1/programs` and `GET /alp/v1/programs/{id}` are a separate, legacy pair of routes authenticated with a generic wallet-scoped JWT (`Authorization: Bearer`, decoded with the platform's `jwt_secret` — the same legacy claims shape used by the out-of-scope wallet-balance route documented in [Members API](./members.md#out-of-scope-here)). They return the `ProgramDto` shape shown in [Program Ownership](#program-ownership) above (5-minute Redis-cached), with an optional `?issuer=` filter on the list route. They predate the admin program-management surface and are not tenant-scoped the way `/admin/v1/programs` is. New integrations should use `/admin/v1/programs` instead. ## Errors | HTTP | Notes | | --- | --- | | `400` | Malformed JSON, validation failure (bad reserve ratio, invalid contract address, ordering error on tier rungs, invalid reward-mode combination), or a lifecycle no-op guard (pause-when-paused, resume-when-active, deprecate-when-deprecated) | | `401` | Missing or invalid API key/session/operator token | | `403` | Valid credential lacks required permission/scope, tenant-scope violation, or `PATCH .../reward-settings` attempted with an `X-API-Key` | | `404` | Program not found, or program outside credential/tenant reach | | `429` | Program-creation rate limit (5/hour/organization) exceeded | | — | Subscription-tier quota (`max_programs`) exceeded returns a dedicated `{quota, limit, upgradePath}` body rather than the shared error shape | | `500` | Database or event-store failure | ## Source References | Area | Source | | --- | --- | | Admin program route mounts (dual-mounted at `/admin/v1` and `/internal/v1/admin`) | `backend/src/api/mod.rs` lines 106–154, 296–313 | | M2M program route mounts | `backend/src/api/mod.rs` lines 787–791 | | Legacy JWT program route mounts | `backend/src/api/mod.rs` lines 764–769 | | Route permission mapping | `backend/src/api/middleware/admin_permissions.rs` lines 120–154 | | Create program (both mounts) and reserve deposit (M2M) | `backend/src/api/programs.rs` `create_program`, `program_creation_organization`, `deposit_reserve` | | `ProgramDto` / `ReserveStatusDto` (legacy JWT + M2M shape) | `backend/src/api/programs.rs` lines 27–53 | | Admin list/status/lifecycle handlers | `backend/src/api/admin/programs.rs` `list_programs`, `get_program_status`, `pause_program`, `resume_program`, `adjust_reserve_ratio`, `deprecate_program`, `set_contract_address` | | `ProgramSummaryDto` / `ProgramStatusResponse` / `SimpleStatusResponse` | `backend/src/api/admin/programs.rs` lines 26–178 | | Domain lifecycle methods (`create`, `pause`, `resume`, `adjust_reserve_ratio`, `deprecate`) | `backend/src/domain/program.rs` | | `ProgramStatus` enum | `backend/src/domain/program.rs` `ProgramStatus` | | `ReserveRatio` validation | `backend/src/domain/types.rs` `ReserveRatio` | | Reward settings handlers and validation | `backend/src/api/admin/program_reward_settings.rs` | | Reward settings API-key rejection | `backend/src/api/admin/program_reward_settings.rs` `reject_api_key_reward_settings` | | Tier ladder handlers and validation | `backend/src/api/admin/tier_ladders.rs` | | `TierRung` / `TierLadder` | `backend/src/services/rule_simulator.rs` lines 662–695 | | Tenant scoping (`OrgScope`, `enforce_program_scope`) | `backend/src/api/admin/org_scope.rs` | | Legacy wallet JWT claims | `backend/src/api/middleware/jwt.rs` `Claims`, `jwt_auth` | | Program-creation rate limit and quota | `backend/src/api/programs.rs` `create_program`; `backend/src/api/middleware/quota.rs` `QuotaKind::Programs` | | Stablecoin reward model (deep dive) | [Stablecoin Rewards](../stablecoin-rewards.md) | --- # API Reference Source: ../docs/api-reference.md URL: https://docs.rails.sh/api-reference Markdown: https://docs.rails.sh/markdown/api-reference.md # API Reference Status: route inventory rebuilt against `backend/src/api/mod.rs` end-to-end (2026-08-25). This is an INDEX page — method, path, auth, and a one-line purpose, with links out to per-area pages where one exists. Deep prose (request/response schemas, error tables, CLI usage) belongs on those pages, not here. ## Source Of Truth Current route inventory comes from: - `backend/src/api/mod.rs` - `api-contracts/loyaltyrails-cli.v1.openapi.json` - Handler DTOs under `backend/src/api/**`, `backend/src/games/api/**`, and `backend/src/intelligence/api/**` The CLI-supported stable API surface is pinned by `api-contracts/loyaltyrails-cli.v1.openapi.json`. Regenerate derived CLI and backend bindings after changing that contract: ```bash pnpm contract:generate pnpm contract:check ``` ## API Deep Dives | Guide | Covers | |-------|--------| | [Authentication](./authentication.md) | Full auth-surface map, credential types, header names, tier/trial gating, webhook signature verification | | [Events API](./api/events.md) | Event ingestion, idempotency, async processing, `transaction.completed`, and CLI smoke tests | | [Members API](./api/members.md) | Member identity, external ID lookup, balances, wallet identifiers, and browser-safe host routes | | [Rules API](./api/rules.md) | Rule types, conditions, lifecycle, simulation, builder metadata, and operator-auth surfaces | | [Games API](./api/games.md) | Runtime game APIs, setup APIs, sessions, generated session tokens, experiences, and campaigns | | [Programs API](./api/programs.md) | Program creation, lifecycle (pause/resume/deprecate), reserve ratio, reward-mode settings, and tier ladders | | [Campaigns API](./api/campaigns.md) | Campaign CRUD, scheduling/activation lifecycle, experience attachment, personalization rules, and audience targeting | | [Stablecoin Rewards](./stablecoin-rewards.md) | Reward assets, issuance/settlement, wallets and custody for the stablecoin admin surface below | | [Webhook Outcomes](./integrations/webhook-outcomes.md) | Vendor webhook delivery, retries, and dead-letter handling | ## Authentication Surfaces rails has multiple API surfaces with different credential types — see [Authentication](./authentication.md) for the full model. Summary: | Surface | Auth | Primary caller | |---------|------|----------------| | `/health`, `/ready` | None | Load balancers, local health checks | | `/auth/*` | Public or browser session depending on route | Admin frontend | | `/admin/v1/*` | Browser session cookie (`lr_session`) or `Authorization: Bearer lr_cli_...` operator CLI token | Admin frontend and operator CLI | | `/internal/v1/admin/*` | `X-API-Key` with `admin` scope — deprecated mirror of `/admin/v1/*` | Legacy M2M admin callers | | `/internal/v1/*` | Integration API key in `X-API-Key` | Server-to-server integrations | | `/public/v1/members/me/*` | Short-lived member JWT (`Authorization: Bearer`), minted via `/api/v1/member-tokens/issue` | Browser SDK — the current end-user surface | | `/alp/v1/*` | Wallet-context JWT (`Authorization: Bearer`) | Legacy end-user surface — do not build new integrations against it | | `/alp/v1/integrations/:id/webhook` | Per-connection webhook secret, verified in the handler | Vendor webhooks | | `/webhooks/stripe` | `Stripe-Signature` header, verified in the handler | Stripe billing events | | `/commerce/v1/*` | Commerce credential (`CommercePrincipal`) — never api-key/session/agent | ADR-014 merchant commerce integrations | Never send `X-API-Key`, operator CLI tokens, or member JWTs from browser code. Every mutating request on `/admin/v1/*`, `/internal/v1/*`, and `/internal/v1/admin/*` is subject to tier/trial gating (see [Authentication § Tier & Trial Gating](./authentication.md#tier--trial-gating)). ## Health | Method | Path | Auth | Purpose | |--------|------|------|---------| | `GET` | `/health` | None | Liveness | | `GET` | `/ready` | None | Readiness | ## Auth And Sessions Public (unauthenticated) auth routes: | Method | Path | Purpose | |--------|------|---------| | `POST` | `/auth/login` | Login | | `POST` | `/auth/register` | Register | | `POST` | `/auth/verify-email` | Verify email | | `POST` | `/auth/resend-verification` | Resend verification email (anti-enumeration, generic 200) | | `POST` | `/auth/forgot-password` | Request password reset | | `POST` | `/auth/reset-password` | Reset password | | `POST` | `/auth/mfa/verify` | Complete MFA during login | | `POST` | `/auth/mfa/enroll/totp` | Start pending-session TOTP enrollment | | `POST` | `/auth/mfa/enroll/totp/confirm` | Confirm pending-session TOTP enrollment | | `POST` | `/auth/mfa/enroll/passkey/challenge` | Start pending-session passkey enrollment | | `POST` | `/auth/mfa/enroll/passkey/verify` | Verify pending-session passkey enrollment | | `POST` | `/auth/invites/lookup` | Look up an org team invite by token | | `POST` | `/auth/invites/accept` | Accept an org team invite | Session-protected auth routes (browser session cookie): logout, current user, self-service org creation (`/auth/organizations`), step-up re-authentication, TOTP/passkey/backup-code management, session listing/revocation, and password/email change under `/admin/v1/auth/*` and `/auth/*`. Operator CLI token routes: | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/admin/v1/auth/cli-tokens` | Browser session | Create operator CLI token | | `GET` | `/admin/v1/auth/cli-tokens` | Browser session | List operator CLI tokens | | `DELETE` | `/admin/v1/auth/cli-tokens/:token_id` | Browser session | Revoke operator CLI token | | `GET` | `/admin/v1/auth/cli-token/introspect` | Bearer `lr_cli_...` | Introspect operator token | ### CLI Pairing Device-flow pairing between the CLI and a browser session: | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/api/v1/cli/pair/start` | None (rate-limited per-IP) | Start a pairing request | | `POST` | `/api/v1/cli/pair/poll` | None — requires the `pollToken` secret | Poll a pairing request | | `GET` | `/auth/session/cli-pair` | Browser session | Inspect a pending pairing | | `POST` | `/auth/session/cli-pair/decide` | Browser session | Approve or deny a pending pairing | | `POST` | `/api/v1/cli/exchange` | Bearer machine token (inline) | Exchange a CLI machine token for an agent JWT | ### Organization Team Invites | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/admin/v1/organization/invites` | Session/operator, `team:invite` | Create a team invite | | `POST` | `/auth/invites/lookup` | Public (bearer invite token) | Look up invite metadata | | `POST` | `/auth/invites/accept` | Public (bearer invite token) | Accept invite | ### Share / Handoff Links | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/share/v1/create` | Browser session | Create a share/handoff link | | `GET` | `/share/v1/list` | Browser session | List share links | | `POST` | `/share/v1/revoke/:id` | Browser session | Revoke a share link | | `POST` | `/share/v1/claim` | Bearer machine token (inline) | Claim a share link (CLI) | ### Member Token Revocation | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/admin/v1/members/:member_id/revoke-tokens` | Browser session (org must own the member's program) | Revoke all issued member JWTs for a member | ## Public Member API (Current) `/public/v1/members/me/*` is the current end-user surface — see [Authentication § End-User Access](./authentication.md#end-user-access). Auth is a short-lived member JWT (`Authorization: Bearer`) only; no `X-API-Key`, no session cookie. | Method | Path | Purpose | |--------|------|---------| | `GET` | `/public/v1/members/me/summary` | One-shot balance + tier aggregate | | `GET` | `/public/v1/members/me/balance` | Balance | | `GET` | `/public/v1/members/me/tier` | Tier | | `GET` | `/public/v1/members/me/active-experiences` | Active game experiences | | `GET` | `/public/v1/members/me/redemptions` | Redemption history | | `GET` | `/public/v1/members/me/consent-grants` | List commerce consent grants | | `POST` | `/public/v1/members/me/consent-grants/:grant_id/confirm` | Confirm a consent grant | | `POST` | `/public/v1/members/me/consent-grants/:grant_id/revoke` | Revoke a consent grant | | `POST` | `/public/v1/members/me/reservations/:reservation_id/confirm` | Confirm a commerce reservation | | `POST` | `/public/v1/members/me/reservations/:reservation_id/cancel` | Cancel a commerce reservation | | `GET` | `/public/v1/members/me/receipts` | List commerce receipts | | `GET` | `/public/v1/members/me/receipts/:receipt_id` | Get a commerce receipt | ### Member Tokens Server-to-server callers (holding an integration `X-API-Key`) mint and refresh the member JWTs above: | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/api/v1/member-tokens/issue` | `X-API-Key` | Mint a short-lived member JWT | | `POST` | `/api/v1/member-tokens/refresh` | `X-API-Key` | Refresh a member JWT before expiry | ## Legacy End-User ALP API `/alp/v1/*` is an older end-user surface authenticated by a separate wallet-context JWT. It predates the member-token model above and uses a different signing secret. **New integrations should use `/public/v1/members/me/*` instead — treat `/alp/v1/*` as legacy.** | Method | Path | Purpose | |--------|------|---------| | `GET` | `/alp/v1/programs` | List public programs | | `GET` | `/alp/v1/programs/:id` | Get public program | | `GET` | `/alp/v1/balance?wallet=...` | Get wallet balance | | `POST` | `/alp/v1/redeem` | Redeem tokens | ## Internal M2M API These routes use integration API keys through `X-API-Key`. ### Programs And Awards | Method | Path | Purpose | |--------|------|---------| | `POST` | `/internal/v1/programs` | Create program through M2M path | | `POST` | `/internal/v1/programs/:id/reserve` | Deposit reserve | | `POST` | `/internal/v1/mint` | Legacy/internal mint | | `POST` | `/internal/v1/award` | Award points to member by member ID or external ID | | `GET` | `/internal/v1/auth/introspect` | Introspect integration API key | Award request: ```json { "programId": "550e8400-e29b-41d4-a716-446655440000", "externalId": "customer_123", "externalIdType": "customer_id", "amount": 100, "reference": "order_456", "metadata": { "orderId": "456" } } ``` Award response: ```json { "programId": "550e8400-e29b-41d4-a716-446655440000", "memberId": "550e8400-e29b-41d4-a716-446655440001", "walletAddress": "0x1234567890abcdef1234567890abcdef12345678", "baseAmount": 100, "finalAmount": 200, "rulesApplied": [ { "ruleId": "550e8400-e29b-41d4-a716-446655440002", "ruleName": "Double Points Tuesday", "bonusApplied": 100, "multiplierApplied": 2 } ], "reference": "order_456", "status": "awarded", "settled": false, "newMember": false } ``` `reference` is required for idempotency. ### Members | Method | Path | Purpose | |--------|------|---------| | `POST` | `/internal/v1/members` | Create member | | `GET` | `/internal/v1/members/:member_id` | Get member | | `GET` | `/internal/v1/members/:member_id/balance` | Get member balance | | `GET` | `/internal/v1/programs/:program_id/members` | List program members | | `POST` | `/internal/v1/programs/:program_id/members/balance/lookup` | Program-scoped external-ID balance lookup | Balance lookup request: ```json { "externalId": "customer_123", "externalIdType": "customer_id" } ``` ### Event Ingestion See [Events API](./api/events.md) for the full contract. | Method | Path | Purpose | |--------|------|---------| | `POST` | `/internal/v1/programs/:program_id/events` | Ingest one event | | `POST` | `/internal/v1/programs/:program_id/events/batch` | Ingest a batch | | `GET` | `/internal/v1/programs/:program_id/events/:event_id/status` | Read event status | Example: ```json { "eventType": "transaction.completed", "idempotencyKey": "order_123", "externalId": "customer_123", "externalIdType": "customer_id", "occurredAt": "2026-05-01T12:00:00Z", "payload": { "amount": 2500, "currency": "USD" } } ``` ### Game Runtime See [Games API](./api/games.md) for the full contract. | Method | Path | Purpose | |--------|------|---------| | `POST` | `/internal/v1/games/resolve` | Resolve playable experiences for a member | | `POST` | `/internal/v1/games/sessions` | Start an idempotent game session | | `POST` | `/internal/v1/games/sessions/:session_id/actions` | Submit a mechanic action | | `POST` | `/internal/v1/games/sessions/:session_id/complete` | Complete a game session | | `GET` | `/internal/v1/games/programs/:program_id/members/:member_id/reward-state` | Read member game reward state | Resolve request: ```json { "programId": "550e8400-e29b-41d4-a716-446655440000", "memberId": "550e8400-e29b-41d4-a716-446655440001" } ``` Start session request: ```json { "programId": "550e8400-e29b-41d4-a716-446655440000", "memberId": "550e8400-e29b-41d4-a716-446655440001", "experienceId": "550e8400-e29b-41d4-a716-446655440002", "campaignId": "550e8400-e29b-41d4-a716-446655440003", "idempotencyKey": "game_session_123", "surface": "web", "fingerprint": "optional-device-or-session-fingerprint" } ``` ### Value Decisions And Agentic Commerce ADR-014 loyalty/value-decision substrate. Mounted as its own sub-router so the commerce-containment layer (kill switch default-off, agent-principal mutation rejection, unattributed-write fail-closed) wraps exactly this surface. | Method | Path | Purpose | |--------|------|---------| | `POST` | `/internal/v1/programs/:program_id/value/consent-grants` | Create member consent grant | | `GET` | `/internal/v1/value/consent-grants/:grant_id` | Get consent grant | | `POST` | `/internal/v1/value/consent-grants/:grant_id/revoke` | Revoke consent grant | | `POST` | `/internal/v1/value/consent-grants/:grant_id/expire` | Expire consent grant | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/consent-grants` | Create adapter consent grant | | `POST` | `/internal/v1/programs/:program_id/value/agentic-sessions` | Create agentic session | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions` | Create adapter checkout session | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/complete` | Complete checkout session | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/cancel` | Cancel checkout session | | `GET` | `/internal/v1/value/agentic-sessions/:session_id` | Get agentic session | | `POST` | `/internal/v1/value/agentic-sessions/:session_id/complete` | Complete agentic session | | `POST` | `/internal/v1/value/agentic-sessions/:session_id/cancel` | Cancel agentic session | | `POST` | `/internal/v1/value/agentic-sessions/:session_id/expire` | Expire agentic session | | `POST` | `/internal/v1/programs/:program_id/value/quotes` | Create value quote | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/quotes` | Create adapter checkout quote | | `GET` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/quotes/:quote_id` | Get adapter checkout quote | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/quotes/:quote_id/reservations` | Create adapter reservation | | `GET` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/reservations/:reservation_id` | Get adapter reservation | | `POST` | `/internal/v1/programs/:program_id/agentic-commerce/:protocol/checkout-sessions/:session_id/reservations/:reservation_id/release` | Release adapter reservation | | `GET` | `/internal/v1/value/quotes/:quote_id` | Get value quote | | `POST` | `/internal/v1/value/quotes/:quote_id/reservations` | Create value reservation | | `GET` | `/internal/v1/value/reservations/:reservation_id` | Get value reservation | | `POST` | `/internal/v1/value/reservations/:reservation_id/release` | Release value reservation | | `POST` | `/internal/v1/value/reservations/:reservation_id/commit` | Commit value reservation | ### Integration Management | Method | Path | Purpose | |--------|------|---------| | `POST` | `/internal/v1/integrations` | Create integration | | `GET` | `/internal/v1/programs/:id/integrations` | List program integrations | | `GET` | `/internal/v1/integrations/:id` | Get integration status | | `POST` | `/internal/v1/integrations/:id/connect` | Connect integration | | `POST` | `/internal/v1/integrations/:id/disconnect` | Disconnect integration | ### Batch Lineage | Method | Path | Purpose | |--------|------|---------| | `GET` | `/internal/v1/programs/:program_id/members/:member_address/batches` | List point batches | | `GET` | `/internal/v1/batches/:batch_id/lineage` | Trace batch lineage | | `GET` | `/internal/v1/programs/:program_id/liability` | Liability forecast | | `GET` | `/internal/v1/contexts/:context_ref` | Batch context | ## Admin API `/admin/v1/*` is the primary admin/control-plane mount (browser session or operator CLI token). The same handler set — with the exception of the platform-only and operator-only routes noted below — is also mounted under `/internal/v1/admin/*` for the deprecated API-key admin transition path. ### Programs, Rules, Tier Ladders See [Programs API](./api/programs.md) for the full program-lifecycle contract (create, pause/resume/deprecate, reserve ratio, reward settings, tier ladders) and [Rules API](./api/rules.md) for rule conditions, lifecycle, and simulation detail. | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs` | List programs | | `POST` | `/admin/v1/programs` | Create program | | `GET` | `/admin/v1/programs/:program_id/status` | Program status | | `POST` | `/admin/v1/programs/:program_id/pause` | Pause program | | `POST` | `/admin/v1/programs/:program_id/resume` | Resume program | | `PATCH` | `/admin/v1/programs/:program_id/reserve-ratio` | Adjust reserve ratio | | `POST` | `/admin/v1/programs/:program_id/deprecate` | Deprecate program | | `POST` | `/admin/v1/programs/:program_id/contract` | Set contract address | | `GET`/`PATCH` | `/admin/v1/programs/:program_id/reward-settings` | Read/update program reward settings | | `GET` | `/admin/v1/programs/:program_id/rules` | List rules | | `POST` | `/admin/v1/programs/:program_id/rules` | Create rule | | `POST` | `/admin/v1/programs/:program_id/rules/simulate` | Simulate a draft rule config (see [Rules API](./api/rules.md)) | | `GET` | `/admin/v1/rules/:rule_id` | Get rule | | `PATCH` | `/admin/v1/rules/:rule_id` | Update rule | | `DELETE` | `/admin/v1/rules/:rule_id` | Delete rule | | `POST` | `/admin/v1/rules/:rule_id/pause` | Pause rule | | `POST` | `/admin/v1/rules/:rule_id/activate` | Activate rule | | `POST` | `/admin/v1/rules/:rule_id/simulate` | Trace-simulate a stored rule against test data (see [Rules API](./api/rules.md)) | | `GET`/`PUT` | `/admin/v1/programs/:program_id/tier-ladder` | Read/replace the active tier ladder | | `GET` | `/admin/v1/programs/:program_id/tier-ladder/versions` | List tier ladder versions | ### Analytics | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs/:program_id/analytics/summary` | Summary analytics | | `GET` | `/admin/v1/programs/:program_id/analytics/minting-trend` | Minting trend | | `GET` | `/admin/v1/programs/:program_id/analytics/redemption-trend` | Redemption trend | | `GET` | `/admin/v1/programs/:program_id/analytics/reserve-utilization` | Reserve utilization | | `GET` | `/admin/v1/programs/:program_id/analytics/member-segments` | Member segments | | `GET` | `/admin/v1/programs/:program_id/analytics/daily` | Daily analytics | ### Alerts | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs/:program_id/alerts` | List alerts | | `POST` | `/admin/v1/programs/:program_id/alerts` | Create alert | | `GET` | `/admin/v1/alerts/:alert_id` | Get alert | | `PATCH` | `/admin/v1/alerts/:alert_id` | Update alert | | `DELETE` | `/admin/v1/alerts/:alert_id` | Delete alert | | `POST` | `/admin/v1/alerts/:alert_id/pause` | Pause alert | | `POST` | `/admin/v1/alerts/:alert_id/activate` | Activate alert | | `GET` | `/admin/v1/programs/:program_id/alerts/history` | List alert history | | `POST` | `/admin/v1/alerts/history/:history_id/acknowledge` | Acknowledge alert history entry | | `POST` | `/admin/v1/alerts/history/:history_id/resolve` | Resolve alert history entry | ### API Keys | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs/:program_id/api-keys` | List program API keys | | `GET` | `/admin/v1/api-keys/admin` | List all-program/admin keys | | `POST` | `/admin/v1/api-keys` | Create API key | | `GET` | `/admin/v1/api-keys/:key_id` | Get API key | | `POST` | `/admin/v1/api-keys/:key_id/rotate` | Rotate API key | | `POST` | `/admin/v1/api-keys/:key_id/revoke` | Revoke API key | ### Gamification Admin Experience and campaign setup — see [Games API](./api/games.md) for the runtime contract these configure and [Campaigns API](./api/campaigns.md) for campaign CRUD, scheduling, and audience-targeting detail. | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs/:program_id/experiences` | List experiences | | `POST` | `/admin/v1/programs/:program_id/experiences` | Create draft experience | | `GET` | `/admin/v1/experiences/:experience_id` | Get experience | | `PUT` | `/admin/v1/experiences/:experience_id` | Update draft experience | | `GET` | `/admin/v1/experiences/:experience_id/metrics` | Experience metrics | | `POST` | `/admin/v1/experiences/:experience_id/publish` | Publish experience | | `POST` | `/admin/v1/experiences/:experience_id/pause` | Pause experience | | `GET` | `/admin/v1/experiences/:experience_id/prize-pool` | Get prize pool | | `POST` | `/admin/v1/experiences/:experience_id/prize-pool` | Create prize pool | | `GET` | `/admin/v1/experiences/:experience_id/sessions` | List experience sessions | | `POST` | `/admin/v1/experiences/:experience_id/preview` | Cold-path game preview | | `GET` | `/admin/v1/programs/:program_id/leaderboards` | List leaderboards | | `POST` | `/admin/v1/programs/:program_id/leaderboards` | Create leaderboard | | `GET` | `/admin/v1/leaderboards/:leaderboard_id` | Get leaderboard | | `POST` | `/admin/v1/leaderboards/:leaderboard_id/rebuild-ranks` | Rebuild leaderboard ranks | | `GET` | `/admin/v1/leaderboards/:leaderboard_id/sse` | Leaderboard event stream | | `GET` | `/admin/v1/programs/:program_id/campaigns` | List campaigns | | `POST` | `/admin/v1/programs/:program_id/campaigns` | Create campaign | | `GET` | `/admin/v1/campaigns/:campaign_id` | Get campaign | | `PUT` | `/admin/v1/campaigns/:campaign_id` | Update campaign | | `POST` | `/admin/v1/campaigns/:campaign_id/activate` | Activate campaign | | `POST` | `/admin/v1/campaigns/:campaign_id/schedule` | Schedule campaign | | `POST` | `/admin/v1/campaigns/:campaign_id/pause` | Pause campaign | | `POST` | `/admin/v1/campaigns/:campaign_id/experiences` | Attach experience | | `DELETE` | `/admin/v1/campaigns/:campaign_id/experiences/:experience_id` | Detach experience | | `GET` | `/admin/v1/campaigns/:campaign_id/personalization-rules` | List personalization rules | | `POST` | `/admin/v1/campaigns/:campaign_id/personalization-rules` | Create personalization rule | | `GET` | `/admin/v1/campaigns/:campaign_id/audience/preview` | Preview audience | | `POST` | `/admin/v1/members/:member_id/segments` | Update member segments | ### Question Bank And Content Intelligence | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs/:program_id/questions` | List questions | | `POST` | `/admin/v1/programs/:program_id/questions` | Create question | | `GET` | `/admin/v1/questions/:question_id` | Get question | | `DELETE` | `/admin/v1/questions/:question_id` | Delete question | | `POST` | `/admin/v1/questions/:question_id/status` | Update question status | | `POST` | `/admin/v1/programs/:program_id/questions/generate` | Generate question | | `GET` | `/admin/v1/programs/:program_id/content/variants` | List content variants | | `GET` | `/admin/v1/content/variants/:variant_id` | Get variant | | `POST` | `/admin/v1/content/variants/:variant_id/approve` | Approve variant | | `POST` | `/admin/v1/content/variants/:variant_id/reject` | Reject variant | | `POST` | `/admin/v1/content/variants/:variant_id/edit` | Edit variant | | `POST` | `/admin/v1/programs/:program_id/content/generate` | Enqueue content generation | | `GET` | `/admin/v1/programs/:program_id/difficulty/recommendations` | List difficulty recommendations | | `POST` | `/admin/v1/difficulty/recommendations/:recommendation_id/approve` | Approve recommendation | | `POST` | `/admin/v1/difficulty/recommendations/:recommendation_id/reject` | Reject recommendation | ### Members And Audit | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/programs/:program_id/members` | List members | | `POST` | `/admin/v1/members` | Create member | | `GET` | `/admin/v1/members/:member_id` | Get member | | `GET` | `/admin/v1/members/:member_id/balance` | Get member balance | | `GET` | `/admin/v1/programs/:program_id/members/:external_id/balance` | Get external-ID balance | | `POST` | `/admin/v1/members/:member_id/suspend` | Suspend member | | `POST` | `/admin/v1/members/:member_id/reactivate` | Reactivate member | | `GET` | `/admin/v1/audit-events` | List audit events | | `GET` | `/admin/v1/audit-events/stream` | Audit event SSE stream | | `GET` | `/admin/v1/audit-events/actions` | List audit action values | | `GET` | `/admin/v1/audit-events/:id` | Get audit event | | `GET` | `/admin/v1/programs/:program_id/activity-feed` | Human-readable activity feed | | `GET` | `/admin/v1/programs/:program_id/activity-feed/stream` | Activity feed SSE stream | ### Outbound Delivery And Dead Letters See [Webhook Outcomes](./integrations/webhook-outcomes.md). | Method | Path | Purpose | |--------|------|---------| | `GET` | `/admin/v1/dead-letters` | Ingest dead-letter inspection | | `GET` | `/admin/v1/outbound/dead-letters` | List outbound (vendor delivery) dead letters | | `GET` | `/admin/v1/outbound/health` | Outbound live-dispatch health | | `GET`/`POST` | `/admin/v1/outbound/health/snapshots` | List/record health snapshots | | `POST` | `/admin/v1/outbound/connections/:connection_id/credential-smoke` | Smoke-test connection credentials | | `POST` | `/admin/v1/outbound/dead-letters/:dead_letter_id/approve` | Approve dead-letter replay | | `POST` | `/admin/v1/outbound/dead-letters/:dead_letter_id/reject` | Reject dead-letter replay | | `POST` | `/admin/v1/outbound/dead-letters/:dead_letter_id/replayed` | Mark dead letter replayed | | `POST` | `/admin/v1/outbound/simulations` | Simulate outbound vendor readiness — operator/session only, not on the deprecated API-key mirror | ### Commerce And Stablecoin Admin Condensed — see [Stablecoin Rewards](./stablecoin-rewards.md) for the domain model. The `pilot-health` row is the one commerce read the conversational agent may access (ADR-014); everything else on this surface is session/operator only and never agent-reachable. | Method | Path pattern | Auth | Purpose | |--------|--------------|------|---------| | `GET` | `/admin/v1/commerce/pilot-health` | Session/operator or agent (dual-mounted) | ADR-014 aggregate commerce health | | `GET` | `/admin/v1/stablecoin/programs/:program_id/{setup-status, setup-doctor, issuance-config, issuance-funnel, liability-buckets, campaign-roi, provider-ops, game-economics}` | Session/operator only | Stablecoin setup and analytics reads | | `PUT` | `/admin/v1/stablecoin/programs/:program_id/issuance-config` | Session/operator only | Configure stablecoin issuance | | `PATCH` | `/admin/v1/stablecoin/programs/:program_id/setup-checklist/:item_key` | Session/operator only | Update a setup checklist item | | `GET` | `/admin/v1/stablecoin/programs/:program_id/{reward-intents/:id/timeline, members/:id/timeline}` | Session/operator only | Reward intent / member stablecoin timelines | | `GET` | `/admin/v1/stablecoin/programs/:program_id/reconciliation/exceptions` | Session/operator only | List reconciliation exceptions | | `POST` | `/admin/v1/stablecoin/programs/:program_id/reconciliation/runs` | Session/operator only | Create a reconciliation run | | `POST` | `/admin/v1/stablecoin/programs/:program_id/reconciliation/exceptions/:exception_id/{acknowledge, mark-in-review, resolve, dismiss}` | Session/operator only | Reconciliation exception state transitions | | `GET` | `/admin/v1/programs/:program_id/stablecoin/{available-rails, rail-binding}` | Session/operator only | Program-level rail configuration reads | | `GET` | `/admin/v1/organization/stablecoin/rails` | Session/operator (dual-mounted, also on the deprecated API-key mirror) | List org-level stablecoin rails | | `POST` | `/admin/v1/organization/stablecoin/rails` | Session/operator only, not on the deprecated API-key mirror | Configure the Base Sepolia USDC testnet org rail | | `GET` | `/admin/v1/organization/stablecoin/readiness` | Session/operator only | Org stablecoin readiness | | `POST` | `/admin/v1/programs/:program_id/stablecoin/rail-binding` | Session/operator only | Attach a program rail binding | | `GET` | `/admin/v1/platform/stablecoin/provider-capabilities` | Session/operator (platform-admin mount, not on the deprecated API-key mirror) | Provider capability catalog | | `GET` | `/admin/v1/commerce/{grants, sessions, reservations, receipts}` | Session/operator only — never agent-reachable | ADR-014 commerce inspection (read-only, redacted) | | `GET` | `/admin/v1/commerce/records/:resource_type/:resource_id/timeline` | Session/operator only | Commerce record timeline | | `GET`/`POST` | `/admin/v1/commerce/credentials` | Session/operator only | List / create commerce credentials | | `POST` | `/admin/v1/commerce/credentials/:credential_id/rotate` | Session/operator only | Rotate commerce credential | | `POST` | `/admin/v1/commerce/credentials/:credential_id/revoke` | Session/operator only | Revoke commerce credential | | `PATCH` | `/admin/v1/commerce/credentials/:credential_id/scopes` | Session/operator only | Update commerce credential scopes | ## Merchant Commerce API `/commerce/v1/*` is the ADR-014 redemption-slice commerce mount for merchant integrations. Authenticated exclusively by a commerce credential (`CommercePrincipal`) — no API key, session, or agent-principal credential is accepted here. | Method | Path | Purpose | |--------|------|---------| | `POST` | `/commerce/v1/consent-grants` | Create consent grant | | `POST` | `/commerce/v1/consent-grants/:grant_id/revoke` | Revoke consent grant | | `POST` | `/commerce/v1/agentic-sessions` | Create agentic session | | `POST` | `/commerce/v1/quotes` | Create quote | | `GET` | `/commerce/v1/quotes/:quote_id` | Get quote | | `POST` | `/commerce/v1/quotes/:quote_id/reservations` | Create reservation | | `GET` | `/commerce/v1/reservations/:reservation_id` | Get reservation | | `POST` | `/commerce/v1/reservations/:reservation_id/release` | Release reservation | | `POST` | `/commerce/v1/reservations/:reservation_id/commit` | Commit reservation | ## Agent-Internal Surfaces The conversational agent authenticates to the backend with a shared HMAC secret (`X-Agent-Shared-Secret`) plus acting-user headers — see [Authentication § Agent Auth](./authentication.md#agent-auth). This is not a credential type available to integrators. The routes it reaches privately (tenant provisioning, agent memory, adaptive analytics queries, shadow-batch replay, and agent-JWT minting under `/api/v1/tenants/*`, `/api/v1/agent/memory*`, `/internal/v1/analytics/query`, `/internal/v1/shadow-batch/*`, and `/auth/session/refresh-agent-jwt`) are not part of the public integration surface and are not documented further here. ## Webhooks Both webhook receivers verify a signature in the handler before trusting the request body — see [Authentication § Webhooks](./authentication.md#webhooks). | Method | Path | Purpose | |--------|------|---------| | `POST` | `/alp/v1/integrations/:id/webhook` | Vendor integration webhook (per-connection secret) | | `POST` | `/webhooks/stripe` | Stripe billing webhook (`Stripe-Signature`) | ## CLI Contract The CLI's supported route surface is generated from `api-contracts/loyaltyrails-cli.v1.openapi.json`, not hand-copied here — the contract changes independently of this page and a duplicated list goes stale immediately. Run `pnpm contract:check` after any router change to confirm generated bindings still match; inspect the contract file directly for the exact current endpoint list. ## Error Model Most handlers return structured API errors with an error code and message. Endpoint-specific error documentation should be generated from handler behavior and tests in a later pass. Common error categories: - Missing or invalid credentials. - Scope or program mismatch. - Validation failure. - Not found. - Idempotency conflict. - Insufficient balance or unreserved balance. - Stablecoin safety gate rejection. - Provider or settlement failure. ## Documentation TODO - Generate per-endpoint schema pages from handler DTOs or OpenAPI. - Add example requests and responses for every CLI-supported endpoint. - Add exact status-code behavior from tests. - Add scope requirements for every admin and M2M route. - Add stablecoin and value-decision DTO references. --- # Events API Source: ../docs/api/events.md URL: https://docs.rails.sh/events-api Markdown: https://docs.rails.sh/markdown/events-api.md # Events API Source-derived status: event ingestion is implemented in the backend and partially declared in the CLI OpenAPI contract. The single-event route is contract-declared and CLI-backed; batch ingestion and status lookup are backend routes but are not part of the current CLI contract. ## Route Summary | Method | Path | Status | Auth | | --- | --- | --- | --- | | `POST` | `/internal/v1/programs/{programId}/events` | Implemented, contract-declared | Integration API key with `write` or `admin` scope | | `POST` | `/internal/v1/programs/{programId}/events/batch` | Implemented, backend-only | Integration API key with `write` or `admin` scope | | `GET` | `/internal/v1/programs/{programId}/events/{eventId}/status` | Implemented, backend-only | Integration API key with `read`, `write`, or `admin` scope | ## Auth Model Event routes are mounted under `/internal/v1` and use the API key middleware. Send the key in `X-API-Key`. Program-scoped keys can only access their own program. A key scoped to another program returns `404` to avoid cross-program resource disclosure. `write` scope implies `read`; `admin` implies both. The static bootstrap key is path-allowlisted by middleware but is rejected by route-level scope checks for normal event operations. ```bash curl -X POST "https://api.loyaltyrails.com/internal/v1/programs/$PROGRAM_ID/events" \ -H "Content-Type: application/json" \ -H "X-API-Key: lr_live_..." \ -d @event.json ``` ## Event Envelope `InboundEvent` uses camelCase JSON: ```json { "eventType": "transaction.completed", "idempotencyKey": "order_123", "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "externalId": "customer_123", "externalIdType": "customer_id", "occurredAt": "2026-05-02T15:04:05Z", "payload": { "amount": 2500, "currency": "USD", "channel": "online", "store_id": "web" } } ``` Required by the contract: `eventType`, `idempotencyKey`, `occurredAt`, and `payload`. Required by backend validation: non-empty `eventType`, non-empty `idempotencyKey`, and either `memberId` or `externalId`. If `externalId` is present, `externalIdType` is also required. `payload` defaults to `{}` in the Rust type, but the CLI contract marks it required, so integrations should always send it. ## `transaction.completed` Shape `transaction.completed` is not a hard-coded schema. It is a convention carried by `eventType` plus merchant-defined `payload`. Any `eventType` beginning with `transaction.` or `purchase.` (for example `transaction.refunded`, `purchase.completed`) gets this same treatment. The rules engine and member activity path currently know these payload conventions: | Field | Purpose | | --- | --- | | `amount` | Legacy points-earn basis used by amount-based rule conditions (`MinAmount`/`MaxAmount`) and bonus/promotion calculations | | `transactionAmountMinor` | Preferred field for the monetary transaction value, in minor currency units. New integrations should send this instead of overloading `amount`. It is recorded as the transaction value independently of whatever `amount` the rules engine uses as its earn basis; if both are present, `transactionAmountMinor` wins for the recorded value | | `currency` | ISO-4217 currency code for the transaction value. Normalized to uppercase; defaults to `USD` when absent or blank | | `channel` | Channel condition input, such as `online`, `mobile`, or `in-store` | | `store_id` | Location condition input | | `items`, `payment_method` | Accepted as merchant payload data; usable by payload-attribute rules when present | For `transaction.`/`purchase.`-prefixed events, the worker additionally appends a typed `TransactionRecorded` domain event to the event store (alongside the generic `EventIngested` event) carrying `amount_usd_cents` (from `transactionAmountMinor`, falling back to `amount`) and the normalized `currency`. This is an internal event-sourcing record, not part of this API's response bodies — other event types only produce `EventIngested`. Illustrative request: ```json { "eventType": "transaction.completed", "idempotencyKey": "checkout_order_100045", "externalId": "customer_123", "externalIdType": "customer_id", "occurredAt": "2026-05-02T15:04:05Z", "payload": { "amount": 2500, "transactionAmountMinor": 2500, "currency": "USD", "channel": "online", "store_id": "web", "items": [ { "sku": "shirt-001", "category": "apparel", "quantity": 1 } ] } } ``` ## Responses Single-event ingestion returns `202` when accepted for asynchronous processing: ```json { "eventId": "0de0ff5b-4f44-4e3f-9a27-dbeae4cb9e52", "status": "pending", "statusUrl": "/internal/v1/programs/0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd/events/0de0ff5b-4f44-4e3f-9a27-dbeae4cb9e52/status" } ``` If the same idempotency key has already been durably queued for delivery — even if the worker hasn't finished processing it yet — the handler returns `200` with the original `eventId` and `status: "duplicate"`, replaying the stored response. A retry that arrives while the original request is still being claimed (not yet durably queued) instead gets `400` — see [Idempotency](#idempotency). ## Batch Ingestion `POST /internal/v1/programs/{programId}/events/batch` accepts up to 1000 events per request: ```json { "events": [ { "eventType": "transaction.completed", "idempotencyKey": "order_1", "externalId": "customer_1", "externalIdType": "customer_id", "occurredAt": "2026-05-02T15:04:05Z", "payload": { "amount": 1000 } }, { "eventType": "transaction.completed", "idempotencyKey": "order_2", "externalId": "customer_2", "externalIdType": "customer_id", "occurredAt": "2026-05-02T15:04:06Z", "payload": { "amount": 2000 } } ] } ``` A batch over 1000 events is rejected outright with `400 bad_request` before any event in it is processed. Otherwise the endpoint always returns `202`, with one result per submitted event in input order — an individual event failing (validation error, quota rejection) does not fail the batch or the other events in it: ```json { "results": [ { "eventId": "0de0ff5b-...", "status": "pending", "statusUrl": "/internal/v1/programs/.../events/0de0ff5b-.../status" }, { "eventId": "00000000-0000-0000-0000-000000000000", "status": "failed", "error": "order_2: Invalid event: external_id_type is required when external_id is provided" } ] } ``` A failed item's `eventId` is the nil UUID (`00000000-0000-0000-0000-000000000000`) and carries no `statusUrl` — there is no queue row to look up. One audit event is emitted for the whole batch (with success/failure/duplicate counts), not one per event. ## Status Endpoint The status endpoint returns queue or idempotency state: ```json { "eventId": "0de0ff5b-4f44-4e3f-9a27-dbeae4cb9e52", "status": "completed", "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "outcomesApplied": [], "attempts": 1, "enqueuedAt": "2026-05-02T15:04:06Z", "processingStartedAt": "2026-05-02T15:04:07Z", "completedAt": "2026-05-02T15:04:08Z" } ``` `status` is one of `pending`, `processing`, `completed`, or `dead`, regardless of how the platform is deployed. ## Idempotency Idempotency is keyed by `(programId, idempotencyKey)`. Use a merchant-stable event reference such as an order ID, checkout ID, refund ID, or webhook delivery ID. Behavior: | Case | Result | | --- | --- | | First request for a new key | `202`, new `eventId`, `status: "pending"` | | Retry once the original has been durably queued (worker may still be processing it) | `200`, same `eventId` as the original, `status: "duplicate"` | | Retry that races the original before it's durably queued (still mid-claim) | `400`, `bad_request` | | Retry of a key whose prior attempt failed | `202`, a **new** `eventId` reusing the same idempotency key, `status: "pending"` — poll status using this new `eventId`, not one from the earlier failed attempt | | Delivery failure during enqueue | `500`; the idempotency slot is marked failed so the caller can retry with the same key | Events are processed asynchronously — accepting an event does not mean it has been evaluated against rules yet; poll the status endpoint to observe completion. ## Processing Relationship The API does not synchronously award points. It enqueues an inbound event, then the worker resolves or creates the member, records member activity, evaluates active rules, executes outcomes, persists member state, and appends an `EventIngested` domain event in the worker transaction. This is the backend target used by integration recipes such as `award-route` and Shopify/Hydrogen webhook scaffolding. Recipe-generated server routes should hold the API key server-side and forward normalized identity plus a stable idempotency key. ## Errors Most `AppError` responses use the shared shape: ```json { "error": "bad_request", "message": "Invalid event: external_id_type is required when external_id is provided" } ``` Common cases: | HTTP | `error` | Notes | | --- | --- | --- | | `400` | `bad_request` | Invalid event, missing identity, batch over 1000 events, in-progress idempotency key | | `401` | `unauthorized` or empty middleware response | Missing or invalid API key | | `403` | `forbidden` | API key lacks required scope, or the key is the static bootstrap key | | `404` | `not_found` | Program outside key reach or event not found | | `429` | `quota_exceeded` | Monthly event quota or active-member capacity limit reached for the org's tier — bespoke body, not the shared shape (see below) | | `500` | `internal_error` | Database, queue, or transport failure | The `429` case is a bespoke body, since the frontend/agent narration layer reads `quota`/`limit`/`upgradePath` directly rather than a `message` string: ```json { "error": "quota_exceeded", "quota": "monthly_events", "limit": 10000, "upgradePath": "/admin/settings/billing" } ``` This applies only to the write endpoints (single and batch ingestion); the status lookup has no quota check. ## CLI Smoke Test The CLI `events send` command is a smoke client for the single-event route. It requires an active integration API-key profile with `write` scope. ```bash loyaltyrails events send \ --type transaction.completed \ --external-id customer_123 \ --external-id-type customer_id \ --idempotency-key order_123 \ --payload '{"amount":2500,"currency":"USD"}' ``` Use `--member-id` instead of `--external-id` when the storefront or backend already has the member's rails UUID. `--idempotency-key` is optional on the CLI — a random one is generated if omitted. ## Source References | Area | Source | | --- | --- | | Route mounts | `backend/src/api/mod.rs` lines 813-822 | | Response/request DTOs and single-event handler | `backend/src/api/ingest.rs` lines 29-219 | | Batch handler | `backend/src/api/ingest.rs` lines 221-411 | | Status handler | `backend/src/api/ingest.rs` lines 413-480 | | Core enqueue + transport dispatch | `backend/src/api/ingest.rs` lines 506-719 | | Event envelope and validation | `backend/src/domain/event_envelope.rs` | | Idempotency claim semantics | `backend/src/infrastructure/database/ingest_idempotency.rs` lines 56-124 | | Worker processing core, `TransactionRecorded` gating, `transactionAmountMinor`/`currency` extraction | `backend/src/services/event_processor.rs` lines 61-258 | | Transaction-event-type detection | `backend/src/domain/member.rs` `is_transaction_event` | | CLI contract | `api-contracts/loyaltyrails-cli.v1.openapi.json` path `/internal/v1/programs/{programId}/events` | | CLI smoke command | `packages/cli/src/commands.ts` `events send` | --- # Members API Source: ../docs/api/members.md URL: https://docs.rails.sh/members-api Markdown: https://docs.rails.sh/markdown/members-api.md # Members API Source-derived status: member creation, lookup, listing, balance, and lifecycle (suspend/reactivate) routes are implemented in the backend. The external-ID balance lookup route is also declared in the CLI OpenAPI contract and used by generated browser-safe integration routes. **Availability note:** the two admin member-lifecycle routes (`/admin/v1/members/{memberId}/suspend` and `.../reactivate`) are currently reachable only via the deprecated API-key mirror (`/internal/v1/admin/members/{memberId}/suspend|reactivate`) with an admin-scoped key; browser-session and operator-token calls to the `/admin/v1` paths are not yet supported. See [Member Lifecycle](#member-lifecycle-suspend--reactivate) below. This page covers the merchant-facing member management surface: creating, looking up, listing, and administering members. For the end-user-facing member surface (`/public/v1/members/me/*`, member-JWT `Authorization: Bearer` auth) and how to mint that JWT (`/api/v1/member-tokens/issue` / `.../refresh`), see [Authentication](../authentication.md) and the [API Reference](../api-reference.md) — those pages are the source of truth for that surface and aren't duplicated here. ## Route Summary | Method | Path | Status | Auth | | --- | --- | --- | --- | | `POST` | `/internal/v1/members` | Implemented | `X-API-Key` with `write` or `admin` scope | | `GET` | `/internal/v1/members/{memberId}` | Implemented | `X-API-Key` with `read`, `write`, or `admin` scope | | `GET` | `/internal/v1/members/{memberId}/balance` | Implemented | `X-API-Key` with `read`, `write`, or `admin` scope | | `GET` | `/internal/v1/programs/{programId}/members` | Implemented | `X-API-Key` with `read`, `write`, or `admin` scope | | `POST` | `/internal/v1/programs/{programId}/members/balance/lookup` | Implemented, contract-declared | `X-API-Key` with `read`, `write`, or `admin` scope | | `GET` | `/admin/v1/programs/{programId}/members` | Implemented | Browser session or operator token with `programs:read` | | `POST` | `/admin/v1/members` | Implemented | Browser session or operator token with `programs:update` | | `GET` | `/admin/v1/members/{memberId}` | Implemented | Browser session or operator token with `programs:read` | | `GET` | `/admin/v1/members/{memberId}/balance` | Implemented | Browser session or operator token with `programs:read` | | `GET` | `/admin/v1/programs/{programId}/members/{externalId}/balance` | Implemented | Browser session or operator token with `programs:read` | | `POST` | `/admin/v1/members/{memberId}/suspend` | Implemented | Not yet supported for browser session or operator token; use the API-key mirror below | | `POST` | `/admin/v1/members/{memberId}/reactivate` | Implemented | Not yet supported for browser session or operator token; use the API-key mirror below | Every route in the `/admin/v1/members...` group above is also mounted, deprecated, under `/internal/v1/admin/...` (e.g. `/internal/v1/admin/members`) for machine-to-machine callers still on the legacy admin API-key path, using an `admin`-scoped `X-API-Key`. For suspend/reactivate specifically, **this deprecated mirror is the only currently-supported way to call these two operations** — see [Member Lifecycle](#member-lifecycle-suspend--reactivate) below. For public storefronts, use an app/server route that calls `/internal/v1/...`; do not expose `X-API-Key` in browser code. ## Auth Model Internal member routes use `X-API-Key`. Admin member routes use the admin auth stack: browser session cookies or operator CLI tokens on `/admin/v1`, and a deprecated admin API-key mount under `/internal/v1/admin`. For integration keys: | Operation | Required scope | | --- | --- | | Create/upsert member | `write` or `admin` | | Lookup member, list members, read balance | `read`, `write`, or `admin` | | Suspend/reactivate member (M2M via deprecated `/internal/v1/admin/members/...` mirror only) | `admin` | For browser/operator admin routes, route permissions are feature permissions rather than API-key scopes. Member list/read paths require `programs:read`; member creation requires `programs:update`. Suspend and reactivate are not yet available on the browser-session/operator-token path at all — see [Member Lifecycle](#member-lifecycle-suspend--reactivate) below for the currently-supported route. ## Member Identity Members are identified two ways: | ID | Meaning | | --- | --- | | `memberId` | rails UUID from `member_mappings.id` | | `externalId` + `externalIdType` | Merchant/customer identifier pair, such as `customer_123` + `customer_id` or a Shopify customer ID + `shopify_id` | Creating a member upserts by external identity and generates a deterministic wallet address from `externalIdType:externalId`. Event ingestion can also create a member when an event supplies external identity but no `memberId`. Create/upsert request: ```json { "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "externalId": "customer_123", "externalIdType": "customer_id", "profile": { "display_name": "Avery" } } ``` Response: ```json { "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "walletAddress": "0x...", "tier": "bronze", "new": true } ``` `new` is `false` when the member already existed and the call behaved as an upsert. ## External-ID Balance Lookup This is the browser-safe integration route target and the only member route currently declared in the CLI contract: ```http POST /internal/v1/programs/{programId}/members/balance/lookup X-API-Key: lr_test_... Content-Type: application/json ``` ```json { "externalId": "customer_123", "externalIdType": "customer_id" } ``` Response: ```json { "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "balance": 1250, "settledBalance": 1000, "pendingSettlement": 250, "totalEarned": 1750, "totalRedeemed": 500 } ``` If the member has no balance row yet, the backend returns zeros for balance fields after finding the member mapping. There is also an admin external-ID balance route at `GET /admin/v1/programs/{programId}/members/{externalId}/balance?externalIdType=customer_id`. It returns a richer admin shape with wallet, status, timestamps, and recent transactions. Its query parameter defaults to `crm` when omitted, so pass `externalIdType` explicitly unless the admin caller really uses CRM IDs. ## Full Member Shape `GET /internal/v1/members/{memberId}` returns identity, wallet, status, tier/progression fields, balances, activity counters, profile JSON, badges, active offers, and timestamps: ```json { "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "externalId": "customer_123", "externalIdType": "customer_id", "walletAddress": "0x...", "status": "active", "tier": "bronze", "tierPoints": 1250, "balance": 1250, "settledBalance": 1000, "pendingSettlement": 250, "totalEarned": 1750, "totalRedeemed": 500, "transactionCount": 3, "consecutiveDays": 2, "daysSinceLastActivity": 0, "profile": {}, "badges": [], "activeOffers": [], "lastActivityAt": "2026-05-02T15:04:05Z", "createdAt": "2026-05-01T12:00:00Z" } ``` `status` is `"active"` or `"suspended"` — see the lifecycle routes below. The same `MemberDetailResponse` shape is also the response body of the suspend/reactivate calls. ## Member Lifecycle (Suspend / Reactivate) ```http POST /admin/v1/members/{memberId}/suspend POST /admin/v1/members/{memberId}/reactivate ``` Request body (both routes): ```json { "reason": "Chargebacks flagged by fraud review" } ``` `reason` is required, 1–280 characters after trimming; empty or whitespace-only values are rejected with `400`. It's recorded on the audit event (`mutation.member_suspended` / `mutation.member_reactivated`) so the activity feed shows why the status changed. Calling suspend on an already-suspended member (or reactivate on an already-active one) is a `400` no-op guard, not a silent success. Response: the full `MemberDetailResponse` shape (see above), reflecting the new `status`. **Read this before wiring a UI to these routes.** These operations are currently available only via the deprecated API-key mirror — `POST /internal/v1/admin/members/{memberId}/suspend` and `.../reactivate` — with an admin-scoped `X-API-Key`. Browser-session and operator-CLI-token calls to the `/admin/v1/members/{memberId}/suspend` and `.../reactivate` paths shown above are not yet supported; use the deprecated mirror instead until that changes. The underlying behavior (tenant scoping, plan-approval gating for agent-proxied calls, the no-op guard, the audit event) is the same on both mounts — only the calling convention differs. Agent-proxied calls (requests carrying `x-acting-user-id`) additionally require a valid `X-Agent-Plan-Approval` envelope listing the matching `member.suspend` / `member.reactivate` intent, once the request reaches the handler. ## Browser-Safe Host Route Pattern Storefronts and websites should call a local server route, not rails directly from the browser. The generated Next.js App Router balance route follows this pattern: 1. Browser calls `GET /api/loyaltyrails/balance`. 2. The app route resolves the signed-in customer server-side. 3. The app route calls rails with `X-API-Key`: `POST /internal/v1/programs/{programId}/members/balance/lookup`. 4. The app route returns only the safe response body to the browser. Minimal shape: ```ts export async function GET(request: Request) { const identity = await resolveCustomer(request); const response = await fetch( `${process.env.LOYALTYRAILS_API_URL}/internal/v1/programs/${process.env.LOYALTYRAILS_PROGRAM_ID}/members/balance/lookup`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.LOYALTYRAILS_API_KEY!, }, body: JSON.stringify({ externalId: identity.externalId, externalIdType: identity.externalIdType ?? 'customer_id', }), } ); return Response.json(await response.json(), { status: response.status }); } ``` None of the routes on this page are safe to call directly from a browser: every `/internal/v1/members/...` call needs `X-API-Key` (server-only), and every `/admin/v1/members/...` call needs an authenticated admin-frontend session or operator token (the merchant operator's browser, not an end-customer's). The one browser-safe, end-customer-facing member surface is `/public/v1/members/me/*`, authenticated with a short-lived member JWT — see [Authentication § End-User Access](../authentication.md#end-user-access) for how to mint and use it. ## CLI Smoke Test ```bash loyaltyrails members balance \ --external-id customer_123 \ --external-id-type customer_id ``` The CLI checks the active integration profile for `read` scope before calling the external-ID balance lookup route. ## Errors | HTTP | Notes | | --- | --- | | `400` | Malformed JSON, validation failure, or (suspend/reactivate) a status no-op | | `401` | Missing or invalid API key/session/operator token | | `403` | Valid credential lacks required scope or permission (includes the suspend/reactivate gap above) | | `404` | Member not found, program not found, or program outside credential reach | | `500` | Database or event-store failure | ## Out Of Scope Here `backend/src/api/balance.rs` (`GET /alp/v1/balance?wallet=...`, JWT-authed) is a different, legacy wallet-address balance lookup on the `/alp/v1` surface — it predates the member-identity model on this page and is not part of the member CRUD/lifecycle surface. See [Authentication](../authentication.md) and the [API Reference](../api-reference.md) for its (legacy) coverage. ## Source References | Area | Source | | --- | --- | | Internal (M2M) member route mounts | `backend/src/api/mod.rs` lines 799-812 | | Admin member route mounts (dual-mounted at `/admin/v1` and `/internal/v1/admin`) | `backend/src/api/mod.rs` lines 508-535 (CRUD/list/balance/suspend/reactivate) and line 155 (external-ID balance) | | Member request/response DTOs | `backend/src/api/members.rs` lines 49-161 | | Create/upsert behavior | `backend/src/api/members.rs` `create_member` | | Detail and balance behavior | `backend/src/api/members.rs` `get_member`, `get_member_balance` | | External-ID lookup | `backend/src/api/members.rs` `lookup_member_balance` | | Suspend/reactivate behavior | `backend/src/api/members.rs` `suspend_member`, `reactivate_member`, `change_member_status` | | Admin external-ID balance | `backend/src/api/admin/members.rs` | | Scope enforcement (integration keys) | `backend/src/api/members.rs` `require_member_auth` | | Permission enforcement (session/operator token) — the suspend/reactivate gap | `backend/src/api/middleware/admin_permissions.rs` `required_admin_permission` | | Deprecated admin API-key mount scope check | `backend/src/api/middleware/api_key.rs` `require_admin_api_key_scope` | | Legacy wallet-balance route (out of scope here) | `backend/src/api/balance.rs` | | Member-token mint/refresh + `/public/v1/members/me/*` | `backend/src/api/member_tokens.rs`, [Authentication](../authentication.md) | | CLI contract | `api-contracts/loyaltyrails-cli.v1.openapi.json` path `/internal/v1/programs/{programId}/members/balance/lookup` | | Generated browser route | `packages/cli/src/integration/next-app-router.ts` `balanceRouteContent` | --- # Rules API Source: ../docs/api/rules.md URL: https://docs.rails.sh/rules-api Markdown: https://docs.rails.sh/markdown/rules-api.md # Rules API Source-derived status: rules management is implemented in the backend. List/create/simulate are declared in the CLI OpenAPI contract and supported by operator-token CLI commands. Get/update/delete/pause/activate and the single-rule trace simulator are backend routes but are not all part of the current CLI contract. ## Route Summary | Method | Path | Status | Auth | | --- | --- | --- | --- | | `GET` | `/admin/v1/programs/{programId}/rules` | Implemented, contract-declared | Browser session or operator token with `rules:read` | | `POST` | `/admin/v1/programs/{programId}/rules` | Implemented, contract-declared | Browser session or operator token with `rules:create` | | `POST` | `/admin/v1/programs/{programId}/rules/simulate` | Implemented, contract-declared | Browser session or operator token with `rules:read` | | `GET` | `/admin/v1/rules/{ruleId}` | Implemented | Browser session or operator token with `rules:read` | | `PATCH` | `/admin/v1/rules/{ruleId}` | Implemented | Browser session or operator token with `rules:update` | | `DELETE` | `/admin/v1/rules/{ruleId}` | Implemented | Browser session or operator token with `rules:delete` | | `POST` | `/admin/v1/rules/{ruleId}/pause` | Implemented | Browser session or operator token with `rules:update` | | `POST` | `/admin/v1/rules/{ruleId}/activate` | Implemented | Browser session or operator token with `rules:update` | | `POST` | `/admin/v1/rules/{ruleId}/simulate` | Implemented backend trace simulator | Browser session or operator token with `rules:read` | Admin handlers are also mounted under deprecated `/internal/v1/admin/*` for DB-backed API keys with `admin` scope. New human/operator tooling should use `/admin/v1/*`. ## Concepts Rules evaluate member and event context to produce outcomes. Only `active` rules enter the hot path; `draft`, `paused`, and `expired` rules are persisted and visible to admins but do not evaluate. Rule types in backend source: | `ruleType` | Purpose | Contract status | | --- | --- | --- | | `bonus` | Multiplies point amount, for example 2x points | Contract-declared; `rules create --type` accepts it | | `promotion` | Adds a fixed bonus amount | Contract-declared; `rules create --type` accepts it | | `decay` | Models expiration policy | Contract-declared; `rules create --type` accepts it | | `stablecoin_mint` (alias `mint_stable` on input) | Queues a USDC-style asset reward — see [Stablecoin Rewards](../stablecoin-rewards.md) | Contract-declared; `rules create --type` does not accept it (CLI command flag is narrower than the wire schema) | | `integration_action` (alias `vendor_action` on input) | Queues a durable third-party outbound action for async dispatch | Contract-declared; `rules create --type` does not accept it | `ruleType` values are parsed independently from `config.type` (below) — the backend does not check that they agree. Note the one intentional naming mismatch: the `ruleType` value is `integration_action`, but the corresponding `config.type` tag is `vendor_action`. ## Rule Config Shapes `config` is a JSON object internally tagged by a required `type` field (`#[serde(tag = "type")]`). A `config` missing `type`, or carrying an unrecognized `type`, is rejected with `400 Invalid config`. Field names are `snake_case`; some fields also accept a `camelCase` alias for the same value (noted below). This applies everywhere `config` appears: [Create Rule](#create-rule), [Update Rule](#update-rule), and [Simulate Unsaved Rule](#simulate-unsaved-rule). | `config.type` | Fields | Notes | | --- | --- | --- | | `bonus` | `multiplier: number` | Multiplies the transaction amount. | | `promotion` | `bonus_amount` (alias `bonusAmount`): integer · `max_uses` (alias `maxUses`): integer or `null` | Adds a fixed bonus. | | `decay` | `days_until_expiry` (alias `daysUntilExpiry`): integer · `warning_days` (alias `warningDays`): integer | Point expiration policy. | | `tier_upgrade` | `target_tier: string` · `threshold: integer` | No corresponding `ruleType` value — only reachable via `config.type` directly. | | `badge_award` | `badge_id: string` · `badge_name: string` | No corresponding `ruleType` value. | | `offer_unlock` | `offer_id: string` · `offer_name: string` | No corresponding `ruleType` value. | | `stablecoin_mint` (alias `mint_stable`) | `amount_base_units` (aliases `amount`, `amountBaseUnits`): integer · `display_amount` (alias `displayAmount`): string or `null` · `currency` (aliases `asset_code`, `assetCode`, `reward_asset_code`, `rewardAssetCode`; defaults to `"USDC"`): string | See [Stablecoin Rewards](../stablecoin-rewards.md) for validation rules (for example, `display_amount` must match `amount_base_units`). | | `vendor_action` | `vendor_namespace` (alias `vendorNamespace`): string · `action_type` (alias `actionType`): string · `runtime_adapter`/`runtimeAdapter`, `action_key`/`actionKey`, `source_event_type`/`sourceEventType`, `consent_purpose`/`consentPurpose`, `idempotency_key_template`/`idempotencyKeyTemplate`: optional strings · `payload_template`/`payloadTemplate`: object, defaults to `{}` | The corresponding `ruleType` value is `integration_action`, not `vendor_action`. | ## Rule Shape ```json { "id": "6c827eba-cb56-4b6b-9c52-06f1da99af63", "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "ruleType": "bonus", "name": "Double points on mobile orders", "description": "2x points for mobile checkout", "config": { "type": "bonus", "multiplier": 2 }, "conditions": [ { "type": "event_type", "event_types": ["transaction.completed"] }, { "type": "channel", "channels": ["mobile"] }, { "type": "min_amount", "amount": 1000 } ], "priority": 10, "status": "active", "startsAt": "2026-05-02T00:00:00Z", "endsAt": "2026-06-02T00:00:00Z", "createdBy": "operator-token:...", "createdAt": "2026-05-02T15:04:05Z", "updatedAt": "2026-05-02T15:04:05Z", "builderMetadata": { "nodes": [], "edges": [] } } ``` `builderMetadata` is stored and returned for the visual builder. The rule engine ignores it. ## Create Rule ```http POST /admin/v1/programs/{programId}/rules Authorization: Bearer lr_cli_... Content-Type: application/json ``` ```json { "ruleType": "promotion", "name": "First purchase bonus", "config": { "type": "promotion", "bonusAmount": 500, "maxUses": 1 }, "conditions": [ { "type": "event_type", "event_types": ["transaction.completed"] }, { "type": "first_purchase" } ], "priority": 20, "status": "draft", "builderMetadata": { "layout": "canvas-v1" } } ``` `status` is optional. Missing or empty status defaults to `active` for backward compatibility. Unknown status strings return `400`; typos are not silently coerced. ## Update Rule ```http PATCH /admin/v1/rules/{ruleId} Authorization: Bearer lr_cli_... Content-Type: application/json ``` ```json { "name": "Double points on mobile orders (updated)", "config": { "type": "bonus", "multiplier": 3 }, "priority": 15, "updatedBy": "operator-token:jane@acme.com" } ``` `updatedBy` is **required** on every update, unlike `createdBy` on create (which is ignored — attribution is derived from the caller's session/token). All other fields are optional; a field that is present replaces the current value, and a field that is omitted is left unchanged. `startsAt`/`endsAt` are the exception: if either key is present in the request, both are overwritten together from the request body — sending only `startsAt` without `endsAt` clears `endsAt` to `null` rather than leaving it unchanged. The response is the updated rule (see [Rule Shape](#rule-shape)). ## Conditions Conditions are an AND list: every condition must match. Backend-supported condition tags: | Condition | Shape | | --- | --- | | Day of week | `{ "type": "day_of_week", "days": [1, 2] }` where `0` is Sunday | | Time range | `{ "type": "time_range", "start_hour": 9, "end_hour": 17 }` | | Min/max amount | `{ "type": "min_amount", "amount": 1000 }`, `{ "type": "max_amount", "amount": 5000 }` | | Segment | `{ "type": "member_segment", "segments": ["gold"] }` | | Transaction count | `{ "type": "transaction_count", "operator": "gte", "count": 3 }` | | First purchase | `{ "type": "first_purchase" }` | | Event type | `{ "type": "event_type", "event_types": ["transaction.completed"] }` | | Channel | `{ "type": "channel", "channels": ["mobile"] }` | | Location | `{ "type": "location_id", "location_ids": ["store_12"] }` | | Member tier | `{ "type": "member_tier", "tiers": ["gold"] }` | | Payload attribute | `{ "type": "payload_attribute", "path": "/items/0/category", "op": "eq", "value": "apparel" }` | | Streak/activity | `{ "type": "min_consecutive_days", "days": 3 }`, `{ "type": "max_days_since_activity", "days": 14 }` | Supported comparison operators include `eq`, `gt`, `gte`, `lt`, `lte`, and `contains` for string/array payload checks. ## Simulate Unsaved Rule `POST /admin/v1/programs/{programId}/rules/simulate` parses a `config` and projects its point-calculation effect against each of `testTransactions`. It does not persist a rule, and its request schema has no `conditions` field at all — this endpoint never evaluates a condition list; `matched` is derived solely from `config.type`. `testTransactions` fields (every field is optional — an entry may be `{}`): | Field | Type | Default | | --- | --- | --- | | `amount` | integer | `0` — so event-shaped fixtures with no monetary amount (for example `{"eventType": "member.registered"}`) still parse | | `eventType` | string or `null` | `null`; echoed into the result `reason` for context, not matched against | | `dayOfWeek` | integer or `null` | `null` | | `hour` | integer or `null` | `null` | | `memberSegment` | string or `null` | `null` | | `transactionCount` | integer or `null` | `null` | | `isFirstPurchase` | boolean or `null` | `null` | If `testTransactions` is omitted or empty, the endpoint runs 3 built-in fixtures (amounts 100/500/1000 on Monday/Tuesday/Saturday) instead of erroring. ```json { "config": { "type": "bonus", "multiplier": 2 }, "testTransactions": [ { "amount": 2500, "dayOfWeek": 2, "hour": 14, "memberSegment": "gold", "transactionCount": 3, "isFirstPurchase": false } ] } ``` Response: ```json { "config": { "type": "bonus", "multiplier": 2 }, "results": [ { "transactionIndex": 0, "matched": true, "originalAmount": 2500, "calculatedAmount": 5000, "bonusApplied": 2, "reason": null } ], "summary": { "totalTransactions": 1, "matchedCount": 1, "matchRatePct": "100.0%", "totalOriginalAmount": 2500, "totalCalculatedAmount": 5000, "totalBonusAmount": 2500 } } ``` ## Simulate Saved Rule (Trace) `POST /admin/v1/rules/{ruleId}/simulate` runs the saved rule's full condition list against one sample context and returns a step-by-step trace plus a dry-run outcome preview. It reads `config` and `conditions` from the stored rule identified by `ruleId` — the request body carries only the sample context, never a `config`. It is read-only: no rule state changes, no counters increment, and it never calls into the hot path. ```http POST /admin/v1/rules/{ruleId}/simulate Authorization: Bearer lr_cli_... Content-Type: application/json ``` ```json { "sample": { "amount": 2500, "dayOfWeek": 2, "hour": 14, "memberSegment": "gold", "memberTier": "gold", "transactionCount": 3, "isFirstPurchase": false, "eventType": "transaction.completed", "channel": "mobile", "locationId": "store_12", "consecutiveDays": 5, "daysSinceLastActivity": 1, "payload": {} } } ``` `sample.amount`, `sample.dayOfWeek`, and `sample.hour` are required. `memberSegment`, `memberTier`, `eventType`, `channel`, and `locationId` default to `null`; `transactionCount`, `consecutiveDays`, and `daysSinceLastActivity` default to `0`; `isFirstPurchase` defaults to `false`; `payload` defaults to `{}`. There is no wall-clock fallback — the caller supplies `dayOfWeek`/`hour` explicitly, so a Tuesday-only rule can be simulated on any day. Response: ```json { "matched": true, "trace": [ { "index": 0, "kind": "channel", "passed": true, "detail": "channel=Some(\"mobile\") ∈ [\"mobile\"] · ok" } ], "outcome": { "kind": "bonus", "multiplier": 2.0, "baseAmount": 2500, "calculatedAmount": 5000, "bonusApplied": 2500 }, "ruleStatus": "active" } ``` `trace` has one entry per condition on the saved rule, in declaration order, and always runs to completion (a failing condition does not short-circuit later entries). `outcome` is present only when every condition matched (`matched: true`) and is tagged by `kind`, mirroring the rule's `config.type` (`bonus`, `promotion`, `decay`, `tier_upgrade`, `badge_award`, `offer_unlock`, `stablecoin_mint`, `vendor_action`) with `camelCase` fields matching the corresponding entry in [Rule Config Shapes](#rule-config-shapes). `ruleStatus` echoes the saved rule's current lifecycle status — a `paused` or `draft` rule can still show `matched: true` here for preview purposes without ever firing in production. ## Lifecycle | Status | Meaning | | --- | --- | | `draft` | Stored and listable; excluded from rule evaluation | | `active` | Eligible for evaluation when time bounds match | | `paused` | Stored but excluded from evaluation | | `expired` | Stored but excluded from evaluation | Lifecycle routes currently support `pause` and `activate`. Expiration is represented in the domain model and by time bounds, but there is no explicit public `expire` route in the mounted API. ## CLI Commands Rules CLI commands use an operator token, not an integration API key: ```bash loyaltyrails auth login --operator-token lr_cli_... --api-url http://localhost:8080 loyaltyrails rules list --program loyaltyrails rules create \ --program \ --type bonus \ --name "Double points" \ --config '{"type":"bonus","multiplier":2}' loyaltyrails rules simulate \ --program \ --config '{"type":"promotion","bonusAmount":500}' ``` ## Errors | HTTP | Notes | | --- | --- | | `400` | Invalid rule type, config, condition, status, or simulation body | | `401` | Missing or invalid session/operator token/API key | | `403` | Missing permission such as `rules:create` or `rules:update` | | `404` | Program or rule not found within caller scope | | `500` | Database, event-store, or cache invalidation failure | ## Source References | Area | Source | | --- | --- | | Rule route declarations | `backend/src/api/mod.rs` lines 126-143 (inside `build_admin_handler_routes`) | | Dual mount (`/admin/v1` session + `/internal/v1/admin` API-key) and permission/auth layering | `backend/src/api/mod.rs` lines 1360-1404; permission strings in `backend/src/api/middleware/admin_permissions.rs` lines 141-160; session-or-operator-token acceptance in `backend/src/api/middleware/operator.rs` | | Rule DTOs (`RuleDto`, `CreateRuleRequest`, `UpdateRuleRequest`) | `backend/src/api/admin/rules.rs` lines 26-108 | | Create/list/get/update/pause/activate/delete handlers | `backend/src/api/admin/rules.rs` lines 254-763 | | Unsaved-rule simulate (`SimulateRuleRequest`/`TestTransaction`/response) | `backend/src/api/admin/rules.rs` lines 765-1048 | | Saved-rule trace simulate (`SimulateByIdRequest`, handler) | `backend/src/api/admin/rules.rs` lines 1370-1421 | | `SampleContext`, `EvalStep`, `SimulatedOutcome`, `SimulationResult`, `simulate()` | `backend/src/services/rule_simulator.rs` lines 44-359 | | Rule types, status, conditions, config (`RuleType`, `RuleStatus`, `RuleCondition`, `RuleConfig`) | `backend/src/domain/rule.rs` lines 14-302 | | Draft status integration coverage | `backend/tests/rule_draft_status.rs` | | CLI contract | `api-contracts/loyaltyrails-cli.v1.openapi.json` paths `/admin/v1/programs/{programId}/rules` and `/rules/simulate`, and schemas `RuleType`/`CreateRuleRequest`/`SimulateRuleRequest` | | CLI commands | `packages/cli/src/commands.ts` `rules` command group (`parseRuleType` restricts `--type` to `bonus`/`promotion`/`decay`) | --- # Games API Source: ../docs/api/games.md URL: https://docs.rails.sh/games-api Markdown: https://docs.rails.sh/markdown/games-api.md # Games API Source-derived status: the game surface runtime routes are implemented and declared in the CLI OpenAPI contract. Experience and campaign setup routes are implemented under the admin API; the core list/create/publish/activate/attach routes are contract-declared, while additional admin routes such as prize pools, sessions, personalization rules, pause/schedule, audience preview, experience metrics/preview, leaderboards, and the question bank are backend-declared but not all in the CLI contract. ## Runtime Route Summary | Method | Path | Status | Auth | | --- | --- | --- | --- | | `POST` | `/internal/v1/games/resolve` | Implemented, contract-declared | Integration API key with `read`, `write`, or `admin` scope | | `POST` | `/internal/v1/games/sessions` | Implemented, contract-declared | Integration API key with `write` or `admin` scope | | `POST` | `/internal/v1/games/sessions/{sessionId}/actions` | Implemented, contract-declared | Integration API key with `write` or `admin` scope | | `POST` | `/internal/v1/games/sessions/{sessionId}/complete` | Implemented, contract-declared | Integration API key with `write` or `admin` scope | | `GET` | `/internal/v1/games/programs/{programId}/members/{memberId}/reward-state` | Implemented, backend-only | Integration API key with `read`, `write`, or `admin` scope | Runtime routes are server-to-server. Browser apps should call a host app route that holds the integration API key server-side. ## Setup Route Summary | Method | Path | Status | Auth | | --- | --- | --- | --- | | `GET`/`POST` | `/admin/v1/programs/{programId}/experiences` | Implemented, contract-declared | `games:read` for GET, `games:create` for POST | | `GET`/`PUT` | `/admin/v1/experiences/{experienceId}` | Implemented | `games:read` for GET, `games:update` for PUT | | `GET` | `/admin/v1/experiences/{experienceId}/metrics` | Implemented | `games:read` | | `POST` | `/admin/v1/experiences/{experienceId}/publish` | Implemented, contract-declared | `games:update` | | `POST` | `/admin/v1/experiences/{experienceId}/pause` | Implemented | `games:update` | | `GET`/`POST` | `/admin/v1/experiences/{experienceId}/prize-pool` | Implemented | `games:read` for GET, `games:update` for POST | | `GET` | `/admin/v1/experiences/{experienceId}/sessions` | Implemented | `games:read` | | `POST` | `/admin/v1/experiences/{experienceId}/preview` | Implemented | `games:read` (cold-path dry run; no inventory mutated) | | `GET`/`POST` | `/admin/v1/programs/{programId}/leaderboards` | Implemented | `games:read` for GET, `games:create` for POST | | `GET` | `/admin/v1/leaderboards/{leaderboardId}` | Implemented | `games:read` | | `POST` | `/admin/v1/leaderboards/{leaderboardId}/rebuild-ranks` | Implemented | `games:update` | | `GET` | `/admin/v1/leaderboards/{leaderboardId}/sse` | Implemented | `games:read` (server-sent events, polled every 5s) | | `GET`/`POST` | `/admin/v1/programs/{programId}/campaigns` | Implemented, contract-declared | `games:read` for GET, `games:create` for POST | | `GET`/`PUT` | `/admin/v1/campaigns/{campaignId}` | Implemented | `games:read` for GET, `games:update` for PUT | | `POST` | `/admin/v1/campaigns/{campaignId}/activate` | Implemented, contract-declared | `games:update` | | `POST` | `/admin/v1/campaigns/{campaignId}/schedule` | Implemented | `games:update` | | `POST` | `/admin/v1/campaigns/{campaignId}/pause` | Implemented | `games:update` | | `POST` | `/admin/v1/campaigns/{campaignId}/experiences` | Implemented, contract-declared | `games:update` | | `DELETE` | `/admin/v1/campaigns/{campaignId}/experiences/{experienceId}` | Implemented | `games:update` | | `GET`/`POST` | `/admin/v1/campaigns/{campaignId}/personalization-rules` | Implemented | `games:read` for GET, `games:update` for POST | | `GET` | `/admin/v1/campaigns/{campaignId}/audience/preview` | Implemented | `games:read` | | `POST` | `/admin/v1/members/{memberId}/segments` | Implemented | `games:update` | | `GET`/`POST` | `/admin/v1/programs/{programId}/questions` | Implemented | `content:read` for GET, `content:generate` for POST | | `GET`/`DELETE` | `/admin/v1/questions/{questionId}` | Implemented | `content:read` for GET, `content:approve` for DELETE | | `POST` | `/admin/v1/questions/{questionId}/status` | Implemented | `content:approve` | | `POST` | `/admin/v1/programs/{programId}/questions/generate` | Implemented | `content:generate` (LLM-backed) | ## Auth Model Runtime APIs use `X-API-Key` on `/internal/v1/games/*`. Setup/admin APIs use `/admin/v1/*` with browser session or operator CLI token. A deprecated `/internal/v1/admin/*` mount exists for DB-backed API keys with `admin` scope, but new tooling should use operator tokens or sessions. Permission scopes on setup routes are `games:read` / `games:create` / `games:update` for experiences, leaderboards, campaigns, and member-segment overrides. The question bank uses a separate `content:*` permission family (`content:read`, `content:generate`, `content:approve`) shared with the intelligence/content-generation surface, not `games:*`. ## Experience vs Campaign An experience is the game definition: mechanic, display config, visual theme, session policy, and publish status. Supported mechanics are `spin_wheel`, `onboarding_challenge`, `scratch_off`, and `quiz_trivia`. A campaign packages one or more experiences with audience targeting, schedule, budget config, and personalization. Attaching an experience to any campaign flips its `campaign_gated` flag; at runtime, only active attached campaigns can make that experience visible through campaign resolution. Runtime `resolve` may return both standalone experiences and campaign-provided experiences; campaign-provided entries include `campaignId`. ## Leaderboards A leaderboard is optionally scoped to one experience (`experienceId`) and ranks members by a `scoreStrategy` (for example `sum`). `GET /admin/v1/leaderboards/{id}` and the `rebuild-ranks` endpoint return the leaderboard plus ranked entries (`memberId`, `displayName`, `score`, `rank`, `tierSnapshot`, and, when the leaderboard has an `experienceId`, `streakCount` and `trajectory`). Display names are deterministically anonymized from tier + member id — no PII is exposed. The `sse` endpoint streams the same entry shape as a `leaderboard` server-sent event, polling every 5 seconds. ## Question Bank The question bank stores reusable `onboarding_challenge` questions per program, independent of any single experience. Questions carry a `category`, `questionType`, optional `options`, a `captureField` (the member-profile field an answer is written to), a `source` (`manual` or LLM-generated), `priority`, and audience `conditions`. `POST /admin/v1/programs/{programId}/questions/generate` drafts a new question via the LLM content pipeline; `POST /admin/v1/questions/{questionId}/status` transitions a question's approval status. Question bank routes are gated by `content:*` permissions, not `games:*` — see Auth Model above. ## Resolve Playable Experiences ```http POST /internal/v1/games/resolve X-API-Key: lr_test_... Content-Type: application/json ``` ```json { "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111" } ``` Illustrative response: ```json [ { "experienceId": "4c8bcfd5-4122-4dd4-9d96-7ac066411111", "mechanicType": "spin_wheel", "playableConfig": { "mechanicType": "spin_wheel", "segments": [ { "label": "100 points", "color": "#22c55e" }, { "label": "Try again", "color": "#94a3b8" } ], "spinDurationMs": 3000 }, "displayConfig": {}, "renderedContent": "Spin for a reward", "campaignId": "5c21d943-c848-4452-8ca9-654ec9c11111", "personalized": false, "visualTheme": {}, "sessionExpirySeconds": 1800 } ] ``` `playableConfig` is client-safe. For example, spin-wheel prize keys and weights are stripped; quiz correct answers are not included in resolve output. ## Start Session ```http POST /internal/v1/games/sessions X-API-Key: lr_test_... Content-Type: application/json ``` ```json { "experienceId": "4c8bcfd5-4122-4dd4-9d96-7ac066411111", "memberId": "8d7f5f18-4a35-4c73-9c58-0e6e7d2cb111", "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "campaignId": "5c21d943-c848-4452-8ca9-654ec9c11111", "idempotencyKey": "game_session_123", "surface": "web", "fingerprint": "optional-device-or-session-ref" } ``` Response: ```json { "sessionId": "11cc7691-f220-4493-9d88-655d50a11111", "experienceId": "4c8bcfd5-4122-4dd4-9d96-7ac066411111", "campaignId": "5c21d943-c848-4452-8ca9-654ec9c11111", "maxActions": 1, "expiresAt": "2026-05-02T15:34:05Z", "alreadyExisted": false } ``` Session start is idempotent by `(programId, idempotencyKey)`. Reusing the key with the same member and experience returns the existing session with `alreadyExisted: true`; reusing it with a different member or experience is rejected. Campaign-gated experiences require a `campaignId` from `resolve`. When supplied, the engine checks that the campaign is active, attached to the experience, and that the member matches the campaign audience. ## Submit Action ```http POST /internal/v1/games/sessions/{sessionId}/actions X-API-Key: lr_test_... Content-Type: application/json ``` ```json { "sequence": 1, "actionType": "spin", "payload": {} } ``` Illustrative response: ```json { "sessionId": "11cc7691-f220-4493-9d88-655d50a11111", "sequence": 1, "mechanicState": { "mechanicType": "spin_wheel", "segment_index": 0 }, "prize": { "prizeKey": "points_100", "prizeLabel": "100 points", "prizeValueCents": 0, "isNoPrize": false, "segmentIndex": 0 }, "outcomesApplied": ["Awarded 100 points"], "sessionComplete": true } ``` `mechanicState` is internally tagged on `mechanicType` (not `type`), and its per-mechanic fields keep their Rust snake_case names (`segment_index`, `cells_revealed`, `total_cells`, `correct_index`, `points_earned`, `correct_count`, `total_questions`, `step_completed`, `total_steps`) rather than the camelCase used elsewhere in the response — the enum's field-level serde attributes don't inherit the container's `rename_all`. Mechanic-specific payloads: | Mechanic | Action payload | | --- | --- | | `spin_wheel` | Usually `{}`; one action auto-completes | | `onboarding_challenge` | `{ "answer": ... }`; answer is stored in member profile for the configured field | | `scratch_off` | `{ "cellIndex": 0 }`; repeated cells and out-of-range cells are rejected | | `quiz_trivia` | `{ "answerIndex": 1 }`; answer index is required | Actions are sequence-numbered starting at `1`; out-of-order submissions are rejected. ## Complete Session ```http POST /internal/v1/games/sessions/{sessionId}/complete X-API-Key: lr_test_... ``` Explicit completion is useful for multi-step mechanics. If the session is already completed, the endpoint returns the stored outcome summary. Otherwise it applies completion rewards where relevant and writes a `GameSessionCompleted` event. Illustrative response: ```json { "completed": true, "actionCount": 3, "outcomesApplied": ["Awarded 250 points"] } ``` ## Session IDs vs Generated Session Tokens The backend runtime API uses raw UUID `sessionId` path parameters for submit and complete. Generated app routes should not expose raw backend session IDs to browsers. The Next.js and Hydrogen recipes call `start`, remove `sessionId` from the browser response, and return a signed `sessionToken` instead. Later browser calls send `sessionToken`; the host route verifies the HMAC, checks the token belongs to the current member, extracts the backend `sessionId`, and proxies to rails. For generated routes, set `LOYALTYRAILS_GAME_SESSION_SECRET` server-side. It is a host-app signing secret, not a rails API credential. ## CLI Smoke Test ```bash loyaltyrails games resolve --member-id loyaltyrails games start \ --experience-id \ --member-id \ --campaign-id \ --idempotency-key game_session_123 loyaltyrails games submit \ --session-id \ --sequence 1 \ --action-type spin \ --payload '{}' loyaltyrails games complete --session-id ``` The CLI smoke commands use integration API-key profiles. Setup commands such as `experiences create`, `experiences publish`, `campaigns create`, and `campaigns activate` use operator tokens. ## Errors | HTTP | Notes | | --- | --- | | `400` | Invalid mechanic config, idempotency key reused for another member/experience, invalid sequence, invalid action payload, ineligible campaign | | `401` | Missing or invalid API key/session/operator token | | `403` | Missing API-key scope or admin permission | | `404` | Session, program, experience, campaign, or member not found within credential reach | | `429` | Session start rate limit exceeded | | `500` | Database, cache, prize pool, or outcome execution failure | ## Source References | Area | Source | | --- | --- | | Runtime route mounts | `backend/src/api/mod.rs` lines 834-852 | | Admin route mounts | `backend/src/api/mod.rs` games section in `build_admin_handler_routes` (lines ~352-469) | | Runtime DTOs and auth checks | `backend/src/games/api/surface.rs` | | Runtime response shapes | `backend/src/games/service/engine.rs` lines 31-85 | | Resolve behavior | `backend/src/games/service/engine.rs` `resolve_experience` | | Start idempotency, gating, max actions | `backend/src/games/service/engine.rs` `start_session` (lines 565-967) | | Submit and complete behavior | `backend/src/games/service/engine.rs` `submit_action` (line 967) and `complete_session` (line 1622) | | Experience admin DTOs | `backend/src/games/api/admin.rs` | | Campaign admin DTOs and lifecycle | `backend/src/games/api/campaigns.rs` and `backend/src/games/domain/campaign.rs` | | Mechanic-state wire shape | `backend/src/games/domain/types.rs` `MechanicState` | | Leaderboard admin DTOs | `backend/src/games/api/leaderboard.rs` | | Question bank admin DTOs | `backend/src/games/api/question_bank.rs` | | Setup-route permission mapping | `backend/src/api/middleware/admin_permissions.rs` | | CLI contract | `api-contracts/loyaltyrails-cli.v1.openapi.json` `/internal/v1/games/*`, experiences, and campaigns paths | | Generated session-token route | `packages/cli/src/integration/next-app-router.ts` `gameSessionRouteContent` | --- # Campaigns API Source: ../docs/api/campaigns.md URL: https://docs.rails.sh/campaigns-api Markdown: https://docs.rails.sh/markdown/campaigns-api.md # Campaigns API Source-derived status: campaign create/read/list/update, the schedule / activate / pause lifecycle, experience attachment, personalization rules, audience preview, and manual member-segment tagging are all implemented, dual-mounted on `/admin/v1` (session/operator, primary) and the deprecated `/internal/v1/admin` API-key mirror — the same pattern documented in [Programs API](./programs.md#auth-model). This page is the deep dive on campaign CRUD, scheduling, and audience targeting. [Games API](./games.md#experience-vs-campaign) covers how an active campaign gates experience visibility at resolve time; read that page for the runtime (member-facing) side of campaigns. ## Route Summary | Method | Path | Auth | | --- | --- | --- | | `POST` | `/admin/v1/programs/{programId}/campaigns` | Session/operator with `games:create`; deprecated API-key mirror also works | | `GET` | `/admin/v1/programs/{programId}/campaigns` | Session/operator with `games:read`; deprecated API-key mirror also works | | `GET` | `/admin/v1/campaigns/{campaignId}` | Session/operator with `games:read`; deprecated API-key mirror also works | | `PUT` | `/admin/v1/campaigns/{campaignId}` | Session/operator with `games:update`; deprecated API-key mirror also works | | `POST` | `/admin/v1/campaigns/{campaignId}/activate` | Session/operator with `games:update`; deprecated API-key mirror also works | | `POST` | `/admin/v1/campaigns/{campaignId}/schedule` | Session/operator with `games:update`; deprecated API-key mirror also works | | `POST` | `/admin/v1/campaigns/{campaignId}/pause` | Session/operator with `games:update`; deprecated API-key mirror also works | | `POST` | `/admin/v1/campaigns/{campaignId}/experiences` | Session/operator with `games:update`; deprecated API-key mirror also works | | `DELETE` | `/admin/v1/campaigns/{campaignId}/experiences/{experienceId}` | Session/operator with `games:update`; deprecated API-key mirror also works | | `GET` | `/admin/v1/campaigns/{campaignId}/personalization-rules` | Session/operator with `games:read`; deprecated API-key mirror also works | | `POST` | `/admin/v1/campaigns/{campaignId}/personalization-rules` | Session/operator with `games:update`; deprecated API-key mirror also works | | `GET` | `/admin/v1/campaigns/{campaignId}/audience/preview` | Session/operator with `games:read`; deprecated API-key mirror also works | | `POST` | `/admin/v1/members/{memberId}/segments` | Session/operator with `games:update`; deprecated API-key mirror also works | There is no dedicated `resume` route — a paused campaign is resumed the same way it is first activated, by calling `POST .../campaigns/{campaignId}/activate` again (see [Campaign Lifecycle](#campaign-lifecycle) below). There is also no `complete` or `archive` route: the domain model defines `completed` and `archived` statuses, but no handler on this surface ever sets them — a campaign's only reachable statuses today are `draft`, `scheduled`, `active`, and `paused`. Campaigns end in practice by setting `endsAt` (the runtime resolve path stops surfacing the campaign once `endsAt` passes) or by pausing it. ## Auth Model Campaign routes use the same admin auth stack as [Programs API](./programs.md#auth-model): browser session cookies or operator CLI tokens on `/admin/v1`, and a deprecated API-key mount at `/internal/v1/admin`. Route permissions are feature permissions (`games:read`, `games:create`, `games:update`), not API-key scopes. Every route is also tenant-scoped to the campaign's (or, for `/members/{memberId}/segments`, the member's) owning program: the caller's organization must own that program, verified by resolving the campaign or member to its `program_id` and checking it against the caller's organization scope, the same tenant-scoping check documented in [Program Ownership](./programs.md#program-ownership). Platform-scope (cross-org) access requires an explicit `X-Org-Scope-Reason` header and is audit-logged. ## Create And List Campaigns ```http POST /admin/v1/programs/{programId}/campaigns Cookie: Content-Type: application/json ``` ```json { "name": "Summer Double Points", "description": "Double points on purchases for gold-tier members", "campaignType": "manual", "audienceConfig": { "targetAll": false, "conditions": [ { "type": "member_tier", "tiers": ["gold"] } ] }, "budgetConfig": { "maxPrizePerMemberCents": 5000 }, "startsAt": "2026-06-01T00:00:00Z", "endsAt": "2026-08-31T23:59:59Z" } ``` Only `name` is required. `campaignType` defaults to `manual` if omitted and must be one of `manual`, `scheduled`, `triggered`, `agent_assembled` (`CampaignType`). When omitted, `audienceConfig` defaults to `{"targetAll": false, "conditions": []}` and `budgetConfig` defaults to all four `BudgetConfig` keys serialized as `null` (see [Audience Targeting](#audience-targeting) and [Budget Config](#budget-config) below). A newly created campaign always starts in `draft` status regardless of `campaignType`, `startsAt`, or `endsAt` — creating a campaign never auto-activates or auto-schedules it. Response (`CampaignDto`): ```json { "id": "5c21d943-c848-4452-8ca9-654ec9c11111", "programId": "0a7a4b14-8a1d-4fef-8d2b-5d49cc6c55fd", "name": "Summer Double Points", "description": "Double points on purchases for gold-tier members", "campaignType": "manual", "status": "draft", "startsAt": "2026-06-01T00:00:00Z", "endsAt": "2026-08-31T23:59:59Z", "audienceConfig": { "targetAll": false, "conditions": [ { "type": "member_tier", "tiers": ["gold"] } ] }, "budgetConfig": { "maxTotalPrizeCents": null, "maxPrizePerMemberCents": 5000, "maxSessionsTotal": null, "maxSessionsPerMember": null }, "createdBy": "operator:ops@acme.example", "version": 1, "createdAt": "2026-05-01T12:00:00Z", "updatedAt": "2026-05-01T12:00:00Z" } ``` `createdBy` is derived server-side from the caller's identity (`":"`, e.g. `operator:ops@acme.example` or `api_key:lr_live_...`) — a `createdBy` field in the request body is accepted for backward compatibility but ignored. `CampaignDto` never includes `agentContext` (an internal, agent-assembly-only field on the domain model); it is not part of the public response shape. `budgetConfig` has no field-level omit-if-unset behavior: all four `BudgetConfig` keys are always present in the response, with unset ones serialized as literal `null` (as shown above) rather than being left out — unlike `description`, which is a plain `Option` that also serializes as `null` when unset, `budgetConfig` itself is never omitted or `{}`. ```http GET /admin/v1/programs/{programId}/campaigns?status=active&limit=20 Cookie: ``` Response is a bare array of `CampaignDto` (same shape as above), newest first. `status` is an optional exact-match filter (`draft`, `scheduled`, `active`, `paused`, `completed`, or `archived`); `limit` defaults to `50`. ```http GET /admin/v1/campaigns/{campaignId} Cookie: ``` Returns a single `CampaignDto`, `404` if not found or outside the caller's tenant reach. ## Update Campaign ```http PUT /admin/v1/campaigns/{campaignId} Cookie: Content-Type: application/json ``` ```json { "name": "Summer Double Points (Extended)", "description": "Double points on purchases for gold-tier members", "audienceConfig": { "targetAll": false, "conditions": [ { "type": "member_tier", "tiers": ["gold", "platinum"] } ] }, "budgetConfig": { "maxPrizePerMemberCents": 5000 }, "startsAt": "2026-06-01T00:00:00Z", "endsAt": "2026-09-30T23:59:59Z" } ``` This is a **full-replace update**: `name` is the only required field, but every other field is replaced wholesale with whatever the request supplies — there is no partial-patch semantics. Omitting `audienceConfig` resets it to its empty default (`{"targetAll": false, "conditions": []}`, wiping out existing targeting), and omitting `budgetConfig` resets every `BudgetConfig` field to `null` (serialized as all four keys with `null` values, per [Budget Config](#budget-config) below — not `{}`), wiping out existing budget caps. Omitting `description`, `startsAt`, or `endsAt` likewise clears each to `null`. Always send the full desired campaign shape, not a diff. The update is rejected with `400` if the campaign is not currently `draft`, `scheduled`, or `paused` — an `active` (or `completed`/`archived`) campaign cannot be updated in place; pause it first. Response is the updated `CampaignDto`. ## Campaign Lifecycle ```http POST /admin/v1/campaigns/{campaignId}/schedule Cookie: ``` `draft` → `scheduled`. Rejected with `400` if the campaign is not currently `draft`, or if it has no `startsAt` set (a scheduled campaign without a start time could never be picked up by the activation worker). ```http POST /admin/v1/campaigns/{campaignId}/activate Cookie: ``` `draft`, `scheduled`, **or `paused`** → `active`. This is the only way to resume a paused campaign — there is no separate `resume` endpoint; calling `activate` again on a `paused` campaign moves it back to `active`. Rejected with `400` from any other status (including `active` itself — calling activate twice is not a no-op). ```http POST /admin/v1/campaigns/{campaignId}/pause Cookie: ``` `active` → `paused` only. Rejected with `400` (`"Campaign not found or not currently active"`) if the campaign isn't currently `active`. Response (all three; `CampaignDto`) reflects the new `status`. All three routes take no request body. There is no `complete` transition on this API. `CampaignStatus` includes `completed` and `archived` variants and the domain model has an internal `complete()` method, but no route calls it — it is unreachable dead code today. A campaign's natural end is its `endsAt` timestamp (the runtime resolve path in [Games API](./games.md#experience-vs-campaign) stops surfacing it once `endsAt` passes, without changing its stored `status`), or an operator pausing it. ## Audience Targeting `audienceConfig` (`AudienceConfig`) has two fields: ```json { "targetAll": false, "conditions": [ { "type": "member_tier", "tiers": ["gold", "platinum"] } ] } ``` - `targetAll: true` matches every member in the program; `conditions` must be empty in that case — sending both `targetAll: true` and a non-empty `conditions` array is rejected with `400` as ambiguous. - With `targetAll: false`, each entry in `conditions` is a tagged object (`"type"` selects the variant) and **all** conditions must match a member for the campaign to apply (AND semantics). An empty `conditions` array with `targetAll: false` — the default when `audienceConfig` is omitted entirely — matches **every** member, not none; there is no "matches nobody" default. Be explicit: an intentionally narrow campaign needs at least one condition. - An unrecognized `"type"` value, or a condition object missing the `"type"` tag, fails to deserialize and the whole create/update request is rejected with `400` — it does not silently drop the bad condition. Audience-eligible condition types (usable in campaign `audienceConfig`): | `type` | Fields | Matches when | | --- | --- | --- | | `member_tier` | `tiers: string[]` | member's tier is (case-insensitively) one of `tiers` | | `member_segment` | `segments: string[]` | member has a segment matching one of `segments` (see [Member Segments](#member-segments)) | | `transaction_count` | `operator, count` | member's transaction count compares against `count` | | `consecutive_days` | `days: number` | member's consecutive active-days streak is `>= days` | | `max_days_since_activity` | `days: number` | member has activity within the last `days` days (no activity ever = no match) | | `engagement_tier` | `tiers: string[]` | member's computed engagement tier is one of `tiers` | | `profile_attribute` | `path, op, value` | the member profile's JSON-pointer field at `path` compares against `value` | | `member_ids` | `ids: string[]` (UUIDs) | member's id is in `ids` | `operator`/`op` is one of `eq`, `ne`, `lt`, `lte`, `gt`, `gte` (`ComparisonOp`). Two further condition types — `progression_level` and `progression_xp` — exist on the same enum but are **personalization-only**; using either in a campaign's `audienceConfig` is rejected with `400` (they are valid in [personalization rule](#personalization-rules) `conditions` instead, where they can reference a member's progress on the specific experience being resolved). ### Audience Preview ```http GET /admin/v1/campaigns/{campaignId}/audience/preview Cookie: ``` For `targetAll: true`, returns a count of the program's active members: ```json { "campaignId": "5c21d943-c848-4452-8ca9-654ec9c11111", "targetAll": true, "estimatedSize": 4820 } ``` For a conditioned audience, evaluates conditions against up to 10,000 sampled active members in the program (not the full membership for very large programs): ```json { "campaignId": "5c21d943-c848-4452-8ca9-654ec9c11111", "targetAll": false, "matchingMembers": 612, "sampleSize": 10000, "conditionCount": 1 } ``` ## Budget Config `budgetConfig` (`BudgetConfig`) is stored as-is and echoed back on every campaign response; all fields are optional: ```json { "maxTotalPrizeCents": 500000, "maxPrizePerMemberCents": 5000, "maxSessionsTotal": 10000, "maxSessionsPerMember": 20 } ``` Only `maxPrizePerMemberCents` is currently enforced — the reward engine reads it at prize-award time and caps payouts per member for sessions attributed to this campaign. `maxTotalPrizeCents`, `maxSessionsTotal`, and `maxSessionsPerMember` are persisted and returned but not enforced by any running code path today; treat them as reserved/advisory until a future release wires them up. ## Experience Attachment ```http POST /admin/v1/campaigns/{campaignId}/experiences Cookie: Content-Type: application/json ``` ```json { "experienceId": "b2e5b6e0-1111-2222-3333-444455556666", "priority": 10 } ``` Attaches an experience to the campaign (flips the experience's `campaign_gated` flag — see [Games API](./games.md#experience-vs-campaign)). Rejected with `400` if the experience belongs to a different program than the campaign, or is already attached. `priority` defaults to `0`. Response: ```json { "id": "9f1c...", "campaignId": "5c21d943-c848-4452-8ca9-654ec9c11111", "experienceId": "b2e5b6e0-1111-2222-3333-444455556666" } ``` ```http DELETE /admin/v1/campaigns/{campaignId}/experiences/{experienceId} Cookie: ``` Detaches the experience; `404` if it wasn't attached to this campaign. Response: `{ "status": "detached" }`. ## Personalization Rules Personalization rules layer per-member configuration overrides on top of a campaign (or a standalone experience) without changing audience eligibility — they run *after* a member is already deemed eligible. ```http POST /admin/v1/campaigns/{campaignId}/personalization-rules Cookie: Content-Type: application/json ``` ```json { "experienceId": "b2e5b6e0-1111-2222-3333-444455556666", "name": "Boost VIP spin value", "description": "VIP segment gets a higher prize multiplier", "conditions": [ { "type": "member_segment", "segments": ["vip"] } ], "configOverrides": { "prize_value_multiplier": 1.5 }, "priority": 10 } ``` `experienceId` is optional — omit it for a campaign-wide rule, or set it to require the experience to already be attached to this campaign (`400` if it isn't). Unlike campaign `audienceConfig`, `conditions` here may freely use the personalization-only `progression_level` / `progression_xp` types alongside the audience-eligible ones. `configOverrides` is validated to reject any key or value that looks like it would introduce or mutate a stablecoin reward outcome (asset codes, amounts, provider identifiers) — configure stablecoin rewards on the experience or prize pool directly instead; see [Stablecoin Rewards](../stablecoin-rewards.md). Response (`PersonalizationRuleDto`) includes a server-assigned `status` of `"active"` (a rule can later be `"paused"` or `"archived"`, though no route on this page exposes changing it). ```http GET /admin/v1/campaigns/{campaignId}/personalization-rules Cookie: ``` Returns a bare array of `PersonalizationRuleDto` for the campaign (up to 100), ordered by priority. ## Member Segments Manual segment tagging feeds the `member_segment` audience condition above. ```http POST /admin/v1/members/{memberId}/segments Cookie: Content-Type: application/json ``` ```json { "action": "add", "segment": "vip" } ``` `action` is `add` or `remove` (`400` otherwise). `segment` is a bare label — the server namespaces it with a `manual:` prefix before storing it (so `"vip"` is stored as `"manual:vip"`), keeping manually-tagged segments distinguishable from rule- or system-computed ones. A `member_segment` audience condition matches on the bare label against any namespace, so `{"type": "member_segment", "segments": ["vip"]}` matches a member tagged `manual:vip` (or `rule:vip`, `computed:vip`). Response: ```json { "memberId": "c9d1...", "segments": ["manual:vip"] } ``` ## Errors | HTTP | Notes | | --- | --- | | `400` | Malformed JSON, invalid `campaignType`/audience condition shape, ambiguous `targetAll`+`conditions` combination, a personalization-only condition used in campaign audience targeting, a stablecoin-shaped `configOverrides` key/value, an experience from a different program, a duplicate experience attachment, or a lifecycle no-op/invalid-state guard (schedule-without-`startsAt`, activate-from-`active`, pause-when-not-`active`, update-on-non-draft/scheduled/paused) | | `401` | Missing or invalid API key/session/operator token | | `403` | Valid credential lacks the required `games:*` permission, or a tenant-scope violation | | `404` | Campaign, program, experience, or member not found, or outside credential/tenant reach | | `500` | Database or event-store failure | ## Source References | Area | Source | | --- | --- | | Campaign admin route mounts (dual-mounted at `/admin/v1` and `/internal/v1/admin`) | `backend/src/api/mod.rs` lines 407–450 | | Route permission mapping (`games:read`/`games:create`/`games:update`) | `backend/src/api/middleware/admin_permissions.rs` lines 300–322, 172 | | Campaign handlers (`create_campaign`, `list_campaigns`, `get_campaign`, `update_campaign`) | `backend/src/games/api/campaigns.rs` lines 204–390 | | Lifecycle handlers (`activate_campaign`, `pause_campaign`, `schedule_campaign`) | `backend/src/games/api/campaigns.rs` lines 394–626 | | Experience attach/detach handlers | `backend/src/games/api/campaigns.rs` lines 630–773 | | Personalization rule handlers | `backend/src/games/api/campaigns.rs` lines 777–1109 | | Audience preview handler | `backend/src/games/api/campaigns.rs` lines 1113–1187 | | Member segment handler | `backend/src/games/api/campaigns.rs` lines 1196–1274 | | `CampaignDto`, `CreateCampaignRequest`, `UpdateCampaignRequest`, `PersonalizationRuleDto` | `backend/src/games/api/campaigns.rs` lines 26–193 | | `Campaign`, `CampaignType`, `CampaignStatus`, `AudienceConfig`, `AudienceCondition`, `ComparisonOp`, `BudgetConfig` domain model and lifecycle transitions | `backend/src/games/domain/campaign.rs` | | `AudienceConfig::matches_member` / condition evaluation (audience-matching semantics) | `backend/src/games/service/audience.rs` lines 220–234, 101–151 | | Campaign repository (`update` status guard, `find_by_program`, `find_active_for_program`) | `backend/src/games/repository/campaigns.rs` | | Per-member budget cap enforcement (`maxPrizePerMemberCents`) | `backend/src/games/service/engine.rs` lines 2240–2274 | | Tenant scoping (`OrgScope`, `enforce_program_scope`) | `backend/src/api/admin/org_scope.rs` | | Campaign-gated experience resolution (runtime/member-facing side) | [Games API](./games.md#experience-vs-campaign) | --- # CLI Source: ../docs/cli.md URL: https://docs.rails.sh/cli Markdown: https://docs.rails.sh/markdown/cli.md # rails CLI The rails CLI helps developers wire an existing app to rails from inside the app repository. The MVP supports Next.js App Router projects, Shopify Hydrogen config detection, integration API-key auth, local health checks, event smoke requests, member balance smoke requests, operator-token program/API-key/rule/campaign/game setup management, and generated Next.js recipes. ## Run From The Monorepo ```bash pnpm install pnpm --filter @loyaltyrails/cli build node packages/cli/dist/index.js --help ``` The package exposes both `loyaltyrails` and `lr` binaries when installed as `@loyaltyrails/cli`. ## Authenticate Use an integration API key, not an operator session and not the static bootstrap key. ```bash loyaltyrails auth login --api-key lr_test_... --api-url http://localhost:8080 loyaltyrails auth status ``` Login introspects the key and stores: - API URL. - Key prefix in profile metadata. - Environment. - Permission scopes. - Program reach. - Accessible program metadata when available. The raw API key is stored separately in the local CLI credential store so later commands can authenticate. Treat that credential store as sensitive local machine state. Project config and command output should only contain non-secret metadata such as the key prefix. Generated app integrations require a program-scoped, non-admin key. All-program or admin keys are rejected for generated app env/config writes. ## Pair This Machine (Browser Approval) `loyaltyrails auth login --pair` binds this machine to your account without ever typing a secret into the terminal: ```bash loyaltyrails auth login --pair loyaltyrails auth login --pair --no-browser ``` The CLI starts a browser-approval (device) flow: it prints a device code and an approval URL, and opens the URL in your browser unless `--no-browser` is passed. Confirm the device code shown in the browser matches your terminal, then approve. The CLI polls until you decide and stores the issued machine token locally. This machine token is scoped narrowly to two things: `loyaltyrails chat` (the agent JWT exchange) and `loyaltyrails join` (claiming a share/pairing code, see [Demo Handoff](#demo-handoff)). It is never accepted on admin or integration-key endpoints, and approving a pairing in the browser requires a freshly stepped-up session. ## Operator Auth Gate Control-plane commands are separate from integration API-key auth. The backend now supports session-issued operator CLI tokens: ```txt POST /admin/v1/auth/cli-tokens GET /admin/v1/auth/cli-token/introspect DELETE /admin/v1/auth/cli-tokens/:token_id ``` Tokens use the `lr_cli_` prefix, are presented as `Authorization: Bearer`, and are accepted on `/admin/v1/*` control-plane routes that have explicit operator permission policies. They are not accepted by generated app integrations and must not be written to host app env files. Store an issued operator token locally with: ```bash loyaltyrails auth login --operator-token lr_cli_... --api-url http://localhost:8080 ``` Program, API-key, rule, experience, and campaign management commands are available for operator-token profiles. Account/MFA/session management remains browser-session only. ## Manage Programs These commands require an operator-token profile: ```bash loyaltyrails program list loyaltyrails program create \ --name "Acme Rewards" \ --symbol ACME \ --issuer "Acme" \ --reserve-ratio 8000 loyaltyrails program status ``` `program list` and `program status` require `programs:read`; `program create` requires `programs:create`. The backend also enforces tenant scope for the operator token. ## Manage API Keys These commands require an operator-token profile with `api_keys:manage`: ```bash loyaltyrails keys list --program loyaltyrails keys create \ --program \ --name "Web app" \ --scopes read,write \ --environment test loyaltyrails keys rotate loyaltyrails keys revoke ``` All-program key operations require platform scope and an audit reason: ```bash loyaltyrails keys list --all-program --reason "support investigation" loyaltyrails keys create \ --all-program \ --name "Platform admin" \ --scopes admin \ --reason "support investigation" ``` Created and rotated raw keys are shown once. Use `--save-profile ` on `keys create` or `keys rotate` to immediately store the generated key as a local integration profile. ## Manage Rules These commands require an operator-token profile. Listing and simulation require `rules:read`; creation requires `rules:create`. The backend stamps attribution from the authenticated operator token and enforces tenant scope for the target program. ```bash loyaltyrails rules list --program loyaltyrails rules create \ --program \ --name "Tuesday 2x" \ --type bonus \ --config '{"type":"bonus","multiplier":2}' \ --conditions '[{"type":"day_of_week","days":[2]}]' \ --status draft loyaltyrails rules simulate \ --program \ --config '{"type":"bonus","multiplier":2}' \ --test-transactions '[{"amount":100}]' ``` `--config`, `--conditions`, `--builder-metadata`, and `--test-transactions` accept inline JSON or a file path. Prefixing a file path with `@` is also accepted. ## Manage Experiences These commands require an operator-token profile. Listing requires `games:read`, creation requires `games:create`, and publishing requires `games:update`. ```bash loyaltyrails experiences list --program loyaltyrails experiences create \ --program \ --name "Spin to win" \ --mechanic spin_wheel \ --mechanic-config '{"segments":[]}' loyaltyrails experiences publish ``` The backend derives creator attribution from the authenticated operator and enforces tenant scope for the owning program. Config options accept inline JSON or a file path. ## Manage Campaigns These commands require an operator-token profile. Listing requires `games:read`, creation requires `games:create`, and activation/experience attachment require `games:update`. ```bash loyaltyrails campaigns list --program --status draft --limit 10 loyaltyrails campaigns create \ --program \ --name "Spring launch" \ --type manual \ --audience-config '{"targetAll":true}' loyaltyrails campaigns attach-experience \ --experience \ --priority 5 loyaltyrails campaigns activate ``` Campaign and experience setup commands are intentionally operator-auth only. Generated storefront integrations should still use the integration-auth game runtime commands below. ## Initialize A Next.js App Router Project Run this inside the host app: ```bash loyaltyrails init \ --program \ --external-id-type customer_id \ --identity-strategy custom ``` This writes non-secret project metadata to `.loyaltyrails/config.json` and tracks generated files in `.loyaltyrails/manifest.json`. By default, `init` does not write `.env.local`. To write local development env values, use an explicit confirmation: ```bash loyaltyrails init --write-env --yes ``` Production secrets should be configured in the host deployment platform, not by committing generated env files. ## Agentic Integration Recipes The CLI uses **integration recipes** for host-app code changes. A recipe is a reviewed implementation path with project detection, dry-run output, secret-safety scanning, idempotent manifests, and generated-file markers. It is not the same thing as a backend vendor connection or runtime adapter. For example: - `shopify` is a backend/vendor namespace. - `hydrogen` is a storefront/framework adapter detected by the CLI. - `loyaltyrails integrate hydrogen` is the Shopify Hydrogen integration recipe. Recipes are designed to be adapted to the customer's architecture by the CLI and agentic apply flow. They should preserve existing routing, auth, deployment, and identity patterns instead of assuming every third-party implementation is the same. ## Report Recipe Next Steps Use the integration recipe report when a developer or coding agent needs the next safe action before live vendor credentials exist: ```bash loyaltyrails integrate plan loyaltyrails integrate plan --json ``` The report is read-only. It detects the local project, reads `.loyaltyrails/config.json` and `.loyaltyrails/manifest.json` when present, and does not read raw credentials, call the network, write files, or invoke package managers. The current report focuses on the MVP agentic integration layer tracks: - Shopify/Hydrogen storefront recipe. - Braze backend readiness recipe. - mParticle backend readiness recipe. - SFMC backend readiness recipe. Structured output includes status, blockers, next commands, expected files, credential mode, and taxonomy for each track: - `vendorNamespace`: `shopify`, `braze`, `mparticle`, or `salesforce_marketing_cloud` (`sfmc` is accepted as the CLI alias). - `frameworkAdapter`: `hydrogen` for Shopify Hydrogen, or the initialized local framework adapter for backend readiness recipes. - `integrationRecipe`: the reviewed CLI recipe command, such as `integrate hydrogen --webhooks` or `integrate vendor mparticle`. - `runtimeAdapter`: `none` for this workflow. `runtimeAdapter: "none"` is intentional. These reports and readiness recipes help plan customer-owned code and backend connection preparation; they do not claim that LoyaltyRails owns live vendor dispatch, retries, reconciliation, or credential resolution. ## Generate Backend Vendor Readiness Recipes Backend vendor readiness recipes align a host application with an approved LoyaltyRails backend integration contract. They do not create live vendor dispatchers, call vendor networks, or store raw credentials. Supported readiness recipes: - `braze` - `mparticle` - `salesforce_marketing_cloud` (alias: `sfmc`) Preview and apply: ```bash loyaltyrails integrate vendor braze --dry-run loyaltyrails integrate vendor mparticle --dry-run loyaltyrails integrate vendor sfmc ``` The command requires an initialized project so the generated recipe can record the current `frameworkAdapter` taxonomy. It writes `.loyaltyrails/integrations/-readiness.md`, updates `.loyaltyrails/config.json` with the installed readiness recipe, and updates `.loyaltyrails/manifest.json` with vendor-specific taxonomy: - `vendorNamespace`: `braze`, `mparticle`, or `salesforce_marketing_cloud` - `frameworkAdapter`: the initialized local framework adapter - `integrationRecipe`: `integrate vendor ` - `runtimeAdapter`: `none` Credential fields are secret-manager references only. For example, mParticle readiness records `MPARTICLE_API_KEY_SECRET_REF` and `MPARTICLE_API_SECRET_SECRET_REF`; SFMC readiness records `SFMC_CLIENT_ID_SECRET_REF` and `SFMC_CLIENT_SECRET_SECRET_REF`. The plan-only agent scaffold can also route vendor requests to these recipes: ```bash loyaltyrails agent "integrate mParticle Events API" --preview loyaltyrails agent "integrate SFMC Journey Builder" --apply --yes ``` ## Draft Rule Vendor Actions Use `integrate rule-action` when a developer or coding agent needs a reviewed starter JSON payload for a rule-driven vendor outcome. This is a read-only surface: it does not create rules, call vendor APIs, write files, read secrets, or require live vendor credentials. Supported draft targets: - `shopify-hydrogen` (aliases: `shopify`, `hydrogen`) - `braze` - `mparticle` - `salesforce_marketing_cloud` (alias: `sfmc`) Examples: ```bash loyaltyrails integrate rule-action sfmc --json loyaltyrails integrate rule-action mparticle --event-type member.signup_completed loyaltyrails integrate rule-action shopify-hydrogen --action-key hydrogen_order_paid_outcome ``` The command prints a `CreateRuleRequest` draft with: - `ruleType: "integration_action"` - `config.type: "vendor_action"` - vendor-specific `vendorNamespace`, `actionType`, and `actionKey` - `status: "draft"` - an event-type condition and a payload template using rule, member, and event placeholders Shopify/Hydrogen drafts are intentionally marked draft-only until a backend Shopify or generic outbound webhook runtime adapter exists. Braze, mParticle, and SFMC drafts still require backend `integration_connections` metadata and secret references before live dispatch. ## Integrate A Shopify Hydrogen Storefront Hydrogen integration detects the storefront, writes LoyaltyRails project metadata, and generates the storefront balance and campaign game surfaces: ```bash loyaltyrails integrate hydrogen --dry-run loyaltyrails integrate hydrogen loyaltyrails integrate hydrogen --webhooks ``` Hydrogen setup defaults to: - `framework: "hydrogen"` - `adapter: "hydrogen"` - `vendorNamespace: "shopify"` - `frameworkAdapter: "hydrogen"` - `runtimeAdapter: "none"` - `externalIdType: "shopify_id"` - `identityStrategy: "shopify"` `runtimeAdapter: "none"` is intentional for the current CLI recipe: generated Hydrogen code runs in the customer storefront. It does not imply that LoyaltyRails owns Shopify Admin API calls, retry, reconciliation, or native Shopify webhook ingestion. Those belong to a future backend `shopify` runtime adapter. Generated Hydrogen files: - `app/lib/loyaltyrails.server.ts` - `app/routes/api.loyaltyrails.balance.ts` - `app/routes/api.loyaltyrails.games.ts` - `app/components/loyaltyrails/LoyaltyRailsBalance.tsx` - `app/components/loyaltyrails/LoyaltyRailsCampaignTiles.tsx` When `--webhooks` is passed, the CLI also generates: - `app/routes/api.loyaltyrails.webhooks.shopify.ts` The generated balance route resolves the signed-in Shopify customer through `context.customerAccount`, reads LoyaltyRails secrets from `context.env`, calls the program-scoped member balance lookup endpoint, and returns a typed 404 empty state when the Shopify customer does not have a LoyaltyRails member yet. The generated game route also resolves the LoyaltyRails member ID server-side, proxies game resolve/start/submit/complete operations, signs local `sessionToken` values with Web Crypto and `LOYALTYRAILS_GAME_SESSION_SECRET`, and rejects mutating requests that are not JSON or same-origin. Generated components only call host app routes and do not reference LoyaltyRails server secrets. The generated Shopify webhook route reads the raw request body, verifies `X-Shopify-Hmac-SHA256` with Web Crypto and `SHOPIFY_WEBHOOK_SECRET`, maps Shopify `orders/paid` webhooks to `transaction.completed`, and sends events through the program-scoped `/internal/v1/programs/:program_id/events` endpoint. Other Shopify order topics are acknowledged and skipped. The route uses `X-Shopify-Webhook-Id` in its idempotency key, requires a Shopify customer before emitting, and does not log raw Shopify payloads or customer PII. The command requires a generated-app-safe integration API-key profile: program scoped, non-admin, and write scoped for the storefront game surface. Operator tokens, all-program keys, admin keys, under-scoped keys, and keys scoped to a different program are rejected before project files or env files are written. By default, `integrate hydrogen` does not write `.env`. To write local development env values, use: ```bash loyaltyrails integrate hydrogen --write-env --yes ``` Oxygen production values should be configured in Shopify/Oxygen environment settings. Oxygen environment changes require a redeploy. `loyaltyrails doctor` warns when the Hydrogen game surface is installed and `LOYALTYRAILS_GAME_SESSION_SECRET` is missing, or when the Shopify webhook route is installed and `SHOPIFY_WEBHOOK_SECRET` is missing. ## Check Integration Health ```bash loyaltyrails doctor ``` `doctor` checks API health, auth profile, API-key introspection, program context, project detection, config validity, env var presence, manifest validity, generated file presence, and generated-file secret safety. It reports whether required env vars are present but does not print secret values. ## Check Outbound Dispatch Health ```bash loyaltyrails outbound health --program loyaltyrails outbound health --program --vendor mparticle loyaltyrails outbound health --program --vendor sfmc --window-hours 12 ``` The command uses operator-token auth and calls `GET /admin/v1/outbound/health`. It prints aggregate dispatch attempts, retry/dead-letter pressure, previous-window trends, and machine-readable alert codes. For mParticle and Salesforce Marketing Cloud connections, it also prints the vendor rollout stage, mapping/replay/live readiness booleans, worker enablement, credential namespace, and non-secret rollout checks. ## Snapshot Outbound Rollout Readiness ```bash loyaltyrails outbound snapshot --program \ --vendor mparticle \ --window-hours 24 \ --reason "daily rollout readiness capture" loyaltyrails outbound dashboard --program --vendor sfmc --limit 10 ``` `outbound snapshot` uses operator-token auth and calls `POST /admin/v1/outbound/health/snapshots`. It persists the same aggregate health/readiness view returned by `outbound health`, emits alert-history rows for warning or action-required groups, and writes an audit event with the operator reason. `outbound dashboard` reads `GET /admin/v1/outbound/health/snapshots` and prints recent health status, rollout stage, readiness booleans, worker flags, alert counts, alert codes, and rollout checks needing attention. These commands do not require live mParticle or Salesforce Marketing Cloud credentials. They operate on persisted connection/readiness/dispatch metadata; credential smoke remains a separate operator gate when real secret references are available. ## Simulate Outbound Vendor Readiness ```bash loyaltyrails outbound simulate --program \ --vendor mparticle \ --scenario accepted \ --reason "MVP demo" loyaltyrails outbound simulate --program \ --vendor sfmc \ --scenario rate-limited \ --reason "MVP demo" ``` The command uses operator-token auth and calls `POST /admin/v1/outbound/simulations`. `sfmc` is accepted as an alias for `salesforce_marketing_cloud`; CLI scenarios map to backend outcomes `accepted`, `retryable_failure`, `rate_limited`, or `terminal_failure`. Output includes the report status, replay reference when present, checks, message, and recommended next commands for `outbound snapshot`, `outbound dashboard`, and `outbound credential-smoke` when real credential references exist. It never reads local secrets and redacts token-like values from backend-provided text. Simulation does not write credential-smoke audit events and does not satisfy the `credential_smoke_passed` rollout gate. ## Smoke-Test Outbound Credentials ```bash loyaltyrails outbound credential-smoke \ --reason "pre-production credential verification" ``` The command uses operator-token auth and calls `POST /admin/v1/outbound/connections/:connection_id/credential-smoke`. It validates the mParticle or Salesforce Marketing Cloud connection config and resolves configured secret references through the backend secret resolver. The response prints check names, providers, status, and error codes only; it never prints raw secret references, locators, or resolved secret values. This action requires `programs:update` and writes an audit event with the operator reason. Outbound health treats a fresh successful credential smoke as a live-send promotion gate. If the integration connection changes after the latest smoke check, operators must rerun this command before the rollout can reach `live_send_ready`. ## Smoke-Test Events ```bash loyaltyrails events send \ --type transaction.completed \ --external-id customer_123 \ --external-id-type customer_id \ --idempotency-key order_123 \ --payload '{"amount":2500,"currency":"USD"}' ``` The command sends a single event to `/internal/v1/programs/:program_id/events`. The idempotency key should come from a stable merchant identifier such as an order ID, checkout ID, or webhook event ID. ## Smoke-Test Member Balances ```bash loyaltyrails members balance \ --external-id customer_123 \ --external-id-type customer_id ``` The command uses the program-scoped M2M balance endpoint: `/internal/v1/programs/:program_id/members/balance/lookup`. ## Smoke-Test Games Use the game commands to exercise the integration-auth surface contract after a member and published experience already exist. ```bash loyaltyrails games resolve --member-id loyaltyrails games start \ --experience-id \ --member-id \ --idempotency-key game_session_123 loyaltyrails games submit \ --session-id \ --sequence 1 \ --action-type spin \ --payload '{"segmentIndex":0}' loyaltyrails games complete --session-id ``` `games resolve` requires read scope. `games start`, `games submit`, and `games complete` require write scope. These are smoke commands for existing game runtime state; they do not create campaigns, experiences, or rules. ## Generate Recipes Preview before writing: ```bash loyaltyrails add award-route --dry-run loyaltyrails add balance-widget --dry-run loyaltyrails add member-journey --dry-run loyaltyrails add game-session-route --dry-run loyaltyrails add onboarding-challenge --dry-run loyaltyrails add campaign-tiles --dry-run ``` Apply: ```bash loyaltyrails add award-route loyaltyrails add balance-widget loyaltyrails add member-journey loyaltyrails add game-session-route loyaltyrails add onboarding-challenge loyaltyrails add campaign-tiles ``` `add award-route` generates a server route that emits LoyaltyRails events. It requires a stable idempotency key and includes a fail-closed TODO for verifying the order, checkout, or webhook source. `add balance-widget` generates a server-backed balance route and client widget. The generated balance route derives customer identity server-side through the identity hook and does not trust client-submitted customer identifiers. `add member-journey` generates a server-only journey hook, a member lifecycle event-ingestion route, and a server-backed balance route (both derive identity through the identity hook), plus a client `MemberJourney` component that renders the demo journey against those routes. `add game-session-route` generates a server-backed game-session proxy for resolve, start, submit, and complete operations. It keeps the integration API key server-side and requires the identity hook to return a LoyaltyRails member ID before forwarding game requests. The route returns a signed `sessionToken` instead of exposing the raw backend session ID, and requires a server-only `LOYALTYRAILS_GAME_SESSION_SECRET` for that token binding. `add onboarding-challenge` generates the game-session route if needed and a client React component for onboarding challenges. The component resolves the member's available onboarding experience through the generated server route, starts a session, submits each step as `payload.answer`, and completes the session without exposing the integration API key or raw backend session ID. `add campaign-tiles` generates the game-session route if needed and a client React component that resolves the member's available campaign experiences. It renders campaign tiles and can start a session through the signed `sessionToken` flow for host code to hand off to mechanic-specific UI. The component filters to campaign-backed experiences by default; host apps can opt into standalone experiences through the generated `includeStandalone` prop. All recipes generate or reuse a server-only identity hook. Until the host app wires that hook to its real auth system, generated routes fail closed. ## Plan And Apply With The Agent Scaffold The local CLI integration agent can plan, preview, and apply one reviewed generated-app recipe at a time: ```bash loyaltyrails agent "add a member balance widget" loyaltyrails agent "wire Shopify orders paid webhooks" --json loyaltyrails agent "integrate mParticle Events API" --preview loyaltyrails agent "add campaign tiles" --preview loyaltyrails agent "add a member balance widget" --apply --yes ``` Plan and preview modes are deterministic and read-only. They detect the local project, read `.loyaltyrails/config.json` and `.loyaltyrails/manifest.json` when present, and map the request to already-reviewed rails commands. They do not write files, read raw credentials or env secrets, call the rails API, invoke package managers, run shell commands, or call an LLM provider. Apply mode is intentionally narrow. It requires `--apply --yes`, revalidates the selected plan immediately before writing, refuses dirty git worktrees unless `--allow-dirty` is supplied, writes a redacted run manifest under `.loyaltyrails/agent-runs/`, and only applies one ready local recipe in a run. It does not perform operator-auth administration or arbitrary shell work. Generated app integration requests map to integration-auth commands such as `loyaltyrails init --dry-run`, `loyaltyrails integrate hydrogen --dry-run`, or `loyaltyrails add balance-widget --dry-run`. Backend vendor readiness requests map to no-auth local recipe commands such as `loyaltyrails integrate vendor mparticle --dry-run`. Program, API-key, rule, experience, and campaign administration stays operator-auth only; the agent scaffold points to the relevant command help instead of performing admin work. Pass `--preview` to include redacted dry-run file previews from the existing reviewed planners. Preview mode checks the active integration profile metadata for generated-app safety, scope, and program reach, but it still does not read the stored raw API key or write files. Use `--profile`, `--program`, and `--force` with preview mode when you need the same context controls as the underlying recipe command. ## Demo Handoff Use this flow to install a demo app against the **exact** program that an operator (or the conversational admin agent, with the operator's consent) configured — with no program ID or API key ever typed by hand. 1. In the app, an operator generates a single-use pairing code for the program. The code is short-lived (15-minute expiry) and usable exactly once. 2. The developer's machine must already be paired — see [Pair This Machine](#pair-this-machine-browser-approval). `join` claims the code with that machine's paired credential; it does not accept an integration API key or operator token. 3. The developer runs, inside the target app directory or pointing at one: ```bash loyaltyrails join --configure ./your-app --yes ``` `join` claims the code, then `--configure` writes the target app's `.env.local` (mode 0600, non-destructive — existing keys are left alone) with the program ID and a freshly-minted scoped API key from the claim response, plus the API URL of the backend the CLI just claimed against: ``` LOYALTYRAILS_PROGRAM_ID=... LOYALTYRAILS_API_KEY=... LOYALTYRAILS_API_URL=... ``` These are server-only names — never a `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_` prefix — so the key never reaches a browser bundle. Without `--configure`, `join` only stores the claimed credential in a local CLI profile and prints the next-step recommendation. Without `--yes`, `--configure` fails closed instead of writing a credential to disk: ```bash loyaltyrails join --configure ./your-app # Configuring ./your-app writes a scoped credential to disk. # Re-run with --yes to confirm. ``` `` accepts either the bare code or a full `.../share/v1/` URL. A same-machine re-claim of an already-claimed code is treated as success (using the locally cached config) rather than an error. No secret is ever shown in the app UI, in chat, or in CLI terminal output — the scoped key from the claim response is written straight to the local credential store and `.env.local` and is never printed. ## Local Backend Smoke For local end-to-end smoke, use an isolated database when other workstreams are changing migrations. The smoke pass for the CLI MVP used disposable Postgres and Redis containers on non-default ports, ran the backend on `18080`, and executed: ```bash loyaltyrails auth login --api-key lr_test_... --api-url http://127.0.0.1:18080 loyaltyrails init --program loyaltyrails doctor --api-url http://127.0.0.1:18080 loyaltyrails events send --type transaction.completed --external-id cli-smoke-customer loyaltyrails members balance --external-id cli-smoke-customer ``` Avoid broad Docker restarts or `docker compose down` while parallel database workstreams are active. Use targeted, disposable containers or a coordinated database window. ## Current MVP Boundaries Implemented: - Integration API-key login/status/logout/profile use. - Program context selection. - Next.js App Router `init`. - Shopify Hydrogen detection, config, balance, game route, campaign tile, and Shopify webhook generation. - Backend vendor readiness recipes for Braze, mParticle, and Salesforce Marketing Cloud. - `doctor`. - `events send`. - `members balance`. - `games resolve/start/submit/complete`. - `program list/create/status`. - `keys list/create/rotate/revoke`. - `rules list/create/simulate`. - `experiences list/create/publish`. - `campaigns list/create/attach-experience/activate`. - `add award-route`. - `add balance-widget`. - `add member-journey`. - `add game-session-route`. - `add onboarding-challenge`. - `add campaign-tiles`. - `agent` plan-only scaffold and redacted dry-run previews. - `auth login --pair` browser-approval machine pairing. - `join --configure` demo handoff (see [Demo Handoff](#demo-handoff)). ## API Contract The MVP CLI API surface is pinned by `api-contracts/loyaltyrails-cli.v1.openapi.json`. Regenerate derived backend and CLI bindings after changing that file: ```bash pnpm contract:generate ``` Check for drift before opening a PR: ```bash pnpm contract:check ``` Not yet implemented: - Official packaged gamification runtime components. - Agentic local code modification/apply mode. --- # CLI Reference Source: ../docs/reference/cli.md URL: https://docs.rails.sh/cli-reference Markdown: https://docs.rails.sh/markdown/cli-reference.md # CLI Reference > Generated from rails CLI source. Do not edit by hand; run `node docs-site/scripts/generate-source-references.mjs`. ## Source Inputs - `packages/cli/src/commands.ts`: Commander command tree for `loyaltyrails`, `lr`. - `packages/create-loyaltyrails/src/index.ts`: scaffold command for `create-loyaltyrails`. - `packages/cli/src/config.ts`, `packages/cli/src/program-context.ts`, integration generators, and agent planners: env/config caveats. ## Package Binaries | Package | Version | Bins | Source | | --- | --- | --- | --- | | @loyaltyrails/cli | 0.1.0 | `loyaltyrails`, `lr` | packages/cli/src/commands.ts | | create-loyaltyrails | 0.1.0 | `create-loyaltyrails` | packages/create-loyaltyrails/src/index.ts | ## Command Index | Usage | Kind | Summary | Source | | --- | --- | --- | --- | | `loyaltyrails auth` | group | Manage rails credential profiles | packages/cli/src/commands.ts:514 | | `loyaltyrails auth login` | command | Validate and store an integration API key or operator CLI token, or pair this machine via the browser (--pair) | packages/cli/src/commands.ts:517 | | `loyaltyrails auth status` | command | Show the active authentication profile | packages/cli/src/commands.ts:568 | | `loyaltyrails auth logout` | command | Remove a stored authentication profile | packages/cli/src/commands.ts:596 | | `loyaltyrails auth use ` | command | Switch the active credential profile | packages/cli/src/commands.ts:609 | | `loyaltyrails init` | command | Initialize rails metadata for an existing supported project | packages/cli/src/commands.ts:619 | | `loyaltyrails integrate` | group | Integrate rails with storefront frameworks | packages/cli/src/commands.ts:643 | | `loyaltyrails integrate plan` | command | Report integration recipe status and next actions without credentials | packages/cli/src/commands.ts:646 | | `loyaltyrails integrate hydrogen` | command | Initialize rails metadata for a Shopify Hydrogen storefront | packages/cli/src/commands.ts:659 | | `loyaltyrails integrate vendor` | command | Generate a backend vendor readiness recipe for Braze, mParticle, or SFMC | packages/cli/src/commands.ts:686 | | `loyaltyrails integrate rule-action` | command | Print a draft integration_action rule for a vendor outcome | packages/cli/src/commands.ts:695 | | `loyaltyrails program` | group | Manage loyalty programs and local context | packages/cli/src/commands.ts:715 | | `loyaltyrails program list` | command | List programs visible to the operator profile | packages/cli/src/commands.ts:718 | | `loyaltyrails program create` | command | Create a loyalty program in the operator organization | packages/cli/src/commands.ts:729 | | `loyaltyrails program status ` | command | Show detailed status for a program | packages/cli/src/commands.ts:759 | | `loyaltyrails program use ` | command | Set the active program for the current project/profile | packages/cli/src/commands.ts:771 | | `loyaltyrails enable-stablecoin` | command | Compatibility alias for stablecoin rails attach-program | packages/cli/src/commands.ts:800 | | `loyaltyrails keys` | group | Manage integration API keys | packages/cli/src/commands.ts:812 | | `loyaltyrails keys list` | command | List integration API keys | packages/cli/src/commands.ts:815 | | `loyaltyrails keys create` | command | Create an integration API key | packages/cli/src/commands.ts:833 | | `loyaltyrails keys rotate ` | command | Rotate an integration API key and revoke the old key | packages/cli/src/commands.ts:866 | | `loyaltyrails keys revoke ` | command | Revoke an integration API key | packages/cli/src/commands.ts:887 | | `loyaltyrails stablecoin` | group | Inspect stablecoin operations | packages/cli/src/commands.ts:905 | | `loyaltyrails list` | command | List read-only stablecoin provider capabilities | packages/cli/src/commands.ts:909 | | `loyaltyrails rails` | group | Manage organization USDC reward rails for stablecoin rewards | packages/cli/src/commands.ts:925 | | `loyaltyrails status` | command | Show organization USDC reward rail status | packages/cli/src/commands.ts:929 | | `loyaltyrails setup-usdc` | command | Set up the organization USDC reward rail for Base Sepolia testnet | packages/cli/src/commands.ts:945 | | `loyaltyrails attach-program` | command | Attach a program to an organization USDC reward rail and enable stablecoin rewards | packages/cli/src/commands.ts:967 | | `loyaltyrails status` | command | Show stablecoin launch setup status | packages/cli/src/commands.ts:982 | | `loyaltyrails doctor` | command | Show stablecoin launch setup checks and next actions | packages/cli/src/commands.ts:997 | | `loyaltyrails launch-checklist` | command | Show the stablecoin launch checklist | packages/cli/src/commands.ts:1012 | | `loyaltyrails update-check` | command | Update a manual stablecoin launch checklist item | packages/cli/src/commands.ts:1027 | | `loyaltyrails liability` | command | Summarize current stablecoin liability buckets | packages/cli/src/commands.ts:1059 | | `loyaltyrails issuance-funnel` | command | Summarize stablecoin issuance funnel counts and value | packages/cli/src/commands.ts:1076 | | `loyaltyrails provider-ops` | command | Summarize stablecoin provider operations | packages/cli/src/commands.ts:1093 | | `loyaltyrails game-economics` | command | Summarize stablecoin game economics | packages/cli/src/commands.ts:1125 | | `loyaltyrails campaign-roi` | command | Summarize stablecoin campaign ROI dimensions | packages/cli/src/commands.ts:1154 | | `loyaltyrails reward-intent ` | command | Show the stablecoin timeline for a reward intent | packages/cli/src/commands.ts:1194 | | `loyaltyrails member ` | command | Show the stablecoin timeline for a member | packages/cli/src/commands.ts:1212 | | `loyaltyrails reconciliation` | group | Inspect and manage stablecoin reconciliation | packages/cli/src/commands.ts:1230 | | `loyaltyrails run` | command | Create a scoped stablecoin reconciliation run | packages/cli/src/commands.ts:1234 | | `loyaltyrails exceptions` | command | Inspect and transition scoped stablecoin reconciliation exceptions | packages/cli/src/commands.ts:1265 | | `loyaltyrails list` | command | List scoped stablecoin reconciliation exceptions | packages/cli/src/commands.ts:1289 | | `loyaltyrails acknowledge ` | command | Acknowledge a scoped stablecoin reconciliation exception | packages/cli/src/commands.ts:1298 | | `loyaltyrails outbound` | group | Manage outbound integration operations | packages/cli/src/commands.ts:1363 | | `loyaltyrails outbound health` | command | Summarize live outbound dispatch health | packages/cli/src/commands.ts:1365 | | `loyaltyrails outbound snapshot` | command | Persist outbound rollout readiness health and alert history | packages/cli/src/commands.ts:1385 | | `loyaltyrails outbound dashboard` | command | Show persisted outbound rollout readiness snapshots | packages/cli/src/commands.ts:1408 | | `loyaltyrails outbound credential-smoke ` | command | Resolve outbound vendor credential references without printing secrets | packages/cli/src/commands.ts:1425 | | `loyaltyrails outbound simulate` | command | Run a no-live-credentials outbound vendor readiness simulation | packages/cli/src/commands.ts:1440 | | `loyaltyrails list` | command | List outbound dead letters for a program | packages/cli/src/commands.ts:1466 | | `loyaltyrails approve ` | command | Approve an outbound dead letter for replay | packages/cli/src/commands.ts:1483 | | `loyaltyrails reject ` | command | Reject outbound dead-letter replay | packages/cli/src/commands.ts:1498 | | `loyaltyrails mark-replayed ` | command | Mark an approved outbound dead letter as replayed | packages/cli/src/commands.ts:1513 | | `loyaltyrails rules` | group | Manage program rules | packages/cli/src/commands.ts:1529 | | `loyaltyrails rules list` | command | List rules for a program | packages/cli/src/commands.ts:1532 | | `loyaltyrails rules create` | command | Create a rule for a program | packages/cli/src/commands.ts:1544 | | `loyaltyrails rules simulate` | command | Simulate a rule config without saving it | packages/cli/src/commands.ts:1567 | | `loyaltyrails experiences` | group | Manage game experiences | packages/cli/src/commands.ts:1588 | | `loyaltyrails experiences list` | command | List game experiences for a program | packages/cli/src/commands.ts:1591 | | `loyaltyrails experiences create` | command | Create a draft game experience | packages/cli/src/commands.ts:1603 | | `loyaltyrails experiences publish ` | command | Publish a draft game experience | packages/cli/src/commands.ts:1627 | | `loyaltyrails campaigns` | group | Manage game campaigns | packages/cli/src/commands.ts:1638 | | `loyaltyrails campaigns list` | command | List campaigns for a program | packages/cli/src/commands.ts:1641 | | `loyaltyrails campaigns create` | command | Create a campaign | packages/cli/src/commands.ts:1658 | | `loyaltyrails campaigns activate ` | command | Activate a campaign | packages/cli/src/commands.ts:1678 | | `loyaltyrails campaigns attach-experience ` | command | Attach an experience to a campaign | packages/cli/src/commands.ts:1690 | | `loyaltyrails doctor` | command | Run local CLI and API connectivity checks | packages/cli/src/commands.ts:1712 | | `loyaltyrails events` | group | Send event-ingestion smoke requests | packages/cli/src/commands.ts:1727 | | `loyaltyrails events send` | command | Send a single rails event | packages/cli/src/commands.ts:1730 | | `loyaltyrails members` | group | Read member data with integration keys | packages/cli/src/commands.ts:1770 | | `loyaltyrails members balance` | command | Look up a member balance by external ID | packages/cli/src/commands.ts:1773 | | `loyaltyrails games` | group | Smoke test gamification surface APIs | packages/cli/src/commands.ts:1797 | | `loyaltyrails games resolve` | command | Resolve playable game experiences for a member | packages/cli/src/commands.ts:1800 | | `loyaltyrails games start` | command | Start an idempotent game session | packages/cli/src/commands.ts:1824 | | `loyaltyrails games submit` | command | Submit one action to a game session | packages/cli/src/commands.ts:1858 | | `loyaltyrails games complete` | command | Complete a game session | packages/cli/src/commands.ts:1878 | | `loyaltyrails chat [request...]` | command | Open an interactive conversation with the rails agent ' +
'(developer mode, streams from the agent service) | packages/cli/src/commands.ts:1892 | | `loyaltyrails join ` | command | Claim a rails share link (URL or bare token) and bootstrap the integration | packages/cli/src/commands.ts:1942 | | `loyaltyrails scaffold` | command | Generate a fresh Next.js storefront wired to the program claimed by `loyaltyrails join` | packages/cli/src/commands.ts:1979 | | `loyaltyrails agent ` | command | Plan a local rails integration from a natural-language request | packages/cli/src/commands.ts:2026 | | `loyaltyrails add` | group | Add rails integration recipes | packages/cli/src/commands.ts:2085 | | `loyaltyrails add award-route` | command | Generate a Next.js App Router event-ingestion route | packages/cli/src/commands.ts:2088 | | `loyaltyrails add balance-widget` | command | Generate a server-backed balance route and React widget | packages/cli/src/commands.ts:2099 | | `loyaltyrails add member-journey` | command | Generate bounded member lifecycle event routes and a demo journey component | packages/cli/src/commands.ts:2110 | | `loyaltyrails add game-session-route` | command | Generate a server-backed game session route | packages/cli/src/commands.ts:2121 | | `loyaltyrails add onboarding-challenge` | command | Generate a server-backed onboarding challenge component | packages/cli/src/commands.ts:2137 | | `loyaltyrails add campaign-tiles` | command | Generate a server-backed campaign tile component | packages/cli/src/commands.ts:2153 | | `create-loyaltyrails [project-name]` | command | Create a new rails app with the SDK pre-configured | packages/create-loyaltyrails/src/index.ts:22 | ## Environment And Local State - `LOYALTYRAILS_API_URL`: API base URL; defaults to `http://localhost:8080` when not supplied. - `LOYALTYRAILS_CONFIG_HOME`: user credential profile directory; defaults to `~/.loyaltyrails`. - `LOYALTYRAILS_PROGRAM_ID`: program context fallback for integration-key commands. - Generated app/server code can require `LOYALTYRAILS_API_KEY`, `LOYALTYRAILS_PROGRAM_ID`, `LOYALTYRAILS_API_URL`, `LOYALTYRAILS_EXTERNAL_ID_TYPE`, `LOYALTYRAILS_GAME_SESSION_SECRET`, and for Shopify webhooks `SHOPIFY_WEBHOOK_SECRET`. - Env vars discovered in source: `DEBUG`, `LOYALTYRAILS_API_KEY`, `LOYALTYRAILS_API_URL`, `LOYALTYRAILS_CONFIG_HOME`, `LOYALTYRAILS_EXTERNAL_ID_TYPE`, `LOYALTYRAILS_GAME_SESSION_SECRET`, `LOYALTYRAILS_PROGRAM_ID`, `SHOPIFY_WEBHOOK_SECRET`. - Local project state is stored under `.loyaltyrails/config.json` and `.loyaltyrails/manifest.json`. - JSON-valued CLI options accept inline JSON or a path; source also strips a leading `@` before reading the file path. ## Implementation Status Caveats - `auth`, `program`, `keys`, `rules`, `experiences`, `campaigns`, `events`, `members`, and `games` call backend APIs and depend on the active profile scopes. - `init`, `integrate hydrogen`, `add *`, and `agent --apply` write local files through safety planners and manifest checks. - `agent` plan mode is deterministic and local-only; `agent --apply` is limited to a single ready local recipe and requires `--yes` plus a clean git worktree unless `--allow-dirty` is supplied. - `create-loyaltyrails` scaffolds a Next.js starter from templates, writes `.env.local`, and attempts dependency installation with the selected package manager. ## Command Details ### `loyaltyrails auth` - Source: `packages/cli/src/commands.ts:514` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Manage rails credential profiles ### `loyaltyrails auth login` - Source: `packages/cli/src/commands.ts:517` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Validate and store an integration API key or operator CLI token, or pair this machine via the browser (--pair) - Optional options: `--api-key `, `--operator-token `, `--pair`, `--no-browser`, `--profile ` default=default, `--api-url `, `--base-url ` ### `loyaltyrails auth status` - Source: `packages/cli/src/commands.ts:568` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Show the active authentication profile - Optional options: `--profile ` ### `loyaltyrails auth logout` - Source: `packages/cli/src/commands.ts:596` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Remove a stored authentication profile - Optional options: `--profile ` ### `loyaltyrails auth use ` - Source: `packages/cli/src/commands.ts:609` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Switch the active credential profile - Arguments: `` (Credential profile name) ### `loyaltyrails init` - Source: `packages/cli/src/commands.ts:619` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Initialize rails metadata for an existing supported project - Optional options: `--profile `, `--program `, `--external-id-type `, `--identity-strategy `, `--identity-file `, `--package-manager `, `--write-env` default=false, `--no-env-write`, `--dry-run` default=false, `--force` default=false, `-y, --yes` default=false ### `loyaltyrails integrate` - Source: `packages/cli/src/commands.ts:643` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Integrate rails with storefront frameworks ### `loyaltyrails integrate plan` - Source: `packages/cli/src/commands.ts:646` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Report integration recipe status and next actions without credentials - Optional options: `--json` default=false ### `loyaltyrails integrate hydrogen` - Source: `packages/cli/src/commands.ts:659` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Initialize rails metadata for a Shopify Hydrogen storefront - Optional options: `--profile `, `--program `, `--external-id-type ` default=shopify_id, `--identity-strategy ` default=shopify, `--package-manager `, `--webhooks` default=false, `--write-env` default=false, `--no-env-write`, `--dry-run` default=false, `--force` default=false, `-y, --yes` default=false ### `loyaltyrails integrate vendor` - Source: `packages/cli/src/commands.ts:686` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Generate a backend vendor readiness recipe for Braze, mParticle, or SFMC - Optional options: `--dry-run` default=false, `--force` default=false ### `loyaltyrails integrate rule-action` - Source: `packages/cli/src/commands.ts:695` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Print a draft integration_action rule for a vendor outcome - Optional options: `--json` default=false, `--name `, `--action-key `, `--event-type ` ### `loyaltyrails program` - Source: `packages/cli/src/commands.ts:715` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Manage loyalty programs and local context ### `loyaltyrails program list` - Source: `packages/cli/src/commands.ts:718` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: List programs visible to the operator profile - Optional options: `--profile ` ### `loyaltyrails program create` - Source: `packages/cli/src/commands.ts:729` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Create a loyalty program in the operator organization - Required options: `--name `, `--symbol `, `--issuer ` - Optional options: `--reserve-ratio `, `--chain-id `, `--contract-address
`, `--profile ` ### `loyaltyrails program status ` - Source: `packages/cli/src/commands.ts:759` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Show detailed status for a program - Arguments: `` (Program ID) - Optional options: `--profile ` ### `loyaltyrails program use ` - Source: `packages/cli/src/commands.ts:771` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Set the active program for the current project/profile - Arguments: `` (Program ID) - Optional options: `--profile `, `--global` ### `loyaltyrails enable-stablecoin` - Source: `packages/cli/src/commands.ts:800` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Compatibility alias for stablecoin rails attach-program - Required options: `--program `, `--rail ` - Optional options: `--settlement-policy ` default=testnet-only, `--client-mutation-id `, `--yes` default=false, `--profile ` ### `loyaltyrails keys` - Source: `packages/cli/src/commands.ts:812` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Manage integration API keys ### `loyaltyrails keys list` - Source: `packages/cli/src/commands.ts:815` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: List integration API keys - Optional options: `--program `, `--all-program`, `--reason `, `--profile ` ### `loyaltyrails keys create` - Source: `packages/cli/src/commands.ts:833` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Create an integration API key - Required options: `--name `, `--scopes ` - Optional options: `--program `, `--all-program`, `--environment ` default=test, `--expires-at `, `--reason `, `--save-profile `, `--profile ` ### `loyaltyrails keys rotate ` - Source: `packages/cli/src/commands.ts:866` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Rotate an integration API key and revoke the old key - Arguments: `` (API key ID) - Optional options: `--reason `, `--save-profile `, `--profile ` ### `loyaltyrails keys revoke ` - Source: `packages/cli/src/commands.ts:887` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Revoke an integration API key - Arguments: `` (API key ID) - Optional options: `--reason `, `--profile ` ### `loyaltyrails stablecoin` - Source: `packages/cli/src/commands.ts:905` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Inspect stablecoin operations ### `loyaltyrails list` - Source: `packages/cli/src/commands.ts:909` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: List read-only stablecoin provider capabilities - Optional options: `--provider `, `--mode `, `--profile ` ### `loyaltyrails rails` - Source: `packages/cli/src/commands.ts:925` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Manage organization USDC reward rails for stablecoin rewards ### `loyaltyrails status` - Source: `packages/cli/src/commands.ts:929` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Show organization USDC reward rail status - Optional options: `--program `, `--profile ` ### `loyaltyrails setup-usdc` - Source: `packages/cli/src/commands.ts:945` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Set up the organization USDC reward rail for Base Sepolia testnet - Optional options: `--client-mutation-id `, `--yes` default=false, `--profile ` ### `loyaltyrails attach-program` - Source: `packages/cli/src/commands.ts:967` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Attach a program to an organization USDC reward rail and enable stablecoin rewards - Required options: `--program `, `--rail ` - Optional options: `--settlement-policy ` default=testnet-only, `--client-mutation-id `, `--yes` default=false, `--profile ` ### `loyaltyrails status` - Source: `packages/cli/src/commands.ts:982` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Show stablecoin launch setup status - Required options: `--program ` - Optional options: `--profile ` ### `loyaltyrails doctor` - Source: `packages/cli/src/commands.ts:997` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Show stablecoin launch setup checks and next actions - Required options: `--program ` - Optional options: `--profile ` ### `loyaltyrails launch-checklist` - Source: `packages/cli/src/commands.ts:1012` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Show the stablecoin launch checklist - Required options: `--program ` - Optional options: `--profile ` ### `loyaltyrails update-check` - Source: `packages/cli/src/commands.ts:1027` - Status: implemented in source; backend-dependent commands require a reachable rails API and valid credentials. - Summary: Update a manual stablecoin launch checklist item - Required options: `--program ` - Optional options: `--status `, `--blocking `, `--owner