React
This guide shows how to integrate MonetizationOS into a React application by calling the Surface Decision API directly from the browser. By the end you'll have a reusable hook that resolves a user's identity, requests a Surface decision, and renders Features and behaviors, using nothing beyond useState and fetch.
This is the client-side integration path. If you serve static or edge-rendered HTML, consider a server-side proxy instead, which enforces decisions at the edge before HTML reaches the browser.
Prerequisites
- A Surface with a slug such as
hello-world. Follow Hello, World! if you don't have one - The Public key (
pk_...) for your Preview environment - A React application. Vite, Create React App and Next.js all work, since this guide uses standard browser APIs
Public API key
Every environment has two API keys, one secret and one public. React runs in the browser, so it must use the public key. Public keys support anonymous identifiers and JWTs, which makes them safe to ship in client-side code.
Never ship a secret key to the browser. A secret key grants trusted, server-side capabilities such as
identifying a user directly by userIdentifier. Bundling it into client code exposes it to anyone who opens their
developer tools. Use a public key in React, and route any secret-key calls through your own backend. See
Custom identities via middleware.
Making a decision
A Surface decision is a single POST to /api/v1/surface-decisions. The request carries your public key in the Authorization header and an identity in the body. The Surface Decision API allows cross-origin requests, so you can call it directly from the browser. For a first call, let MonetizationOS mint an anonymous identifier with createAnonymousIdentifier, so there's nothing to set up yet.
curl -X POST https://api.monetizationos.com/api/v1/surface-decisions \
-H "Authorization: Bearer $PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{
"surfaceSlug": "hello-world",
"identity": {
"createAnonymousIdentifier": true
}
}'The response describes the resolved identity, the customer, and any Features and behaviors produced by your Workflows:
{
"status": "success",
"eventId": "941c5b52-39ff-4dab-9031-e39ef37e061a",
"identity": {
"authType": "anonymous",
"identifier": "mos_anon_v1_8f3c…", // the generated anonymous identifier
"isAuthenticated": false,
"jwtClaims": {}
},
"customer": {
"isCustomer": false,
"hasProducts": false,
"customerIdentifiers": []
},
"features": {},
"surfaceBehavior": {},
"componentsSkipped": false,
"componentBehaviors": {}
}A reusable hook
Inline fetch is fine for one call, but you'll want loading and error state everywhere you gate content. Wrap the request in a useSurfaceDecision hook built from useState and useEffect. Typing only the fields you read keeps the hook short and still narrows correctly. Store your public key in an environment variable. Public keys are safe to expose to the client, so a VITE_/NEXT_PUBLIC_ prefix is fine.
Alongside the surface and identity, the hook takes an optional resource, the specific item being accessed, such as the article on screen.
import { useEffect, useState } from "react";
const PUBLIC_KEY = import.meta.env.VITE_MOS_PUBLIC_KEY; // or process.env.NEXT_PUBLIC_MOS_PUBLIC_KEY
const API_URL = "https://api.monetizationos.com/api/v1/surface-decisions";
export type Identity =
| { anonymousIdentifier: string }
| { createAnonymousIdentifier: true }
| { userJwt: string; createAnonymousIdentifierFallback?: boolean };
export interface Resource {
id: string;
meta?: Record<string, string>;
}
// A discriminated union, so narrowing on `type` gives you the right fields.
type FeatureProperty =
| { type: "boolean"; isFallback: boolean; value: boolean | null }
| {
type: "meterable";
isFallback: boolean;
counterId: string;
hasAccess: boolean;
remainingUnits?: number;
totalUnits?: number;
uniqueResources?: boolean;
resourceIdUsed?: boolean;
};
interface SurfaceDecision {
status: "success";
eventId: string;
identity: {
identifier: string;
isAuthenticated: boolean;
authType: "provided" | "jwt" | "anonymous" | "middleware";
};
features: Record<string, { featureSlug: string; properties: Record<string, FeatureProperty> }>;
surfaceBehavior: Record<string, unknown>;
componentBehaviors: Record<string, unknown>;
}
export function useSurfaceDecision(
surfaceSlug: string,
identity: Identity,
resource?: Resource,
) {
const [decision, setDecision] = useState<SurfaceDecision | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
setError(null);
fetch(API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${PUBLIC_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ surfaceSlug, identity, resource }),
signal: controller.signal,
})
.then(async (res) => {
const body = await res.json();
// Errors carry a `message` worth surfacing, such as "Surface with slug 'x' not found".
if (!res.ok) throw new Error(body?.message ?? `Decision failed: ${res.status}`);
return body as SurfaceDecision;
})
.then((data) => {
setDecision(data);
setIsLoading(false);
})
.catch((err) => {
if (err.name === "AbortError") return;
setError(err);
setIsLoading(false);
});
// Aborting stops an outdated decision from landing, and stops a fast
// navigation from leaving a request in flight.
return () => controller.abort();
// Serialized so a new object with the same contents doesn't re-request,
// and a changed resource does. See "Deciding per resource" below.
}, [surfaceSlug, JSON.stringify(identity), JSON.stringify(resource)]);
return { decision, error, isLoading };
}With the hook in place, gate content by reading a Feature property. Here the article Feature exposes a boolean property premium that decides between full content and a paywall.
import { useSurfaceDecision } from "./useSurfaceDecision";
export function Article({ articleId }: { articleId: string }) {
const { decision, isLoading, error } = useSurfaceDecision(
"hello-world",
{ createAnonymousIdentifier: true },
{ id: articleId },
);
if (isLoading) return <Spinner />;
if (error) return <FullContent />; // fail open, or closed. Your call
const premium = decision?.features.article?.properties.premium;
const hasAccess = premium?.type === "boolean" && premium.value;
return hasAccess ? <FullContent /> : <Paywall />;
}Client-side gating controls presentation only. It can't protect content already delivered to the browser, since users control the JavaScript and can bypass either branch. For premium or sensitive content, enforce the decision on your server or at the edge, and send the protected payload only after you authorize access. Reserve the fail-open vs fail-closed choice below for non-sensitive UI behavior.
A failed decision can fail open, granting access, or closed, denying it. That's a product call. Choose based on what a false grant of non-sensitive UI costs you.
Deciding per resource
A Surface is the whole interface, your website or app. A resource is the specific item within it that a decision is about, such as the article being read or the video being played.
{
"surfaceSlug": "hello-world",
"identity": { "anonymousIdentifier": "mos_anon_v1_8f3c…" },
"resource": { "id": "article_xyz" }
}Send it whenever the decision depends on which item the user is viewing. On a content site that's most decisions. Metered Features are the main reason. A meter set to Unique Resources counts distinct resource.id values, so re-reading an article the user already opened costs no extra unit. With it off, every view counts. See Add Features to configure that, and the Surface Decision reference for the full resource shape including meta.
The hook re-decides when its arguments change, so the id has to come from something React re-renders on. A route prop works, as Article above shows, and so does a router hook. Where neither fits, put a key on the component and let it remount instead.
Identifying users
Every Surface decision is for one user, and a request carries exactly one identity. The API accepts four forms:
| Identity field | Who it's for | Key required |
|---|---|---|
createAnonymousIdentifier | A brand-new anonymous visitor | Public |
anonymousIdentifier | A returning anonymous visitor | Public |
userJwt | An authenticated user (via JWT) | Public |
userIdentifier | An authenticated user (by raw ID) | Secret |
Passing a userIdentifier with a public key returns a 400 error (userIdentifier cannot be specified using a public API key). By design, public keys reach only anonymous and JWT identities.
Anonymous users
For unauthenticated visitors, reuse a single identifier so their usage and grants survive requests and reloads. Minting one with a separate call would spend a throwaway Surface decision, so fold it into the decision you already make. Send createAnonymousIdentifier when you have no stored identifier, persist the identity.identifier the API returns, then send that as anonymousIdentifier from then on. A first visit stays a single Surface decision.
import { useEffect, useState } from "react";
import { useSurfaceDecision, type Identity, type Resource } from "./useSurfaceDecision";
const STORAGE_KEY = "mos_anonymous_id";
export function useAnonymousDecision(surfaceSlug: string, resource?: Resource) {
// Resolved once at mount and never updated during it, so learning the
// identifier from the response can't change the identity and re-decide.
const [identity] = useState<Identity>(() => {
const stored = typeof window === "undefined" ? null : localStorage.getItem(STORAGE_KEY);
return stored ? { anonymousIdentifier: stored } : { createAnonymousIdentifier: true };
});
const result = useSurfaceDecision(surfaceSlug, identity, resource);
useEffect(() => {
const resolved = result.decision?.identity.identifier;
if (resolved) localStorage.setItem(STORAGE_KEY, resolved);
}, [result.decision?.identity.identifier]);
return result;
}Freezing the identity for the mount is what keeps that promise. The hook writes the returned identifier to storage for the next mount to read, and deliberately not back into state. Putting it in state would change the identity mid-mount, change the dependency array with it, and fire a second decision for the visitor you just identified. On a meter that isn't counting unique resources, that second decision costs a second unit.
It also means the hook never posts an empty identity while it waits for one. The value is always either a stored anonymousIdentifier or createAnonymousIdentifier.
When an anonymous visitor signs up or logs in, link their anonymous identity to their authenticated one so grants and usage carry over. See the Link Identities recipe.
Authenticated users with a JWT
If your authentication provider issues JWTs, pass the token as userJwt. MonetizationOS validates it against your configured JWT integration and resolves the user from the token's claims. This needs no secret key, so it works directly from the browser.
const { decision } = useSurfaceDecision(
"hello-world",
{ userJwt: session.accessToken },
{ id: articleId },
);You can combine this with a fallback. Set createAnonymousIdentifierFallback: true alongside a supplied userJwt, and the API treats the request as anonymous instead of erroring when that token fails authentication. The fallback applies only when a token is present. For a signed-out user with no token, send createAnonymousIdentifier or a stored anonymousIdentifier instead.
{
"surfaceSlug": "hello-world",
"identity": {
"userJwt": "<jwt>",
"createAnonymousIdentifierFallback": true
}
}Custom identities via middleware
If you identify users by a raw userIdentifier (your own user ID, rather than a JWT), the request requires a secret key. Because a secret key must never reach the browser, the call has to come from a server you control. In a React app that means a backend route acting as a thin proxy. Your component calls your own endpoint, and that endpoint attaches the secret key and forwards the request to MonetizationOS.
This example uses a Next.js Route Handler, but any backend works.
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth"; // your existing auth
const SECRET_KEY = process.env.MOS_SECRET_KEY!; // server-only, never NEXT_PUBLIC_
export async function POST(req: NextRequest) {
const session = await getSession(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { surfaceSlug, resource } = await req.json();
const decision = await fetch(
"https://api.monetizationos.com/api/v1/surface-decisions",
{
method: "POST",
headers: {
Authorization: `Bearer ${SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
surfaceSlug,
resource,
// Trusted: the user ID comes from the server session, not the client.
identity: { userIdentifier: session.userId },
}),
},
);
return NextResponse.json(await decision.json(), {
status: decision.status,
});
}Your React code then calls the proxy instead of MonetizationOS directly. Point the hook at your own route and drop the Authorization header, so the browser never sees the secret key.
const decision = await fetch("/api/decision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surfaceSlug: "hello-world", resource: { id: articleId } }),
}).then((res) => res.json());Deriving userIdentifier from a server-side session is what makes this safe. Never accept a userIdentifier
straight from the request body, or a caller could impersonate any user. The secret key stays in a server
environment variable and never reaches the client.
This proxy is also the right place to use secret-key-only features, such as forwarding the original client user agent via the x-mos-user-agent header or including request information for use in decisions.
Related
Surface Decision API reference
The full request and response schema for surface decisions.
Features
How meterable and boolean Feature properties model access and usage.
Authentication & Identity
Configure the JWT integration that validates userJwt tokens.
Link Identities
Carry anonymous grants and usage over when a user signs up or logs in.