SubSovereign
All guides

SubSovereign Web Component — integration guide (Vue, Svelte, Angular, plain HTML)

This guide covers sdk-web/ — the framework-agnostic browser SDK. It ships a plain-fetch data client plus two Web Components, <subsovereign-paywall> and <subsovereign-withdrawal>, that work in any site — Vue, Svelte, Angular, or a plain HTML page — with no build step and no dependencies. (Building in React? Use the React guide instead.)

Before you start

A running SubSovereign server, an app registered in the dashboard (an appId and an SDK-role API key), and a published paywall.

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, and the SDK reports it only in the browser console. Self-hosters can also set the server's ALLOWED_ORIGINS environment variable.

Step 1 — Load and configure

<script type="module">
  import { SubSovereign } from './sdk-web/src/index.js'; // importing registers the elements

  SubSovereign.configure({
    apiKey:  'YOUR_SDK_KEY',
    appId:   'your-app-id',
    baseUrl: 'https://subs.yourdomain.com/api/v1', // YOUR self-hosted server
    userId:  currentUser.id,                        // your own stable user id
    locale:  'en',
  });
</script>

The elements can sit anywhere in your markup, before or after this script: the withdrawal element waits for configure() before fetching anything, so a page laid out exactly as above gets the notice in the customer's language. If configure() takes longer than two seconds to arrive (a slow session call first, say) the element shows the English default meanwhile and switches to the served words the moment you configure. Configure once, early, on every page that uses them.

Step 2 — Check what the user can access

const { hasAccess } = await SubSovereign.checkEntitlements();
if (hasAccess) showPremiumContent();
else showPaywall();

For a simple gate there is hasAccess(), optionally for one named access level. It fails closed: on a network error it returns false, so a blip can never unlock paid content by accident.

if (await SubSovereign.hasAccess('pro')) showPremiumContent();

checkEntitlements() throws on failure so you can keep the user on their last-known access; hasAccess() never throws. Pick the one whose failure behaviour you want.

Step 3 — Show the paywall and sell

<subsovereign-paywall id="pw"></subsovereign-paywall>

<script type="module">
  const pw = document.getElementById('pw');
  pw.config = await SubSovereign.getPaywallConfig(); // platform defaults to 'web'
  pw.addEventListener('select-product', (e) => {
    startCheckout(e.detail.productId);               // wire to your checkout
  });
</script>

Sizing is your page's job. Default width is 420px; widen it with the CSS custom property (custom properties pierce the shadow DOM) and/or scale it uniformly:

subsovereign-paywall { --ss-paywall-max-width: 900px; transform: scale(1.4); }

Granting the purchase: this SDK has no client-side validate… call. Complete checkout with your provider, grant the entitlement server-side — your backend calling your SubSovereign server, or the signed bring-your-own-billing bridge (POST /entitlements/external) — then re-check:

const again = await SubSovereign.checkEntitlements();
if (again.hasAccess) unlock();

Feature flags

Roll features out from the server without a deploy. Fails closed: on a network error you get {}, so every flag reads as off.

const flags = await SubSovereign.getFeatureFlags(); // { newPlayer: true, ... }
if (flags.newPlayer) showNewPlayer();

AI answer-engine attribution (GEO)

If you use the Acquisition/GEO features, capture where visitors came from — the AI referrer exists only on the landing page, and the user id exists only after signup, so it's a two-step stash-and-flush:

import { ssCaptureLanding } from '@subsovereign/web';

// On every page of your marketing site, ONCE THE VISITOR HAS ACCEPTED YOUR CONSENT BANNER:
onConsentAccepted(() => ssCaptureLanding());

// …later, the moment the user signs up / signs in, after SubSovereign.configure():
await SubSovereign.recordAttributionTouch();

