> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livepeer.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-Tenant Billing with pymthouse

> Build a multi-tenant Livepeer app with OIDC auth, per-user billing, and payment signing using pymthouse. Hosted setup, Builder API, usage dashboard.

export const CenteredContainer = ({children, maxWidth = "800px", padding = "0", preset = "default", width = "", minWidth = "", marginRight = "", marginBottom = "", textAlign = "", style = {}, className = "", ...rest}) => {
  const presets = {
    default: {},
    fitContent: {
      width: "fit-content",
      minWidth: "fit-content"
    },
    readable70: {
      width: "70%",
      minWidth: "fit-content"
    },
    readable80: {
      width: "80%",
      minWidth: "fit-content"
    },
    readable90: {
      width: "90%"
    },
    wide900: {
      maxWidth: "900px"
    }
  };
  const presetStyle = presets[preset] || presets.default;
  return <div className={className} style={{
    maxWidth: presetStyle.maxWidth || maxWidth,
    margin: "0 auto",
    padding: padding,
    ...presetStyle.width ? {
      width: presetStyle.width
    } : {},
    ...presetStyle.minWidth ? {
      minWidth: presetStyle.minWidth
    } : {},
    ...width ? {
      width
    } : {},
    ...minWidth ? {
      minWidth
    } : {},
    ...marginRight ? {
      marginRight
    } : {},
    ...marginBottom ? {
      marginBottom
    } : {},
    ...textAlign ? {
      textAlign
    } : {},
    ...style
  }} {...rest}>
      {children}
    </div>;
};

