Getting started

Integrate ExtMonetize in 5 minutes

Three steps: create an account, register your extension and its plans, then add the SDK to your service worker. No server to run.

#Installation

The SDK is a TypeScript client with zero runtime dependencies, shipping ESM, CommonJS and type declarations. It works in Chrome and Chromium browsers on Manifest V3, in a service worker or a page, as well as in Firefox.

npm install @extmonetize/ciiti

#Quick start

  1. Sign up, then in Extensions, create your extension and at least one plan (monthly or yearly subscription, or a one-off payment).
  2. On your extension’s Keys tab, grab the publishable key (pk_pub_…) and the verification public key — its own signing pair if it has promoted one, the platform pair otherwise. Neither is a secret: both ship in your bundle.
  3. Instantiate the SDK in your service worker.
import { Ciiti } from '@extmonetize/ciiti';
import { EXTMONETIZE_PUBLIC_KEY } from './extmonetize-key.js';

const ciiti = Ciiti.create({
  apiKey: 'pk_pub_xyz789',
  publicKey: EXTMONETIZE_PUBLIC_KEY,
});

// Once at startup: registers the device.
await ciiti.init();

const status = await ciiti.getPremiumStatus();
if (!status.isPremium) {
  await ciiti.openCheckout({ planId: 'plan_pro' });
}

#Offline verification (recommended)

Premium status is delivered as an RS256-signed token. For the SDK to verify that signature locally, pin the public key in your bundle via publicKey. It is then read from your store-signed bundle and never from writable storage: an attacker cannot slip in their own key to self-sign a fake token.

A cached token is answered offline only while its signature verifies against the pinned key, it was minted for this extension and this device, it has not expired, and it is less than four hours old — so a subscription cancelled mid-period cannot stay premium offline until the period ends.

Copy the public key (PEM / SPKI format) from your extension's Keys tab — its own signing pair if it has promoted one, the platform pair otherwise. Embed them at build time: a key fetched at runtime is a key whoever controls the network can replace, and verification would prove nothing. Without publicKey the SDK refuses to start: set allowUnpinnedKey: true to accept that mode. It then re-fetches the status from the server on every check, premium reads as false offline, and a warning is logged at startup.
// src/extmonetize-key.ts — embedded at build time
export const EXTMONETIZE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1f8s...QIDAQAB
-----END PUBLIC KEY-----`;
import { Ciiti } from '@extmonetize/ciiti';
import { EXTMONETIZE_PUBLIC_KEY } from './extmonetize-key.js';

export const ciiti = Ciiti.create({
  apiKey: 'pk_pub_xyz789',
  publicKey: EXTMONETIZE_PUBLIC_KEY, // ← enables offline verification
});

const status = await ciiti.getPremiumStatus();
console.log(status.isPremium, status.features);

#Gating a feature

canUse() reads the entitlements carried by the token — no need to hit the network on every click.

if (await ciiti.canUse('ai_gen')) {
  runAiGeneration();
} else {
  await ciiti.openCheckout({ planId: 'plan_pro' });
}

The name comes from your dashboard: declare it under Features on the extension, then tick it on the plans that unlock it. A name that is not declared can never be granted, which is what stops a typo from silently returning false forever.

#Opening the paywall

openCheckout() opens Stripe checkout in a tab. You write no payment form and never touch card data.

await ciiti.openCheckout({
  planId: 'plan_pro',                     // required — the plan to check out
  email: 'user@mail.com',                 // pre-fills the checkout email
  successUrl: 'https://myapp.dev/thanks', // optional — origin must be allowed
  onSuccess: (status) => console.log('Premium!', status),
  onCancel:  () => console.log('closed without paying'),
  onError:   (err) => console.error(err),
});

Once a checkout has completed, openManagePage() opens the Stripe-hosted billing portal: payment method, invoices, cancellation. Wire it to a “Manage subscription” button shown when isPremium is true.

await ciiti.openManagePage({
  returnUrl: 'https://myapp.dev/settings',
  onError: (err) => console.error(err),
});

#API reference

Ciiti.create({ apiKey, publicKey })

Instantiates the client. publicKey is required — unless allowUnpinnedKey — because it is what makes offline token verification tamper-proof.

await init(options?)

Registers the device and records sdk_init. Call it once at startup. It opens no trial: a trial is now attached to a verified address, so it takes startTrial() then confirmTrial() — clearing the extension's storage used to hand out a brand-new one, as often as anyone asked.

await getPremiumStatus()

Returns { isPremium, isInFreeTrial, trialUsageLeft, planName, expiresAt, features }. Verified locally when the key is pinned.

await canUse(name)

true when the entitlement carried by the token unlocks this feature.

await openCheckout({ planId, ... })

Opens Stripe checkout. Options: planId (required), email, successUrl, cancelUrl, onSuccess, onCancel, onError.

await openManagePage(options?)

Opens the Stripe billing portal so the user updates or cancels their own subscription. Available after the first checkout.

await loginWithEmail(email)

Sends a code by email — no link in the message, the code is bound to the browser that asked for it. Spent with confirmLogin(), it attaches this browser to the subscription registered to that address.

await confirmLogin({ email, code })

Spends the code sent by loginWithEmail() and returns this browser's premium status, read fresh.

await startTrial({ email })

Asks for a free trial on an email address. The server picks what comes next — a code sent, or a page to open — and answers the same way either way: nothing reveals whether the address is known.

await confirmTrial({ email, code })

Spends the code sent by startTrial(): the address is confirmed, this browser is attached to it, and the trial starts.

trackEvent(name, metadata?)

Fire-and-forget call for usage analytics and trial limits.

destroy()

Clears timers, alarms and any checkout poll — call it on teardown (when the popup closes, for instance).

#Security note

Client-side verification raises the bar — an attacker must re-sign the token and rebuild your extension — but, like any purely client-side check, it can be patched by a determined user. The authoritative check must stay on your server: gate the real premium capabilities (expensive API calls, for example) server-side. The SDK exists to drive the UX, not to be your only line of defense.