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
- Data layer (entitlements, purchases, GDPR): Web & React Native guide.
- Framework-agnostic sites (Vue/Svelte/plain HTML): Web Component guide.
- New to the concepts? Read How SubSovereign works.