export const CustomDivider = ({color = "var(--lp-color-border-default)", middleText = "", spacing = "default", style = {}, className = "", ...rest}) => {
  const spacingPresets = {
    default: {
      margin: "24px 0"
    },
    overlap: {
      margin: "-1rem 0 -1rem 0"
    },
    tight: {
      margin: "0 0 -1rem 0"
    },
    section: {
      margin: "0 0 -2rem 0"
    },
    sectionOverlap: {
      margin: "-1rem 0 -2rem 0"
    },
    deepOverlap: {
      margin: "-1rem 0 -1.5rem 0"
    }
  };
  const spacingStyle = spacingPresets[spacing] || spacingPresets.default;
  return <div role="separator" aria-orientation="horizontal" className={className} style={{
    display: "flex",
    alignItems: "center",
    ...spacingStyle,
    fontSize: style?.fontSize || "16px",
    height: "fit-content",
    ...style
  }} {...rest}>
      <span style={{
    marginRight: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
      </span>
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      {middleText && <>
          <Icon icon="circle" size={2} />
          <span style={{
    margin: "0 8px",
    fontWeight: "bold",
    color: color,
    opacity: 0.7
  }}>
            {middleText}
          </span>
          <Icon icon="circle" size={2} />
        </>}
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      <span style={{
    marginLeft: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <span style={{
    display: "inline-block",
    transform: "scaleX(-1)"
  }}>
          <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
        </span>
      </span>
    </div>;
};

export const LinkArrow = ({href, label, description, newline = true, borderColor, className = '', style = {}, ...rest}) => {
  const linkArrowStyle = {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    gap: "var(--lp-spacing-1)",
    width: 'fit-content',
    ...borderColor && ({
      borderColor
    })
  };
  return <span className={className} style={style} {...rest}>
      {newline && <br />}
      <span style={linkArrowStyle}>
        <a href={href} target="_blank" rel="noopener noreferrer">
          {label}
        </a>
        <Icon icon="arrow-up-right" size={14} color="var(--lp-color-accent)" />
      </span>
      {description && description}
      {description && <div style={{
    height: "var(--lp-spacing-3)"
  }} />}
    </span>;
};

<CenteredContainer preset="readable90">
  <Tip>Three problems solved at once: OIDC identity, per-user billing, payment ticket signing. Hosted pymthouse + Next.js app. Forty-five minutes to multi-tenant.</Tip>
</CenteredContainer>

***

By the end of this tutorial you'll have a Next.js 15 app where two end users each authenticate via OIDC, run AI inference calls against the Livepeer Network, and accumulate usage on per-user ledgers visible in a dashboard. The infrastructure layer is <LinkArrow href="https://pymthouse.com" label="pymthouse" newline={false} />, a community-built backend that combines OpenID Connect identity, multi-tenant billing plans, and remote payment-ticket signing. You write the app; pymthouse handles the three things that turn a single-user demo into a multi-tenant product.

This is the Persona 2 activation moment for SaaS. The VOD and live-streaming tutorials proved your app can call Livepeer; this one proves your customers can call Livepeer through your app and you can bill them for it.

<Warning>
  pymthouse is a community project by John ([@eliteprox](https://github.com/eliteprox)) in active beta, not an official Livepeer Foundation product. Verify compatibility with the current go-livepeer release before production deployment. The hosted service is free during beta.
</Warning>

<CustomDivider />

## Required Tools

* Node.js 20 or later
* A pymthouse account at <LinkArrow href="https://pymthouse.com" label="pymthouse.com" newline={false} /> (hosted, free during beta) or a self-hosted instance
* A Livepeer Gateway accessible through pymthouse's signer
* A code editor

Self-hosting pymthouse adds a Postgres database, the Next.js app, and a `go-livepeer` signer sidecar. Full deployment instructions live in the pymthouse repository at <LinkArrow href="https://github.com/eliteprox/pymthouse" label="github.com/eliteprox/pymthouse" newline={false} />. The tutorial below uses the hosted path for the shortest time-to-running.

<CustomDivider />

## Pymthouse Responsibilities

Three infrastructure problems sit between a single-user Livepeer demo and a multi-tenant product. Pymthouse solves all three.

| Problem                                    | Pymthouse component               | Standard                     |
| ------------------------------------------ | --------------------------------- | ---------------------------- |
| Authenticating end users                   | OIDC provider with token exchange | RFC 8693 + OAuth 2.0         |
| Tracking per-user usage and applying plans | Builder API + usage ledger        | Wei-denominated; BigInt-safe |
| Signing probabilistic micropayment tickets | Remote signer proxy               | go-livepeer signer protocol  |

The runtime flow for a user-initiated inference call:

```
Your App
  ↓ POST /api/v1/oidc/token (token exchange for end-user)
Pymthouse
  ↓ access_token (scoped, short-lived)
Your App
  ↓ AI inference request with access_token
Pymthouse
  ↓ Validates token, checks billing plan
  ↓ Forwards to signer (signs payment ticket)
  ↓ Forwards to Livepeer Gateway
Livepeer Gateway
  ↓ Inference result
Pymthouse (records usage to ledger)
  ↓ Returns result
Your App
```

Your app exchanges its confidential client credentials for a user-scoped token, sends the inference request through pymthouse, and pymthouse handles ticket signing and usage recording transparently.

<CustomDivider />

## Pymthouse Setup

<Steps>
  <Step title="Register your app">
    Create an account at <LinkArrow href="https://pymthouse.com" label="pymthouse.com" newline={false} />. In the dashboard, register a new application; pymthouse issues a `clientId` and `clientSecret` for confidential-client authentication.
  </Step>

  <Step title="Configure a billing plan">
    In the app settings, pick a billing plan type. Three plan types ship in beta:

    | Plan         | Use for                                           |
    | ------------ | ------------------------------------------------- |
    | Free         | Open beta apps, internal testing                  |
    | Subscription | Flat-rate per user per month                      |
    | Usage-based  | Wei-per-request metering against per-user ledgers |

    The tutorial below uses usage-based. Each AI call deducts the wei cost from the user's ledger; the dashboard shows running totals.
  </Step>

  <Step title="Capture the OIDC discovery URL">
    Pymthouse exposes a standard OIDC discovery document at `/.well-known/openid-configuration`. The dashboard shows the discovery URL for your app. Use the discovery URL in production integrations to avoid path drift between pymthouse versions.
  </Step>
</Steps>

<CustomDivider />

## Project Bootstrap

<Steps>
  <Step title="Create the project">
    ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    npx create-next-app@latest livepeer-multitenant \
      --typescript \
      --tailwind \
      --app \
      --src-dir \
      --import-alias "@/*"
    cd livepeer-multitenant
    ```
  </Step>

  <Step title="Configure environment">
    Save as `.env.local`:

    ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    # Pymthouse app credentials (server-side only)
    PYMTHOUSE_URL=https://pymthouse.com
    PYMTHOUSE_CLIENT_ID=<your-client-id>
    PYMTHOUSE_CLIENT_SECRET=<your-client-secret>

    # Optional: self-hosted pymthouse signer endpoint
    PYMTHOUSE_SIGNER_URL=https://pymthouse.com/api/v1/signer
    ```

    Both credentials stay server-side. The browser never sees the client secret.
  </Step>
</Steps>

<CustomDivider />

## User Provisioning

End users live in pymthouse's user registry, scoped to your application's `clientId`. The Builder API at `/api/v1/apps/{clientId}/users` provisions and manages them.

Save as `src/lib/pymthouse.ts`:

```ts theme={"theme":{"light":"github-light","dark":"dark-plus"}}
const PYMTHOUSE_URL = process.env.PYMTHOUSE_URL!;
const CLIENT_ID = process.env.PYMTHOUSE_CLIENT_ID!;
const CLIENT_SECRET = process.env.PYMTHOUSE_CLIENT_SECRET!;

interface AdminToken {
  access_token: string;
  expires_in: number;
}

let cachedAdminToken: { token: string; expiresAt: number } | null = null;

async function getAdminToken(): Promise<string> {
  if (cachedAdminToken && cachedAdminToken.expiresAt > Date.now() + 60_000) {
    return cachedAdminToken.token;
  }

  const res = await fetch(`${PYMTHOUSE_URL}/api/v1/oidc/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      scope: 'admin',
    }),
  });

  if (!res.ok) {
    throw new Error(`Admin token request failed: ${res.status}`);
  }

  const data = (await res.json()) as AdminToken;
  cachedAdminToken = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
  return data.access_token;
}

export async function provisionUser(externalUserId: string, email: string) {
  const token = await getAdminToken();
  const res = await fetch(
    `${PYMTHOUSE_URL}/api/v1/apps/${CLIENT_ID}/users`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ external_id: externalUserId, email }),
    },
  );

  if (!res.ok) {
    throw new Error(`User provisioning failed: ${res.status}`);
  }

  return (await res.json()) as { user_id: string; external_id: string };
}

