PlayaOS Developer Docs

@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/sdk

Requirements:

  • React 19+

  • A Tailwind setup in your consumer app. Drop-in components ship Tailwind utility class names in their compiled JS — your Tailwind content scan must include the SDK so the classes are emitted. Add this to your tailwind.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 in app/layout.tsx without 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-consumer directory 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>
PropTypeRequiredDescription
apiKeystringyesPer-camp API key (pk_live_*). Read from your env.
organizationstringyesCamp slug — derives the default base URL https://{organization}.playaos.app. Lowercase letters, digits, and hyphens only.
baseUrlstringnoOverride the derived base URL. Useful for staging or self-hosted. When passed, the slug check on organization is skipped.
appearancePlayaOSAppearancenoSlot 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:

statusdataerrorWhen
"idle"undefinedundefinedEmpty memberId (still loading); no fetch fires
"loading"DuesStatus[] | undefinedundefinedFetch in flight (stale data preserved on refetch)
"success"DuesStatus[]undefinedFetch settled
"error"DuesStatus[] | undefinedErrorFetch 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 clsx import is just for class merging — any string-join helper works (template literal, plain + " ", tailwind-merge for 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} />
PropTypeRequiredDescription
memberIdstringyesCamp member id whose dues to display. Empty string is treated as "not loaded yet" and renders No dues.
classNamestringnoApplied 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.

StateTriggerLabelAmountColor
loadingmount-fetch in flight… (animated)—gray
paidbalance ≤ 0, amount owed > 0Paid$N (amount paid)green
partialsome amount paid, balance remainingPartial$paid / $owedyellow
unpaidnothing paid, amount owedUnpaid$owed owedred
no-duesempty list, amountOwed: 0, or empty memberIdNo dues—neutral
errorfetch failed——muted

Slots (override via appearance.elements):

Slot keyApplies to
duesStatusBadge.rootOuter pill <span>
duesStatusBadge.labelVariant label text
duesStatusBadge.amountAmount 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: #FF6B6B

The 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:

  1. The API key doesn't have the dues:read scope. Check the key's scopes in the PlayaOS portal.
  2. The memberId you passed doesn't match a profile in the camp. Verify the id.
  3. The latest year's amountOwed is 0. The badge shows the latest year only — if dues weren't assessed for the current year, the badge correctly renders No 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" } }