Skip to main content

Documentation Index

Fetch the complete documentation index at: https://na-36-handover-docs-v2-into-docs-v2-dev-20260518.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.


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 , 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.
pymthouse is a community project by John (@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.

Required Tools

  • Node.js 20 or later
  • A pymthouse account at (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 . The tutorial below uses the hosted path for the shortest time-to-running.

Pymthouse Responsibilities

Three infrastructure problems sit between a single-user Livepeer demo and a multi-tenant product. Pymthouse solves all three.
ProblemPymthouse componentStandard
Authenticating end usersOIDC provider with token exchangeRFC 8693 + OAuth 2.0
Tracking per-user usage and applying plansBuilder API + usage ledgerWei-denominated; BigInt-safe
Signing probabilistic micropayment ticketsRemote signer proxygo-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.

Pymthouse Setup

1

Register your app

Create an account at . In the dashboard, register a new application; pymthouse issues a clientId and clientSecret for confidential-client authentication.
2

Configure a billing plan

In the app settings, pick a billing plan type. Three plan types ship in beta:
PlanUse for
FreeOpen beta apps, internal testing
SubscriptionFlat-rate per user per month
Usage-basedWei-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.
3

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.

Project Bootstrap

1

Create the project

npx create-next-app@latest livepeer-multitenant \
  --typescript \
  --tailwind \
  --app \
  --src-dir \
  --import-alias "@/*"
cd livepeer-multitenant
2

Configure environment

Save as .env.local:
# 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.

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

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

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:
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:
// 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.

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 includes deployment recipes for Vercel + Railway, Vercel + Render, and Vercel + Fly.io. Full hardening guidance in .

Common Errors

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.
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.
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.
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.
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.
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

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.

Next Steps

Pymthouse Docs

Full integration documentation, deployment recipes, troubleshooting.

Pymthouse Repo

Source, self-hosting deployment files, contribution guide.

AI Jobs Quickstart

The direct-to-gateway path, for comparison.

Production Hardening

Auth, observability, rate limits, secret management.
Last modified on May 19, 2026