ReferenceTenant-facing API

Tenant-facing API

Every route below is reached with a tenant credential (an fdk_-prefixed API key, Authorization: Bearer <key>), resolved to a tenant_ref per credential-carries-ownership — never a path or body parameter. This page describes the real route handlers as they exist in the modules today; it is not a hand-written OpenAPI spec, and it will grow as the headless SDK effort formalizes it.

This page is maintained by hand and can drift. The machine-generated, always-current contract for the public storefront and merchant fdk_ surface is the OpenAPI document the API serves directly: browse the interactive reference at https://api.ruo.pro/docs and fetch the raw spec at https://api.ruo.pro/openapi.json (ADR 0007). Internal operator routes are deliberately not part of that contract and are undocumented there.

Self-serve store management — stores module

/v1/my/* — store-owner routes, authenticated by verifyTenantAuth (the tenant credential, rate-limited on verification attempts before the DB lookup — an fdk_ key is online-guessable with no limiter otherwise).

MethodPathPurpose
GET/v1/my/storeFetch the caller’s own store.
GET/v1/my/store/manifestFetch the caller’s store manifest (theme tokens + sections).
PUT/v1/my/store/manifestUpdate the caller’s store manifest.
GET/v1/my/siteSite overview: active manifest, latest draft, version history.
PUT/v1/my/site/draftSave a draft (new version row; pointer untouched).
POST/v1/my/site/publishPublish (or roll back to) an existing version.
POST/v1/my/site/preview-tokenMint a signed token to preview one version.
POST/v1/my/domainsRegister a custom domain for domain-verification (DCV).
GET/v1/my/domainsList the caller’s registered domains.
GET/v1/my/domains/:idFetch one domain’s verification status.
POST/v1/my/domains/:id/verifyTrigger (re-)verification of a domain. On success, this is the only write path that creates the Host -> tenant_ref mapping row the storefront renderer resolves against (see Tenancy).
DELETE/v1/my/domains/:idRemove a registered domain.
GET/v1/my/credentialsList the caller’s own API keys — metadata only (id, label, scopes, timestamps), never key material. Scope manage:own_credentials.
POST/v1/my/credentialsMint a new API key. The issued scopes must be a subset of the issuing key’s scopes (else 403 scope_escalation); the plaintext key appears in the 201 body once. Scope manage:own_credentials.
POST/v1/my/credentials/:id/revokeRevoke a key — a recorded state change, never a delete. Self-revocation is allowed. Scope manage:own_credentials.
POST/v1/my/credentials/initialBootstrap the first fdk_ key for a session-verified owner (session-gated, rate-limited, not manage:own_credentials-scoped).

The draft-to-publish-to-rollback lifecycle, the manifest shape, signed preview tokens, and the RUO claim-scan gate are covered in full on the Site builder page.

Merchant API — merchant-api module

/v1/merchant/* — owns no Postgres schema of its own; it is pure composition over several other modules’ published repo factories (createStoresRepo, createCommerceRepo, createOutboundWebhooksRepo, and the persons/merchant-billing repos), each scoped to the credential’s tenant_ref. Scopes are enforced per route via requireScope(...).

MethodPathScopePurpose
GET/v1/merchant/ordersread:own_ordersList the caller’s orders.
GET/v1/merchant/orders/:idread:own_ordersFetch one order with its line items and refund records (refunds: [] = verified none; null = the refund read failed).
GET/v1/merchant/catalogread:own_catalogList the caller’s catalog.
GET/v1/merchant/customersread:own_customersSearch the caller’s customers (with per-customer stats).
GET/v1/merchant/customers/resolveread:own_customersBatch-resolve customer refs to {name, email}.
GET/v1/merchant/customers/:idread:own_customersOne customer’s profile and merged timeline.
GET/v1/merchant/billing/invoicesread:own_billingList the caller’s billing invoices.
GET/v1/merchant/billing/invoices/:idread:own_billingOne invoice with its line items.
GET/v1/merchant/billing/statementread:own_billingOutstanding balance (a read whose failure surfaces as an error, never a laundered $0). See Merchant billing.
GET/v1/merchant/webhooksmanage:own_webhooksList the caller’s registered outbound webhook endpoints.
POST/v1/merchant/webhooksmanage:own_webhooksRegister an outbound webhook endpoint.
DELETE/v1/merchant/webhooks/:idmanage:own_webhooksRemove a webhook endpoint.
POST/v1/merchant/webhooks/:id/testmanage:own_webhooksFire a real signed test delivery (event type platform.test.v1, payload test: true) at the endpoint. Returns the endpoint’s honest status + latency, or the connection failure; the attempt is recorded in the delivery ledger. Failed tests never retry and never count toward auto-pause.
GET/v1/merchant/deliveriesmanage:own_webhooksList recent delivery attempts (the outbound dispatch ledger).

Merchant order actions — checkout module

/v1/merchant/orders/:id/* — the money-path inverse actions on the merchant’s OWN orders (scope manage:own_orders, distinct from read:own_orders so a read-only key can never move money). Both drive the same executors as the operator refund/cancel routes; the card charge is derived server-side from the order (no charge_id in the request) and the idempotency keys are deterministic per order, so a retried click replays instead of refunding twice.

MethodPathScopePurpose
POST/v1/merchant/orders/:id/refundmanage:own_ordersFull refund — card charge plus any store-credit/gift-card tenders.
POST/v1/merchant/orders/:id/cancelmanage:own_ordersCancel an unfulfilled order (releases reserved inventory, refunds any captured payment). A shipped/delivered order is refused with order_fulfilled_use_returns — use a return.

Checkout — checkout module

/v1/tenants/:tenant/checkout* — an honesty note on tenancy: unlike the fdk_ routes above, the checkout placement route does not derive the tenant from the credential. The x-storefront-token accepted on the bare checkout path (requireCheckoutCaller) is a platform-wide but checkout-only credential — the tenant is taken from the :tenant path segment. Folding it into per-tenant fdk_ credentials with a narrow checkout scope is planned but not shipped. The order-action routes beneath it (/checkout/orders/*) are operator-token-gated today. All mutating routes require an Idempotency-Key header — a retried request with the same key replays the original outcome rather than double-charging (see Money invariants).

MethodPathPurpose
POST/v1/tenants/:tenant/checkoutCart -> priced order (pending_payment) -> payment intent -> capture -> placed, or a classified decline. Body: customer_ref, items[], optional card, coupon_code, referral_code, apply_store_credit, gift_card_code, subscribe, expedited, shipping_address, email, ip, and the store’s shipping/tax policy blocks.
POST/v1/tenants/:tenant/checkout/previewPrice a cart without placing it — same pricing engine, no payment.
POST/v1/tenants/:tenant/checkout/orders/:id/refundRefund a placed order’s charge. In the public spec and wrapped by the SDK, but gated by the operator token today — the storefront token does not reach it. Merchant-credentialed refunds go through /v1/merchant/orders/:id/refund. Body: charge_id, optional reason, optional amount_cents for a partial refund.
POST/v1/tenants/:tenant/checkout/orders/:id/cancelCancel an order (operator-gated today; merchants use /v1/merchant/orders/:id/cancel).
POST/v1/tenants/:tenant/checkout/orders/:id/editEdit a placed order’s lines, repricing and settling the charge difference (operator-gated today).
POST/v1/tenants/:tenant/checkout/returns/:id/approveApprove a requested return (RMA). Operator-gated.
POST/v1/tenants/:tenant/checkout/returns/:id/receiveMark an approved return received. Operator-gated.
POST/v1/tenants/:tenant/checkout/returns/:id/rejectReject a requested return. Operator-gated.

Public storefront catalog — commerce module

/v1/tenants/:tenant/catalog/*no operator token, no tenant credential. This is the one deliberately public read surface: a storefront’s product catalog. A draft/archived product, or a draft/suspended tenant’s catalog, is unreachable here even by direct ID/slug guess — enforced at the query layer, not just the UI.

MethodPathPurpose
GET/v1/tenants/:tenant/catalog/productsList a tenant’s live catalog (products/variants, integer-cents prices).
GET/v1/tenants/:tenant/catalog/products/:slugFetch one product by slug.
GET/v1/tenants/:tenant/catalog/searchFaceted catalog search (q, collection, price_min_cents, price_max_cents, limit, offset).
GET/v1/tenants/:tenant/catalog/collectionsList the tenant’s collections.
GET/v1/tenants/:tenant/catalog/collections/:slugFetch one collection (and its products) by slug.

A note on throttling: these public catalog reads are currently unthrottled — there is no per-caller rate limit on them today. Some management and operator routes do return 429 with a retry-after header when a limiter is spent. Client code should handle 429 defensively (respect retry-after, back off) everywhere, rather than assuming any route’s current behavior is permanent.

This is the exact route the storefront fleet renderer’s manifest-source resolves against for host -> tenant lookups, and the route ADR 0003 commits to as contract identity for the future headless SDK: the renderer binds to the same types, same tenancy resolution, and same error surface a headless caller would use — it just invokes them in-process today instead of over the network.

The product shape

Each catalog product carries integer-cents prices and its variants. Media is returned in two forms so no consumer has to re-implement asset resolution:

FieldTypeNotes
id, tenant_ref, slug, namestringIdentity. slug is the lookup key for the by-slug route.
descriptionstring | nullHonest absence, not an empty string.
statusdraft | active | archivedOnly active products are reachable on the public surface.
image_asset_refstring | nullThe raw, portable media ref: asset:<storeKey>/<sha256>.<ext> (or null).
image_urlstring | nullimage_asset_ref resolved to a fetchable URL — prefer this.
is_subscriptionbooleanFD subscribe-and-save eligibility.
subscription_intervalday|week|month|year | nullSet only when is_subscription.
subscription_interval_countint | nulle.g. 1 = every interval.
subscription_price_centsint | nullSubscribe-and-save price.
variants[]arrayEach: id, sku, label (nullable), price_cents, currency.

Products that carry a gallery also return an images[] array; each entry has a raw asset_ref and, alongside it, the resolved url — the same two-form pattern as the top-level fields.

{
  "product": {
    "slug": "bpc-157",
    "name": "BPC-157",
    "status": "active",
    "image_asset_ref": "asset:ruo-demo/3f8a…e1.png",
    "image_url": "https://assets.ruo.pro/stores/ruo-demo/assets/3f8a…e1.png",
    "is_subscription": true,
    "subscription_interval": "month",
    "subscription_interval_count": 1,
    "subscription_price_cents": 4499,
    "variants": [
      { "id": "var_…", "sku": "NOVA-BPC-5MG", "label": "5mg vial", "price_cents": 4999, "currency": "USD" }
    ]
  }
}

How image resolution works

The platform resolves a <store> ref to a fetchable URL by rewrite, with no call into the media module (the module-boundary rule holds — see Media):

asset:<store>/<sha256>.<ext>  ->  <ASSET_BASE_URL>/stores/<store>/assets/<sha256>.<ext>

The rewrite is driven by the API’s ASSET_BASE_URL. It is honest about absence: when no image is set, the base is unconfigured, or the ref is malformed, image_url is null — never a broken or guessed URL. Clients should prefer image_url and fall back to resolving image_asset_ref themselves only if they have a reason to.

What’s not here yet

payments and inventory have no tenant-self-serve routes yet — every route in those modules today is operator-gated (x-operator-token), because no consuming module calls them on a tenant’s behalf yet. persons is the same: operator-gated, consumed by other modules and operator tooling, not directly by an end shopper with a tenant key.