The SDK stores nothing on the visitor's device until you call ssCaptureLanding(). Importing the package writes nothing. The stash (referrer + UTM tags) is personal data under GDPR and storage under ePrivacy, so when to capture it is your consent decision, not ours — call it from your banner's accept handler. Without a capture, recordAttributionTouch() reports only what the sign-up page itself can see — UTM tags on that URL, and a referrer if it is not your own site — so an AI or search referral that happened on an earlier landing page is not attributed. That is the cost of not capturing, and it is the visitor's choice.

recordAttributionTouch() sends the stashed first landing (it beats the current page), clears the stash only after a successful send so a later login retries, and never throws. If your landing pages cannot call configure() (a static marketing site with no user), ssFlushAttribution(userId, { apiUrl, apiKey }) does the same send with explicit credentials — or set window.SS_API_URL and window.SS_API_KEY.

Your website's address must be on the app's allow-list (Website addresses on the app's page in the dashboard, or ALLOWED_ORIGINS on a self-hosted server), or the browser will silently report nothing.

Privacy (GDPR and CCPA)

await SubSovereign.recordConsent({ purpose: 'analytics', granted: true });

await SubSovereign.requestErasure();               // "forget me" — GDPR Art. 17
const myData = await SubSovereign.exportMyData();  // data export — GDPR Art. 20
await SubSovereign.setDoNotSell(true);             // CCPA "Do Not Sell" / GPC signal

The three privacy calls throw on failure (ApiError, with .status). A user exercising a legal right must be told when it did not go through — catch the error and say so; never show a silent success. Billing records are retained by the server under GDPR Art. 17(3)(b) and anonymised.

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. The element hands it to you: listen for the withdrawal-submitted event, whose detail is the receipt.

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.

<subsovereign-withdrawal subscription-id="SUB_ID" appearance="auto"></subsovereign-withdrawal>

Prominent labelled button → single confirm → idempotent submit → acknowledgement, per Directive (EU) 2023/2673. For custom UIs: SubSovereign.withdraw(subscriptionId, opts) and SubSovereign.getWithdrawalConfig().

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 host page; auto follows the visitor's browser setting (prefers-color-scheme), and the attribute can be changed at any time — the card repaints. There is deliberately no way to pass a colour: this is a statutory notice, and the wording and the colours are fixed so it reads the same for every customer of every tenant. The words arrive from your server in the customer's language (locale in configure()); with an older server they fall back to English rather than blank.

The element emits withdrawal-shown, withdrawal-submitted (detail: the receipt) and withdrawal-error (detail: { reason, status? }) for your analytics. If you forget subscription-id, the customer sees the translated error and you get a console error and a withdrawal-error event. If the element cannot load its settings (wrong key, wrong app id, your server refusing this page's origin), the customer still gets the button — in English — and you get a console.warn naming the likely cause plus a withdrawal-error event with reason: 'config-unavailable' and, when there is one, the HTTP status (a refused origin or a wrong URL has none). Listen for that event in every environment: it is how you learn that German customers are seeing English. Note it fires on load, before any customer action — do not count it as a failed cancellation. A misconfiguration is never silent for the developer, and never visible to the customer.

Quick reference

You want to… Use
Set the SDK up SubSovereign.configure(config)
See what the user unlocked await checkEntitlements(){ hasAccess, … } (throws on failure)
Simple gate (fail-closed) await hasAccess('pro')boolean
Load + draw the paywall pw.config = await getPaywallConfig() + select-product event
Size the paywall --ss-paywall-max-width · transform: scale(…)
Grant after checkout server-side (your backend or the BYO-billing bridge), then re-check
Feature flags (fail-closed) await getFeatureFlags(){ [flag]: boolean }
GEO attribution ssCaptureLanding() after consent · recordAttributionTouch() once identified
Record consent await recordConsent({ purpose, granted })
GDPR / CCPA (throw on failure) requestErasure() · exportMyData() · setDoNotSell(bool)
EU withdrawal <subsovereign-withdrawal subscription-id="…" appearance="light|dark|auto"> · withdraw()

Next steps