SubSovereign
All guides

SubSovereign on React (web) — integration guide

This guide covers @subsovereign/react (sdk-react/) — the paywall UI layer for React web apps. It renders the paywall you design in the SubSovereign dashboard as real React components, from the same renderer the dashboard preview uses, so what you approve in the dashboard is exactly what ships.

This SDK draws the paywall; it does not talk billing or entitlements. Pair it with the Web/JS data SDK (@subsovereign/js-sdk) for checkEntitlements() and purchase validation — the two are designed to be used together.

Before you start

You'll need a running SubSovereign server, an app registered in the dashboard (an appId and an SDK-role API key), and a published paywall (dashboard → Paywall → Publish).

Your website's address must be on the app's allow-list. In the dashboard open your app and, under Website addresses, add every address the page will run on (for example https://www.example.com, and http://localhost:3000 while developing). Browsers at any other address are refused before they reach the server. Self-hosters can also set the server's ALLOWED_ORIGINS environment variable.

Step 1 — Install

The package lives in sdk-react/ and is not on npm yet — add it as a path or git dependency (or copy the folder into your project):

npm install ./sdk-react   # path dependency while it's pre-npm
import { Paywall, usePaywallConfig } from '@subsovereign/react';

Step 2 — Load the published paywall

usePaywallConfig fetches the config for this app/user. The A/B variant is chosen server-side by userId, so the same user always sees the same paywall.

const { config, loading, error } = usePaywallConfig({
  apiUrl: 'https://subs.yourdomain.com/api/v1', // YOUR self-hosted server
  appId:  'your-app-id',
  userId: currentUser.id,                        // your own stable user id
  apiKey: 'YOUR_SDK_KEY',
  locale: 'en',                                  // optional, default 'en'
  platform: 'web',                               // optional, default 'web'
});

Pass null instead of params to skip fetching (e.g. while the user is signed out).

Step 3 — Render it

<Paywall
  config={config}
  loading={loading}
  loadingState={<Spinner />}          // optional
  emptyState={<FallbackPaywall />}    // optional — shown when no config is published
  onSelectProduct={(productId) => startCheckout(productId)}
/>

onSelectProduct fires when the user taps a product — wire it to your checkout (Stripe on web). After checkout succeeds, validate with the data SDK and re-check entitlements:

await SubSovereign.validateStripeSubscription({ subscriptionId, productId, accessLevelId: 'pro' });
const { hasAccess } = await SubSovereign.checkEntitlements();
if (hasAccess) unlockProFeatures();

If you already have a config (fetched via the data SDK), skip the hook and pass it straight in. For full control there's the raw renderer: <PaywallRenderer config={config} onSelectProduct={…} />.

EU withdrawal button (Compliance Passport)

What happens to the money. When a customer completes this screen, we record the withdrawal and stop the subscription renewing at the end of the period they have already paid for — they keep access until then. For a Stripe subscription we tell Stripe directly, using the keys you saved for this app; you can turn that off under When a customer cancels under EU law on the app's page in the dashboard, and if you do, stopping the renewal becomes your job. Apple, Google, Amazon and Roku do not allow a merchant to cancel on a customer's behalf — for those the customer must also cancel in the store, and you should say so in your own cancellation copy.

Confirming receipt is yours to do, and it is a legal duty. EU law requires the seller to send the customer an acknowledgement of receipt, on a durable medium such as email, without delay, showing the date and time. We do not send it and cannot: we hold no email address for your customers, by design. The customer is signed in to your app, and the successful cancel call returns the withdrawal reference and the exact submission time — send them the letter below from your own system at that moment, and keep a record of when you sent it. In React the drop-in <WithdrawalButton> keeps the receipt to itself: build your own control on the useWithdrawal() hook and act when phase.status becomes 'done', where phase.receipt carries both fields.

Subject: Your withdrawal has been received

Hello,

We confirm we received your withdrawal from your subscription on {submittedAt}. Your reference is {withdrawalId}.

Your subscription will not auto-renew. This is your acknowledgement of receipt.

submittedAt and withdrawalId are the two fields the successful cancel call returns.

If you sell subscriptions to EU consumers online, Directive (EU) 2023/2673 requires a clearly labelled withdrawal function. The SDK ships it ready-made:

import { WithdrawalButton, useWithdrawalConfig } from '@subsovereign/react';

function CancelSubscription({ user, sub }: { user: { id: string; locale: string }; sub: { id: string } }) {
  // Fetches the withdrawal settings AND the notice's wording in the customer's language. The SDK
  // keeps the wording itself; the button below reads it for the same `locale`.
  const { config, error } = useWithdrawalConfig({
    apiUrl: 'https://subs.yourdomain.com/api/v1',
    appId: 'your-app-id',
    apiKey: 'YOUR_SDK_KEY',
    locale: user.locale,          // the CUSTOMER's language, not your console's
  });
  // Log it: a silent failure here is how a tenant ships English to German customers and never finds
  // out. The customer is never shown this — they get the button, in English.
  if (error) console.warn('SubSovereign: withdrawal settings unavailable', error);
  if (config && config.enabled === false) return null;   // the tenant turned it off

  return (
    <WithdrawalButton
      apiUrl="https://subs.yourdomain.com/api/v1"
      appId="your-app-id"
      apiKey="YOUR_SDK_KEY"
      userId={user.id}
      subscriptionId={sub.id}
      locale={user.locale}        // ← the SAME locale you fetched with: it picks the served words and formats the date
      labelKey={config?.labelKey} // ← the label the tenant chose in the console
      appearance="auto"
    />
  );
}

You cannot change the wording. The console offers two labels for the withdrawal function — "Withdraw from contract here" and "Cancel here" — and labelKey carries whichever the tenant chose; the words themselves come from the server in the customer's language. There is no way to pass your own text. A label prop used to accept free text and is now ignored with a console warning: a statutory notice that can be reworded is one that can be softened into marketing, which is the same reason you cannot pass a colour.

🚩 Pass locale too. It formats the timestamp on the acknowledgement. Without it the date is formatted for the device, so a German customer on an American browser is shown 9/3/2026 — which they will read as 9 March, not 3 September. It is the date on the record of a legal act, and the ambiguity runs both ways.

🚩 Fetch first, or the statutory notice is in English for everyone. The words are served by your SubSovereign server in the locale you ask for — they are ours rather than yours on purpose, so a legal notice cannot be softened into marketing — and the SDK keeps what it fetched and reads it when the button draws. The button does not fetch them itself, because the same component contract has to hold on Roku, where a UI component cannot do network at all. useWithdrawalConfig with the customer's locale, and the same locale on the button, is the whole connection. A strings prop used to carry the words; it is now ignored with a console warning, for the same reason as label.

If the fetch fails the button still appears, in English, rather than not appearing. Failing to put a findable withdrawal function in front of the consumer is your breach; showing it in the wrong language is not. The hook returns error so you can log that; never show it to the customer.

Appearance — light, dark or auto (the default), and nothing else. The dialog paints its own card and its own text, so it is legible on any page, light or dark; auto follows the visitor's browser setting, and where no preference can be detected it resolves to dark. There is deliberately no way to pass a colour: this is a statutory notice, and its wording and its colours are fixed so it reads the same for every customer of every tenant. If you mirror the rule elsewhere in your UI, resolveAppearance(appearance, scheme) is exported (scheme is 'light' | 'dark' | null).

It shows the prominent labelled button, confirms once, submits idempotently (a network retry can never create two withdrawals), and shows the acknowledgement. The button itself does no network beyond the submit, so gating on config.enabled and config.jurisdictions is yours to do, as above. Lower-level pieces (fetchWithdrawalConfig, useWithdrawal, submitWithdrawal) are exported for custom UIs.

Quick reference

You want to… Use
Load the published paywall usePaywallConfig(params){ config, loading, error }
Draw it <Paywall config onSelectProduct … />
Full rendering control <PaywallRenderer config onSelectProduct />
EU withdrawal, in the customer's language useWithdrawalConfig({…, locale}) → pass the same locale and labelKey to <WithdrawalButton>
Entitlements / validation the Web/JS data SDK

Next steps