@playaos/sdk
React SDK for embedding PlayaOS into your camp's own site — Provider, hooks, and drop-in components.
@playaos/sdk lets a camp drop PlayaOS data into their own marketing site or member portal without rebuilding the wiring. Same shape as Stripe Elements or a React app provider: a single root <PlayaOSProvider> holds your API key + org, descendant hooks read live data, and drop-in components render brand-themable UI.
This SDK pairs with @playaos/api-client — the SDK uses the API client under the hood, but exposes a React-shaped surface (Provider, hooks, components) so you don't have to wire data fetching yourself.
This browser widget Provider is different from the @playaos/react hooks Provider. Never pass the server-only REST key from Developer → API Keys to it. Public embed keys are separately provisioned in camp_keys, despite sharing the pk_live_* prefix. For authenticated member data, use server-side @playaos/api-client with the member's Supabase session JWT; see the quickstart.
Installation
npm install @playaos/sdk
# or
pnpm add @playaos/sdkRequirements:
-
React 19+
-
A Tailwind setup in your consumer app. Drop-in components ship Tailwind utility class names in their compiled JS — your Tailwind
contentscan must include the SDK so the classes are emitted. Add this to yourtailwind.config:// tailwind.config.ts export default { content: [ "./src/**/*.{ts,tsx}", // Include the SDK so Tailwind sees the class names it ships. "./node_modules/@playaos/sdk/dist/**/*.js", ], // ... };Camps without Tailwind get unstyled output — use the headless hooks instead and bring your own styles.
Quick Start
// app/layout.tsx (Next.js App Router)
import { PlayaOSProvider } from "@playaos/sdk";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<PlayaOSProvider
apiKey={process.env.NEXT_PUBLIC_PLAYAOS_EMBED_KEY!}
organization="my-camp"
>
{children}
</PlayaOSProvider>
</body>
</html>
);
}// any descendant page or component
"use client";
import { DuesStatusBadge } from "@playaos/sdk";
export default function MemberCard({ memberId }: { memberId: string }) {
return (
<div>
<h2>Dues</h2>
<DuesStatusBadge memberId={memberId} />
</div>
);
}Note — SDK hooks and drop-in components are Client Components. Files that consume them in Next.js App Router need a
"use client"directive at the top. The Provider is also a Client Component but Next.js auto-detects this and serializes children correctly through the boundary, so it works inapp/layout.tsxwithout extra wiring.
That's it — the badge fetches the member's dues, dedups requests across multiple instances, and renders a live status pill.
Want a working starting point? The
examples/sdk-consumerdirectory is a deployable Next.js app with both drop-in components wired up. Deploy it to Vercel in one click and swap in your own API key.
<PlayaOSProvider>
Wrap your app once at the root.
<PlayaOSProvider
apiKey="pk_live_..."
organization="my-camp"
// Optional — overrides the derived base URL.
baseUrl="https://staging.playaos.app"
// Optional — brand customization (see Theming).
appearance={{
elements: { "duesStatusBadge.root": "bg-mybrand-50" },
variables: { colorPrimary: "#FF6B6B" },
}}
>
{children}
</PlayaOSProvider>| Prop | Type | Required | Description |
|---|---|---|---|
apiKey | string | yes | Per-camp API key (pk_live_*). Read from your env. |
organization | string | yes | Camp slug — derives the default base URL https://{organization}.playaos.app. Lowercase letters, digits, and hyphens only. |
baseUrl | string | no | Override the derived base URL. Useful for staging or self-hosted. When passed, the slug check on organization is skipped. |
appearance | PlayaOSAppearance | no | Slot class overrides + CSS custom property overrides for descendant components. |
Memoization: the underlying API client is constructed once per Provider mount and re-created only when apiKey or the resolved base URL changes. Two Providers on one page (e.g. multi-camp embed) stay isolated — their dedup caches and CSS variables don't trample.
Hooks
useDuesStatus(memberId?)
Fetches dues status for a member. Pass memberId to scope to one member, or omit to fetch the full list scoped to the calling API key.
"use client";
import { useDuesStatus } from "@playaos/sdk";
function DuesPanel({ memberId }: { memberId: string }) {
const { data, status, error, refetch } = useDuesStatus(memberId);
if (status === "idle" || status === "loading") return <Spinner />;
if (status === "error") return <ErrorBanner error={error} onRetry={refetch} />;
return <DuesList items={data} />;
}Returns a discriminated union over status:
status | data | error | When |
|---|---|---|---|
"idle" | undefined | undefined | Empty memberId (still loading); no fetch fires |
"loading" | DuesStatus[] | undefined | undefined | Fetch in flight (stale data preserved on refetch) |
"success" | DuesStatus[] | undefined | Fetch settled |
"error" | DuesStatus[] | undefined | Error | Fetch failed |
refetch() issues a fresh request, bypasses dedup, and keeps stale data visible during the reload. Identity-changing re-runs (memberId/client change) clear data so the previous scope's results don't leak into the new query.
Dedup: multiple components in the same Provider asking for the same memberId share one network round-trip. Cache key is namespaced (m:<id> for per-member, __all__ for whole-list) so collisions are impossible.
useAppearance()
Read the resolved appearance config from the active Provider.
"use client";
import clsx from "clsx";
import { useAppearance } from "@playaos/sdk";
function MyButton() {
const { elements } = useAppearance();
return <button className={clsx("default-btn", elements["myButton.root"])}>Pay</button>;
}The
clsximport is just for class merging — any string-join helper works (template literal, plain+ " ",tailwind-mergefor Tailwind-conflict resolution). The SDK doesn't ship a class-merge helper; consumers pick their own.
Returns { elements: Record<string, string>, variables: Record<string, string> } — both maps are always defined (empty objects when consumer didn't pass appearance), so no optional chaining needed.
usePlayaOSContext()
Low-level hook returning { client, organization, appearance } — useful for one-off API calls without wrapping in a hook. Throws outside a Provider with a helpful error.
Components
<DuesStatusBadge memberId>
Read-only pill showing a member's current dues status. The smallest end-to-end consumer of the SDK.
<DuesStatusBadge memberId={member.id} />| Prop | Type | Required | Description |
|---|---|---|---|
memberId | string | yes | Camp member id whose dues to display. Empty string is treated as "not loaded yet" and renders No dues. |
className | string | no | Applied to the outer pill, after defaults + appearance slot. |
Render states (six, all visually distinct, all carry data-variant for styling). The label and amount render in separate <span> elements separated by CSS gap — there's no literal separator character between them, so the rendered output is e.g. Paid $100, not Paid · $100.
| State | Trigger | Label | Amount | Color |
|---|---|---|---|---|
loading | mount-fetch in flight | … (animated) | — | gray |
paid | balance ≤ 0, amount owed > 0 | Paid | $N (amount paid) | green |
partial | some amount paid, balance remaining | Partial | $paid / $owed | yellow |
unpaid | nothing paid, amount owed | Unpaid | $owed owed | red |
no-dues | empty list, amountOwed: 0, or empty memberId | No dues | — | neutral |
error | fetch failed | — | — | muted |
Slots (override via appearance.elements):
| Slot key | Applies to |
|---|---|
duesStatusBadge.root | Outer pill <span> |
duesStatusBadge.label | Variant label text |
duesStatusBadge.amount | Amount text (when shown) |
Theming
Brand customization happens through the Provider's appearance prop: slot classes + CSS variables.
<PlayaOSProvider
apiKey={...}
organization="my-camp"
appearance={{
elements: {
"duesStatusBadge.root": "bg-mybrand-50 rounded-2xl border border-mybrand-200",
"duesStatusBadge.label": "uppercase tracking-wide",
},
variables: {
colorPrimary: "#FF6B6B",
borderRadiusSm: "8px",
},
}}
>
{children}
</PlayaOSProvider>appearance.elements
Per-component slot class overrides. Slot keys are documented per-component (see "Slots" tables above). Override classes merge after default classes via the SDK's internal cn() — pair with tailwind-merge on the consumer side if you want later-class-wins semantics for Tailwind utilities.
elements: {
"duesStatusBadge.root": "bg-mybrand-50", // override the pill background
"duesStatusBadge.label": "font-mono", // override the label typography
"duesStatusBadge.amount": "tabular-nums", // override the amount typography
}appearance.variables
CSS custom properties applied at the Provider boundary. Keys are normalized from camelCase to kebab-case and prefixed with --playaos-:
variables: { colorPrimary: "#FF6B6B" }
// renders as:
// --playaos-color-primary: #FF6B6BThe Provider hosts these on a display: contents wrapper that's layout-invisible — your consumer flex/grid trees are unaffected. CSS variables cascade to every descendant. Drop-in components consume them via var(--playaos-color-primary) etc.
The wrapper element only renders when variables is non-empty. With no variables (most common case), the Provider returns its children as a Fragment — zero DOM noise.
Troubleshooting
usePlayaOSContext must be used inside a <PlayaOSProvider>
You called a SDK hook (useDuesStatus, useAppearance, etc.) or rendered a SDK component outside the Provider. Wrap your root layout in <PlayaOSProvider>. In Next.js App Router, that's typically app/layout.tsx.
Error: "Invalid `organization` value"
The organization prop must be a camp slug — lowercase letters, digits, and hyphens only. Characters like /, @, :, or whitespace would otherwise interpolate into the API host and route requests to the wrong domain. If you need a non-slug host (staging, self-hosted), pass baseUrl explicitly:
<PlayaOSProvider apiKey="..." organization="anything" baseUrl="https://staging.playaos.app"><DuesStatusBadge> renders "No dues" but the member has unpaid dues
Three common causes:
- The API key doesn't have the
dues:readscope. Check the key's scopes in the PlayaOS portal. - The
memberIdyou passed doesn't match a profile in the camp. Verify the id. - The latest year's
amountOwedis0. The badge shows the latest year only — if dues weren't assessed for the current year, the badge correctly rendersNo dues.
Empty memberId shows "No dues" instead of fetching
Intentional. useDuesStatus("") stays in idle and fires no request — sending an empty memberId to the API would 400 every render while a parent component is still resolving the id. Once memberId is non-empty, the hook re-runs and fetches.
Tailwind classes don't apply
Drop-in components ship Tailwind utility class names that need the consumer's Tailwind config to compile. Either:
- Add a Tailwind setup to your app (recommended for branded camps), or
- Use the headless hooks (
useDuesStatus) and render your own UI.
A precompiled CSS bundle is on the roadmap.
Versioning
@playaos/sdk is currently in 0.x — breaking changes can land between minor versions until a 1.0.0 release. Pin a specific version in production until then:
{ "dependencies": { "@playaos/sdk": "0.1.0" } }