Browser SDK
The Browser SDK (@monetizationos/browser) is a client-side MonetizationOS SDK. It runs in the user's browser, makes a surface decision against the Surface Decision API with your environment's public key, and applies the resulting component transformations directly to the live DOM.
For a step-by-step first integration, see the Browser SDK quickstart. If you want the same decision pipeline applied server-side at the edge instead, see the Proxies reference.
Install
npm i @monetizationos/browserThe package lists @monetizationos/proxy as a dependency for the shared contract types only. No code from it runs at runtime.
The package ships two entry points:
| Entry point | Format | Purpose |
|---|---|---|
@monetizationos/browser | ESM | createMOS and all named exports, with bundled type declarations. |
@monetizationos/browser/iife | IIFE | Self-initializing script-tag bundle that exposes window.MOS. |
Initialization
The SDK has two front doors that share one core: the ESM (ECMAScript module) createMOS factory for bundler and single-page application (SPA) codebases, and an IIFE (immediately invoked function expression) script-tag bundle for static sites.
ESM
import { createMOS } from "@monetizationos/browser";
const mos = createMOS({
publicKey: "pk_live_...",
surface: "article-paywall",
identity: { jwtGlobal: () => authClient.getToken() }, // or { jwtCookie: "name" }
onDecision: (decision) => {}, // full response: features, properties, identity
onError: (err) => {},
});
mos.identify({ userJwt }); // optional; otherwise anonymous
await mos.decide(); // auto-fires once on load unless `manual: true`On an SPA route change, re-call decide() only after the targeted DOM has been re-rendered. Insertions aren't idempotent and removed content is never restored (see Component rendering).
Script tag
For static sites, one synchronous paste in <head> installs a call queue and injects the asynchronous SDK in a single loader. The queue means MOS.identify(), MOS.decide(), and MOS.reveal() calls made before the bundle loads aren't lost. They're captured and replayed on boot. Edit the config object at the bottom of the snippet; its keys become data-mos-* attributes on the injected tag.
<script>
/* MonetizationOS loader (queue + async SDK, one paste). Edit the config below. */
(function(c){var w=window,d=document,g="MOS",q="q";
w[g]=w[g]||{};w[g][q]=w[g][q]||[];
["identify","decide","reveal"].forEach(function(m){w[g][m]=w[g][m]||function(){w[g][q].push([m,arguments]);};});
if(d.getElementById("mos-sdk"))return;
var j=d.createElement('script');j.id="mos-sdk";j.async=true;j.src="https://assets.monetizationos.com/browser/v1.js";j.setAttribute('fetchpriority','high');
for(var k in c)j.setAttribute('data-mos-'+k,c[k]);
(d.head||d.documentElement).appendChild(j);
})({"pk":"pk_live_...","surface":"article-paywall"});
</script>- The bundle self-initializes with one decision on load and exposes
window.MOSwith the imperative methodsidentify,decide, andreveal. data-mos-*attributes carry strings only. For callbacks or a customfetchImpl, setwindow.MOSConfig = { ... }above the loader block.- Place the tag after your
<meta>tags. The SDK derives the decision'sresourcefrom page metadata and starts the request as early as it can. - ESM users can mint the same loader string from code with
buildLoaderSnippet({ config: { pk, surface } }).
The loader's config keys map to the following attributes:
| Attribute | Config key |
|---|---|
data-mos-pk | publicKey |
data-mos-surface | surface |
data-mos-api-base-url | apiBaseUrl |
data-mos-manual | manual |
data-mos-timeout (or data-mos-cloak-timeout) | decisionTimeoutMs |
data-mos-jwt-global | identity.jwtGlobal |
data-mos-jwt-cookie | identity.jwtCookie |
data-mos-cloak, data-mos-cloak-selectors | cloak |
Configuration precedence
Configuration can come from createMOS(config), from window.MOSConfig, or from data-mos-* attributes on the script tag. When the same key is set in more than one place, createMOS(config) wins over window.MOSConfig, which wins over data-mos-* attributes.
Configuration
All keys are optional except publicKey and surface. The publicKey field carries your environment's public key.
| Key | Default | Description |
|---|---|---|
publicKey | None | Required. Your environment's public key (pk_*). Safe to ship in page source. |
publishableKey | None | Deprecated alias for publicKey, kept for v1 compatibility and slated for removal. publicKey wins when both are set. |
surface | None | Required. Surface slug, sent as surfaceSlug. |
apiBaseUrl | https://api.monetizationos.com | MonetizationOS API base URL. |
manual | false | Disables the auto-fired decision on load. |
cloak | None | Anti-flicker reveal timeout and selectors. See Anti-flicker cloak. |
decisionTimeoutMs | 5000 | How long the cloak mask stays up while awaiting the decision. See decisionTimeoutMs semantics. |
identity | Anonymous | One declarative identity source plus anonymous-identifier storage options. See Identity. |
render | None | Element render options: a renderCustom hook for custom elements and an onUnsupported(info) callback. |
resourceProvider | Derived from the page | Hook merged over the derived { id, meta } resource. See Decision request. |
revealTransforms | [] | Extra reveal transforms run before the default reveal. |
fetchImpl | Global fetch | Custom transport. See fetchImpl contract. |
onReady() | None | Called once the SDK is initialized. |
onDecision(decision) | None | Called with the full decision: features, properties, identity. |
onError(err) | None | Called on any decision failure (network, non-2xx, malformed response, missing config) and on the max-wait timeout. A timed-out decision still applies when it lands. |
onWarn(w) | None | Called for non-fatal applicator issues, such as an invalid selector or a missing replaceRange marker. |
onLog(event) | None | Structured lifecycle and telemetry trace. See Logging and observability. |
decisionTimeoutMs semantics
decisionTimeoutMs bounds how long the cloak mask stays up, not the request itself. On expiry the cloak reveals, and the page may briefly show content the decision would have hidden. The request isn't aborted: the decision still applies when it lands, and onError fires so the slow request stays observable. When decisionTimeoutMs isn't set, cloak.timeoutMs serves as its fallback before the 5000 ms default applies.
fetchImpl contract
fetchImpl lets you bring your own transport: attach auth, route through an edge proxy, add retries, or mock the network in tests. It receives the standard (input, init) signature, and the SDK never passes init.signal. Because it's a function, it can be set via createMOS(config) or window.MOSConfig but not via data-mos-* attributes.
Faster first decision
The bundle comes from assets.monetizationos.com. The decision request then goes to api.monetizationos.com, a second origin the browser can't connect to until the bundle has run. Warming that origin up front is the single biggest win for time to first decision. Add one preconnect hint in <head>, before the MOS tag:
<link rel="preconnect" href="https://api.monetizationos.com" crossorigin />
<link rel="dns-prefetch" href="https://api.monetizationos.com" /><!-- fallback for old browsers -->The crossorigin attribute is required. The decision is an anonymous CORS fetch. Without crossorigin, the browser warms a connection the request never uses. The bundle itself needs no hint: it's a static <script async fetchpriority="high"> in <head>, already discovered early while the browser scans the incoming HTML.
Client instance
createMOS returns an MOSClient. The script-tag build exposes the same methods on window.MOS.
| Member | Description |
|---|---|
identify(value) | Sets an explicit identity, the highest-precedence identity source. Optional; without it the SDK uses the configured declarative source or falls back to anonymous. |
decide(resource?) | Runs a live surface decision and applies it. Auto-fires once on load unless manual: true. The optional resource argument is merged over the derived resource with the highest precedence. |
reveal() | Manually reveals cloaked regions. Rarely needed; reveal is automatic. |
config | The resolved configuration, for inspection. |
All methods are safe to call outside a browser; they no-op during server-side rendering.
Decision request
Each decide() call sends POST /api/v1/surface-decisions to apiBaseUrl, authenticated with Authorization: Bearer <public key> (the publicKey value). The JSON body carries surfaceSlug, identity, and resource, and an X-MOS-Browser-Version header carries the SDK package version. Unlike proxy-issued decisions, the body omits the http and provider request-context blocks entirely. A public key can't assert those, and the server observes the real User-Agent and Referer from the browser request itself.
The resource is built at call time: id defaults to location.pathname, and meta defaults to the page's <meta> tags, with each name or property attribute mapped to its content value. A configured resourceProvider is merged shallowly over these defaults, and a resource argument passed to decide() is merged last.
See the Surface Decision API reference for the full request and response schema.
Identity
The SDK resolves identity in this order, highest precedence first:
- An explicit
mos.identify({ userJwt })value. - The single configured declarative source.
- Anonymous: an existing persisted anonymous identifier, or, if none exists, the SDK asks the server to mint one and persists what comes back. The SDK never relies on a server-set MOS-domain
Set-Cookie.
A missing or expired token falls through to anonymous, so staleness degrades gracefully.
Configure at most one declarative source:
| Source | Description |
|---|---|
jwtGlobal | Reads a JWT off window. Accepts a dotted-path string ('provider.jwt', the script-tag form) or a getter thunk (() => authClient.getToken(), the ESM form) read fresh at each decide(). Preferred for SPAs: the token lives in memory, sidestepping HttpOnly entirely. |
jwtCookie | Reads a named non-HttpOnly cookie via document.cookie. Carries an XSS-exposure caveat: only the named token is exposed to page JavaScript. Weigh that for your threat model. |
Anonymous identifier storage
The identity key also configures where the anonymous identifier persists. By default the SDK writes to localStorage, falling back to a first-party cookie.
| Key | Default | Description |
|---|---|---|
store | localStorage with cookie fallback | A custom IdentityStore implementing get() and set(value), synchronous or asynchronous. |
storeKind | Combined default store | Selects a built-in store when store isn't provided: 'localStorage' or 'cookie'. |
storeKey | 'mos_anon_id' | Storage key or cookie name for the persisted anonymous identifier. |
createAnonymousIdentifierFallback | true | When a JWT is presented, also asks the server to mint an anonymous identifier in case the JWT turns out to be unauthenticated. |
Component rendering
The decision's componentBehaviors are applied to the live DOM. The target is metadata.cssSelector, matched with querySelectorAll, so the full CSS selector range works, including :last-child.
| Operation | Effect |
|---|---|
before / after | Inserts content adjacent to each matched element. |
prepend / append | Inserts content inside each matched element. |
remove | Removes each matched element. |
replaceRange | Replaces the content between two marker positions, located by fromMarker and toMarker CSS selectors. |
Content is delivered as typed web elements:
| Element type | Rendering |
|---|---|
text | Inserted as escaped text. |
html | Inserted as parsed markup, and its scripts execute (via the cloned-script technique). Decision html is fully trusted, customer-authored workflow output, never end-user input. There is no sanitizer in v1. |
custom | Unsupported by default. Provide render.renderCustom to handle it; an unhandled custom element renders nothing and triggers render.onUnsupported. |
Transformations never recreate existing elements. New nodes are built off-document and inserted at boundaries; existing nodes are only ever left in place or moved adjacent to, so live ad iframes, players, analytics-bound nodes, and attached listeners keep working. replaceRange deletes only nodes fully inside the range; the markers and any node straddling a boundary survive.
Insertions aren't idempotent, and removed content is never restored. Re-calling decide() is only safe once the targeted DOM regions have been freshly re-rendered, for example after an SPA route change re-renders the view. A second decision over an already-transformed DOM duplicates inserts.
Anti-flicker cloak
The cloak is optional and matters only for subtractive transforms, meaning transforms that remove or truncate content. Those flash because the content paints before the asynchronous decision returns. The cloak hides declared regions before paint and reveals them after the decision. Additive-only surfaces don't need it.
The cloak is a synchronous inline snippet pasted in <head>, above the MOS tag. Copy the whole block; the only things to change are the selectors and the timeout on the first line of the IIFE:
<!-- MOS anti-flicker: paste in <head>, ABOVE the MOS script tag -->
<script>
/* MOS anti-flicker (optional): edit the selector(s) and timeout below */
(function(){var S=["[data-mos-cloak]"],T=5000;
var I="mos-cloak-style";
var css=S.map(function(s){return s+'{visibility:hidden!important}';}).join('');
var st=document.createElement('style');st.id=I;st.textContent=css;
var n=document.currentScript&&document.currentScript.nonce;if(n)st.nonce=n;
(document.head||document.documentElement).appendChild(st);
var done=false,timer;function reveal(){if(done)return;done=true;var e=document.getElementById(I);if(e&&e.parentNode)e.parentNode.removeChild(e);if(timer)clearTimeout(timer);}
timer=setTimeout(reveal,T);
window["__mosCloak"]={reveal:reveal,get revealed(){return done;},styleId:I,selectors:S,timeoutMs:T};})();
</script>- Mark your regions: add
data-mos-cloakto the elements to hide until the decision returns, or change theSarray to your own selectors. - The cloak always reveals. A built-in safety timeout lifts the mask even if the SDK is slow, blocked, or never loads, so content is never left hidden because MOS failed.
- Under a strict Content Security Policy (CSP), put a
nonceon the cloak<script>. The snippet copies it onto the<style>it injects, so one nonce covers both.
On timeout expiry the page may briefly show content a late decision would have hidden; see decisionTimeoutMs semantics. The SDK runtime consumes only the cloak timeout; the selectors take effect inside the snippet, which is where the hiding happens.
Bundler users can mint the same snippet string from code:
import { buildCloakSnippet } from "@monetizationos/browser";
const snippet = buildCloakSnippet({ selectors: ["[data-mos-cloak]"], timeoutMs: 5000 });Failure model
The SDK fails open:
- Any decision failure (network, non-2xx, malformed response) leaves the page intact, with no transforms applied.
- Cloaked regions are always revealed: on success, on error, and on the max-wait timeout (default 5 seconds, configurable via
decisionTimeoutMsorcloak.timeoutMs). - The max-wait timeout only lifts the cloak mask; it doesn't abort the request. A slow decision still applies when it lands, and
onErrorfires so it stays observable. - The SDK never retries
surface-decisionsautomatically. The endpoint consumes on each call and offers no way to mark a retried request as a duplicate, so a retry risks double-consuming.
decide() resolves to a discriminated result: { ok: true, data } on success, or { ok: false, reason, error } on failure. The failure reasons are:
| Reason | Meaning |
|---|---|
request-failed | The request couldn't be sent or threw before a response. |
invalid-json | The response body wasn't valid JSON. |
api-error | The API returned a structured error response. |
http-error | The API returned a non-2xx status without a structured error. |
invalid-response | The response didn't match the expected decision shape. |
aborted | A custom fetchImpl aborted the request. |
Logging and observability
The SDK never writes to console. Everything is surfaced through the host callbacks in the configuration table, so nothing lands in your users' consoles unless you put it there.
onLog receives structured { level, code, message, context } trace events with stable codes, for example decision:success (with latencyMs and applied counts) and decision:timeout. Tokens are never logged; identity appears only as its discriminant. Pipe events to your own logger, or use the bundled, opt-in console logger for quick local debugging:
import { consoleLogger, createMOS } from "@monetizationos/browser";
createMOS({ ...config, onLog: (e) => myLogger.log(e.level, e.code, e.context) });
createMOS({ ...config, onLog: consoleLogger });consoleLogger is the only path that writes to console, and only when you explicitly pass it.
Known limitations
- Soft-gating, not enforcement. Anything delivered to the browser is extractable, and metering is honor-system. The SDK hides and rearranges content; hard enforcement remains a server-side concern.
- No DOM restoration between decisions. Re-calling
decide()is only safe once the targeted DOM regions have been re-rendered, because insertions aren't idempotent and removed content is never restored. - No declarative source reads HttpOnly tokens yet. Use
jwtGlobalor an explicitidentify()call. - No HTTP response manipulation (
surfaceBehavior.http). Redirects, status changes, body replacement, and header or cookie application are out of scope for v1. - No
surfaceDecisionsCookiesforwarding. Arbitrary matched-cookie pass-through is unavailable with a public key. If your decisioning depends on forwarded cookies, that input is absent.
Related documentation
- The Browser SDK quickstart walks through a first integration.
- The Surface Decision API reference documents the request and response schema.
- The Proxies reference covers the server-side edge implementations of the same decision pipeline.