export async function mintUserToken(userId: string) {
  const token = await getAdminToken();
  const res = await fetch(
    `${PYMTHOUSE_URL}/api/v1/apps/${CLIENT_ID}/users/${userId}/token`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ scope: 'inference' }),
    },
  );

  if (!res.ok) {
    throw new Error(`User token mint failed: ${res.status}`);
  }

  return (await res.json()) as { access_token: string; expires_in: number };
}
```

The admin token (`client_credentials` grant) is your app authenticating to pymthouse. The user token (RFC 8693 token exchange) is one of your end users authenticating through your app's namespace. Cache the admin token in memory; mint user tokens per request.

<CustomDivider />

## Inference Endpoint

The route handler takes a user identifier from your app's session, mints a short-lived user token through pymthouse, and forwards the inference request. Pymthouse handles ticket signing and usage recording before returning the result.

Save as `src/app/api/inference/route.ts`:

```ts theme={"theme":{"light":"github-light","dark":"dark-plus"}}
import { provisionUser, mintUserToken } from '@/lib/pymthouse';

export async function POST(req: Request) {
  const { externalUserId, email, prompt } = (await req.json()) as {
    externalUserId: string;
    email: string;
    prompt: string;
  };

  // Ensure the user exists in pymthouse (idempotent).
  const user = await provisionUser(externalUserId, email);

  // Mint a short-lived token scoped to this user.
  const userToken = await mintUserToken(user.user_id);

  // Call the AI Jobs API through pymthouse's signer proxy.
  const inferenceRes = await fetch(
    `${process.env.PYMTHOUSE_URL}/api/v1/inference/text-to-image`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${userToken.access_token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model_id: 'ByteDance/SDXL-Lightning',
        prompt,
        width: 1024,
        height: 1024,
      }),
    },
  );

  if (!inferenceRes.ok) {
    return Response.json(
      { error: `Inference call failed: ${inferenceRes.status}` },
      { status: 502 },
    );
  }

  const data = await inferenceRes.json();
  return Response.json(data);
}
```

Two things differ from a direct Livepeer call. First, the request goes to pymthouse's inference endpoint, not directly to the Livepeer Gateway; pymthouse forwards after signing. Second, the bearer token is the user-scoped pymthouse token, not a static Gateway API key. The token tells pymthouse who to bill.

<CustomDivider />

## Usage Dashboard

The Usage API at `/api/v1/apps/{clientId}/usage` returns per-user usage data, tenant-scoped to your app. Pymthouse denominates monetary values in wei to match Livepeer's on-chain payment unit; parse with a BigInt-capable library to avoid floating-point precision loss.

Save as `src/app/api/usage/route.ts`:

```ts theme={"theme":{"light":"github-light","dark":"dark-plus"}}
import { provisionUser } from '@/lib/pymthouse';

