diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 00000000..f5383e5e
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "docs",
+ "runtimeExecutable": "bun",
+ "runtimeArgs": ["run", "dev"],
+ "port": 3000
+ }
+ ]
+}
diff --git a/content/docs/dashboard/superwall-ai.mdx b/content/docs/dashboard/superwall-ai.mdx
index 03dd46b7..35afd94c 100644
--- a/content/docs/dashboard/superwall-ai.mdx
+++ b/content/docs/dashboard/superwall-ai.mdx
@@ -49,6 +49,10 @@ The composer's model picker switches the underlying AI model: Sonnet is the defa
Conversations persist: reopen the panel and your last chat is right where you left it. The list icon in the top bar shows your chat history, and the pencil starts a fresh chat. Chats are private to you and stored per browser: teammates never see them, and a different browser or device starts with a fresh history.
+You can also share conversations with colleagues. Click the share icon to generate a public link:
+
+
+
## On the docs site and superwall.com
The same assistant is available outside the dashboard: the floating bubble in the bottom-right of the [docs](https://superwall.com/docs) and superwall.com, and the **Ask AI** button in the docs search dialog, which hands your search straight to it. Outside the dashboard it answers Superwall questions from the docs and SDK source; connect from the dashboard when you want it working with your account and analytics data.
diff --git a/content/docs/images/ai_dash_share.jpg b/content/docs/images/ai_dash_share.jpg
new file mode 100644
index 00000000..46d1b475
Binary files /dev/null and b/content/docs/images/ai_dash_share.jpg differ
diff --git a/content/docs/images/stripe_discount_apply.jpg b/content/docs/images/stripe_discount_apply.jpg
new file mode 100644
index 00000000..1151013c
Binary files /dev/null and b/content/docs/images/stripe_discount_apply.jpg differ
diff --git a/content/docs/meta.json b/content/docs/meta.json
index 26920f61..c5419580 100644
--- a/content/docs/meta.json
+++ b/content/docs/meta.json
@@ -18,6 +18,7 @@
"expo",
"flutter",
"unity",
+ "web",
"react-native",
"community"
]
diff --git a/content/docs/web-checkout/web-checkout-discount-codes.mdx b/content/docs/web-checkout/web-checkout-discount-codes.mdx
index 3d8d1ce3..ab581418 100644
--- a/content/docs/web-checkout/web-checkout-discount-codes.mdx
+++ b/content/docs/web-checkout/web-checkout-discount-codes.mdx
@@ -5,11 +5,15 @@ description: "Let customers enter a Stripe promotion code on your web paywall, u
Discount codes let a customer type a promotion code into your web paywall, have it validated against Stripe, and see the price update before they check out. The code is then carried through to the Stripe checkout session, so the discount is applied to the purchase they complete.
-This works entirely on your paywall — there is no separate discount widget to configure. You add a normal text **Input** for the customer to type into, then a button whose tap behavior runs the **Apply Discount** action. When the customer taps it, Superwall resolves the code against your Stripe promotion codes, re-prices the products on the paywall, and applies the code at checkout.
+This works entirely on your paywall. There is no separate discount widget to configure. You add a normal text **Input** for the customer to type into, then a button whose tap behavior runs the **Apply Discount** action. When the customer taps it, Superwall resolves the code against your Stripe promotion codes, re-prices the products on the paywall, and applies the code at checkout.
Discount codes are a Stripe web checkout feature. The customer redeems a promotion code you have
- already created in your Stripe dashboard — Superwall does not create or manage the coupon itself.
+ already created in your Stripe dashboard. Superwall does not create or manage the coupon itself.
+
+ Don't confuse **Apply Discount** with **Redeem Purchase**, a separate tap action in the same
+ Purchases group that routes a customer after a completed purchase. This page covers Apply
+ Discount.
## Before you start
@@ -27,22 +31,24 @@ Create the coupon and promotion code in Stripe first. Superwall resolves whateve
## Add discount code redemption to a paywall
-You will add a text input for the customer to type into, then a button that redeems whatever they entered. Open the paywall you want to edit in the [paywall editor](/dashboard/dashboard-creating-paywalls/paywall-editor-overview) and follow along.
+Open the paywall you want to edit in the [paywall editor](/dashboard/dashboard-creating-paywalls/paywall-editor-overview) and follow along.
-
- Add an [Input element](/dashboard/dashboard-creating-paywalls/paywall-editor-input-component) where the customer will type their code, and give it a helpful placeholder such as "Promo code". Bind the input to a state variable — for example, `state.discountCode` — so the value the customer types is available to the Apply Discount action.
+
+ Add an [Input element](/dashboard/dashboard-creating-paywalls/paywall-editor-input-component) where the customer types their code, and give it a placeholder such as "Promo code". There is nothing to configure on the input itself. Its value is automatically available under **Element** in the variable picker.
-
- Add a button (or reuse an existing one) next to the input. This is the element the customer taps to apply their code.
+
+ Add a button (or reuse an existing one) next to the input. This is what the customer taps to apply the code.
-
+
Select the button and, under [Tap Behavior](/dashboard/dashboard-creating-paywalls/paywall-editor-styling-elements#tap-behaviors), click **+ Add Action**. In the **Purchases** group, choose **Apply Discount**.
-
- Set the action's **code** to reference the input's state variable using Liquid, for example `{{ state.discountCode }}`. This redeems whatever the customer typed.
+
+ Click the **`<>`** icon at the right edge of the action's **Discount Code** field, then choose your input under **Element**. The editor inserts a reference to the input, so the action redeems whatever the customer typed.
+
+ 
- To offer a single fixed promotion instead of customer-entered codes, you can skip the input and set the code to a hardcoded literal such as `LAUNCH20`. Tapping the button then always applies that one code.
+ To offer a single fixed promotion instead of customer-entered codes, skip the input and type a literal code such as `LAUNCH20` into the field. Tapping the button then always applies that one code.
[Publish](/dashboard/dashboard-creating-paywalls/paywall-editor-publishing) the paywall, then enter a real promotion code to confirm the price updates and the discount carries through to checkout. See [Testing purchases](/web-checkout/web-checkout-testing-purchases) for how to run test checkouts safely.
@@ -63,12 +69,14 @@ If the code is not valid, the products stay at their original price.
After a code applies, discount display variables become available so your paywall can react to the discount and show the new price. You can insert these from the variable picker in a text element or wherever you use [dynamic values](/dashboard/dashboard-creating-paywalls/paywall-editor-dynamic-values), for example:
-- `hasDiscount` — whether a discount is currently applied. Use it to show or hide a "discount applied" message, a strikethrough of the original price, or a badge.
-- `discountCode` — the code that was applied.
-- `discountedPrice` — the price after the discount.
+- `hasDiscount`: whether a discount is currently applied. Use it to show or hide a "discount applied" message, a strikethrough of the original price, or a badge.
+- `discountCode`: the code that was applied.
+- `discountedPrice`: the price after the discount.
A common pattern is to show the original price with a strikethrough alongside `discountedPrice` once `hasDiscount` is true, so the savings are obvious before the customer checks out.
## Where discount codes apply
-A redeemed code carries through Superwall's web checkout flows — redirect, embedded, and elements checkout all apply it at the Stripe checkout session. The one exception is deferred **Apple Pay** checkout, which does not apply a redeemed discount code.
+A redeemed code carries through Superwall's web checkout flows: redirect, embedded, and elements checkout all apply it at the Stripe checkout session. The one exception is deferred **Apple Pay** checkout, which does not apply a redeemed discount code.
+
+If you are building with the [Web SDK](/web), you can also redeem and clear codes from your own code via `activePaywall.redeemDiscount()`. See [Purchases](/web/guides/purchases#discount-codes).
diff --git a/content/docs/web/guides/events.mdx b/content/docs/web/guides/events.mdx
new file mode 100644
index 00000000..d4e971a0
--- /dev/null
+++ b/content/docs/web/guides/events.mdx
@@ -0,0 +1,111 @@
+---
+title: "Events"
+description: "Listen to SDK lifecycle events and register a global delegate."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Listening
+
+`sw.events` is a native `EventTarget` with typed overloads, including `AbortSignal` for cleanup.
+
+```ts
+const ac = new AbortController();
+
+sw.events.addEventListener(
+ "subscriptionStatus_didChange",
+ (event) => console.log(event.detail),
+ { signal: ac.signal },
+);
+
+// Removes every listener registered with this signal.
+ac.abort();
+```
+
+Handlers receive a typed `CustomEvent`. The payload is on `event.detail`.
+
+
+ In React, use [`useSuperwallEvent`](/web/react/hooks), which ties the subscription to the
+ component lifecycle.
+
+
+## What is emitted
+
+The event map covers the same lifecycle the mobile SDKs report:
+
+| Area | Events |
+| --- | --- |
+| Session | `first_seen`, `app_open`, `app_close`, `app_launch`, `app_install`, `session_start`, `reset` |
+| Configuration | `config_refresh`, `config_fail`, `config_attributes` |
+| Identity and attributes | `identity_alias`, `user_attributes`, `device_attributes`, `integration_attributes` |
+| Subscription | `subscriptionStatus_didChange`, `customerInfo_didChange` |
+| Placements | `trigger_fire`, `paywallPresentationRequest`, `confirm_all_assignments` |
+| Paywall lifecycle | `paywall_open`, `paywall_close`, `paywall_page_view`, `paywall_decline` |
+| Transactions | `transaction_start`, `transaction_complete`, `transaction_fail`, `transaction_abandon`, `transaction_timeout`, `transaction_restore`, `subscription_start`, `freeTrial_start`, `nonRecurringProduct_purchase` |
+| Restore | `restore_start`, `restore_complete`, `restore_fail` |
+| Discounts | `discount_redeem_complete`, `discount_redeem_fail` |
+| Paywall loading | `paywallPreload_start`, `paywallPreload_complete`, `paywallResponseLoad_start`, `paywallResponseLoad_complete`, `paywallResponseLoad_notFound`, `paywallResponseLoad_fail`, `paywallProductsLoad_start`, `paywallProductsLoad_complete`, `paywallProductsLoad_fail` |
+| Webview | `paywallWebviewLoad_start`, `paywallWebviewLoad_complete`, `paywallWebviewLoad_fail`, `paywallWebviewLoad_timeout` |
+| Surveys | `survey_response`, `survey_close` |
+| Custom | `custom_placement`, `customPaywallAction` |
+| Other | `deepLink_open`, `page_view`, `enrichment_start`, `enrichment_complete`, `enrichment_fail` |
+
+Some events are local-only and never reach Superwall's servers. Those live in `LocalSuperwallEventMap`. `AllSuperwallEvents` is the union of both.
+
+## Global delegate
+
+The delegate receives global callbacks from the SDK. Register one at creation, or swap it later.
+
+```ts
+const sw = createSuperwall({
+ apiKey: "pk_…",
+ delegate: {
+ onSubscriptionStatusChange: (from, to) => {
+ console.log("subscription", from.status, "->", to.status);
+ },
+ },
+});
+
+sw.setDelegate(otherDelegate);
+sw.setDelegate(null);
+```
+
+The delegate covers around a dozen callbacks: subscription and customer changes, the paywall present and dismiss pairs, URL and deep-link interception, link redemption, and `onLog`.
+
+Per-call handlers passed to `register` fire alongside the delegate. The two are separate surfaces covering different callbacks. `onSkip` exists only on a per-call handler, and the delegate's methods cannot be supplied per call. Neither overrides the other, and there is no fallback between them.
+
+
+ Custom actions reach you two ways. `onCustomPaywallAction(name)` on the delegate receives the
+ legacy `custom` action from the paywall. Actions defined as custom placements arrive as the
+ `custom_placement` event, whose detail carries `placementName`, `paywall_info`, and `params`.
+
+
+## Forwarding to your analytics
+
+The delegate has a catch-all for this. It receives every wire-bound event, fully typed.
+
+```ts
+const sw = createSuperwall({
+ apiKey: "pk_…",
+ delegate: {
+ onEvent: (name, detail) => analytics.track(name, detail),
+ },
+});
+```
+
+To subscribe to a specific set instead, type the names so `detail` narrows:
+
+```ts
+import type { AllSuperwallEvents } from "@superwall/paywalls-js";
+
+const forward = (type: keyof AllSuperwallEvents) =>
+ sw.events.addEventListener(type, (e) => analytics.track(type, e.detail));
+
+["paywall_open", "paywall_close", "subscriptionStatus_didChange"].forEach(forward);
+```
diff --git a/content/docs/web/guides/purchases.mdx b/content/docs/web/guides/purchases.mdx
new file mode 100644
index 00000000..30833920
--- /dev/null
+++ b/content/docs/web/guides/purchases.mdx
@@ -0,0 +1,98 @@
+---
+title: "Purchases"
+description: "How checkout works, and how to take it over."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## The default: Superwall handles it
+
+Create an instance without a `purchaseController` and the SDK runs the standard checkout itself: Stripe checkout from the paywall, `?code=` redemption on return, and polling for the resulting entitlements.
+
+```ts
+const sw = createSuperwall({ apiKey: "pk_…" });
+```
+
+Products come from the paywall you configured in the dashboard. Subscription status updates on its own once the purchase settles.
+
+## Reading the catalog
+
+```ts
+const products = await sw.purchases.getProducts();
+```
+
+Products come from the parsed config. Expect an empty array before configuration lands, or when the config carries no products.
+
+## Restoring
+
+```ts
+await sw.purchases.restore();
+```
+
+## Discount codes
+
+When a paywall is on screen, `sw.activePaywall` exposes controls for Stripe promotion codes.
+
+```ts
+const active = sw.activePaywall.value;
+
+if (active) {
+ const outcome = await active.redeemDiscount("LAUNCH20");
+ if (!outcome.valid) console.log("rejected:", outcome.reason);
+}
+```
+
+Redeeming validates the code against the checkout backend, re-prices the paywall's Stripe products, and forwards the code to every Stripe web checkout session created afterwards, including rebuilding sessions that were already prefetched.
+
+```ts
+active.clearDiscount();
+```
+
+`clearDiscount` is fire-and-forget and returns `void`. The paywall sends no acknowledgement, so there is nothing to await.
+
+Behavior to know:
+
+- `redeemDiscount` resolves after roughly ten seconds with `{ valid: false, reason: "timeout" }` if the paywall never replies, which happens when it has no Stripe products loaded.
+- A second `redeemDiscount` while one is in flight supersedes the first, which settles as `{ valid: false, reason: "superseded" }`. `clearDiscount()` settles an in-flight redemption the same way.
+- It rejects with a `DiscountError` for an empty or whitespace-only code, when no paywall is presented (including a stale `activePaywall` handle from a paywall that has since been dismissed), and when the presenter has no message channel.
+- A discount does not survive dismissal. Re-apply it after each presentation.
+- `reason` is an open union. Known values include `code_not_found`, `code_invalid`, `no_valid_products`, `no_applicable_products`, `error`, `timeout`, `superseded`, and `paywall_dismissed`. Do not write an exhaustive `switch` over it.
+
+
+ This is the programmatic equivalent of the **Apply Discount** tap action in the paywall editor.
+ See [Discount codes](/web-checkout/web-checkout-discount-codes) for setting the code up in Stripe.
+
+
+## Running your own checkout
+
+To bill through your own system while keeping the SDK's paywalls and placements, push the outcome in yourself:
+
+```ts
+sw.purchases.setSubscriptionStatus({
+ status: "ACTIVE",
+ entitlements: [
+ { id: "pro", type: "SERVICE_LEVEL", isActive: true, productIds: [] },
+ ],
+});
+```
+
+The SDK treats what you push as truth for client-side state. It is still not a security boundary. See [Server-side gating](/web/guides/server-side-gating).
+
+## Taking over the controller
+
+For full control of the transaction, pass a `purchaseController` at creation. It replaces the built-in automatic controller entirely, including `?code=` redemption and entitlements polling, so you own those too.
+
+```ts
+const sw = createSuperwall({
+ apiKey: "pk_…",
+ purchaseController: myPurchaseController,
+});
+```
+
+There is no runtime setter. The controller is wired once, at `createSuperwall`.
diff --git a/content/docs/web/guides/server-side-gating.mdx b/content/docs/web/guides/server-side-gating.mdx
new file mode 100644
index 00000000..f1934f03
--- /dev/null
+++ b/content/docs/web/guides/server-side-gating.mdx
@@ -0,0 +1,157 @@
+---
+title: "Server-side gating"
+description: "Enforce entitlements on your backend, where users cannot tamper with them."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Why gate on the server
+
+The browser SDK keeps subscription state locally so your UI can react instantly. That state is readable and writable from DevTools. Client-side checks control what the browser shows; they do not control access. Anything that costs money or exposes real data must be checked on the server.
+
+Two packages do that. They differ in whether the check needs a network call.
+
+| Package | How it checks | Use when |
+| --- | --- | --- |
+| `@superwall/server` | Calls Superwall's `/entitlements` endpoint, with caching | You want the simplest correct thing |
+| `@superwall/verify` | Verifies a signed token against bundled keys, no network on the steady-state path | You want per-request checks with no round trip |
+
+## `@superwall/server`
+
+Create an instance, tell it how to find the current user, and gate routes with `requires`.
+
+```ts
+import { Superwall } from "@superwall/server";
+
+const sw = Superwall({
+ apiKey: process.env.SUPERWALL_API_KEY!,
+ userId: (req) => req.session?.userId ?? null,
+});
+
+app.get("/api/export", sw.requires("pro"), exportHandler);
+```
+
+The middleware is Connect-style and works with Express and anything with the same shape. Pass your request type as the generic parameter, or `req` infers as `unknown`.
+
+
+
+**The `userId` extractor is the trust boundary.** Read it from an authenticated session. Reading it from the request body, a query string, or an unverified header lets any caller claim to be any user.
+
+
+
+### Describing what is required
+
+```ts
+sw.requires("pro"); // one entitlement
+sw.requires(["pro", "team"]); // all of them
+sw.requires({ all: ["pro", "team"] }); // the same, explicitly
+sw.requires({ any: ["pro", "team"] }); // at least one
+```
+
+### Options
+
+| Option | Purpose |
+| --- | --- |
+| `apiKey` | Your Superwall API key. Keep it in the environment, not in source. |
+| `userId` | How to pull the user out of a request. Overridable per `requires` call. |
+| `cache` | Cache adapter and TTL for entitlement lookups. Defaults to 60 seconds, in-memory, single-process. |
+| `environment` | Network environment. Defaults to `"release"`. |
+| `timeoutMs` | Timeout for calls to Superwall. Defaults to `5000`. |
+| `onRequest` | Called after a completed check. Useful for tracing. |
+
+`requires` also takes `allowAnonymous`, which defaults to `false`. A request with no resolvable user is rejected. Setting it to `true` passes those requests through.
+
+### What a rejection looks like
+
+By default a blocked request gets `403` with `{ error: "entitlement_required", entitlement }`. Pass `onUnauthorized` to `requires` to customize it. It receives the missing entitlements and a `reason` of `"no_user_id"` or `"not_entitled"`.
+
+If Superwall cannot be reached, the middleware calls `next(err)` rather than rejecting with a 403, so it surfaces through your framework's error handler, typically as a 500. It fails closed either way.
+
+### Cache staleness
+
+Entitlement lookups are cached for 60 seconds by default, so a revocation can take that long to take effect. Call `sw.invalidate(userId)` or `sw.invalidateAll()` when you need it immediately. The default cache is in-memory and single-process. Supply a Redis or KV adapter for multi-instance deployments.
+
+### Checking without middleware
+
+```ts
+if (await sw.userHas(userId, "pro")) {
+ // …
+}
+```
+
+
+ The user ID comes first. Both parameters are strings, so reversing them still typechecks. It
+ looks up a user named `"pro"`, finds nothing, and denies everyone.
+
+
+## `@superwall/verify`
+
+The client holds a Superwall-signed token describing its entitlements. Send it to your backend and verify the signature. A valid signature proves Superwall issued exactly those entitlements, with no call to Superwall needed.
+
+Read the token in the browser:
+
+```ts
+sw.entitlementsToken.value; // reactive
+sw.purchases.getEntitlementsToken(); // snapshot
+```
+
+Verify it on the server. `verifyEntitlements` throws on any failure and never returns a partial result, so the call belongs in a `try/catch`:
+
+```ts
+import { verifyEntitlements, VerifyError } from "@superwall/verify";
+
+try {
+ const result = await verifyEntitlements(tokenFromClient, {
+ publicApiKey: process.env.SUPERWALL_PUBLIC_API_KEY!,
+ });
+
+ if (!result.entitlements.some((e) => e.identifier === "pro")) {
+ return res.status(402).end();
+ }
+} catch (err) {
+ if (err instanceof VerifyError) return res.status(401).end();
+ throw err;
+}
+```
+
+
+ The token's entitlements use `identifier`, not `id`. This is a different type from the client
+ SDK's `Entitlement`, which uses `id`. See [Tracking subscription
+ state](/web/quickstart/tracking-subscription-state).
+
+
+There are convenience helpers for the common shapes, both taking the token first:
+
+```ts
+import { userHasEntitlement, userHasAnyEntitlement } from "@superwall/verify";
+```
+
+### Errors
+
+Verification failures are typed. Catch `VerifyError` for any of them, or narrow to `InvalidSignatureError`, `ExpiredError`, `AudienceMismatchError`, `MalformedTokenError`, or `KeyUnavailableError`.
+
+### Expiry
+
+A token carries its own expiry, and each entitlement inside it carries an `expiresAt` of its own. An entitlement can lapse while the token wrapping it is still valid.
+
+The convenience helpers account for this, so prefer them over iterating the array yourself:
+
+```ts
+if (!(await userHasEntitlement(tokenFromClient, "pro", { publicApiKey }))) {
+ return res.status(402).end();
+}
+```
+
+If you read `result.entitlements` directly, check `expiresAt`. It is epoch milliseconds, and `null` means lifetime.
+
+
+
+Run verification on the server. A check that runs in the browser can be bypassed in the browser.
+
+
diff --git a/content/docs/web/index.mdx b/content/docs/web/index.mdx
new file mode 100644
index 00000000..73317cba
--- /dev/null
+++ b/content/docs/web/index.mdx
@@ -0,0 +1,59 @@
+---
+title: "Welcome"
+description: "Welcome to the Superwall Web SDK documentation"
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta. The API may change between releases. If something does not match these docs, [open an issue](https://github.com/superwall/Superwall-Web/issues).
+
+
+
+The Superwall Web SDK brings paywalls, placements, and entitlements to the browser. It uses the same campaigns, audience rules, and experiments as the iOS, Android, and Flutter SDKs. Events use the standard `addEventListener`. State can be read synchronously or subscribed to. Import only the parts you use and bundlers leave the rest out of your app. React 19 bindings are included.
+
+## Packages
+
+The SDK ships as ESM-only packages. Most apps need one or two of them.
+
+| Package | What it does | Runs in |
+| --- | --- | --- |
+| `@superwall/paywalls-js` | Headless core. `createSuperwall`, placements, entitlements, events. No DOM references at module load. | Node, Bun, edge, workers, SSR, browser |
+| `@superwall/paywalls-js/browser` | Browser presenter and storage. Mounts the paywall iframe and persists identity. | Browser-intended; imports safely anywhere |
+| `@superwall/paywalls-react` | `SuperwallProvider` plus hooks. React 19. | Browser |
+| `@superwall/server` | Server-side entitlement gating for your own routes. | Node, Bun, edge |
+| `@superwall/verify` | Verification of Superwall-signed entitlement tokens against bundled keys. | Node, Bun, edge |
+
+
+
+**Gate real resources on the server.**
+
+Subscription state in the browser can be edited from DevTools. Use `@superwall/paywalls-js` to decide what UI to show. Use [`@superwall/server` or `@superwall/verify`](/web/guides/server-side-gating) to control what your backend returns.
+
+
+
+## Quick Links
+
+
+
+ Install, configure, and present your first paywall
+
+
+ Provider and hooks for React 19
+
+
+ Enforce entitlements on your server
+
+
+ Runnable vanilla TypeScript and React examples
+
+
+
+## Feedback
+
+To report an SDK bug, [open an issue on GitHub](https://github.com/superwall/Superwall-Web/issues). To rate these docs, use the buttons at the bottom of any page.
diff --git a/content/docs/web/meta.json b/content/docs/web/meta.json
new file mode 100644
index 00000000..64da908d
--- /dev/null
+++ b/content/docs/web/meta.json
@@ -0,0 +1,23 @@
+{
+ "title": "Web SDK",
+ "icon": "Globe",
+ "root": true,
+ "pages": [
+ "index",
+ "---Quickstart---",
+ "quickstart/install",
+ "quickstart/configure",
+ "quickstart/present-first-paywall",
+ "quickstart/user-management",
+ "quickstart/feature-gating",
+ "quickstart/tracking-subscription-state",
+ "---React---",
+ "react/provider",
+ "react/hooks",
+ "---Guides---",
+ "guides/server-side-gating",
+ "guides/purchases",
+ "guides/events",
+ "[Example Apps](https://github.com/superwall/Superwall-Web/tree/main/example)"
+ ]
+}
diff --git a/content/docs/web/quickstart/configure.mdx b/content/docs/web/quickstart/configure.mdx
new file mode 100644
index 00000000..bcf8e86a
--- /dev/null
+++ b/content/docs/web/quickstart/configure.mdx
@@ -0,0 +1,113 @@
+---
+title: "Configure"
+description: "Create a Superwall instance and understand what happens before it is ready."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Create an instance
+
+`createSuperwall` returns an instance synchronously and starts configuring in the background.
+
+```ts
+import { createSuperwall } from "@superwall/paywalls-js";
+
+const sw = createSuperwall({ apiKey: "pk_…" });
+```
+
+Do this once, as early as your app boots. Keep the returned instance around, or use the [named exports](#named-exports) and let the SDK hold it for you.
+
+## Wait for `ready` before presenting
+
+`register` waits for identity to hydrate, but it does not wait for configuration. Called before config lands, it returns `{ type: "error" }` carrying a `PaywallNotAvailableError`.
+
+```ts
+const sw = createSuperwall({ apiKey: "pk_…" });
+
+await sw.ready;
+
+const result = await sw.register({ placement: "checkout" });
+```
+
+Reads never block. `sw.user.id.value` and `sw.subscriptionStatus.value` return synchronously at any time. Before hydration lands they return defaults (`""` and `{ status: "UNKNOWN" }`). Persisted state replaces the defaults shortly after.
+
+
+ Treat `UNKNOWN` as unresolved, not as unsubscribed. A returning subscriber reads `UNKNOWN` until
+ hydration completes, so branching on "not ACTIVE" at startup shows paying users an upsell.
+
+
+
+
+**Check `configurationStatus`, not `ready`, to detect failure.** A failed config fetch is swallowed internally: `sw.ready` still resolves, and `sw.configurationStatus` becomes `"failed"`.
+
+```ts
+await sw.ready;
+
+if (sw.configurationStatus.value === "failed") {
+ // Superwall could not configure. Paywalls will not present.
+}
+```
+
+
+
+## Options
+
+```ts
+const sw = createSuperwall({
+ apiKey: "pk_…",
+ options: {
+ /* SuperwallOptions: logging, networking, paywall behavior */
+ },
+ delegate: myDelegate,
+ storage: myStorageAdapter,
+ purchaseController: myPurchaseController,
+ identity: {
+ appUserId: "user_123",
+ aliasId: "$SuperwallAlias:…",
+ vendorId: "…",
+ vendorIdProvider: async () => "…",
+ },
+});
+```
+
+| Option | Purpose |
+| --- | --- |
+| `apiKey` | Your `pk_…` publishable key. Required. |
+| `options` | Tuning for logging, networking, and paywall behavior. |
+| `delegate` | Global callbacks for SDK-wide events. See [Events](/web/guides/events). |
+| `storage` | Custom storage adapter. Defaults to `localStorage` plus cookies in the browser. |
+| `purchaseController` | Take over checkout. Omit to use the built-in Stripe flow. See [Purchases](/web/guides/purchases). |
+| `identity` | Pre-seed identity. Useful on the server or during SSR hydration. |
+| `surveyPresenter` | Renderer for post-paywall surveys. Omit and the SDK skips survey presentation. |
+
+
+ `identity.vendorIdProvider` is where a fingerprinting library plugs in. The SDK does not bundle
+ fingerprinting.
+
+
+## Named exports
+
+Instead of threading the instance through your app, import the namespaces directly. The first `createSuperwall` call registers the default instance, and these bind to it.
+
+```ts
+import { createSuperwall, user, register, events } from "@superwall/paywalls-js";
+
+createSuperwall({ apiKey: "pk_…" });
+
+await user.identify("user_42");
+const result = await register({ placement: "checkout" });
+```
+
+Importing only `user` lets bundlers drop the rest as dead code.
+
+## Multiple instances
+
+Creating more than one instance is supported. Useful for tests, Storybook, and multi-tenant edge workers. The default instance is the first one created in the process, and it is the one the named exports target.
+
+Next, [present your first paywall](/web/quickstart/present-first-paywall).
diff --git a/content/docs/web/quickstart/feature-gating.mdx b/content/docs/web/quickstart/feature-gating.mdx
new file mode 100644
index 00000000..08e8dbbd
--- /dev/null
+++ b/content/docs/web/quickstart/feature-gating.mdx
@@ -0,0 +1,92 @@
+---
+title: "Feature gating"
+description: "Run code only when a user is entitled to it."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## The feature callback
+
+Wrap the code you are gating in `feature` and the SDK decides whether it runs.
+
+```ts
+await sw.register({
+ placement: "export_report",
+ feature: () => downloadReport(),
+});
+```
+
+The callback runs when:
+
+- The user is already entitled, so no paywall is shown.
+- The user purchased or restored on the paywall.
+- The paywall was non-gated and the user dismissed it without buying.
+- The placement was skipped: not found, no audience match, or holdout.
+
+It does not run when a gated paywall is dismissed without a purchase.
+
+
+
+**A skipped placement still runs your feature.** If the placement name does not exist in any campaign, or the user matches no audience rule, the SDK runs `feature` and returns `{ type: "skipped" }`. A typo in a placement name grants the feature. This matches the iOS and Android SDKs. To check whether someone has paid, read entitlements and enforce on [your server](/web/guides/server-side-gating).
+
+
+
+
+ Gated versus non-gated is set on the paywall in the dashboard, not passed from code.
+
+
+## Branching yourself
+
+To decide in your own code, read the result instead.
+
+```ts
+const result = await sw.register({ placement: "export_report" });
+
+switch (result.type) {
+ case "presented":
+ if (result.result.type === "purchased") unlock();
+ break;
+ case "skipped":
+ console.log("no paywall shown:", result.reason);
+ break;
+ case "error":
+ console.error(result.error);
+ break;
+}
+```
+
+## Checking entitlements directly
+
+To read entitlement state without triggering a placement, use the entitlements namespace.
+
+```ts
+sw.entitlements.active.value; // Entitlement[]
+sw.entitlements.inactive.value;
+sw.entitlements.all.value;
+
+sw.entitlements.byProductIds(["pro_monthly"]);
+```
+
+These are reactive. Subscribe to re-render when they change:
+
+```ts
+const unsubscribe = sw.entitlements.active.subscribe((active) => {
+ render({ isPro: active.some((e) => e.id === "pro") });
+});
+```
+
+## Enforcement
+
+
+
+These checks control UI only. Local subscription state can be edited from DevTools. Enforce access to paid resources on your server with [`@superwall/server` or `@superwall/verify`](/web/guides/server-side-gating).
+
+
+
+Next, [track subscription state](/web/quickstart/tracking-subscription-state).
diff --git a/content/docs/web/quickstart/install.mdx b/content/docs/web/quickstart/install.mdx
new file mode 100644
index 00000000..316c5af7
--- /dev/null
+++ b/content/docs/web/quickstart/install.mdx
@@ -0,0 +1,79 @@
+---
+title: "Install the SDK"
+description: "Add the Superwall Web SDK to your browser or React app."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Install
+
+Install the headless core. Add the React package too if you are using React.
+
+
+
+```bash bun
+bun add @superwall/paywalls-js
+
+# React apps
+bun add @superwall/paywalls-react react
+```
+
+```bash npm
+npm install @superwall/paywalls-js
+
+# React apps
+npm install @superwall/paywalls-react react
+```
+
+```bash pnpm
+pnpm add @superwall/paywalls-js
+
+# React apps
+pnpm add @superwall/paywalls-react react
+```
+
+
+
+`@superwall/paywalls-react` re-exports everything public from `@superwall/paywalls-js`, so React apps can import from the React package alone and skip the second dependency if they prefer.
+
+## Requirements
+
+- **ESM only.** The packages ship as ES modules with an `exports` map. There is no CommonJS build.
+- **React 19** for `@superwall/paywalls-react`, declared as a peer dependency.
+- **A modern browser** for the default presenter, which mounts the paywall in an iframe.
+
+## Server and edge runtimes
+
+`@superwall/paywalls-js` holds no DOM references at module load, so it imports cleanly in Node, Bun, edge functions, workers, and during SSR. The browser-only pieces live behind a subpath:
+
+```ts
+import { createSuperwall } from "@superwall/paywalls-js"; // anywhere
+import { createBrowserStorage } from "@superwall/paywalls-js/browser"; // browser-intended
+```
+
+The `/browser` subpath imports cleanly on the server. Nothing on it touches the DOM at module load, and its factories degrade gracefully. Only mounting a paywall needs a real DOM.
+
+
+ The SDK lazy-loads the presenter on first `register()`. If your bundler cannot ship dynamic
+ chunks, or you want to avoid the chunk-load cost on the first presentation, import
+ `@superwall/paywalls-js/browser/auto` once for its side effect to pull the presenter in up front.
+
+
+## Get an app and API key
+
+Web SDK paywalls belong to a **Web App** application in the Superwall dashboard. This is a separate application type from iOS, Android, and Web Checkout.
+
+
+ Web App applications cannot yet be created from the dashboard or the API. During the beta,
+ Superwall creates one for you. Ask your Superwall contact.
+
+
+Once you have the app, copy its public API key, a `pk_…` value, from the dashboard. It is a publishable key and safe to ship in client code. Use the Web App application's key, not the key from an iOS, Android, or Web Checkout app.
+
+Next, [configure the SDK](/web/quickstart/configure).
diff --git a/content/docs/web/quickstart/present-first-paywall.mdx b/content/docs/web/quickstart/present-first-paywall.mdx
new file mode 100644
index 00000000..ef352d00
--- /dev/null
+++ b/content/docs/web/quickstart/present-first-paywall.mdx
@@ -0,0 +1,114 @@
+---
+title: "Present your first paywall"
+description: "Register a placement and show a paywall in the browser."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Register a placement
+
+`register` is the primary entry point. It mirrors `Superwall.shared.register(...)` on iOS and Android. You give it a placement name; the SDK evaluates your campaign's audience rules and shows a paywall if the user matches one.
+
+```ts
+const result = await sw.register({ placement: "checkout" });
+```
+
+The placement must exist in a campaign in your Superwall dashboard. If nothing matches, nothing is shown. That is not an error.
+
+## What you get back
+
+```ts
+type RegisterPlacementResult =
+ | { type: "presented"; info: PaywallInfo; result: PaywallResult }
+ | { type: "skipped"; reason: PaywallSkippedReason }
+ | { type: "error"; error: Error };
+```
+
+Skipped reasons and errors surface in the return value rather than throwing.
+
+```ts
+const result = await sw.register({ placement: "checkout" });
+
+if (result.type === "presented" && result.result.type === "purchased") {
+ console.log("Bought:", result.result.productId);
+}
+```
+
+## Run code when the user is entitled
+
+Pass a `feature` callback and the SDK runs it when the user should get access.
+
+```ts
+await sw.register({
+ placement: "checkout",
+ feature: () => unlockExportButton(),
+});
+```
+
+See [Feature gating](/web/quickstart/feature-gating) for exactly when it runs.
+
+## Per-call callbacks
+
+`handler` gives you lifecycle callbacks scoped to this one call.
+
+```ts
+await sw.register({
+ placement: "checkout",
+ handler: {
+ onPresent: (info) => console.log("opened", info.identifier),
+ onDismiss: (info, result) => console.log("dismissed", result),
+ onSkip: (reason) => console.log("skipped", reason),
+ onError: (error) => console.error(error),
+ },
+});
+```
+
+## Passing parameters
+
+`params` are the values your campaign's audience rules filter on.
+
+```ts
+await sw.register({
+ placement: "checkout",
+ params: { plan: "team", seats: 12 },
+});
+```
+
+## Overriding presentation
+
+Three more optional arguments change how the paywall is shown for a single call:
+
+| Argument | Effect |
+| --- | --- |
+| `overrides` | Per-call presentation tweaks applied before the iframe mounts. Currently `presentationStyle`. |
+| `paywall` | Render your own UI instead of the default iframe. The SDK still runs the full pipeline and fires the same events. |
+| `presenter` | Replace the presenter entirely for this call. Highest precedence. |
+
+Precedence is `presenter` > `paywall` > the default browser presenter. In React, [`useCustomPaywall`](/web/react/hooks) wraps the `paywall` path.
+
+## Preloading
+
+Paywalls load in an iframe. During configuration the SDK preloads up to six paywalls that have a URL, two at a time.
+
+
+ `sw.placements.preloadAll()` and `preloadFor()` currently do nothing and are being removed from
+ the API. Do not build on them.
+
+
+## One paywall at a time
+
+Only one paywall can be on screen at once. Calling `register` while another is presented fails with `PaywallAlreadyPresentedError`. To close the current one yourself:
+
+```ts
+sw.dismiss();
+```
+
+`sw.isPaywallPresented` is a reactive read of whether one is currently up. `sw.activePaywall` carries the presented paywall's info.
+
+Next, [identify your users](/web/quickstart/user-management).
diff --git a/content/docs/web/quickstart/tracking-subscription-state.mdx b/content/docs/web/quickstart/tracking-subscription-state.mdx
new file mode 100644
index 00000000..4a632a7a
--- /dev/null
+++ b/content/docs/web/quickstart/tracking-subscription-state.mdx
@@ -0,0 +1,117 @@
+---
+title: "Tracking subscription state"
+description: "Read and react to a user's subscription status and entitlements."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Subscription status
+
+`sw.subscriptionStatus` is a reactive value with three shapes:
+
+```ts
+type SubscriptionStatus =
+ | { status: "UNKNOWN" }
+ | { status: "INACTIVE" }
+ | { status: "ACTIVE"; entitlements: Entitlement[] };
+```
+
+```ts
+const status = sw.subscriptionStatus.value;
+
+if (status.status === "ACTIVE") {
+ console.log("entitled to:", status.entitlements.map((e) => e.id));
+}
+```
+
+`UNKNOWN` means the SDK has not resolved status yet. Do not treat it as `INACTIVE`.
+
+## Reacting to changes
+
+Subscribe to the signal, or listen for the event.
+
+```ts
+const unsubscribe = sw.subscriptionStatus.subscribe((status) => {
+ render(status);
+});
+```
+
+```ts
+const ac = new AbortController();
+
+sw.events.addEventListener(
+ "subscriptionStatus_didChange",
+ () => render(sw.subscriptionStatus.value),
+ { signal: ac.signal },
+);
+
+// later
+ac.abort();
+```
+
+
+ Subscribing fires the callback synchronously once with the current value before it returns, so you
+ do not need a separate initial read to prime your UI.
+
+
+## Customer info
+
+`sw.customerInfo` carries the fuller picture of the customer, or `null` until the first entitlements read lands. There is a snapshot method too, and a way to force a refresh:
+
+```ts
+sw.customerInfo.value;
+await sw.purchases.getCustomerInfo();
+await sw.purchases.refreshCustomerInfo();
+```
+
+## Restoring
+
+```ts
+await sw.purchases.restore();
+```
+
+## Setting status yourself
+
+If you run your own checkout instead of Superwall's, push the resulting status in:
+
+```ts
+sw.purchases.setSubscriptionStatus({
+ status: "ACTIVE",
+ entitlements: [
+ { id: "pro", type: "SERVICE_LEVEL", isActive: true, productIds: [] },
+ ],
+});
+```
+
+
+ An `Entitlement` needs all four of `id`, `type`, `isActive`, and `productIds`. Note that
+ `@superwall/verify` defines a **different** type also called `Entitlement`, whose field is
+ `identifier` rather than `id`. The two are not interchangeable.
+
+
+Setting a status equivalent to the current one is a no-op: no event fires and the delegate is not called.
+
+See [Purchases](/web/guides/purchases) for the full picture on custom checkout.
+
+## Entitlements tokens
+
+For server-side checks, the SDK exposes a Superwall-signed token describing the user's entitlements.
+
+```ts
+sw.entitlementsToken.value; // reactive, null until issued
+sw.purchases.getEntitlementsToken(); // snapshot
+```
+
+Forward it to your backend and verify it with `@superwall/verify`. A valid signature proves Superwall issued those entitlements, with no round trip per request. See [Server-side gating](/web/guides/server-side-gating).
+
+
+ The token is best-effort and will be `null` when the backend is not issuing one. It refreshes on
+ roughly a ten-minute poll while the tab is in the foreground, and eagerly after checkout, after a
+ restore, on `identify` with a different user, and on reset.
+
diff --git a/content/docs/web/quickstart/user-management.mdx b/content/docs/web/quickstart/user-management.mdx
new file mode 100644
index 00000000..e918ca95
--- /dev/null
+++ b/content/docs/web/quickstart/user-management.mdx
@@ -0,0 +1,99 @@
+---
+title: "User management"
+description: "Identify users, set attributes, and sign them out."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Identify a user
+
+Call `identify` once you know who the user is, typically right after sign-in.
+
+```ts
+await sw.user.identify("user_42");
+```
+
+Before that, the SDK tracks an anonymous **alias ID** it generates and persists itself, so placements and experiments work for logged-out visitors too.
+
+## Restoring paywall assignments
+
+A user who browsed logged-out already has experiment assignments tied to their anonymous identity. Once they sign in, those assignments may not be the ones their real account should see.
+
+Pass `restorePaywallAssignments` to re-fetch config and re-run assignment for the identified user before `identify` resolves:
+
+```ts
+await sw.user.identify("user_42", { restorePaywallAssignments: true });
+```
+
+`register` calls block until the restore finishes, so the user never gets a paywall picked from their anonymous identity's stale assignments. It is best-effort. If the fetch fails, the cached assignments stay in place.
+
+## Reading identity
+
+Identity is exposed as reactive values. Read `.value` for a snapshot, or `.subscribe()` to react to changes.
+
+```ts
+sw.user.id.value; // "" until identify()
+sw.user.aliasId.value; // the anonymous ID
+sw.user.effectiveId.value; // id || aliasId
+sw.user.isLoggedIn.value; // boolean
+```
+
+
+ `sw.user.id` is an empty string until you call `identify`. `effectiveId` returns `id` when set,
+ otherwise `aliasId`.
+
+
+## Sign out
+
+```ts
+await sw.user.signOut();
+```
+
+This clears the identified user and returns the SDK to its anonymous alias.
+
+## User attributes
+
+Attributes are values you attach to the user, and your campaign's audience rules can filter on them.
+
+```ts
+sw.user.setAttributes({
+ plan: "team",
+ seats: 12,
+ signedUpAt: "2026-08-11",
+});
+```
+
+`setAttributes` merges. It does not replace the whole set. Read the current values with `sw.user.attributes.value`.
+
+## Integration attributes
+
+Integration attributes carry third-party identifiers so Superwall can line its data up with your analytics tools.
+
+```ts
+sw.user.setIntegrationAttribute("amplitudeUserId", "abc123");
+
+sw.user.setIntegrationAttributes({
+ amplitudeUserId: "abc123",
+ mixpanelDistinctId: "xyz789",
+});
+```
+
+Pass `null` for a value to clear it.
+
+## Resetting
+
+`reset` clears local state (identity, attributes, and cached assignments), then resyncs.
+
+```ts
+await sw.reset();
+```
+
+Reach for `signOut` when a user logs out and `reset` when you want a clean slate, such as between tests.
+
+Next, [gate your features](/web/quickstart/feature-gating).
diff --git a/content/docs/web/react/hooks.mdx b/content/docs/web/react/hooks.mdx
new file mode 100644
index 00000000..9df8d0ff
--- /dev/null
+++ b/content/docs/web/react/hooks.mdx
@@ -0,0 +1,186 @@
+---
+title: "Hooks"
+description: "React hooks for identity, placements, events, and reactive SDK state."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+All hooks must be used under a [`SuperwallProvider`](/web/react/provider).
+
+## `useUser`
+
+Identity, attributes, and subscription state in one object. It re-renders when any of them change.
+
+```tsx
+import { useUser } from "@superwall/paywalls-react";
+
+function Account() {
+ const {
+ id,
+ aliasId,
+ effectiveId,
+ isLoggedIn,
+ attributes,
+ subscriptionStatus,
+ entitlements,
+ identify,
+ signOut,
+ setAttributes,
+ // also available: integrationAttributes, customerInfo,
+ // setIntegrationAttribute, setIntegrationAttributes
+ } = useUser();
+
+ return (
+ <>
+
{isLoggedIn ? id : "anonymous"}
+
+
+ >
+ );
+}
+```
+
+`entitlements` is derived from `subscriptionStatus` and contains the active ones.
+
+## `usePlacement`
+
+Returns a `register` function plus the state of the latest placement from this hook's calls. The state is local to the component.
+
+```tsx
+import { usePlacement } from "@superwall/paywalls-react";
+
+function UpgradeButton() {
+ const { register, state } = usePlacement({
+ onPresent: (info) => console.log("opened", info.identifier),
+ onDismiss: (_info, result) => console.log("dismissed", result),
+ onSkip: (reason) => console.log("skipped", reason),
+ onError: (error) => console.error(error),
+ });
+
+ return (
+ <>
+
+
paywall: {state.type}
+ >
+ );
+}
+```
+
+The handler you pass fires alongside the global delegate rather than replacing it.
+
+
+ The `state` is per-hook, so two components never see each other's outcomes. The SDK still allows
+ only one paywall on screen at a time. If another component already has one up, `register`
+ resolves as `{ type: "error" }` with a `PaywallAlreadyPresentedError`.
+
+
+## `useSuperwall`
+
+The instance itself, for anything the other hooks do not cover.
+
+```tsx
+const sw = useSuperwall();
+const products = await sw.purchases.getProducts();
+```
+
+## `useSignal`
+
+Subscribes to any `Readable` the SDK exposes and re-renders on change.
+
+```tsx
+import { useSignal, useSuperwall } from "@superwall/paywalls-react";
+
+function PaywallBadge() {
+ const sw = useSuperwall();
+ const isPresented = useSignal(sw.isPaywallPresented);
+ return isPresented ? Paywall open : null;
+}
+```
+
+## `useSuperwallEvent`
+
+Subscribes to a typed SDK event for the lifetime of the component.
+
+```tsx
+useSuperwallEvent("subscriptionStatus_didChange", () => {
+ refetchAccount();
+});
+```
+
+Cleanup is handled on unmount. See [Events](/web/guides/events) for what is emitted.
+
+## `useDelegate`
+
+Registers a global delegate from inside React.
+
+```tsx
+useDelegate({
+ onSubscriptionStatusChange: (from, to) => {
+ console.log("subscription", from.status, "->", to.status);
+ },
+});
+```
+
+
+ Only one delegate is active at a time. `useDelegate` overrides a `delegate` passed to
+ `SuperwallProvider`, and when the last `useDelegate` unmounts it clears the delegate entirely,
+ including the provider's. Use one or the other, not both.
+
+
+Custom placements defined in the paywall editor arrive as an event rather than through the delegate:
+
+```tsx
+useSuperwallEvent("custom_placement", (e) => {
+ if (e.detail.placementName === "contact_support") openIntercom();
+});
+```
+
+## `useCustomPaywall`
+
+Renders your own paywall UI instead of the default iframe. The SDK still runs the full trigger pipeline and fires the same lifecycle events.
+
+The hook does not present anything on its own. Call `register()` yourself. `paywall` is `null` until a paywall presents, and carries the state snapshot and the controller.
+
+```tsx
+const { register, paywall } = useCustomPaywall({ placement: "checkout" });
+
+return (
+ <>
+
+ {paywall && (
+ void paywall.controller.buy(product)}
+ onRestore={() => void paywall.controller.restore()}
+ onClose={() => paywall.controller.close()}
+ />
+ )}
+ >
+);
+```
+
+`controller.buy` takes the `Product` the user chose. `paywall.state` also carries `restoration` and `paywallInfo`.
+
+## `SuperwallPaywall`
+
+A declarative gate that renders the SDK's default paywall, not your own UI. It calls `register` on mount, shows `loading` until the paywall presents, and renders `children` once the feature unlocks.
+
+```tsx
+}>
+
+
+```
+
+Pass `inline` to mount the paywall iframe in place instead of as a full-viewport overlay.
+
+
+ Custom paywall rendering is the least stable part of the beta API. Check the
+ [example apps](https://github.com/superwall/Superwall-Web/tree/main/example) before building on it.
+
diff --git a/content/docs/web/react/provider.mdx b/content/docs/web/react/provider.mdx
new file mode 100644
index 00000000..f5018ee4
--- /dev/null
+++ b/content/docs/web/react/provider.mdx
@@ -0,0 +1,98 @@
+---
+title: "Provider"
+description: "Wire the Superwall Web SDK into a React 19 app."
+---
+
+
+
+**Beta**
+
+The Web SDK is in beta and its API may change between releases.
+
+
+
+## Wrap your app
+
+`SuperwallProvider` creates and holds the instance. It takes the same options as `createSuperwall`, plus `children`.
+
+```tsx
+import { SuperwallProvider } from "@superwall/paywalls-react";
+
+function App() {
+ return (
+
+
+
+ );
+}
+```
+
+Everything below it can reach the instance through the hooks.
+
+
+ Instances are cached by `apiKey` in a module-level registry. Mounting two providers with the same
+ key reuses one instance, and the instance survives Fast Refresh. The registry is never evicted:
+ swapping `apiKey` leaves the old instance configured and running for the page's lifetime.
+
+
+## Configuration is read once
+
+The provider reads its options on the first mount for a given `apiKey`. A later provider with the same key and different options silently reuses the original instance's configuration. Changing other props afterwards does not reconfigure the SDK. Changing `apiKey` swaps to a different instance.
+
+Pass configuration statically:
+
+```tsx
+
+
+
+```
+
+If identity is not known at mount, leave it out and call `identify` from [`useUser`](/web/react/hooks) once you have it.
+
+## Gating render on configuration
+
+Registering a placement before configuration lands fails, so gate the parts of your UI that present paywalls. One way is a Suspense boundary over `sw.ready`:
+
+```tsx
+import { use, Suspense } from "react";
+import { useSuperwall } from "@superwall/paywalls-react";
+
+function ConfigGate({ children }: { children: React.ReactNode }) {
+ const sw = useSuperwall();
+ use(sw.ready);
+ return <>{children}>;
+}
+
+function App() {
+ return (
+
+ }>
+ }>
+
+
+
+
+
+
+ );
+}
+```
+
+
+ `sw.ready` can reject, and `use()` rethrows. Without an error boundary the subtree unmounts. A
+ failed config fetch does not reject `ready`; check
+ `sw.configurationStatus.value === "failed"` for that. Do not gate with `use(sw.ready)` during
+ server rendering.
+
+
+## Server rendering
+
+`@superwall/paywalls-react` is safe to import during SSR. Its entry pulls in the core package's `/browser` module, but nothing on that path touches the DOM at module load. Every access is inside a function and guarded. Paywall presentation happens after hydration.
+
+The React package re-exports the entire public surface of `@superwall/paywalls-js`, so you never need to import both.
+
+Next, [the hooks](/web/react/hooks).