Access and entitlements
Ask the API what an account can use, gate features on the answer, and record usage as it happens. The same calls work from your server and, with the right key, from the browser.
The React SDK is a small set of TypeScript modules: a fetch client, a provider and hooks. It depends on React and nothing else. The checkout loads PayPal's JavaScript SDK from paypal.com when it renders the payment buttons. This page covers the core entry point. Checkout and Cancel flows cover the other two.
Add the SDK to your app
The SDK isn't published to npm. Its source lives in the dashboard at offer-app/src/sdk, and apps in the monorepo import it through TypeScript path aliases, so SDK changes show up without a build step. Map each entry point you use.
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@offer/sdk": ["../offer-app/src/sdk/index.ts"],
"@offer/sdk/checkout": ["../offer-app/src/sdk/checkout/index.ts"],
"@offer/sdk/cancel": ["../offer-app/src/sdk/cancel/index.ts"]
}
}
}- offer-app/src/sdk/
- index.ts@offer/sdk: OfferClient, OfferApiError, OfferProvider, useOfferClient
- checkout/@offer/sdk/checkout: the checkout provider, useOffer, Offer.*, getOffer
- cancel/@offer/sdk/cancel: CancelFlowProvider, useCancelFlow, CancelFlow.*
Outside the monorepo, copy offer-app/src/sdk into your project and point the same aliases at the copy. The modules that use hooks start with "use client", so they work in the Next.js app router. OfferClient and getOffer also run on the server.
A coding agent can do the copying for you. Developers → Integration → Install the React SDK with an agent in the dashboard gives you a SKILL.md that carries the SDK's source and your app's API URL and app ID. Save it in your coding agent's skills folder, or paste it into any other agent. It never includes the secret key.
@offer/sdk and @offer/sdk/checkout both export an OfferProvider. They are different components with different props. Import each from its own path.Which key to use
Every call sends a key as Authorization: Bearer …. Pick the key by where the code runs. The full rules are in Authentication.
| Key | Runs in | Use it for |
|---|---|---|
| key_… | Your server | Everything in your app: creating accounts, changing plans, recording usage, minting account tokens. |
| pub_… | Browser or server | Reading an account's plan, reading pricing, recording usage and running checkouts. |
| act_… | Browser | One account's own plan and subscription, and its cancel flow. Minted by your server. |
How access is resolved
GET /apps/:appId/namespaces/:accountId/plan returns one answer that already combines three layers. Each layer overrides the one before it for the same entitlement id:
- The account's plan.
- Extras from the offer the account bought through, while its subscription is active and the extras haven't ended.
- The account's incentive, until its
incentive_expires_atpasses.
Add-ons from all three are merged with the add-ons the account owns itself. Each entitlement comes back with the account's current usage.
{
"plan": { "id": "pro", "name": "Pro", "description": "For the prolific blogger.", "isFree": false, "meta": { "badge": "PRO" } },
"incentive": null,
"offer": "launch_50",
"addons": [],
"entitlements": [
{ "id": "posts", "feature": "posts", "name": "Posts", "type": "usage", "usage": 12, "max": 75, "left": 63, "can": true },
{ "id": "comments", "feature": "comments", "name": "Comments", "type": "usage", "usage": 140, "max": null, "left": null, "can": true },
{ "id": "pin_posts", "feature": "pin_posts", "name": "Pin posts", "type": "boolean", "usage": 0, "max": null, "left": null, "can": true }
]
}| Field | Meaning |
|---|---|
| id | The entitlement id. feature carries the same value. |
| type | usage for metered features, boolean for on and off features. |
| usage | The account's current count. Always 0 for boolean entitlements. |
| max | The limit after all three layers. null means unlimited. |
| left | max minus usage, never below 0. null when unlimited. |
| can | true while usage is below max, or when there is no limit. |
A boolean entitlement the plan doesn't include is missing from the list, so treat a missing entitlement as can: false. /plan leaves out the meta keys listed in the plan's privateMetaKeys. /full-plan returns the same thing with private meta included.
Check access on the server
Server code uses OfferClient with the secret key. Its request() method takes any API path, adds the key and JSON headers, and throws an OfferApiError for any non-2xx response. Keep helpers like these in one server-only module.
import "server-only";
import { OfferApiError, OfferClient } from "@offer/sdk";
export const offer = new OfferClient({
baseUrl: process.env.OFFER_API_URL, // http://localhost:6767 locally
apiKey: process.env.OFFER_SECRET_KEY,
});
const APP_ID = process.env.OFFER_APP_ID!;
export const accountPath = (id: string) => `/apps/${APP_ID}/namespaces/${encodeURIComponent(id)}`;
type Entitlement = {
id: string;
name: string;
type: "usage" | "boolean";
usage: number;
max: number | null;
left: number | null;
can: boolean;
};
export type AccountPlan = { plan: { id: string; name: string }; offer: string | null; addons: string[]; entitlements: Entitlement[] };
/** Creates the account on the free plan the first time you see the user. */
export async function ensureAccount(user: { id: string; name: string }) {
try {
return await offer.request(accountPath(user.id));
} catch (err) {
if (!(err instanceof OfferApiError) || err.status !== 404) throw err;
}
try {
return await offer.request(`/apps/${APP_ID}/namespaces`, {
method: "POST",
body: JSON.stringify({ id: user.id, name: user.name, plan: "free" }),
});
} catch (err) {
// Another request created it first.
if (err instanceof OfferApiError && err.status === 409) return offer.request(accountPath(user.id));
throw err;
}
}
export const getPlan = (accountId: string) => offer.request<AccountPlan>(`${accountPath(accountId)}/plan`);
/** Boolean entitlements the plan doesn't include are missing from the list, so default to false. */
export const can = (plan: AccountPlan, entitlementId: string) =>
plan.entitlements.find((e) => e.id === entitlementId)?.can ?? false;Then gate the feature where the action happens, not only in the UI:
"use server";
import { can, getPlan } from "@/lib/offer";
import { requireUser } from "@/lib/session"; // your own auth helper
export async function pinPost(postId: number) {
const user = await requireUser();
if (!can(await getPlan(user.id), "pin_posts")) {
return { error: "Pinned posts aren't included in your plan" };
}
// ...pin the post
}| Option | Type | Description |
|---|---|---|
| baseUrl | string | The Offer API's URL. Defaults to an empty string, which means same origin, so pass it outside the dashboard. |
| apiKey | string | Sent as the bearer token: a secret key, publishable key or account token. |
| fetch | typeof fetch | A custom fetch, for logging, tests or caching options. |
Record usage
Usage counters live in the API. Change them when the account uses a metered feature, and the next /plan reflects it.
| Route | What it does |
|---|---|
| POST …/usage/:entitlementId/add | Adds 1. |
| POST …/usage/:entitlementId/remove | Subtracts 1, for example when a post is deleted. Secret key only. |
| POST …/usage/:entitlementId/amount | Adds { "amount": n }. Use a negative integer to subtract, with the secret key. |
| GET …/usage | Counts for every entitlement on the account's plan. |
| GET …/usage/:entitlementId | One counter. |
The routes sit under /apps/:appId/namespaces/:accountId and return { "entitlement": "posts", "count": 4 }. Calling them on a boolean entitlement returns 400.
Soft and hard limits
By default a counter keeps counting past its limit. can turns false and your code decides what to do. Check can first, then record the usage.
To have the API enforce a limit, set overage on the entitlement with PATCH /apps/:appId/entitlements/:entitlementId. With { "mode": "block" }, an add or positive amount that would go past the limit is refused with 402 and the counter doesn't move. Add offer_id to choose which offer the upgrade comes from.
import { OfferApiError } from "@offer/sdk";
import { accountPath, offer } from "@/lib/offer";
type LimitReached = {
error: "limit_reached";
message: string;
entitlement: { id: string; name: string; usage: number; max: number };
offer: { id: string; plan: { id: string; name: string }; price: number; checkout_url: string | null } | null;
retry_after_purchase: boolean;
};
export async function createPost(userId: string, input: { title: string; body: string }) {
try {
await offer.request(`${accountPath(userId)}/usage/posts/add`, { method: "POST" });
} catch (err) {
if (err instanceof OfferApiError && err.status === 402) {
const limit = err.body as LimitReached;
return { error: limit.message, upgradeUrl: limit.offer?.checkout_url ?? "/pricing" };
}
throw err;
}
// ...save the post. If that fails, give the usage back with /usage/posts/remove.
}{
"error": "limit_reached",
"message": "You've used 3 of 3 Posts on the Free plan. Pro includes 75 Posts for $4.50/month for 3 months, then $9/month. Upgrade here: https://blog.example.com/pricing?offer=launch_50&plan=pro&interval=month&account=user_42",
"entitlement": { "id": "posts", "name": "Posts", "usage": 3, "max": 3 },
"offer": {
"id": "launch_50",
"name": "Launch week: 50% off",
"plan": { "id": "pro", "name": "Pro" },
"interval": "month",
"currency": "USD",
"price": 4.5,
"list_price": 9,
"cycles": 3,
"new_limit": 75,
"checkout_url": "https://blog.example.com/pricing?offer=launch_50&plan=pro&interval=month&account=user_42",
"expires_at": null
},
"retry_after_purchase": true
}The message is plain language, written so you can show it as is or hand it to an AI agent. The upgrade is the cheapest plan that raises this limit, taken from the entitlement's offer_id when the account can buy it, else at regular prices. When the account pays and you retry, the API first checks that account's open checkouts with PayPal, so the retry works even before PayPal's webhook arrives.
Paywalls
There is no paywall component. Build one from can and a link to your checkout page, or from the checkout_url in a 402. Where that link points depends on the app:
- With
checkout_urlset on the app (PATCH /apps/:appId { "checkout_url": "https://…/pricing" }), it is your page withoffer,plan,intervalandaccountin the query string. Pass all four from the query string to the checkout SDK. - Without it, the API creates a PayPal approval link on the spot and reuses it for an hour.
- Without PayPal connected,
checkout_urlisnull.
To nudge before the wall, subscribe to the usage.limit_warning webhook. It fires once when an account crosses 80% of a limit. See Webhooks.
Read access in the browser
Wrap client components in the core OfferProvider and call useOfferClient() to get an OfferClient that sends the publishable key. Pass the key down from a server component; it doesn't need a NEXT_PUBLIC_ variable.
// app/account/page.tsx (server component)
import { AccountPanel } from "./account-panel";
import { requireUser } from "@/lib/session"; // your own auth helper
export default async function AccountPage() {
const user = await requireUser();
return (
<AccountPanel
apiUrl={process.env.OFFER_API_URL!}
appId={process.env.OFFER_APP_ID!}
publishableKey={process.env.OFFER_PUBLIC_KEY!}
accountId={user.id}
/>
);
}"use client";
import { OfferProvider, useOfferClient } from "@offer/sdk";
import { useEffect, useState } from "react";
type AccountPlan = { entitlements: { id: string; can: boolean }[] };
// Your own hook on top of the SDK's client. The SDK has no plan hook yet.
function useAccountPlan(appId: string, accountId: string) {
const client = useOfferClient();
const [plan, setPlan] = useState<AccountPlan | null>(null);
useEffect(() => {
const controller = new AbortController();
client
.request<AccountPlan>(`/apps/${appId}/namespaces/${encodeURIComponent(accountId)}/plan`, { signal: controller.signal })
.then(setPlan, () => {});
return () => controller.abort();
}, [client, appId, accountId]);
return plan;
}
function PinButton({ appId, accountId }: { appId: string; accountId: string }) {
const plan = useAccountPlan(appId, accountId);
if (!plan) return null;
const pin = plan.entitlements.find((e) => e.id === "pin_posts");
if (!pin?.can) return <a href="/pricing?plan=pro">Upgrade to pin posts</a>;
return <button type="button">Pin post</button>;
}
export function AccountPanel(props: { apiUrl: string; appId: string; publishableKey: string; accountId: string }) {
return (
<OfferProvider baseUrl={props.apiUrl} apiKey={props.publishableKey}>
<PinButton appId={props.appId} accountId={props.accountId} />
</OfferProvider>
);
}| Prop | Type | Description |
|---|---|---|
| baseUrl | string | The Offer API's public URL. |
| apiKey | string | The publishable key, or an account token. |
| fetch | typeof fetch | Optional custom fetch. |
| client | OfferClient | A client you built yourself. Replaces the three props above. |
/plan by id and add usage for it. It can't read /full-plan or lower a counter; those need the secret key. For reads that must stay with one account, mint an account token on your server (see Cancel flows) and pass it as apiKey. A token can read its own account's /plan, /full-plan and /subscription.Show a pricing table
GET /apps/:appId/plans/pricing returns the pricing card of every plan that has one, and accepts the publishable key. The checkout entry point exports the price formatters.
import { priceLabel } from "@offer/sdk/checkout";
import { OfferClient } from "@offer/sdk";
type PricingCard = { plan_id: string; title: string; monthlyPrice?: number; yearlyPrice?: number; currency?: string; isFree: boolean };
export default async function PricingTable() {
const client = new OfferClient({ baseUrl: process.env.OFFER_API_URL, apiKey: process.env.OFFER_PUBLIC_KEY });
const cards = await client.request<PricingCard[]>(`/apps/${process.env.OFFER_APP_ID}/plans/pricing`);
return (
<ul>
{cards.map((c) => (
<li key={c.plan_id}>
{c.title}: {c.isFree ? "Free" : priceLabel(c.monthlyPrice ?? 0, "month", c.currency ?? "USD")}
</li>
))}
</ul>
);
}Errors
Every SDK client throws OfferApiError for a non-2xx response. It carries status, message (the API's error string) and body (the parsed JSON). The codes you'll see are listed in Authentication.
/api/v1/offers on the dashboard, an older endpoint that serves a fixed list. They don't read the Offer API. To sell an offer, use the checkout entry point's useOffer().