# Lockerverse SDK

Stateless browser clients for Lockerverse checkout, signup, public event
listings, and public Event attendee APIs. Checkout and signup validate responses and return immutable snapshots.
The generated public Event client supplies request and response types, but it
does not validate responses at runtime or freeze them. These clients never
render UI or own host application state.

This README ships with the package and describes that release. For an installed
project, use its local README, `package.json` exports, and public `.d.ts` files.
Online documentation can describe a newer release. Import public package entry
points, not private `dist` files. For ready-made React UI, use the README shipped
with `@lockerverse/react`.

Read [SDK quality rules](SDK_RULES.md) before integration changes. For every
upgrade, read [MIGRATIONS.md](MIGRATIONS.md) from the target package and apply
all applicable steps, including skipped releases. A version bump alone does not
complete an upgrade. Record the old installed versions before installation.

## Usage guide

- [Checkout and payment recovery](#checkout)
- [Runtime configuration](#runtime-configuration)
- [Public Event attendees](#public-event-attendees)
- [Signup](#signup)
- [Auctions and purchase recovery](#auctions)
- [Public event listings](#public-event-listings)
- [Security and observability](#security-and-observability)

## Checkout

```ts
import { createLockerverseCheckoutClient } from "@lockerverse/sdk/checkout";

const client = createLockerverseCheckoutClient({
  communitySlug: "auburn",
  widgetSlug: "auburn-tailgate-party",
  onError(error) {
    console.error(error.code, error.operation);
  },
});

// The host owns Product selection.
const selection = [{ productSlug: "adult", quantity: 2 }];
const pricing = {
  email: "customer@example.com",
  includeServiceFee: true,
  tipCents: 500,
};

// One request returns the catalog and the initial authoritative quote.
const { catalog, checkout } = await client.load({ selection });

// Render Stripe Elements with checkout.payment.publishableKey,
// checkout.payment.connectedAccountId, and checkout.totalCents. The prepared
// checkout also binds the normalized items and pricing used for this total.
// Recalculate with the final email and other customer-controlled pricing
// fields before confirmation.
const finalCheckout = await client.calculateTotal({
  catalog,
  selection,
  ...pricing,
});
const payment = await client.submitPayment({
  checkout: finalCheckout,
  submission: {
    confirmationToken: "ctoken_...",
    customFieldAnswers: [
      { fieldId: "guest-name", value: "Ada Lovelace" },
      { fieldId: "interests", value: ["meetups", "merch"] },
    ],
    email: "customer@example.com",
    metadata: { source: "community-site" },
  },
});

// Safe after a refresh or an interrupted confirmation response.
const recovered = await client.getPaymentStatus(finalCheckout.paymentReference);
```

Load a catalog and its initial authoritative quote in one GET request:

```ts
const { catalog, checkout } = await client.load({
  selection,
});
```

If the encoded selection is too long for a safe URL, the client uses a catalog
GET request and then a quote POST request instead.

Deploy the backend support for selected widget GET requests before publishing
an SDK version that uses this flow. The selected response must include `quote`.

If the host already has a catalog, it can show an immediate local estimate and
refresh the authoritative quote without another catalog request:

```ts
import { estimateLockerverseCheckout } from "@lockerverse/sdk/checkout";

const estimate = estimateLockerverseCheckout(catalog, {
  includeServiceFee: catalog.widget.fields.serviceFee,
  selection,
});
const checkout = await client.calculateTotal({
  catalog,
  includeServiceFee: catalog.widget.fields.serviceFee,
  selection,
});
```

The estimate supports catalog prices, quantities, custom amounts, tips, and
the published service-fee rate. It does not apply discounts. Do not use it for
payment submission. Only the authoritative quote can create a payment.

`createLockerverseCheckoutClient()` exposes only `load`, `calculateTotal`,
`submitPayment`, and `getPaymentStatus`. The client has no selection store,
lifecycle state, recurring billing lifecycle, or Stripe state. React
applications can use `@lockerverse/react` for the complete payment UI and
orchestration.

Select one fixed recurring Product without a quantity. Lockerverse returns its
monthly, six-month, or annual cadence in the prepared checkout:

```ts
const recurringCheckout = await client.calculateTotal({
  catalog,
  email: "customer@example.com",
  items: [{ productId: monthlyProduct.id }],
});
```

One-time fixed Products use `quantity`. One-time custom Products use
`amountCents`. A recurring checkout contains exactly one fixed Product.

Fixed prices in the catalog are display data. `calculateTotal()` sends Product
IDs and selections to Lockerverse, which resolves the payable amount. The host
returns them together as one immutable prepared checkout. Pass that prepared
checkout to `submitPayment()` so a host cannot accidentally submit different
inputs from the ones Lockerverse quoted. It owns one stable payment reference
so Lockerverse can prevent duplicate checkout creation and recover ambiguous
outcomes.

If `submitPayment()` throws an error with `recoveryRecommended: true`, the
request may still have reached Lockerverse. Query `getPaymentStatus()` with the
prepared checkout's payment reference before allowing another payment. Other
failures are definitive and do not require recovery.

## Runtime configuration

Production is the default endpoint and environment. Checkout, signup, auctions,
and public event listings share these options. A custom endpoint requires an
explicit environment; development also requires an explicit `apiBaseUrl`.
Include `/api` in this URL. The generated attendee client uses a separate
`baseUrl` option with the origin only, as shown in its section below.

```ts
const client = createLockerverseCheckoutClient({
  apiBaseUrl: "https://portal-dev.lockerverse.com/api",
  communitySlug: "auburn",
  environment: "development",
  widgetSlug: "auburn-tailgate-party",
});
```

Custom endpoints must be HTTP(S) URLs without credentials, query strings, or
fragments. HTTPS is required. Local HTTP endpoints are accepted only with
`environment: "development"` and a host of `localhost`, `127.0.0.1`, `[::1]`,
or a `.localhost` subdomain. Requests have a 20-second deadline by default;
override it with a positive `requestTimeoutMs` value. The same deadline includes
reading successful JSON responses and supported payment or signup error bodies.

## Public Event attendees

Use the generated public API client. It already owns the route, parameters,
response type, and production origin:

```ts
import { getPublicEventAttendees } from "@lockerverse/sdk";

try {
  const { data } = await getPublicEventAttendees({
    path: {
      communitySlug: "auburn",
      eventId: "11111111-1111-4111-8111-111111111111",
    },
    throwOnError: true,
  });
  console.log(data.registrations);
} catch (error) {
  console.error("Could not load attendees", error);
}
```

`data.registrations` contains each public name and party size. Pass
`data.nextCursor` as `query.cursor` to load the next page. For development,
create the same generated client with the development origin and pass it to
each request:

```ts
import { createLockerversePublicApiClient } from "@lockerverse/sdk";

const client = createLockerversePublicApiClient({
  baseUrl: "https://portal-dev.lockerverse.com",
});

const firstPage = await getPublicEventAttendees({
  client,
  path: {
    communitySlug: "auburn",
    eventId: "11111111-1111-4111-8111-111111111111",
  },
  throwOnError: true,
});

if (firstPage.data.nextCursor) {
  await getPublicEventAttendees({
    client,
    path: {
      communitySlug: "auburn",
      eventId: "11111111-1111-4111-8111-111111111111",
    },
    query: { cursor: firstPage.data.nextCursor },
    throwOnError: true,
  });
}
```

## Signup

```ts
import { createLockerverseSignupClient } from "@lockerverse/sdk/signup";

const signup = createLockerverseSignupClient({
  communitySlug: "auburn",
  signupSlug: "tailgate-guests",
});

const definition = await signup.load();
const submissionReference = crypto.randomUUID();
const submission = await signup.submit({
  answers: [{ fieldId: "guest-type", value: "student" }],
  email: "customer@example.com",
  name: "Ada Lovelace",
  participantCount: 2,
}, { submissionReference });
```

The signup client is stateless. Calls to `load` and `submit` are independent.
Signup definitions always have an enabled, required name field. For all other
built-in fields, a disabled field is never required. The SDK rejects a response
that breaks these rules before it reaches the UI.
Create a submission reference before a request. If an error has
`recoveryRecommended: true`, reuse that reference with the same values so
Lockerverse can return the original submission.

## Auctions

Use the auction client without React or Stripe UI dependencies:

```ts
import { createLockerverseAuctionClient } from "@lockerverse/sdk/auctions";

const auctions = createLockerverseAuctionClient({ communitySlug: "auburn" });
const catalog = await auctions.list();
const auction = await auctions.load("community-auction");
const item = auction.items[0];
```

The default environment is production. For development, set both `apiBaseUrl`
and `environment: "development"`, as shown in
[runtime configuration](#runtime-configuration). `load` returns the auction,
enabled items, bid amounts, inventory, fees, and payment configuration. The payment
configuration supplies the Stripe publishable key and connected account; no
secret key belongs in a browser. Older backends can omit this configuration;
update the backend before using the built-in auction checkout.

The following functions show the raw mutation API. They do not run until your
application calls them after the customer confirms their choice:

```ts
import type {
  LockerverseAuctionBidSubmission,
  LockerverseAuctionPurchaseSubmission,
} from "@lockerverse/sdk/auctions";

function submitBid(itemId: string, submission: LockerverseAuctionBidSubmission) {
  return auctions.createBid(auction.path, itemId, submission);
}

function submitPurchase(itemId: string, submission: LockerverseAuctionPurchaseSubmission) {
  return auctions.createPurchase(auction.path, itemId, submission);
}
```

Create a Stripe confirmation token with the payment configuration from the
auction. Bids use card setup for later off-session settlement. Buy-now uses
immediate payment. Both submissions include the token, email, and E.164 phone.
Amounts are integer USD cents. Send the expected subtotal, service fee, and total
that the customer accepted. Handle `requiresAction` with Stripe
`handleNextAction` and the returned `clientSecret`.

- Auction and Buy Now items can define `additionalCharge` with a name, category, first-unit
  amount, and additional-unit amount. For Buy Now items call
  `getPurchaseQuote(auctionSlug, itemId, { quantity, includeServiceFee })` and show
  its named charge and total before payment. Send `expectedAdditionalChargeCents`
  and `expectedTotalCents` with the purchase. Merchandise subtotal excludes the
  charge, and service fees apply to merchandise only. Receipts retain the charge
  name and rule at purchase time. The category does not enable address collection;
  use `collectShippingAddress` for that. Respect `maxQuantityPerOrder` when set;
  null `inventoryQuantity` means unlimited stock. Bids use the first-unit charge
  only, payable when the bid wins. Disclose that charge and submit
  `expectedAdditionalChargeCents` and `expectedTotalCents` with the bid.
- Never automatically retry a bid. The backend does not deduplicate bids and
  has no public bid-status recovery endpoint. An unknown result needs support
  follow-up; a submitted bid is not a winning bid or a payment receipt.
- Create one `clientRequestId` before a buy-now request. Keep that ID and the
  same purchase details for recovery. Call
  `getPurchaseStatus(auctionSlug, itemId, clientRequestId)` to check the existing
  purchase without creating another payment. It returns a purchase result or
  `null` and can reconcile Stripe status and deliver the purchase notification.
  `null` means no purchase was found at that moment; it does not prove an
  original in-flight request has ended. If you retry a submission, use the same
  original request ID and details. Use `status === "paid"` to establish payment
  completion; a pending result is not completion.
- `getPurchaseReceipt(auctionSlug, itemSlug, purchaseId)` takes the item **slug**,
  while mutations take its **ID**. The receipt contains amounts and quantity,
  not payment status. It is not proof of successful payment.

For card-only buy-now Elements configured with `paymentMethodTypes: ["card"]`,
pass the same `paymentMethodTypes: ["card"]` in `createPurchase`. Omit this field
when collecting payment details with automatic payment methods. Stripe requires
the client and server payment-method configuration to match. Include a
`return_url` when creating the confirmation token.

The built-in React auction checkout accepts cards only. The core client accepts
the confirmation tokens supported by the backend; a custom UI must obey its
payment-method rules.

For ready-made browsing and checkout, use `@lockerverse/react/auctions` and
`@lockerverse/react/auction-item`.

## Public event listings

Use `@lockerverse/sdk/public-events` to read a community's publicly listed
Events. This API is separate from the ticket attendee API at the package root.
It does not require a user account or expose ticket data.

```ts
import { createLockerversePublicEventsClient } from "@lockerverse/sdk/public-events";

const client = createLockerversePublicEventsClient({ communitySlug: "auburn" });
const upcoming = await client.list();
const past = await client.listPast({ limit: 20, offset: 0 });
const selected = await client.search({
  from: "2026-09-01T00:00:00.000Z",
  to: "2026-10-01T00:00:00.000Z",
  timeScope: "all",
  sort: "startAtAsc",
  limit: 20,
  offset: 0,
});
if (past.hasMore) {
  const nextPage = await client.listPast({ limit: 20, offset: past.events.length });
}
```

`list()` returns Events that are happening now or start in the future.
`listPast()` returns `{ events, hasMore }` for Events that have ended; its defaults
are `limit: 20` and `offset: 0`. Increment the offset by the number of records
already loaded. Each Event has `id`, `name`, `description`, `imageUrl`, `location`,
`link`, `startAt`, and `endAt`. The description, image, location, and link can be
`null`. The backend controls public visibility. The SDK validates responses and
returns immutable snapshots. Use `@lockerverse/react/public-events` for the
styled list with Upcoming and Past views.

`search()` returns the same page shape and defaults to `timeScope: "current"`.
The Event must overlap the window:
it must end after `from` and start before `to`. `timeScope` can be
`current`, `past`, or `all`. Sort with `startAtAsc`, `startAtDesc`, `endAtAsc`,
or `endAtDesc`. Follow `hasMore` with the next `offset` to read the full range.

## Security and observability

- No Stripe or Lockerverse secret is accepted by the public API.
- Successful catalog, quote, confirmation, and status responses are validated
  before being returned.
- Public operation inputs are validated with private Valibot schemas.
- Backend response bodies and validation internals never escape in SDK errors.
- Expected input errors are `reportable: false`; unexpected network and server
  failures are `reportable: true`.
- Reportable failures are sent directly to Lockerverse Sentry with allowlisted
  request context. `sentryDsn: null` disables loading and sending telemetry.
- The Sentry runtime is an isolated failure-only chunk and does not replace or
  mutate the host application's Sentry client.
- Stripe's shared publishable key is selected by environment. The authoritative
  quote supplies the community's connected account.
- Analytics is intentionally out of scope.

## Commands

Run these from the repository root:

```sh
pnpm dev:react
pnpm typecheck
pnpm test
pnpm build
pnpm --filter @lockerverse/sdk pack:check
```

### Product quantity limits

`catalog.products[].maximumQuantity` comes from the widget listing’s
`maxQuantityPerOrder`. A `null` value means no per-order limit. The SDK does not
apply the legacy product-level `maxQuantity` field. Product minimum quantities,
inventory, and catalog availability still apply.