interface UsageRecord {
  user_id: string;
  external_id: string;
  email: string;
  total_wei: string; // decimal string; parse with BigInt
  request_count: number;
  last_used_at: string;
}

export async function GET() {
  const token = await getAdminTokenForRead();
  const res = await fetch(
    `${process.env.PYMTHOUSE_URL}/api/v1/apps/${process.env.PYMTHOUSE_CLIENT_ID}/usage`,
    {
      headers: { Authorization: `Bearer ${token}` },
    },
  );

  if (!res.ok) {
    return Response.json({ error: 'Usage fetch failed' }, { status: 502 });
  }

  const data = (await res.json()) as { users: UsageRecord[] };

  // Convert wei to ETH for display.
  const enriched = data.users.map((u) => ({
    ...u,
    total_eth: (Number(BigInt(u.total_wei)) / 1e18).toFixed(8),
  }));

  return Response.json({ users: enriched });
}

async function getAdminTokenForRead() {
  // Same admin-token helper from lib/pymthouse.ts;
  // imported here for brevity.
  const res = await fetch(`${process.env.PYMTHOUSE_URL}/api/v1/oidc/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.PYMTHOUSE_CLIENT_ID!,
      client_secret: process.env.PYMTHOUSE_CLIENT_SECRET!,
      scope: 'read',
    }),
  });
  const data = (await res.json()) as { access_token: string };
  return data.access_token;
}
```

Render the dashboard as a server component:

```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
// src/app/dashboard/page.tsx
export default async function Dashboard() {
  const res = await fetch('http://localhost:3000/api/usage', {
    cache: 'no-store',
  });
  const data = await res.json();

  return (
    <main className="max-w-4xl mx-auto p-8">
      <h1 className="text-2xl font-bold mb-4">Per-User Usage</h1>
      <table className="w-full border-collapse">
        <thead>
          <tr className="border-b">
            <th className="text-left p-2">User</th>
            <th className="text-left p-2">Requests</th>
            <th className="text-left p-2">Total (ETH)</th>
            <th className="text-left p-2">Last Used</th>
          </tr>
        </thead>
        <tbody>
          {data.users.map((u: any) => (
            <tr key={u.user_id} className="border-b">
              <td className="p-2">{u.email}</td>
              <td className="p-2">{u.request_count}</td>
              <td className="p-2 font-mono">{u.total_eth}</td>
              <td className="p-2 text-sm text-gray-500">{u.last_used_at}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </main>
  );
}
```

The `total_eth` value is derived from the wei-denominated source; precision degrades past 8 decimals because of JavaScript number conversion. For exact billing display, keep the value in wei and format with a BigInt-aware decimal library.

<CustomDivider />

## Production Considerations

Five things change between this local setup and a production deployment.

**OIDC discovery in production.** Hardcoded paths break across pymthouse versions. Production code fetches `/.well-known/openid-configuration` once on app start and uses the URLs from the discovery document for all subsequent calls.

**Token caching across instances.** The admin token cache in `lib/pymthouse.ts` is per-process. In a multi-instance deployment, share the cache via Redis or accept the small overhead of one token fetch per instance per token lifetime.

**User token short-livedness.** User tokens default to short expiries (typically under an hour). Don't store them; mint per request. Caching tokens longer than their TTL produces silent 401 failures.

**Wei precision.** Every monetary number from pymthouse is wei (1e-18 ETH). Floating-point conversion loses precision past 15-16 digits. Keep all internal accounting in wei; convert to display units only at the UI layer.

**Self-hosting trade-off.** Hosted pymthouse during beta is free but constrains you to their infrastructure. Self-hosting takes ownership of three additional services (Next.js app, Postgres, signer sidecar) but removes the dependency. The repository at <LinkArrow href="https://github.com/eliteprox/pymthouse" label="github.com/eliteprox/pymthouse" newline={false} /> includes deployment recipes for Vercel + Railway, Vercel + Render, and Vercel + Fly.io.

Full hardening guidance in <LinkArrow href="/v2/developers/guides/production-hardening-checklist" label="Production Hardening Checklist" newline={false} />.

<CustomDivider />

## Common Errors

<AccordionGroup>
  <Accordion title="Admin token request returns 401">
    The `clientId` and `clientSecret` don't match a registered application. Confirm them in the pymthouse dashboard. If the client was recently rotated, restart your app to flush the cached admin token.
  </Accordion>

  <Accordion title="User token mint returns 403 forbidden">
    The user provisioning step failed silently, or the user belongs to a different `clientId` namespace. Confirm `external_id` is consistent across calls; pymthouse uses it as the idempotency key.
  </Accordion>

  <Accordion title="Inference call returns 402 payment required">
    The billing plan rejected the call. For usage-based plans, check the per-user ledger has sufficient balance or that the plan limits haven't tripped. Pymthouse's dashboard shows per-user balance and plan status.
  </Accordion>

  <Accordion title="Usage totals appear too high or too low">
    Wei-to-ETH conversion lost precision through floating-point arithmetic. Format with a BigInt-aware decimal library at the display layer; never store the lossy value as the source of truth.
  </Accordion>

  <Accordion title="OIDC discovery URL changed after pymthouse version bump">
    Production code that hardcoded `/api/v1/oidc/token` breaks when paths move. Switch to discovery-based URL resolution: fetch `/.well-known/openid-configuration` once on app start and use the URLs from there.
  </Accordion>
</AccordionGroup>

<CustomDivider />

You have a working multi-tenant app with per-user billing backed by pymthouse's OIDC identity and usage ledger. The same pattern extends beyond AI inference to any Livepeer pipeline type.

## AI agent prompt

```text theme={"theme":{"light":"github-light","dark":"dark-plus"}}
Build the "Multi-Tenant Billing with pymthouse" tutorial as a Next.js app backed by pymthouse. Verify current pymthouse docs and repository metadata first, then create a TypeScript app with placeholders PYMTHOUSE_URL=https://pymthouse.com, PYMTHOUSE_CLIENT_ID=<client ID>, PYMTHOUSE_CLIENT_SECRET=<client secret>, PYMTHOUSE_SIGNER_URL=<signer proxy URL>, and LIVEPEER_GATEWAY_URL=<gateway routed through pymthouse>. Implement lib/pymthouse.ts for client credentials, user provisioning, and user-token minting; add an inference route that forwards requests through pymthouse with the user token; and build a dashboard that shows per-user usage. Include local run commands, a seeded test user, one inference verification call, and a note that pymthouse credentials are not Livepeer Studio keys.
```

<CustomDivider />

## Next Steps

<CardGroup cols={2}>
  <Card title="Pymthouse Docs" icon="book" href="https://docs.pymthouse.com">
    Full integration documentation, deployment recipes, troubleshooting.
  </Card>

  <Card title="Pymthouse Repo" icon="github" href="https://github.com/eliteprox/pymthouse">
    Source, self-hosting deployment files, contribution guide.
  </Card>

  <Card title="AI Jobs Quickstart" icon="rocket" href="/v2/developers/build/ai-and-agents/ai-jobs-direct-quickstart">
    The direct-to-Gateway path, for comparison.
  </Card>

  <Card title="Production Hardening" icon="shield" href="/v2/developers/guides/production-hardening-checklist">
    Auth, observability, rate limits, secret management.
  </Card>
</CardGroup>
