# Paywall integration guide

> This document is self-contained and written to be executed by an AI coding agent
> (Claude Code, Codex, …) or a human. It contains everything needed to integrate an
> app with Paywall: architecture, SDK download URLs, code for every flow, and the
> full API reference. The hub serving this document lives at:
>
> **Base URL: `https://paywallhq.com`**

Paywall is a billing & entitlements hub. It owns pricing, plans, feature gating,
payments (Stripe + Razorpay + a built-in simulated test provider) and customer
records for multiple apps. An integrated app holds **no payment code and no payment
credentials** — it copies small SDK files and reads feature entitlements. Apps
with their own login call `identify` server-side; static apps use guest checkout.

## Existing integrations and new features

Before updating an existing app, read [the public changelog](https://paywallhq.com/changelog.md)
and [the upgrade guide](https://paywallhq.com/upgrade.md). [The JSON feed](https://paywallhq.com/changelog.json)
contains stable release IDs, integration steps, breaking-change flags, and verification cases.
Compare vendored SDK copies with [the SDK manifest](https://paywallhq.com/vendor/manifest.json),
which includes the current revision and SHA-256 hashes. Preserve local modifications
and update any custom wrappers or UI needed by the feature; downloading a new SDK
alone does not implement a feature in the consumer app.

For app-level discounts, checkout accepts optional `discountCode` on both server and
client endpoints and SDKs. Codes support one-time purchases; a full discount returns
a completed free order with provider `discount` and amount zero. Continue using the
returned URL and fresh entitlement. The hosted billing portal includes code entry;
custom checkout UIs must forward the code. See the changelog for the adoption checklist.

## Stripe customer creation

In App Settings → Stripe customers, choose:

| Option | Behavior |
| --- | --- |
| Stripe default (`stripe_default`) | Existing behavior. Checkout receives an email; Stripe creates a Customer when required. One-time payments may remain guest payments. |
| When checkout starts (`checkout`) | Before a Stripe checkout opens, create or reuse a Stripe Customer for this Paywall identity and connection. |
| When identified (`identify`) | A successful server identification with a valid email, new browser checkout account, or hosted email sign-in schedules background creation. Checkout creates a missing customer if needed. |

Configure it with `PATCH /v1/admin/apps/:app` and
`{"stripeCustomerCreation":"identify"}` (send this setting separately).
The authenticated admin app detail includes `stripe_customer_creation`.
Changes apply to future requests immediately; publishing and SDK updates are unnecessary.
Consumer apps continue passing their existing user ID and email. No new checkout argument is needed.

Identification does not wait for Stripe or guarantee completion. It uses the app’s
selected Stripe checkout connection and current test/live mode. A price selecting
another connection creates its customer when checkout begins. Missing or disabled
credentials skip background creation; later identification or checkout can retry.
Stripe customer creation errors appear in the Webhooks event log. Customer creation never grants
entitlements and does not add new recurring-payment lifecycle support.

Customers created by this feature are reused per app customer, Stripe connection,
Stripe account and test/live mode. We do not merge by email, import historical Stripe
customers, or automatically synchronize later email/name changes. Stripe can prefill
and lock the email saved on an existing Customer. Switching to Stripe default retains
stored mappings but stops using them for new sessions until an explicit creation mode
is enabled again. Changing this setting does not delete Stripe records.

The first checkout waits for customer creation and storage. Later checkouts reuse the
saved ID. First use of a credential for each customer and connection also requires one
account-verification request; rotation within the same account preserves the customer mapping.
Provider references are stored in the existing Paywall customer’s server-managed
`provider_metadata`, separate from custom `properties`. No consumer-supplied property
can set or overwrite these references. The storage format supports multiple providers,
but this setting implements creation only for Stripe.

## What to ask the human before starting

Ask for the **agent setup prompt** from the app's Overview page ("Set up with your agent").
It includes the app identity and an **app admin token** (`at_…`). New apps receive
a token valid for two days automatically; existing apps can generate one there. It gives you
full admin-API access to that one app: read its keys, configure features/plans/
prices, connect the simulated provider, publish. With it you can do the entire
setup yourself:

```sh
# Everything lives under this subtree (Authorization: Bearer at_…):
curl -s -H "Authorization: Bearer $APP_TOKEN" https://paywallhq.com/v1/admin/apps/<slug>
# -> full app detail including publishable_key (pk_…) and secret_key (sk_…)
```

Then: `POST …/features` `{key,name,type}`, `POST …/plans` `{key,name,isDefault?}`,
`PUT …/plans/<plan>/features/<feature>` `{enabled,limit?}`, `POST …/plans/<plan>/prices`
`{currency,amount,interval?}` (omit provider — the hub routes at checkout), or
`PATCH …/prices/<priceId>` with the same price fields to edit an existing price,
`PUT …/providers/simulated` `{enabled:true}` plus `PATCH …` `{testMode:true}`
for test payments, and finally
`POST …/publish`. The token is scoped to this app only and expires — if any call
returns 401/403, stop and ask the human to check access. They can extend an expired
token in the Overview without changing its value, or generate a new prompt if it was revoked.
Tokens cannot manage their own access.

For master-key provisioning scripts, `POST …/tokens` defaults to two days when
`ttlDays` is omitted; send `ttlDays: null` explicitly for no expiry. Existing tokens
are unchanged. `PATCH …/tokens/:id` with `{days: 1|2|7|30|60}` adds time to the
current expiry, or from now when expired. Only unrevoked keys with an expiry can
be extended. These token-management endpoints require the master key, which must
never be handed to an integration agent.

Fallback (no token): ask for the publishable key and plan/feature keys, and
have the human configure the catalog in the admin UI. Request the secret key
only for an app that will run the server-side identify integration.

## Step 0 — download the SDK files

Zero-dependency, framework-agnostic files (hub-mantle.js needs only React). Download what you need:

```sh
curl -sO https://paywallhq.com/vendor/hub-server.js   # backend (Workers, Node 18+, Bun, Deno)
curl -sO https://paywallhq.com/vendor/hub-client.js   # browser
curl -sO https://paywallhq.com/vendor/hub-react.js    # React hooks (imports ./hub-client.js — keep them together)
curl -sO https://paywallhq.com/vendor/hub-mantle.js   # Shopify apps leaving Mantle: useMantle() drop-in (see "Moving a Shopify app from Mantle")
```

Sanity-check after download: each file should start with a `// @ts-nocheck` comment
and contain plain `import`/`export` statements (e.g. hub-react.js imports from
`"react"` and `"./hub-client.js"`). If you see rewritten imports like
`/node_modules/.vite/...`, you fetched through the wrong path — use `/vendor/…`.

Put them in the app's source tree (e.g. `app/lib/` or `src/lib/`). They are meant to
be vendored, not npm-installed.

## Static apps — checkout without your own backend (phase 1)

Choose this path if the app has no existing login or backend. In Paywall's app
**Settings**, enable **Browser checkout** (off by default). Configure a provider,
a paid plan and prices, then publish the catalog. The admin API equivalent is
`PATCH /v1/admin/apps/:app {"browserCheckoutEnabled":true}` with an admin token.
This setting applies immediately and is deliberately not part of catalog exports.
Disabling it stops new guest sessions without revoking existing access.

Vendor `hub-client.js`. The static app needs only the Paywall URL and publishable
key; it must never contain the app secret or admin token.

```js
import { createHubClient } from "./hub-client.js";
const hub = createHubClient({
  baseUrl: "https://paywallhq.com",
  publishableKey: "pk_…",
  guest: true,
});

// On page load: restore this browser's existing credential, if any.
// Errors (including 401) must be shown; never silently replace a lost/revoked identity.
if (hub.hasCustomerToken()) await hub.refreshSession({ fresh: true });

// Render the real price before the user clicks Pay.
const quote = await hub.quote({ planKey: "pro" });
if (!quote.available) throw new Error(quote.reason);

// On the Pay button: SDK saves access locally BEFORE creating hosted checkout.
const { url } = await hub.checkout({
  email: emailInput.value, // unverified contact email; NOT authentication
  planKey: "pro",
  currency: quote.currency,
  successUrl: `${location.origin}/payment-success`,
  cancelUrl: `${location.origin}/pricing`,
});
location.assign(url);
```

The success page must use the same origin, publishable key and `guest: true`:

```js
const checkoutId = new URLSearchParams(location.search).get("paywall_checkout_id");
// Poll this with a bounded retry loop if status is still "created". Keep the
// UI pending, offer "Check again", and do not ask the customer to pay again.
const result = await hub.checkoutResult(checkoutId);
if (result.status === "completed") {
  await hub.refreshSession({ fresh: true }); // fresh D1 read, not stale KV
  if (hub.check("export") === true) showExportButton();
  // mode=test must be shown as a test, never counted as real revenue.
}
```

For React, use `<PaywallProvider baseUrl="…" publishableKey="pk_…" guest>` and
`useCheckout()({email, planKey, successUrl, cancelUrl})`. `useEntitlementPoll`
refreshes from D1 when the target entitlement appears. Without React, call
`ensureSession()` when checking access so an expired entitlement session refreshes.

### Guest contract and limitations

- `POST /v1/client/guest {key, email}` creates a **new** random customer and returns
  `{token, expiresAt, plan, status, features, clientToken, email}` (201, no-store).
  Extra identity/plan/mode parameters are rejected. Two requests with the same
  email create separate customers; entering someone's email never retrieves their access.
- The SDK's `startGuestSession({email})` reuses its saved customer. `checkout()`
  calls it automatically. Keep the original email for subsequent checkouts;
  changing it or merging purchases is not part of phase 1.
- An enabled app and a published catalog are required. Errors include invalid
  input (400), invalid key (401), disabled (403), unpublished (409), oversized
  body (413), and throttled issuance (429, Retry-After: 60).
- Guest issuance is limited to 30 requests per minute per IP per Cloudflare
  location. This is coarse abuse protection, not an exact global quota.
- The client token is an existing long-lived, revocable bearer credential. In
  guest mode the SDK saves it in localStorage under the complete hub URL + key.
  Keep scripts on that origin trusted. Guest setup uses Web Locks to coordinate
  tabs; deployed apps require HTTPS and a browser supporting Web Locks. Never put
  credentials in URLs, analytics, logs or HTML. `storage` may supply a persistent
  compatible adapter; `storage: null` and blocked storage prevent guest checkout.
- Guest emails are unverified and never qualify for email-targeted discount codes
  or automatic offers. Unrestricted offers still apply.
- No email verification, login, self-service recovery, cancellation UI or plan
  change workflow is added in this phase. A typo, cleared storage or another
  browser cannot recover access by entering an email. Explain this before payment.
- `clear()` forgets the credential and feature cache; do not present it as a
  recoverable logout for guests. Admin token rotation invalidates the old credential.
- Paid access comes from Paywall fulfillment. A success redirect alone grants
  nothing. Browser feature checks are UX gating; use server enforcement for
  protected server resources. Configured numeric limits do not track usage.
- Simulated checkout is available only in app test mode for guest customers.
  Phase 1 reuses existing provider capabilities; it does not add renewal or
  subscription-management behavior to a provider that lacks it.

A complete static example is in `examples/browser-checkout/` in the repository,
including bounded payment polling, cancellation, reload and a gated text export.


## Architecture in four sentences (apps with existing authentication)

1. On login, the app's backend calls `POST /v1/identify` with the secret key and its
   own user id; the hub upserts the customer and returns a **15-minute entitlement
   JWT** (plan + features) and a **long-lived `clientToken`** (`ct_…`).
2. The frontend keeps the `clientToken` and talks to the hub directly from then on
   (`/v1/client/*`): refreshing the JWT, reading fresh entitlement, starting checkouts.
3. The backend can verify the JWT **locally** (HMAC with the secret key it already
   holds — zero network calls) whenever server-side enforcement matters.
4. Payments, webhooks, refunds and manual grants all happen inside the hub; the app
   just sees features appear and disappear in the entitlement.

## Step 1 — backend: identify at login

```js
// lib/billing.js
import { createHubServer } from "./hub-server.js";

export const hub = createHubServer({
  baseUrl: process.env.PAYWALL_BASE_URL,      // https://paywallhq.com
  secretKey: process.env.PAYWALL_SECRET_KEY,  // sk_… (server only)
});
```

```js
// In the login / session handler, after the app's own auth succeeds:
const session = await hub.identify({
  userId: user.id,                 // the app's stable user id
  email: user.email,
  properties: { signupSource: "web" },   // optional metadata, shown in the hub admin
});
// session = { token, expiresAt, plan, status, features, clientToken }
// Return `session` to the frontend (e.g. embedded in the login response or a
// /api/billing/session endpoint). `clientToken` is long-lived; store it with the
// user's session data.
```

## Step 2 — frontend (React apps: recommended path)

Wrap the app once. Sessions auto-refresh a minute before each 15-minute expiry.

```jsx
// root/entry component
import { PaywallProvider } from "./lib/hub-react";

<PaywallProvider
  baseUrl="https://paywallhq.com"
  publishableKey="pk_…"                    // from the human
  customerToken={session.clientToken}      // from Step 1
>
  <App />
</PaywallProvider>
```

```jsx
// Any component:
import { usePaywall, useFeature, useCheckout, useHubConfig, useEntitlementPoll } from "./lib/hub-react";

const { plan, status, loading, refresh } = usePaywall();
const exportFeature = useFeature("export");   // {enabled, value, limit}
const checkout = useCheckout();

// Gate UI:
{exportFeature.enabled && <ExportButton />}

// Numeric limits (feature configured with type "number"):
const notes = useFeature("notes");   // notes.limit -> 3, or Infinity when unlimited

// Upgrade button (redirects to the hosted payment page):
<button onClick={() => checkout({ planKey: "pro" })}>Upgrade</button>

// Pricing page data (plans, prices, feature lists):
const { config } = useHubConfig();

// Payment success page — poll until the webhook lands, then features unlock:
const { done } = useEntitlementPoll((ent) => ent.plan === "pro");
```

Non-React frontends use `hub-client.js` directly:

```js
import { createHubClient } from "./hub-client.js";
const hub = createHubClient({ baseUrl: "https://paywallhq.com", publishableKey: "pk_…", customerToken });
await hub.ensureSession();          // restores or refreshes
hub.check("export");                // true | value | false
hub.limit("notes");                 // Infinity | number | 0
const { url } = await hub.checkout({ planKey: "pro", successUrl: location.href });
window.location.href = url;
```

## Step 3 — backend enforcement (optional, add when it matters)

Frontend gating is UX only — a user can flip flags in their own browser. For
anything genuinely expensive (API calls, downloads, server work), verify the JWT
locally:

```js
const claims = await hub.verifyToken(request.headers.get("x-entitlement"));
if (!claims) return unauthorized();               // invalid or expired
if (claims.sub !== sessionUserId) return forbidden();  // token belongs to another user
if (!claims.features["export"]) return paywall();
```

The frontend should send the current JWT (from `hub.getSession().token`) in a header
on protected requests. Full claims shape:
`{iss: "paywall", app: "<app id>", sub: "<external user id>", email?, plan, status, features, iat, exp}`.

## Feature value semantics

Every feature has a type configured in the hub. In `features` maps (JWT, identify,
entitlement, config):

| Feature type | Value when granted | Meaning |
|---|---|---|
| `boolean` | `true` | on/off capability |
| `number` | `3` (number) or `true` | limit; `true` = unlimited |
| `text` | `"priority"` (string) or `true` | arbitrary value, e.g. a tier name |

A feature **absent from the map is not granted**. Checking `features[key] !== undefined`
is the canonical "has access" test (`hub.check()` / `useFeature().enabled` do this).

## Checkout flows

- **Frontend-direct** (recommended): `hub.checkout(...)` / `useCheckout()` with the
  customer token — the hub picks the provider (INR/India → Razorpay, else Stripe;
  `provider: "simulated"` selects the simulator only while app test mode is on) and
  returns `{url}`; redirect to it.
- **Backend-mediated**: `hub.checkout({userId, email, planKey, successUrl, …})` from
  hub-server.js with the secret key. The app-level test-mode flag decides the mode.
- After payment, the provider webhook reaches the hub within seconds and upgrades the
  entitlement automatically. The app needs **no webhook code**. On the success page,
  poll `GET /v1/client/entitlement` (or `useEntitlementPoll`) and refresh the session.

## Shopify apps (App Pricing / Billing API)

Shopify apps do **not** use the checkout flows above: Shopify bills the merchant
itself and App Store rules forbid a third-party checkout. Paywall instead reads the
outcome through the **Shopify Partner API** (Shopify App Pricing sends no webhooks)
and turns it into customers, entitlements and reports. What the app does:

1. **Identify the shop as the customer.** After Shopify auth, call `hub.identify()`
   with `userId` = the permanent myshopify domain, **lower-cased** (for example
   `acme.myshopify.com`, never a custom domain) and the shop owner's email. That is
   the same customer the Partner feed, manual grants and refreshes use. Paywall also
   creates the customer itself when the Partner feed reports an install, so identify
   never has to run first.
2. **Gate on the entitlement JWT and features exactly as for any other app.** A shop on
   a Shopify plan that the human mapped to a Paywall plan (Providers → Shopify → Plan
   mapping) is entitled to that plan; every other shop sits on the default plan.
3. **Ask Paywall how this app sells, and do what it answers.** Shopify has two
   billing systems, and which one an app uses is a setting in Paywall
   (Providers → Shopify → Billing model), so write one call site that handles
   both and nothing changes the day it is switched:

   ```js
   const action = await hub.shopifySubscribe({ shop, shopPlan, planKey, returnUrl });
   if (action.mode === "app_pricing") {
     // Shopify hosts the plans: open its page in the TOP window.
     return redirect(action.url);
   }
   // Legacy Billing API: Paywall holds the plans, prices and trials; this app
   // creates the charge with its own Admin token. Paywall never gets that token.
   const { charge } = action;   // {name, amount, currencyCode, interval, trialDays, test, returnUrl}
   const res = await admin.graphql(APP_SUBSCRIPTION_CREATE, { variables: {
     name: charge.name, returnUrl: charge.returnUrl, trialDays: charge.trialDays,
     test: charge.test,
     lineItems: [{ plan: { appRecurringPricingDetails: {
       price: { amount: charge.amount, currencyCode: charge.currencyCode },
       interval: charge.interval } } }],
   } });
   return redirect(confirmationUrlFrom(res));
   ```

   Render the cards from `hub.shopifyPlans({ shop, shopPlan })`, which answers with
   the plans **this** shop is offered and what **it** pays: Paywall supports a
   different price per Shopify plan tier, plans offered only to the shops you
   assign, and a negotiated price for one shop. Pass `shopPlan` =
   `shop.plan.displayName` from the Admin API, or every plan falls back to its
   tier-less price. On App Pricing the list is empty and `planPageUrl` points at
   Shopify's page, so one renderer serves both.

   Send a merchant to **Shopify's hosted page** only when the mode says so:
   `https://admin.shopify.com/store/{store_handle}/charges/{app_handle}/pricing_plans`.
4. **Refresh the shop when Shopify redirects back.** The Partner feed is polled on a
   ten-minute cron. On the plan picker's return URL, call
   `hub.refreshShopifyShop({ shop, shopId })` from the backend (`shop` = the
   myshopify domain, `shopId` = `gid://shopify/Shop/N` from the Admin API `shop { id }`).
   It reads that one shop's live contract from the Partner API now and returns
   its fresh entitlement, so the new plan is there on the first call (Shopify's
   event feed alone lags a plan change by about 20 seconds). Never trust the
   `plan_handle` query parameter itself. Then re-run
   `identify` (or `POST /v1/client/session`) so the frontend gets a new token.
   The call never fails for Partner API trouble: it answers `refreshed: false` with
   a `reason` and the shop's last-known entitlement. Refreshes of one shop are
   rate-limited to one per 10 seconds (`reason: "cooldown"`), so it is safe on a
   page load but should not be polled.

   **On the Billing API, send the charge you just read.** Shopify's Partner API
   has no live view of a legacy charge (`activeSubscription` covers App Pricing
   only, and the legacy feed runs about an hour late), so on your return URL —
   which Shopify calls with `?charge_id=` — read your own subscription with your
   Admin token and pass it along:

   ```js
   const { data } = await admin.graphql(`#graphql
     query { currentAppInstallation { activeSubscriptions {
       name status test trialDays createdAt
       lineItems { plan { pricingDetails { ... on AppRecurringPricing {
         price { amount currencyCode } interval } } } } } } }`).then((r) => r.json());
   const sub = data.currentAppInstallation.activeSubscriptions[0];
   await hub.refreshShopifyShop({ shop, shopId, subscription: sub && {
     name: sub.name, status: sub.status, test: sub.test,
     amount: Number(sub.lineItems[0]?.plan?.pricingDetails?.price?.amount),
     currency: sub.lineItems[0]?.plan?.pricingDetails?.price?.currencyCode,
     interval: sub.lineItems[0]?.plan?.pricingDetails?.interval,
   } });
   ```

   Paywall maps the **charge name** to a plan and grants it on that same call,
   then lets the feed take over when it catches up. Without this the merchant
   waits for the feed. Nothing else changes: your Admin token never leaves your
   app, and a manual grant still wins.
5. **Read the shop's Shopify subscription from the same answer.** When the plan
   comes from Shopify, identify, `GET /v1/entitlement` and the refresh also return
   `shopify: {planHandle, price, currency, interval, trialEndsAt, cancelsOn, source}`.
   Use `trialEndsAt` for a "trial ends on …" notice and `price`/`currency` for purchase
   tracking. It's absent for manual grants and non-Shopify plans.
6. **A shop without a mapped subscription has the app's default plan.** For a
   Shopify app that should block everything without a subscription, the human keeps
   a default plan with no features (Paywall otherwise treats the lowest-sorted plan
   as the default). Exclude the default plan when you render a plan list from
   `GET /v1/config`.
7. **Keep the app usable when Paywall can't be reached.** Store the last answer per
   shop in the app's own database and serve it when identify fails (network error,
   5xx). Only a shop with no answer at all is blocked: every gated feature stays
   closed rather than open.
8. **Take the app handle from an environment setting.** Local, staging and
   production are different Shopify apps with different handles
   (`shopify.app.toml`), so the plan-page link must use the running app's handle,
   never a hard-coded one. Open the plan page in the top window, not inside the
   embedded iframe.
9. **Add a debug switch.** For example, `?paywall_debug=1` on the app's home page
   prints Paywall's answer for the shop in the browser console. It makes the
   integration easy to check on any store.

### Testing a Shopify integration

Use a development store; its charges are $0 or test charges and never reach
reports.

1. Install the app on the store and open it: with no subscription the shop is on
   the default plan, so gated features show the upgrade prompt.
2. Choose a plan on Shopify's plan page. Back in the app, the debug output shows
   the new plan and features on the first load.
3. Change plan (upgrade and downgrade) and check that the gates follow.
4. On the Paywall dashboard, the app's Shopify page shows the shop in the import,
   and Reports show the subscription.

### Moving a Shopify app from Mantle

Mantle's subscriptions are ordinary Shopify charges named after the Mantle plan,
so Paywall reads them from Shopify: nothing about who is on which plan needs
exporting. The human does the Paywall side in the dashboard (the app's Shopify
page):

1. Link the app, keep the billing model on Shopify App Pricing, tick "Also read
   Billing API subscriptions", then run "Import full history".
2. Map every charge name to a Paywall plan, including annual variants such as
   "Growth Annual" (names match regardless of capital letters).
3. In "Import from Mantle", paste the app's Mantle App ID and API key, copy the
   plan values into Paywall and publish, preview the shops (expect "plan
   matches"), then import the extras.
4. Enable Shopify App Pricing in the Partner Dashboard last, after the app runs on
   Paywall.

The agent does the app side: identify, gates, the plan page and the refresh as
above. Keep the Mantle code behind a setting (for example
`ENTITLEMENTS_SOURCE=mantle|paywall`, default `paywall`) so the switch can be
rolled back, and use Paywall's feature flag keys exactly as they are (they match
the Mantle keys when the values were copied from Mantle).

#### Keeping `useMantle()` — the drop-in

An app built on `@heymantle/react` can keep its components as they are:
`hub-mantle.js` is a `MantleProvider` and `useMantle()` with Mantle's shape
(`customer`, `customer.features`, `subscription`, `plans`, `loading`,
`isFeatureEnabled`, `limitForFeature`, `subscribe`, `cancelSubscription`,
`refetch`), fed by Paywall. Download it next to `hub-server.js`:

```sh
curl -sO https://paywallhq.com/vendor/hub-mantle.js
```

1. **Loader** (server — the secret key stays here):
   ```ts
   import { createHubServer } from "~/lib/hub-server.js";
   import { loadPaywallMantle } from "~/lib/hub-mantle.js";
   const hub = createHubServer({ baseUrl: env.PAYWALL_BASE_URL, secretKey: env.PAYWALL_SECRET_KEY });
   const paywall = await loadPaywallMantle(hub, { shop: session.shop });
   return { paywall };
   ```
2. **Root** — replace Mantle's provider:
   ```tsx
   import { MantleProvider } from "~/lib/hub-mantle.js";
   <MantleProvider
     paywall={paywall}
     onSubscribe={({ planKey, interval, returnUrl }) => /* POST to your subscribe route */}
     onCancel={() => /* POST to your cancel route */}
     onRefetch={() => revalidator.revalidate()}
   >
   ```
3. **Components** — change only the import:
   `import { useMantle } from "@heymantle/react"` → `from "~/lib/hub-mantle.js"`.

What differs from Mantle, and why:

- **Subscribing needs a route in your app.** Mantle created the Shopify charge
  itself with the shop's Admin token; Paywall never holds that token. Your route
  calls `hub.shopifySubscribe()`, runs `appSubscriptionCreate` for the Billing
  API (see "Shopify apps" above), and returns `{confirmationUrl}` — or `{url}` for
  App Pricing. `subscribe()` passes it back as `confirmationUrl`, as Mantle did.
- **Plan ids are Paywall plan keys.** Mantle listed monthly and annual as two
  plans; Paywall has one plan with two prices, so the drop-in lists
  `"<planKey>"` and `"<planKey>:annual"`. `subscription.plan.id` uses the same
  ids, so comparing the two still finds the current plan. Code that hard-codes
  Mantle's plan UUIDs must switch to keys.
- **Limits.** Paywall sends `true` for an unlimited limit, the same as for a
  boolean that is on, so the plans answer carries each feature's type: a
  `number` flag is a Mantle limit, and unlimited reads `-1`, as in Mantle.
  Nothing to configure. (`limitFeatures={[…]}` on the provider overrides it,
  only for a flag created with the wrong type in Paywall.)
- **Features a plan does not grant** are filled in as off (`false`, or `0` for
  a limit), so code reading `customer.features.x.value` does not crash.
- **Every field Mantle's objects have is present.** Paywall fills the shop's
  name, install date, and its Shopify subscription's activation, renewal
  (`currentPeriodEnd`), trial and cancellation dates; usage, reviews, payment
  methods and `customFields` come back empty (`{}` / `[]` / `null`). Custom
  field values set by hand in Mantle do not carry over.
- **`customer.id` is the myshopify domain**, Paywall's customer id. `apiToken`
  is null — nothing in the browser may hold a Paywall secret.
- **Usage billing** (`sendUsageEvent`) is a no-op; `getUsageReport`,
  `addPaymentMethod` and `createHostedSession` reject.

### Pitfalls

- **New public Shopify apps must use expiring offline access tokens** for the
  Admin API (existing public apps from 1 January 2027). With
  `@shopify/shopify-app-remix` that means version 4.1 or later and
  `future: { expiringOfflineAccessTokens: true }`; with an older library every
  Admin API call of a new app fails with "Non-expiring access tokens are no longer
  accepted".
- A charge name without a plan mapping grants nothing. After the history import,
  check the Plan mapping list of names seen for anything unmapped.
- Shopify's Billing API feed runs about an hour behind; Paywall re-reads it on
  every sync, so late changes still arrive.

What to ask the human for: nothing beyond the usual app token. The Partner API
connection (organization id + token, encrypted at rest) and the app link are set up
in the Paywall dashboard, never handed to the agent. Development stores appear as
$0 subscriptions and are excluded from reports.

A plan the human grants by hand on the customer page (provider `manual`) always
wins over the Partner feed: neither the cron nor a refresh overwrites or revokes it.
Clearing it puts the shop back on its Shopify plan. Per-customer feature overrides
replace the plan's value for that feature (they can raise or lower a limit) and can
carry an expiry date, after which they stop applying.

## Testing without a payment account

The hub has a built-in **simulated provider**: the human enables it on the app's
Providers page and adds a `simulated` price to a plan. Checkout with
`provider: "simulated"` then returns a hub-hosted page (`https://paywallhq.com/pay/…`) with
Approve / Decline buttons. Approvals run the real fulfillment path and are always
recorded as test payments. This exercises the entire integration end-to-end with
zero external setup.

With real Stripe/Razorpay **test keys** saved in the hub, checkouts run in test
mode while the app-level test-mode toggle is on.

## API reference

All endpoints are under `https://paywallhq.com`. Errors return `{"error": "message"}` with an
appropriate HTTP status. CORS is open.

### Public (publishable key)

- `GET /v1/config?key=pk_…` → the published config:
  `{app, version, publishedAt, features: [{key,name,description,type}], plans: [{key,name,isDefault,features,prices}]}`
- `GET /v1/quote?key=pk_…&planKey=…&country=?&currency=?` → a recent list-price quote for this visitor: `{available, currency, amount, amountSubunits,
  interval, priceId, provider, mode}` or `{available: false, reason}`. Country is geo-detected
  from the caller when omitted. Without `planKey` it returns `{plans: {key: quote}}`.
  **Render pricing pages from quotes, not local constants**. Quotes use the same resolution
  logic as checkout. Snapshots are retained for seven days and refreshed on settings changes;
  propagation can take 60 seconds or more. Checkout reads current
  settings; pass `expectedQuote` to detect a change before opening payment. `available: false` gives
  the UI a graceful state instead of a failing buy button.
- `POST /v1/track` `{key: pk_…, event, userId?, anonymousId?, properties?}` → `{ok: true}`.
  Fire-and-forget product analytics; events show on the hub's Activity pages (per-event
  counts and per-customer timelines). Event names: 1–64 chars of letters/digits/`_.-`;
  properties ≤ 2 KB JSON. Prefer the SDK: `hub.track("paywall_viewed", {source: "pricing"},
  {userId})` / `useTrack()` — it attaches a stable per-browser anonymous id automatically
  so pre-login funnel steps still count people. The hub emits `checkout_created`,
  `payment_succeeded`, `payment_refunded` and `payment_declined` itself — instrument only
  the client-side steps (e.g. `paywall_viewed`, `checkout_clicked`) and the funnel is
  complete.

### Server-to-server (header `Authorization: Bearer sk_…`)

- `POST /v1/identify` `{userId, email?, properties?}` →
  `{token, expiresAt, plan, status, features, clientToken}`
- `POST /v1/entitlement-recovery/token` `{email}` → for a currently eligible paid
  entitlement, `{token, expiresAt}`; for an unknown, free, refunded, canceled,
  expired, or rate-limited lookup, `{token:null, expiresAt:null}`. The token is an
  opaque, single-use, 15-minute credential. Keep this call on the consumer backend:
  the browser must always receive the same generic "check your email" response,
  regardless of which Paywall result the backend received.
- `POST /v1/entitlement-recovery/exchange` `{token}` →
  `{userId, token, expiresAt, plan, status, features, clientToken}`. `userId` is the
  original external customer identity; the remaining fields are the same typed
  entitlement session returned by identify. Invalid, expired, replayed, cross-app,
  and newly ineligible tokens all return `400 {"error":"invalid_recovery_token"}`.
  The consumer backend should bind its new browser session to `userId`; it must not
  replace that identity with a browser-supplied value.
- `GET /v1/entitlement?userId=…` → `{plan, status, provider, currentPeriodEnd, features, …}` (fresh read)
- `POST /v1/shopify/refresh` `{shop, shopId?}` → `{refreshed, reason?, detail?, userId, shopId, eventsRead, live, plan, status, provider, features, …}`
  (`live` = `{handle, checkedAt}` of Shopify's current contract, `handle: null` when the shop has none).
  Shopify apps only: re-reads one shop from the Partner API now and returns its fresh
  entitlement. `reason` is one of `cooldown`, `unknown_shop` (pass `shopId`),
  `partner_api_error`, `derive_error`, `partner_not_connected`, `not_linked`, `unsupported_billing_model`;
  in every case the body carries the last-known entitlement. `400` only when neither a
  myshopify domain nor a shop id is given.
- `POST /v1/checkout` `{userId, email, planKey, successUrl, cancelUrl?, currency?, country?, provider?}` →
  `{url, provider, mode, priceId, interval, sessionId, providerSessionId, currency, amountSubunits}`.
  The app-level test-mode flag is the single authority for both quote and checkout
  mode. The simulated provider remains unavailable in live mode.

### Frontend-direct (header `Authorization: Bearer ct_…`, self-scoped)

- `POST /v1/client/session` → `{token, expiresAt, plan, status, features}`
- `GET /v1/client/entitlement` → fresh entitlement for this customer
- `POST /v1/client/checkout` `{planKey, successUrl, cancelUrl?, currency?, country?, provider?}` → `{url, …}`
  (mode cannot be requested here; the app's test-mode flag decides)
- `POST /v1/client/portal` `{returnUrl?}` → `{url, expiresAt}` — a hub-hosted **billing
  portal** for this customer (current plan + features, payment history, upgrade buttons).
  The URL expires in ~30 minutes. In the app: `hub.portal()` /
  `useBillingPortal()` and redirect — a complete "Manage billing" page with zero UI to build.

### Notes for the agent

- The entitlement JWT is HS256, signed with the app's secret key, TTL 15 minutes.
  `hub-server.js` `verifyToken()` implements verification — don't re-implement it.
- The `clientToken` is long-lived and revocable from the hub admin. Handle a 401 from
  any `/v1/client/*` call by re-running the backend identify to get a fresh one.
- Paywall does not send recovery email. The consumer app owns its email provider,
  template, magic-link URL, generic UI response and secure browser cookie. Paywall
  owns only token issuance, eligibility validation, one-time exchange and the
  restored Paywall session.
- Recovery selects only `active`, non-default, provider-backed entitlements. Manual
  grants and free/default plans are not treated as purchases; a subscription with
  a past `currentPeriodEnd` is ineligible. If several eligible customer records use
  the same normalized email, Paywall deterministically selects the entitlement with
  the newest `updated_at`, then the oldest customer record, then the smallest
  customer id. Eligibility is checked again in the atomic token-consumption write.
- `GET /v1/config` reflects the last **Publish** in the hub admin (plus ~60s cache
  propagation) — plans/prices added since then won't appear until republished.
  Checkout, however, always validates against the hub's current prices, so a
  checkout can succeed for a price the config doesn't show yet.
- `useEntitlementPoll(predicate, {intervalMs = 2000, maxAttempts = 30})` returns
  `{done, gaveUp}`; handle `gaveUp` by showing a "taking longer than usual" state.
- Refunds/cancellations are handled inside the hub via provider webhooks and drop
  the user to the default plan; there is no client-side way to simulate one. To test
  the downgrade UX, ask the human to use "Clear entitlement" on the customer's page
  in the hub admin (or issue a refund in the provider's test dashboard).
- Environment variables to add: `PAYWALL_BASE_URL=https://paywallhq.com` and
  `PAYWALL_SECRET_KEY=sk_…` (server-side only). The publishable key may be inlined
  in frontend code.

## Automatic discounts

In App → Discounts, choose **Automatic — no code needed**. Use an offer reference for your records, a percentage, and a maximum checkout count. Optionally select a plan, exact email addresses, countries, and a start/end time (UTC). Empty email and country lists include everyone. All configured restrictions must match; lists match any value within that list. Email addresses can be listed before the customer first signs in.

Automatic offers use existing `hub.checkout()` and React `useCheckout()` calls: omit `discountCode`. No SDK replacement is required. The largest eligible percentage applies, with the oldest offer winning ties; discounts do not stack. An explicitly entered code takes precedence and must be valid. One-time purchases only; recurring purchases do not receive automatic discounts.

Email eligibility uses the customer email recorded by your backend through identify/checkout, never an email override in a browser checkout. Keep that identity sourced from your authenticated user. For country eligibility, server SDK callers must supply `country` from trusted customer-location information (for example, the visitor country on the incoming request), not a form field. Browser SDK and hosted portal checkouts use detected visitor country; their `country` argument may select pricing but cannot override discount eligibility. Missing country information cannot match country-restricted offers.

The hosted portal previews eligible automatic offers. Public catalog/quote prices remain list prices; custom checkout UIs should use the returned `amountSubunits`, `originalAmountSubunits`, `discountAmountSubunits`, and optional `discount: { code, application, percentOff }` for the actual checkout. Do not claim a discount based solely on a public quote.

Starting checkout reserves a use, including abandoned payment links. Each customer can reserve an offer once per mode; repeating that eligible checkout resumes its saved result. Caps are separate for live and test. Fully used or ineligible offers are skipped; a concurrent loss of eligibility returns an error rather than silently increasing a checkout price. Disabling or expiring an offer stops new reservations, while existing provider payment links may remain payable. Follow the returned URL even for a 100% free order, and refresh entitlement afterward.

Admin API creation (`POST /v1/admin/apps/:app/discounts`) accepts the existing fields plus:

```json
{
  "code": "SEPTEMBER-OFFER",
  "application": "automatic",
  "percentOff": 25,
  "maxRedemptions": 100,
  "emails": ["alice@example.com", "bob@example.com"],
  "countries": ["IN", "US"],
  "startsAt": "2026-09-10T00:00:00Z",
  "expiresAt": "2026-09-20T00:00:00Z"
}
```

Omit lists and dates to apply immediately to all customers. The default `application` remains `code` for existing callers. Email and country lists accept at most 1,000 and 250 entries respectively. The same optional restrictions can also narrow coded discounts.

## Quote freshness and checkout confirmation

Public quotes retain a pricing snapshot for seven days. Saving plans, prices, provider
settings, app mode, imports, and publishing rebuild it automatically. Updates may take
60 seconds or more to appear across locations; this is a propagation delay, not a
one-minute expiry. Clients may also retain a rendered price.
The response shape and country/currency/provider selection are unchanged. Quotes are list
prices; customer-specific discounts are applied at checkout. Server-secret quote requests
continue to read current settings.

Checkout always reads current pricing. To compare it with the price your customer saw, send
that single-plan quote as optional `expectedQuote` to either checkout endpoint, `hub.checkout()`,
or React `useCheckout()`. The required fields are `planKey`, `priceId`, `currency`,
`amountSubunits`, `interval`, `provider`, and `mode`. Extra quote response fields are accepted.
Forward the same currency, country, and provider selection used to obtain the quote.

If the list price or availability changed, checkout returns HTTP 409 with
`{code: "QUOTE_CHANGED", error, quote}` before creating a payment session. The SDK throws an
error with `status`, `code`, and `quote`. Show this fresh quote and ask the customer to continue;
retry only after confirmation using it as the new `expectedQuote`. An unavailable quote must
stop checkout. Do not immediately refetch the cached quote or silently auto-retry.

Existing clients omitting `expectedQuote` keep their current behavior and receive checkout
at the latest saved price, which can differ from the previously displayed quote. Updating the
SDK alone does not add confirmation UI or forward arguments through your own backend.

App owners can choose **Refresh cached prices** on the Plans page or beside a plan's
prices. Authorized admin API callers can also use
`POST /v1/admin/apps/:app/quote-cache/refresh`. This rebuilds and replaces the whole app's
snapshot, including removed/renamed plans, instead of deleting it and slowing the next
visitor. Authorization follows the existing app-admin-token/master-key rules. Dashboard
refresh uses the same membership and write-access checks as other plan edits.

If a refresh fails after a change was saved, the error explicitly says that the change
was saved and asks for a manual refresh. Existing cached prices can remain until refresh
succeeds or the seven-day retention expires. Checkout still validates current pricing.
Operational edits made directly in the database require a manual refresh. Missing,
invalid, or seven-day-old snapshots fall back to current data and refill automatically.
The existing background scheduler also scans apps in small batches and renews snapshots
at least a day old. This keeps quiet apps warm and reconciles missed refreshes; sweep
time depends on app count. KV propagation and concurrent writes mean the 60-second
notice is an estimate, not an exact freshness guarantee.

## Hosted customer email sign-in (optional)

Static apps can use Paywall's hosted email sign-in and purchase dashboard. This is separate from app-owner/admin sign-in and from your existing server authentication. Only customers issued through Browser checkout are recoverable here. Matching emails do not merge accounts, grant access before verification, or expose server-managed customers.

1. Publish your catalog. In Settings enable **Hosted email** and add your exact app origins, such as `https://notes.example`. HTTPS is required except for local development. Enable **Browser checkout** for new customers.
2. Vendor `hub-client.js` revision `2026-09-11.4` (and matching `hub-react.js` if used). Configure only the public URL and publishable key with `guest: true`.
3. Add a recovery/sign-in button and handle the return **before analytics, checkout polling, or rendering paid features**:

```js
const hub = createHubClient({ baseUrl: "https://paywallhq.com", publishableKey: "pk_...", guest: true });
await hub.completeSignIn(); // no-op without paywall_code; removes code/state from URL
// On a deliberate button click, in the same tab:
await hub.signIn({ returnUrl: "https://notes.example/account" });
// With saved customer access:
const billing = await hub.portal({ returnUrl: location.href });
location.assign(billing.url);
```

The `/account` path must serve your static app too. Return to the same tab and origin that started sign-in. The SDK stores a PKCE verifier and state in sessionStorage; it saves the recovered credential in localStorage under the same app-scoped guest key used by checkout. Both stores must work, and browsers require HTTPS and Web Locks. For custom storage adapters, pass `storage` and `authStorage` with the Web Storage getItem/setItem/removeItem interface. Do not put customer tokens or the verifier into URLs, analytics, or logs.

The hosted page sends an eight-digit code, valid for ten minutes with five attempts. Sign-in lasts 24 hours. The dashboard shows separate matching browser accounts and their latest 20 payment records. Customers select which account to restore; Paywall returns a two-minute single-use code protected by S256 PKCE, state, the exact return URL, and the app key. Logout and session expiry invalidate unused codes. Restoring rotates only that account's long-lived customer token. Already issued entitlement JWTs can remain valid for their remaining lifetime (up to 15 minutes); this does not remotely clear another browser's local storage.

React apps can use `useEmailSignIn()` inside `PaywallProvider` to call `signIn` and `completeSignIn`; the latter refreshes the provider state after exchange. Invoke completion in your callback component and handle failures with a **Sign in again** action. Updating SDK files alone does not add these controls to your app.

You can also link directly to `https://paywallhq.com/customer/sign-in?key=pk_...` for dashboard-only sign-in. Starting from the SDK is required to return a credential to your static app. If customers used the same email in different browsers, choose the intended purchase account instead of paying again.

### Subscription management

The hosted billing page uses the original recorded Stripe checkout to establish ownership; it never looks up a Stripe customer by email. It opens Stripe confirmation screens for cancellation at period end and supported plan changes. Changing a plan updates the existing subscription item and lets Stripe display charges, prorations, and payment confirmation. Recurring checkout is blocked for a browser account with an existing active, trialing, past-due, or unpaid Stripe subscription; use **Manage billing**.

Plan changes require a single-item, unscheduled Stripe subscription with no pending update and an active, unambiguous Paywall price whose Stripe price ID, provider scope, currency, amount, and recurring interval match. Set `providerPriceId` in your catalog; inline generated prices cannot be plan-change targets. Supported choices keep the subscription currency. Provider failures show an error instead of opening another recurring checkout. Cancellation preserves access until the current billing period ends; inactive subscription states lose paid access. A newer purchase or manual grant is not overwritten by updates to an older subscription.

Stripe webhook endpoints must subscribe to `customer.subscription.updated`, `checkout.session.completed`, and `customer.subscription.deleted` (retain `charge.refunded`). Newly created endpoints include the update event; **existing endpoints need that event added in Stripe**. Paywall reads fresh provider state on updates and portal visits. This adds entitlement reconciliation, not a complete recurring invoice ledger: payment history currently records checkout payments, not every renewal invoice.

Razorpay currently uses one-time payment links, and simulated checkout is one-time. Those purchases show payment history and new-plan checkout, without recurring cancellation controls. Browser-only feature checks still run on a device the customer controls; protect server resources with server-side entitlement verification when you have a backend.


### Frontend enablement and app branding

In **Settings → Hosted email sign-in**, enable customer email sign-in and optionally set **Customer-facing name**. Blank uses the current app name. Paywall uses this name on hosted sign-in, purchases, billing, email subjects, and the sender display name, for example `Pocket Notes <accounts@paywallhq.com>`. The sending address is fixed; the name does not authorize a custom email domain.

```js
const settings = await hub.getClientSettings();
signInButton.hidden = !settings.hostedEmail.enabled;
// Refresh after changing settings while this page remains open:
const latest = await hub.getClientSettings({ force: true });
```

These values are also available at `config.client` from `hub.getConfig()` or React `useHubConfig()`: `{ browserCheckout: { enabled }, hostedEmail: { enabled, displayName } }`. SDK copies older than 2026-09-11.4 can read the raw config but do not have `getClientSettings()`. The helper defaults capabilities to disabled when talking to an older hub. Settings are read live by the config endpoint without republishing; the SDK caches config per page until forced. Frontend visibility is a UI choice, and hosted endpoints always enforce the owner-controlled setting independently.

Admin API: `PATCH /v1/admin/apps/:app` with `{ "hostedEmail": { "enabled": true, "returnOrigins": ["https://notes.example"], "displayName": "Pocket Notes" } }`. `displayName` is optional: omission preserves the saved override, and an empty string clears it to the app-name default. Names are limited to 80 characters and reject control characters/angle brackets. Configure this through app-owner settings, never through untrusted browser input.
