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.

tsconfig.json
{
  "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.

Two providers share a name. @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.

KeyRuns inUse it for
key_…Your serverEverything in your app: creating accounts, changing plans, recording usage, minting account tokens.
pub_…Browser or serverReading an account's plan, reading pricing, recording usage and running checkouts.
act_…BrowserOne account's own plan and subscription, and its cancel flow. Minted by your server.
Never send the secret key to the browser. It can change or delete any record in the app. Read it from an environment variable in server code only, and pass the publishable key or an account token to client components.

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:

  1. The account's plan.
  2. Extras from the offer the account bought through, while its subscription is active and the extras haven't ended.
  3. The account's incentive, until its incentive_expires_at passes.

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.

GET /apps/:appId/namespaces/user_42/plan
{
  "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 }
  ]
}
FieldMeaning
idThe entitlement id. feature carries the same value.
typeusage for metered features, boolean for on and off features.
usageThe account's current count. Always 0 for boolean entitlements.
maxThe limit after all three layers. null means unlimited.
leftmax minus usage, never below 0. null when unlimited.
cantrue 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.

lib/offer.ts
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:

app/posts/actions.ts
"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
}
OptionTypeDescription
baseUrlstringThe Offer API's URL. Defaults to an empty string, which means same origin, so pass it outside the dashboard.
apiKeystringSent as the bearer token: a secret key, publishable key or account token.
fetchtypeof fetchA 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.

RouteWhat it does
POST …/usage/:entitlementId/addAdds 1.
POST …/usage/:entitlementId/removeSubtracts 1, for example when a post is deleted. Secret key only.
POST …/usage/:entitlementId/amountAdds { "amount": n }. Use a negative integer to subtract, with the secret key.
GET …/usageCounts for every entitlement on the account's plan.
GET …/usage/:entitlementIdOne 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.

app/posts/actions.ts
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.
}
402 Payment Required
{
  "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_url set on the app (PATCH /apps/:appId { "checkout_url": "https://…/pricing" }), it is your page with offer, plan, interval and account in 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_url is null.

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
// 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}
    />
  );
}
app/account/account-panel.tsx
"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>
  );
}
PropTypeDescription
baseUrlstringThe Offer API's public URL.
apiKeystringThe publishable key, or an account token.
fetchtypeof fetchOptional custom fetch.
clientOfferClientA client you built yourself. Replaces the three props above.
The publishable key is not scoped to one account. Anyone with it can read any account's /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.

app/pricing/pricing-table.tsx
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.

useOffers and useOffer in the core entry point. They read /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().