# API reference
Source: https://docs.pymthouse.com/api-reference/introduction
Live OpenAPI specification, endpoint inventory, and how to authenticate against the PymtHouse HTTP APIs.
PymtHouse serves a machine-readable **OpenAPI 3.0** specification and an interactive **Scalar UI** for every public endpoint.
## Live API docs (Scalar)
| Surface | Interactive docs | OpenAPI spec |
| -------------------------------------------------------- | --------------------------- | ----------------------------------- |
| **Builder + End-user** (M2M integrators, usage, billing) | `GET /api/v1/docs` | `GET /api/v1/openapi.json` |
| **Internal** (dashboard session) | `GET /api/v1/internal/docs` | `GET /api/v1/internal/openapi.json` |
Open `/api/v1/docs` on your PymtHouse deployment to browse and try every endpoint interactively. The Scalar UI accepts HTTP Basic auth directly in the browser.
Internal routes (`/api/v1/internal/…`) are for the PymtHouse dashboard's own session and are not linked from the public Scalar UI. Use Builder routes for all integrator access.
***
## Base URL
All API endpoints share one base URL:
```
https://pymthouse.com
```
OIDC endpoints live under `/api/v1/oidc/…`. Builder API endpoints live under `/api/v1/apps/{clientId}/…`. Usage API canonical paths live under `/api/v1/builder/apps/{clientId}/…`.
Always resolve OIDC endpoint URLs from the discovery document at runtime:
```bash theme={null}
curl https://pymthouse.com/api/v1/oidc/.well-known/openid-configuration
```
***
## Authentication
PymtHouse uses three auth modes depending on the operation:
### HTTP Basic (M2M — most Builder/Usage routes)
```http theme={null}
Authorization: Basic base64(m2m_client_id:m2m_client_secret)
```
Example:
```bash theme={null}
curl -u "m2m_yourId:pmth_cs_yourSecret" \
"https://pymthouse.com/api/v1/apps/app_yourId/users"
```
### Bearer token (machine or user JWT)
Obtain a machine token via client credentials, then use it for multiple calls:
```bash theme={null}
TOKEN=$(curl -sS \
-d "grant_type=client_credentials" \
-d "client_id=m2m_yourId" \
-d "client_secret=pmth_cs_yourSecret" \
-d "scope=users:write users:token" \
"https://pymthouse.com/api/v1/oidc/token" \
| jq -r '.access_token')
curl -H "Authorization: Bearer $TOKEN" \
"https://pymthouse.com/api/v1/apps/app_yourId/users"
```
### Provider session (dashboard-only routes)
Plans, Stripe Connect, and billing mutation routes require a logged-in dashboard session. These are not callable from a backend service — use the PymtHouse dashboard UI or pass the `next-auth.session-token` cookie from a browser session in scripts.
***
## Endpoint inventory
### OIDC / Auth
| Method | Path | Description |
| ------ | ----------------------------------------------- | --------------------------------------------------------------- |
| `POST` | `/api/v1/oidc/token` | Token endpoint: client credentials, device code, token exchange |
| `POST` | `/api/v1/oidc/device/auth` | Start device authorization (RFC 8628) |
| `GET` | `/api/v1/oidc/.well-known/openid-configuration` | Discovery document |
| `GET` | `/api/v1/oidc/jwks` | JSON Web Key Set for JWT verification |
### User management
| Method | Path | Auth | Description |
| -------- | -------------------------------------------------------- | ---- | ---------------------------- |
| `GET` | `/api/v1/apps/{clientId}/users` | M2M | List provisioned users |
| `POST` | `/api/v1/apps/{clientId}/users` | M2M | Create/upsert user |
| `PUT` | `/api/v1/apps/{clientId}/users` | M2M | Update user attributes |
| `DELETE` | `/api/v1/apps/{clientId}/users?externalUserId=…` | M2M | Deactivate user |
| `POST` | `/api/v1/apps/{clientId}/users/{id}/token` | M2M | Mint user-scoped JWT |
| `GET` | `/api/v1/apps/{clientId}/users/{id}/allowances` | M2M | Read user entitlement grants |
| `POST` | `/api/v1/apps/{clientId}/users/{id}/allowances` | M2M | Grant usage credit top-up |
| `GET` | `/api/v1/apps/{clientId}/users/{id}/subscription` | M2M | User subscription state |
| `POST` | `/api/v1/apps/{clientId}/users/{id}/subscription/change` | M2M | Switch plan |
### Usage API
| Method | Path | Auth | Description |
| ------ | ----------------------------------------------- | -------------- | ------------------------------- |
| `GET` | `/api/v1/builder/apps/{clientId}/usage` | M2M Basic only | Aggregated usage (canonical) |
| `GET` | `/api/v1/builder/apps/{clientId}/usage/balance` | M2M Basic only | User entitlement balance |
| `GET` | `/api/v1/apps/{clientId}/usage` | M2M Basic only | Aggregated usage (legacy alias) |
| `GET` | `/api/v1/apps/{clientId}/usage/balance` | M2M Basic only | Balance (legacy alias) |
| `GET` | `/api/v1/user/usage` | User JWT | End-user own usage |
| `GET` | `/api/v1/user/usage/balance` | User JWT | End-user own balance |
| `GET` | `/api/v1/user/usage/requests` | User JWT | End-user signed-ticket history |
### Billing
| Method | Path | Auth | Description |
| ------ | ------------------------------------------------ | -------------- | ------------------------ |
| `GET` | `/api/v1/apps/{clientId}/billing` | M2M or session | Billing summary snapshot |
| `GET` | `/api/v1/apps/{clientId}/billing/invoices` | Session | Invoice list |
| `GET` | `/api/v1/apps/{clientId}/billing/stripe` | Session | Stripe Connect status |
| `POST` | `/api/v1/apps/{clientId}/billing/stripe/connect` | Session | Start Stripe Connect |
| `POST` | `/api/v1/apps/{clientId}/billing/checkout` | Session | End-user plan checkout |
### Plans
| Method | Path | Auth | Description |
| -------- | ----------------------------------------- | -------------- | ------------------------ |
| `GET` | `/api/v1/apps/{clientId}/plans` | M2M or session | List billing plans |
| `POST` | `/api/v1/apps/{clientId}/plans` | Session | Create plan |
| `PUT` | `/api/v1/apps/{clientId}/plans` | Session | Update plan |
| `DELETE` | `/api/v1/apps/{clientId}/plans?planId=…` | Session | Delete plan |
| `POST` | `/api/v1/apps/{clientId}/plans/{id}/sync` | Session | Sync plan to OpenMeter |
| `GET` | `/api/v1/apps/{clientId}/starter-plan` | Session | Get Starter plan |
| `PUT` | `/api/v1/apps/{clientId}/starter-plan` | Session | Update Starter allowance |
### App config
| Method | Path | Auth | Description |
| ------ | ---------------------------------------- | -------------- | --------------------------------- |
| `GET` | `/api/v1/apps/{clientId}` | M2M or session | App metadata |
| `GET` | `/api/v1/apps/{clientId}/manifest` | M2M or session | Network capability manifest |
| `PUT` | `/api/v1/apps/{clientId}/manifest` | Session | Update capability exclusions |
| `GET` | `/api/v1/apps/{clientId}/signer/routing` | M2M or session | Signer DMZ URL and webhook config |
***
## Common response patterns
### Tenant boundary
M2M requests to app-scoped endpoints enforce that the authenticated M2M client belongs to the same app as the `{clientId}` in the URL path. Mismatches return **`404 Not Found`** — not `401` or `403` — to prevent app id enumeration. Session and user-JWT endpoints use different tenant controls.
### Wei values
All `*Wei` fields are **decimal strings**, not numbers. They may exceed `Number.MAX_SAFE_INTEGER`. Parse them with `BigInt()`:
```ts theme={null}
const fee = BigInt(response.totals.totalFeeWei);
const weiPerEth = 10n ** 18n;
const whole = fee / weiPerEth;
const fraction = (fee % weiPerEth)
.toString()
.padStart(18, "0")
.replace(/0+$/, "");
const eth = fraction ? `${whole}.${fraction}` : whole.toString();
```
### USD micros
`*UsdMicros` fields are integer strings where `1000000` = `$1.00`. These are computed once at signing time and never recomputed from the current ETH/USD rate.
### Error format
Denial responses from the activation gate use RFC 9457 problem details:
```json theme={null}
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"code": "stripe_connect_required"
}
```
Machine-readable `code` values: `owner_payment_method_required`, `end_user_cap_reached`, `stripe_connect_required`, `stripe_connect_pending`.
# PymtHouse Docs
Source: https://docs.pymthouse.com/index
Add AI job signing, metered per-user billing, and OAuth authentication to your app in minutes.
PymtHouse is the **identity and billing layer for AI applications**. You bring your users; PymtHouse handles authentication, usage metering, and payment collection so you can focus on your product.
## What you can build
Gate AI job requests by user balance. Charge per inference. Automatically track usage across pipeline and model.
Let users authenticate your CLI with their browser. No password prompts, no API key management.
Provision users on sign-up, issue scoped JWTs per request, and show per-user billing dashboards.
## How it works
Your backend registers as an OAuth client. PymtHouse becomes the **OIDC issuer** for your app — it authenticates your users, issues signed JWTs, and meters usage through OpenMeter. You never touch user credentials or billing infrastructure directly.
```
Your backend → PymtHouse → issues JWT → user makes AI request → usage metered → OpenMeter → billing
```
Three things you configure once and reuse everywhere:
| What | Env variable | Used for |
| ----------------- | ------------------------------------------- | ----------------------------------- |
| Public client id | `PYMTHOUSE_PUBLIC_CLIENT_ID` (`app_…`) | Device login, JWT `client_id` claim |
| M2M client id | `PYMTHOUSE_M2M_CLIENT_ID` (`m2m_…`) | Server-to-server Builder API calls |
| M2M client secret | `PYMTHOUSE_M2M_CLIENT_SECRET` (`pmth_cs_…`) | Authenticating Builder API calls |
## Start here
Provision a user, mint a signed JWT, and verify usage tracking with working curl commands.
Pick your pattern: SaaS app, CLI device flow, or metered billing. Full working examples.
## Build
Official TypeScript SDK. Install in one line, works in Next.js, Node, and Edge.
Client credentials and HTTP Basic auth for server-to-server calls.
Provision, update, and deactivate users. Per-user API keys and subscriptions.
Issue short-lived signed JWTs scoped to a specific user and capability.
Browser-based login for CLIs, terminals, and limited-input devices.
Per-user USD balance gates. Read entitlements, grant credits, check access.
## Monitor and bill
Per-user and per-pipeline usage. OpenMeter-backed, queryable by date range.
Cycle totals, timeline, overage, and USD breakdowns in one API call.
Pricing tiers, Pay-Per-Use thresholds, and included allowances. Synced to OpenMeter.
Builder M2M: owner wallet, top-up, subscription switching, overage gate, and end-user invoices — no dashboard session needed.
## When things go wrong
`400 invalid_scope`, `404` on valid credentials, balance gate failures — common errors and how to fix them.
## Discover endpoints at runtime
OIDC discovery publishes all endpoint URLs for your deployment. Always resolve from discovery rather than hard-coding paths:
```bash theme={null}
curl https://pymthouse.com/api/v1/oidc/.well-known/openid-configuration
```
Local development issuer: `http://localhost:3001/api/v1/oidc`
# Allowances
Source: https://docs.pymthouse.com/integration/allowances
Manage per-user USD micro entitlement balances. Read allowance detail, grant manual top-ups, and check real-time access via the OpenMeter-backed allowances and balance APIs.
PymtHouse tracks per-user usage entitlements as **allowances** — USD micro balances backed by OpenMeter subscriptions and grants. Every new user starts with a Starter plan allowance; providers can add manual top-ups on top of the subscription balance.
All allowance amounts are expressed as **USD micros** (integer strings; `1000000` = \$1.00).
Allowance storage is **OpenMeter-backed**, not a Postgres wei ledger. Reads use OpenMeter entitlement APIs; grants call OpenMeter `createGrant`. `OPENMETER_URL` must be configured.
## Authentication
All allowance endpoints use **M2M HTTP Basic auth**:
```http theme={null}
Authorization: Basic base64(m2m_id:m2m_secret)
```
The authenticated client's app must match the `clientId` in the path. Mismatches return `404`.
***
## Read allowances
```http theme={null}
GET /api/v1/apps/{clientId}/users/{externalUserId}/allowances
Authorization: Basic base64(m2m_id:m2m_secret)
```
Returns the user's allowance breakdown: balance consumed, remaining, and all active grants.
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId"
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/allowances" | jq .
```
Response:
```json theme={null}
{
"externalUserId": "user-123",
"balanceUsdMicros": "4200000",
"consumedUsdMicros": "800000",
"lifetimeGrantedUsdMicros": "5000000",
"grants": [
{
"id": "grant-uuid",
"amountUsdMicros": "5000000",
"source": "plan_adjustment",
"createdAt": "2026-04-01T00:00:00.000Z",
"featureKey": null
}
]
}
```
| Field | Description |
| -------------------------- | ------------------------------------------------------------ |
| `balanceUsdMicros` | Current remaining entitlement in USD micros. |
| `consumedUsdMicros` | Consumed allowance in USD micros this period. |
| `lifetimeGrantedUsdMicros` | Total lifetime granted allowance (Starter + manual top-ups). |
| `grants` | List of active grants with source and amount. |
| `grants[].source` | One of `trial`, `manual`, `promo`, `plan_adjustment`. |
| `grants[].featureKey` | Optional OpenMeter feature key for the grant. |
SDK:
```ts theme={null}
const allowances = await client.getUserAllowances("user-123");
```
***
## Grant a manual top-up
Add additional USD micro balance on top of the user's existing subscription allowance:
```http theme={null}
POST /api/v1/apps/{clientId}/users/{externalUserId}/allowances
Authorization: Basic base64(m2m_id:m2m_secret)
Content-Type: application/json
```
Request body:
```json theme={null}
{
"amountUsdMicros": "5000000",
"source": "manual",
"featureKey": null
}
```
| Field | Required | Description |
| ----------------- | -------- | ------------------------------------------------------------------------------ |
| `amountUsdMicros` | Yes | Amount to grant in USD micros (e.g. `5000000` = \$5.00). String integer. |
| `source` | No | `"manual"` (default), `"trial"`, `"promo"`, or `"plan_adjustment"`. |
| `featureKey` | No | Optional OpenMeter feature key to scope the grant. `null` for general balance. |
Grants are **additive** on top of the Starter subscription's included usage — they do not replace it.
```bash theme={null}
curl -sS -X POST \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{"amountUsdMicros":"5000000","source":"manual"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/allowances"
```
SDK:
```ts theme={null}
await client.grantUserAllowance("user-123", {
amountUsdMicros: "5000000",
source: "manual",
});
```
***
## Check real-time access balance
For a lightweight gate check before allowing a user action that consumes entitlement:
```http theme={null}
GET /api/v1/apps/{clientId}/usage/balance?externalUserId={externalUserId}
Authorization: Basic base64(m2m_id:m2m_secret)
```
Response:
```json theme={null}
{
"balanceUsdMicros": "4200000",
"hasAccess": true,
"remainingUsdMicros": "4200000",
"consumedUsdMicros": "800000",
"lifetimeGrantedUsdMicros": "5000000"
}
```
| Field | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `hasAccess` | `true` when the user has remaining balance from their active plan subscription. Gate signing requests on this field. |
| `balanceUsdMicros` | Current OpenMeter entitlement balance. |
| `remainingUsdMicros` | Remaining balance (may differ from `balanceUsdMicros` after in-flight consumption). |
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage/balance?externalUserId=user-123" | jq .
```
SDK:
```ts theme={null}
const balance = await client.getUsageBalance("user-123");
if (!balance.hasAccess) {
throw new Error("User has insufficient balance");
}
```
***
## Starter plan entitlement
Every app has a **Starter** plan with a default `includedUsdMicros` allowance (typically `5000000` = \$5.00). New users are automatically subscribed to the Starter plan when provisioned, and the Starter allowance is the baseline grant for every new user.
Providers update the Starter allowance via:
```http theme={null}
PUT /api/v1/apps/{clientId}/starter-plan
Content-Type: application/json
{ "includedUsdMicros": "10000000" }
```
This triggers an immediate OpenMeter plan sync. See [Plans](/integration/plans) for more on the Starter plan structure.
***
## User subscription status
Read the full OpenMeter subscription state for a user (plan, period, status):
```http theme={null}
GET /api/v1/apps/{clientId}/users/{externalUserId}/subscription
Authorization: Basic base64(m2m_id:m2m_secret)
```
SDK:
```ts theme={null}
const subscription = await client.getUserSubscription("user-123");
```
***
## Error responses
| Status | Condition |
| -------------------------- | ------------------------------------------------------------------------- |
| `404 Not Found` | `clientId` path mismatch, or `externalUserId` not found. |
| `422 Unprocessable Entity` | `amountUsdMicros` is not a valid positive integer string. |
| `503 Service Unavailable` | OpenMeter is unreachable (`OPENMETER_URL` misconfigured or service down). |
***
## Key design decisions
1. **OpenMeter-authoritative.** Allowance balances are never stored in Postgres. All reads and grants go through OpenMeter entitlement APIs, keeping the billing source of truth consistent with metering.
2. **Additive grants.** `POST /allowances` calls OpenMeter `createGrant` on top of the existing Starter subscription — it does not replace or reset the subscription's included usage.
3. **Separate `balance` endpoint.** `GET .../usage/balance` is a lightweight gate check that returns only `hasAccess` and balance totals, suitable for pre-request checks without pulling full allowance detail.
## Implementation tasks
* Call `getUsageBalance()` (or `GET .../usage/balance`) before dispatching signed requests to check `hasAccess`.
* Use `grantUserAllowance()` (or `POST .../allowances`) with `source: "manual"` for one-time top-ups (e.g. support credits, promotional credits).
* Use `source: "promo"` or `source: "trial"` for time-limited promotional grants when your billing flow distinguishes them.
* Poll `GET /allowances` for the full grant history when building a user billing detail view.
# API keys
Source: https://docs.pymthouse.com/integration/api-keys
Long-lived opaque pmth_* API keys for app-level and per-user integrations. Exchange for short-lived JWTs or signer sessions.
PymtHouse issues **long-lived opaque API keys** prefixed `pmth_*`. These complement short-lived JWTs: instead of repeating a device login or a full OAuth flow, your integration exchanges a stored API key for a short-lived JWT on demand.
Two types of API keys exist:
| Type | Prefix | Scope | Created via |
| --------------------- | ----------- | ------------------------------ | -------------------------- |
| **M2M client secret** | `pmth_cs_…` | App-level (all users) | App credentials endpoint |
| **Per-user API key** | `pmth_ak_…` | Scoped to one `externalUserId` | `POST .../users/{id}/keys` |
| **App-level API key** | `pmth_ak_…` | Tied to an app subscription | `POST .../keys` |
## Per-user API keys
Issue long-lived API keys to specific end-users. These are suitable for CLI tools and service accounts where the user authenticates once and stores the key.
### Create a key
```http theme={null}
POST /api/v1/apps/{clientId}/users/{externalUserId}/keys
Authorization: Basic base64(m2m_id:m2m_secret)
```
The full `pmth_*` secret is returned **once only** at creation time. Store it securely.
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId"
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
curl -sS -X POST \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/keys"
```
Response:
```json theme={null}
{
"keyId": "key-uuid",
"apiKey": "pmth_ak_abc123...",
"createdAt": "2026-04-01T00:00:00.000Z"
}
```
### List keys
```http theme={null}
GET /api/v1/apps/{clientId}/users/{externalUserId}/keys
Authorization: Basic base64(m2m_id:m2m_secret)
```
Returns key IDs and creation timestamps. The `pmth_*` value is never returned after creation.
### Revoke a key
```http theme={null}
DELETE /api/v1/apps/{clientId}/users/{externalUserId}/keys?keyId={keyId}
Authorization: Basic base64(m2m_id:m2m_secret)
```
Revocation is immediate. Any exchange request using the revoked key returns `401`.
***
## App-level API keys
App-level keys are tied to a subscription and suitable for app-wide machine access without a specific user context.
```http theme={null}
GET /api/v1/apps/{clientId}/keys
POST /api/v1/apps/{clientId}/keys
DELETE /api/v1/apps/{clientId}/keys
```
Auth: provider dashboard session. Returns the same `pmth_ak_…` format.
***
## Exchange API key → short-lived JWT
Exchange a `pmth_*` API key for a short-lived user JWT using Bearer auth:
```http theme={null}
POST /api/v1/apps/{clientId}/auth/api-key/token
Authorization: Bearer pmth_ak_abc123...
Content-Type: application/json
```
Optional request body:
```json theme={null}
{ "scope": "sign:job" }
```
Response: OIDC token bundle.
```json theme={null}
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "sign:job",
"externalUserId": "user-123"
}
```
```bash theme={null}
API_KEY="pmth_ak_abc123..."
curl -sS -X POST \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"scope":"sign:job"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/auth/api-key/token"
```
SDK:
```ts theme={null}
const tokens = await client.exchangeApiKeyForUserAccessToken({
apiKey: process.env.PMTH_API_KEY!,
});
// tokens.access_token — short-lived JWT for the user
// tokens.externalUserId — resolved external user id
```
***
## Exchange API key → signer session
Skip the separate signer session exchange by using the SDK helper, which calls the API key token endpoint and then performs the RFC 8693 exchange in one step:
```ts theme={null}
const session = await client.exchangeApiKeyForSignerSession({
apiKey: process.env.PMTH_API_KEY!,
facadeUrl: process.env.DASHBOARD_ORIGIN!, // e.g. https://dashboard.example.com
scope: "sign:job",
});
// session.access_token — opaque pmth_… signer bearer for the DMZ
```
***
## Validate an API key (subscription-backed)
To validate a Bearer `pmth_*` key and check the associated plan and capabilities, use the internal validation endpoint. This is used by integrations that need to gate access before processing a request:
```http theme={null}
GET /api/v1/auth/validate
Authorization: Bearer pmth_ak_abc123...
```
Returns `{ valid: true, planId: "...", capabilities: [...] }` on success.
***
## Security guidance
* Store API keys in a secret manager, not in source code or environment files committed to version control.
* The `pmth_*` secret is returned once at creation. If lost, revoke the key and create a new one.
* Prefer short-lived JWTs (5-minute TTL) on the signing hot path; exchange the stored API key on demand rather than using it directly as a Bearer token for signing.
* Revoke keys immediately when a user is deactivated or an integration is disconnected.
* Use per-user keys (not the M2M client secret `pmth_cs_…`) for end-user-facing integrations so revocation is scoped to one user.
## Related guides
* [User management](/integration/user-management) — provisioning users before issuing keys
* [Token exchange](/integration/token-exchange) — RFC 8693 signer session exchange
* [Signer routing](/integration/signer-routing) — using the signer session at the DMZ
* [Builder SDK](/integration/sdk) — `exchangeApiKeyForUserAccessToken`, `exchangeApiKeyForSignerSession`
# Billing summary
Source: https://docs.pymthouse.com/integration/billing
Retrieve the current billing period, active plan, usage totals, daily timeline, overage calculations, and USD cost breakdown for your app.
The billing summary endpoint gives your backend a single snapshot of everything relevant to the current billing cycle: which plan the app is on, the active subscription period, cumulative usage totals, a day-by-day fee timeline, overage charges, and owner/retail USD breakdowns.
For programmatic wallet management, subscription switching, and billing state checks from your backend, see [Builder M2M Payments API](/integration/payments). This page covers the aggregate billing summary endpoint.
All monetary values are **wei as decimal strings** to preserve precision across the full BigInt range. USD micro values (integer strings, `1000000` = \$1.00) are computed once at signing time using the ETH/USD oracle and stored immutably.
## Authentication
Two auth modes are accepted. The tenant boundary is enforced identically in both: the `clientId` in the URL path must match the authenticated principal's app.
### Confidential client (recommended for server-to-server)
```http theme={null}
GET /api/v1/apps/{clientId}/billing HTTP/1.1
Authorization: Basic base64(m2m_id:m2m_secret)
```
### Provider dashboard session
A logged-in session whose user is the app's owner, a platform admin, or a `providerAdmins` team member may call this endpoint without Basic auth.
Requests that satisfy neither auth mode, or whose authenticated principal does not match the path `clientId`, receive **`404 Not Found`**. The endpoint deliberately does not distinguish "unauthenticated" from "not found" to avoid leaking app existence.
## Endpoint
```http theme={null}
GET /api/v1/apps/{clientId}/billing
```
### Path parameters
| Parameter | Type | Description |
| ---------- | ------ | -------------------------------------------------------------------------------------- |
| `clientId` | string | OAuth `client_id` of the developer app (`app_…`). Must match the authenticated client. |
No query parameters.
## Response
### 200 OK
```json theme={null}
{
"clientId": "app_f4c21e7ac5f35d3e91bfad7f",
"plan": {
"id": "plan-uuid",
"type": "subscription",
"name": "Pro",
"priceAmount": "49.00",
"priceCurrency": "USD",
"includedUnits": "100000",
"includedUsdMicros": "10000000",
"overageRateWei": "1000000000000",
"billingCycle": "monthly",
"status": "active"
},
"subscription": {
"id": "sub-uuid",
"status": "active",
"currentPeriodStart": "2026-04-01T00:00:00.000Z",
"currentPeriodEnd": "2026-04-30T23:59:59.999Z"
},
"cycle": {
"periodStart": "2026-04-01T00:00:00.000Z",
"periodEnd": "2026-04-30T23:59:59.999Z",
"usage": {
"requestCount": 8420,
"totalFeeWei": "8420000000000000",
"totalFeeEth": "0.008420",
"networkFeeUsdMicros": "25260000",
"ownerChargeWei": "9262000000000000",
"ownerChargeUsdMicros": "27786000",
"platformFeeWei": "842000000000000",
"totalUnits": "108300"
},
"timeline": [
{ "date": "2026-04-01", "requestCount": 310, "feeWei": "310000000000000" },
{ "date": "2026-04-02", "requestCount": 0, "feeWei": "0" }
],
"overage": {
"overageUnits": "8300",
"overageWei": "8300000000000000"
}
},
"platformCutPercent": 10
}
```
### Response fields
| Field | Type | Description |
| ---------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `clientId` | string | Echo of the path `clientId`. |
| `plan` | object \| null | Active plan for the app. `null` if no plan is configured. |
| `plan.type` | `free` \| `subscription` \| `usage` | Determines how overage is calculated. |
| `plan.includedUnits` | string \| null | Units included in the base price. Numeric string or `null` for free plans. |
| `plan.includedUsdMicros` | string \| null | Subscription usage allowance in USD micros (e.g. `10000000` = \$10.00). |
| `plan.overageRateWei` | string \| null | Per-unit overage charge in wei. Numeric string or `null` for free plans. |
| `plan.billingCycle` | string | Billing cycle for the plan. Default `"monthly"`. |
| `subscription` | object \| null | Owner's active subscription. `null` when no active subscription exists; the period falls back to the calendar month. |
| `subscription.currentPeriodStart` | string | ISO 8601 start of the current billing period. |
| `subscription.currentPeriodEnd` | string | ISO 8601 end of the current billing period. |
| `cycle.periodStart` | string | Effective period start (subscription period or calendar month). |
| `cycle.periodEnd` | string | Effective period end. |
| `cycle.usage.requestCount` | integer | Number of usage records in the period. |
| `cycle.usage.totalFeeWei` | string | Cumulative network fees in wei, as a decimal string. |
| `cycle.usage.totalFeeEth` | string | Decimal ETH equivalent of `totalFeeWei`. |
| `cycle.usage.networkFeeUsdMicros` | string | Transaction-time USD micros for network cost (oracle-priced at signing time). |
| `cycle.usage.ownerChargeWei` | string | Network fee plus platform cut, in wei. |
| `cycle.usage.ownerChargeUsdMicros` | string | Transaction-time USD micros for owner charge. |
| `cycle.usage.platformFeeWei` | string | PymtHouse platform cut in wei. |
| `cycle.usage.totalUnits` | string | Cumulative units consumed, as a decimal string. |
| `cycle.timeline` | array | One entry per calendar day in the period. Days with no usage have `requestCount: 0` and `feeWei: "0"`. |
| `cycle.timeline[].date` | string | `YYYY-MM-DD` date key (UTC). |
| `cycle.timeline[].requestCount` | integer | Requests recorded on this day. |
| `cycle.timeline[].feeWei` | string | Fees on this day in wei. |
| `cycle.overage.overageUnits` | string | Units consumed beyond `plan.includedUnits`. `"0"` for free plans or when below the included quota. |
| `cycle.overage.overageWei` | string | Overage charge in wei (`overageUnits × plan.overageRateWei`). |
| `platformCutPercent` | number \| null | Platform fee percentage applied to payments. `null` if not configured. |
All `*Wei` fields are decimal strings, not numbers. They can exceed `Number.MAX_SAFE_INTEGER`. Parse with `BigInt(field)` in JavaScript or an equivalent in your language. Use `viem`'s `formatEther` (or equivalent) for human-readable display.
## Plan types and overage logic
| Plan type | `includedUnits` | `overageRateWei` | Overage calculated? |
| -------------- | --------------- | ---------------- | --------------------------------------------------------------- |
| `free` | `null` | `null` | No |
| `subscription` | Required | Required | Yes — when `totalUnits > includedUnits` |
| `usage` | Optional | Optional | Yes — when both fields are set and `totalUnits > includedUnits` |
For `free` plans, `overageUnits` and `overageWei` are always `"0"`.
**Pay-Per-Use plans** (`type: "usage"` with `chargeThresholdUsd` set) charge when accumulated usage crosses a threshold rather than on a billing cycle. The resolved charging behavior appears in `GET .../billing/wallet` as human-readable copy. See [Plans](/integration/plans) and [Builder M2M Payments API](/integration/payments) for threshold configuration and wallet management.
## Starter plan
Every app has a **Starter** plan created automatically (`isStarterDefault: true`). It is separate from custom billing plans and from the Network Price discovery plan. Starter carries an `includedUsdMicros` allowance (default `5000000` = \$5.00) and is automatically synced to OpenMeter.
New end-users are auto-subscribed to the Starter plan when provisioned (via `POST /users`, signer token mint, or signed-ticket ingest) if they have no existing subscription.
Providers can update the Starter allowance via:
```http theme={null}
PUT /api/v1/apps/{clientId}/starter-plan
Content-Type: application/json
{ "includedUsdMicros": "10000000" }
```
This triggers an immediate OpenMeter plan sync. The response echoes the updated Starter plan.
For per-user entitlement balances and manual top-ups, see [Allowances](/integration/allowances).
## Invoices
Tenant-scoped invoice list (OpenMeter DTO mapped):
```http theme={null}
GET /api/v1/apps/{clientId}/billing/invoices
```
Auth: provider dashboard session (read). Returns a list of invoices for the app's OpenMeter subscriptions.
## Merchant billing (Stripe Connect)
Stripe Connect is used for invoicing and end-user checkout, not for plan provisioning in OpenMeter. Builder M2M Basic auth is accepted for wallet and subscription operations; Connect OAuth itself requires a provider dashboard session.
| Method | Path | Auth | Description |
| -------- | ------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------ |
| `GET` | `/api/v1/apps/{clientId}/billing/stripe` | Provider session or M2M Basic | Stripe Connect status for the app |
| `POST` | `/api/v1/apps/{clientId}/billing/stripe/connect` | App owner or platform admin | Start Stripe Connect OAuth flow |
| `DELETE` | `/api/v1/apps/{clientId}/billing/stripe` | App owner or platform admin | Disconnect Stripe |
| `POST` | `/api/v1/apps/{clientId}/billing/checkout` | Provider session | End-user checkout: `{ planId, externalUserId, successUrl?, cancelUrl? }` |
For the full M2M payments surface including owner wallet, subscription switching, and overage gate, see [Builder M2M Payments API](/integration/payments).
## Period fallback
When the app has no active subscription, the billing period defaults to the **current calendar month in UTC** (midnight on the 1st to the last millisecond of the last day). This fallback applies whenever `subscription` is `null` in the response.
## Example
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId"
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing" | jq .
```
Extract overage in wei with `jq`:
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing" \
| jq '.cycle.overage.overageWei'
```
Display owner charge in USD (Node.js):
```bash theme={null}
RESPONSE=$(curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing")
node -e "
const data = $(echo $RESPONSE | jq -c .);
const usdMicros = BigInt(data.cycle.usage.ownerChargeUsdMicros ?? '0');
const usd = Number(usdMicros) / 1_000_000;
console.log('\$' + usd.toFixed(4));
"
```
## Error responses
| Status | Condition |
| --------------- | --------------------------------------------------------------------------------------------------------------------- |
| `404 Not Found` | No authenticated principal, credentials valid but for a different app, or `clientId` does not resolve to a known app. |
## Security boundaries
* Tenant isolation: the authenticated M2M client's `appId` must equal the path `clientId`. A valid credential for a different app returns `404`.
* Provider sessions must be the app owner, a platform admin, or a `providerAdmins` team member.
* No secrets, signer material, or per-request payloads are returned.
* Confidential client secrets must stay server-side. Do not call this endpoint from the browser with Basic auth.
## Key design decisions
1. **Single-call snapshot.** Plan, subscription, usage totals, timeline, overage, and USD breakdowns are assembled in one response so dashboard UIs can render a complete billing view without multiple round trips.
2. **Day-granularity timeline, not raw records.** The timeline buckets fee and request data by calendar day (UTC), keeping the response size bounded. For raw per-pipeline/model data, use `GET /api/v1/apps/{clientId}/usage?groupBy=pipeline_model` (see [Usage API](/integration/usage-api)).
3. **Calendar-month fallback when no subscription.** Apps on the Starter plan or free-tier state still get a consistent period reference (the current calendar month).
4. **`404` for all auth and tenant-mismatch failures.** Prevents enumeration of valid `clientId`s.
5. **USD micros stored at signing time.** Historical USD accuracy depends on the oracle at signing time; do not recompute historical values from the current oracle rate.
## Implementation tasks
* Parse all `*Wei` fields with `BigInt` before any arithmetic. Do not cast to `Number` before comparing or summing.
* Use `networkFeeUsdMicros` and `ownerChargeUsdMicros` for fiat-denominated cost reporting; `totalFeeWei` for wei-denominated analytics.
* Use the `timeline` array to drive sparklines or bar charts — every calendar day in the period is always present, so you never need to fill gaps client-side.
* When `plan` is `null`, surface a "No plan configured" state rather than treating it as an error.
* For overage alerting, poll this endpoint on a schedule and compare `cycle.overage.overageUnits` against thresholds you define in your system.
* For per-user attribution and pipeline/model breakdown, use the [Usage API](/integration/usage-api) with `groupBy=user` or `groupBy=pipeline_model`.
* To create or change plans, use [Plans](/integration/plans) from a provider dashboard session (not M2M).
* For per-user entitlement balances and allowance top-ups, see [Allowances](/integration/allowances).
# Client model
Source: https://docs.pymthouse.com/integration/client-model
The two-client pattern, OAuth scopes, API keys, and billing modes. Read this before writing any integration code.
## TL;DR
Every app has **two OIDC clients**. You use different ones in different places:
| Client | `client_id` prefix | Has secret? | Where you use it |
| ----------------- | ------------------ | ----------------- | --------------------------------------------------------- |
| **Public** | `app_…` | No | URL path `{clientId}`, device flow, JWT `client_id` claim |
| **M2M (backend)** | `m2m_…` | Yes (`pmth_cs_…`) | HTTP Basic auth, Builder API calls, token requests |
The single most common integration mistake: putting `m2m_…` where `app_…` belongs (or vice versa). Keep the env variable names unambiguous:
```bash theme={null}
PYMTHOUSE_PUBLIC_CLIENT_ID="app_…" # safe to embed in CLIs and browser code
PYMTHOUSE_M2M_CLIENT_ID="m2m_…" # server-side only, never expose
PYMTHOUSE_M2M_CLIENT_SECRET="pmth_cs_…" # server-side only, rotate regularly
```
***
## Why two clients?
OAuth 2.0 distinguishes **public** clients (those that cannot keep secrets — CLIs, native apps, browsers) from **confidential** clients (servers that can). A single client cannot satisfy both:
* **Device flow polling** happens on the user's device — no place to safely store a secret.
* **Builder API calls** must prove your backend's identity — a secret is required.
Keeping them separate also means rotating the M2M secret never disrupts active device sessions.
***
## The public client (`app_…`)
Use the public client wherever the user or device needs to see a `client_id`:
* The `client_id` parameter in device authorization requests
* Verification URLs shown to users (`verification_uri_complete`)
* The `client_id` / `azp` claim in issued user JWTs
* The `{clientId}` URL path segment in all Builder API calls
**Never add a secret to the public client.** Device flow polling requires that the public client has no secret — adding one would break every CLI and SDK that polls the device code endpoint.
***
## The M2M client (`m2m_…`)
Use the M2M client exclusively in your server-side backend:
* HTTP Basic auth: `Authorization: Basic base64(m2m_id:m2m_secret)`
* Client credentials grant to get a machine token
* RFC 8693 token exchange (device completion, signer session)
Never expose the M2M client id or secret in browser JavaScript, CLI binaries, or mobile apps.
***
## OAuth scopes
### Public client scopes (`allowed_scopes`)
Configured on the `app_…` client. They control two things:
1. **What claims appear in user JWTs** — requested scopes are validated against this list
2. **Billing mode** — presence of `users:token` switches the app to per-user billing
| Scope | Effect |
| ------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `sign:job` | Authorizes AI job signing. Default scope for user-token mint. Auto-adds `sign:mint_user_token` to the M2M client. |
| `users:token` | Enables per-user billing attribution. Required if you want per-user usage data. Also required on M2M for token minting. |
### M2M client scopes (`allowed_scopes`)
Gate what your backend can do:
| Scope | What it unlocks |
| ---------------------- | ----------------------------------------------------------------------------------------- |
| `users:read` | List provisioned users |
| `users:write` | Create, update, deactivate users |
| `users:token` | Mint user JWTs; RFC 8693 device completion and signer session exchange |
| `device:approve` | Device completion only (alternative to `users:token`) |
| `sign:mint_user_token` | Option A: mint user signer JWT in one call (auto-added when public client has `sign:job`) |
Request only the scopes your backend needs. Excess scopes increase risk without benefit.
***
## Billing mode
The `users:token` scope on the **public** client determines how usage is attributed:
| Public client has `users:token`? | Billing mode |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Yes | **Per-user** — usage attributed to individual end-users. `groupBy=user` on Usage API returns per-user data. |
| No | **App-level** — usage rolls up to the app. No per-user breakdown. |
Add `users:token` only if your use case requires per-user billing data. It cannot be removed without changing billing attribution behavior.
***
## API keys (`pmth_*`)
Long-lived opaque keys issued by PymtHouse:
| Format | Role |
| ----------- | ----------------------------------------------------------------------- |
| `pmth_cs_…` | M2M client secret — used in HTTP Basic auth |
| `pmth_ak_…` | Per-user API key — exchangeable for a short-lived JWT or signer session |
Per-user API keys are created via `POST .../users/{externalUserId}/keys` and must be stored securely server-side. Exchange them for short-lived JWTs on demand rather than using them directly in the signing hot path.
***
## Three-sibling pattern (advanced)
Apps that also need browser-based SSO (e.g., a Kong Dev Portal login) register a third client:
| Role | `client_id` prefix | Has secret? | Used for |
| ------ | ------------------ | ----------- | -------------------------------------------------- |
| Public | `app_…` | No | Device/SDK flows |
| M2M | `m2m_…` | Yes | Builder API, token exchange |
| Web RP | `web_…` | Yes | Authorization code + browser redirect (portal SSO) |
The `web_…` client registers redirect URIs and supports `authorization_code`. It is **not** for Builder API or device flows. See [Interactive login](/integration/interactive-login).
***
## Checklist before writing code
* [ ] Confirm `PYMTHOUSE_PUBLIC_CLIENT_ID` starts with `app_`
* [ ] Confirm `PYMTHOUSE_M2M_CLIENT_ID` starts with `m2m_`
* [ ] Both credentials belong to the same registered developer app
* [ ] M2M secret is stored in your backend secret manager, not in source code
* [ ] Public client has `sign:job` in `allowed_scopes`
* [ ] M2M client has `users:write` and `users:token` in `allowed_scopes`
* [ ] If per-user billing: public client also has `users:token`
## Rotate M2M secrets
Rotate via `POST /api/v1/apps/{clientId}/credentials` (provider session) or the credentials page in the dashboard. After rotation:
1. Update the secret in your backend secret manager.
2. Redeploy or restart your service.
3. **Do not** touch the public client — it has no secret to rotate.
# Deprecated routes and migrations
Source: https://docs.pymthouse.com/integration/deprecated
Migration guide for removed and deprecated endpoints across PymtHouse versions.
This page documents endpoints that have been **removed** (returning `410 Gone`) or **deprecated** across PymtHouse versions. Follow the migration steps for your version to update your integration.
***
## v0.3.5 – v0.3.7 breaking changes
### Auth validate endpoint changed
```diff theme={null}
- GET /api/v1/auth/validate
+ POST /api/v1/auth/validate
Content-Type: application/json
{ "key": "pmth_…" }
```
Set `BPP_VALIDATE_V2=1` in your environment to opt into the new POST form. The `GET` form is removed.
***
### Builder API surface split
Routes are now grouped by audience. Usage API paths moved to the canonical `builder` prefix:
```diff theme={null}
- GET /api/v1/apps/{clientId}/usage
+ GET /api/v1/builder/apps/{clientId}/usage
```
The legacy `/api/v1/apps/{clientId}/usage*` paths remain as **deprecated M2M-only aliases**. Migrate to `/api/v1/builder/apps/{clientId}/usage*`.
**Bearer tokens are rejected on all usage paths.** Use HTTP Basic auth (`-u m2m_id:pmth_cs_secret`) for all usage queries, including the legacy aliases.
***
### Signer session exchange endpoint replaced
```diff theme={null}
- POST /api/v1/apps/{clientId}/auth/api-key/signer-session
+ POST /api/v1/apps/{clientId}/oidc/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=pmth_...
subject_token_type=urn:ietf:params:oauth:token-type:access_token
- POST /api/v1/apps/{clientId}/auth/api-key/token
+ POST /api/v1/apps/{clientId}/oidc/token
(same RFC 8693 form)
```
The issuer-level endpoint `POST /api/v1/oidc/token` is also accepted and resolves the app from the credential.
***
### App-level keys replaced by per-user keys
```diff theme={null}
- GET /api/v1/apps/{clientId}/keys
- POST /api/v1/apps/{clientId}/keys
- DELETE /api/v1/apps/{clientId}/keys
+ GET /api/v1/apps/{clientId}/users/{externalUserId}/keys
+ POST /api/v1/apps/{clientId}/users/{externalUserId}/keys
+ DELETE /api/v1/apps/{clientId}/users/{externalUserId}/keys
```
Builder-minted per-user keys now use the composite format `app_<24hex>_` so the remote-signer identity webhook can recover `client_id` from the credential. Personal keys remain bare `pmth_`.
***
### Discovery pricing endpoint removed
```diff theme={null}
- GET /api/v1/apps/{clientId}/discovery/pricing
```
Removed in v0.3.7. Read routing information from the signer routing endpoint instead:
```http theme={null}
GET /api/v1/apps/{clientId}/signer/routing
Authorization: Basic base64(m2m_id:m2m_secret)
```
***
### Auto top-up removed
The per-user off-session-charge (auto top-up) design was built and retired mid-v0.3.7 development in favor of invoice-based collection. Do not build against any `pymthouse_auto_topup` metadata. Use [Builder M2M Payments API → Overage gate](/integration/payments#overage-gate-and-soft-negative-ceiling) for ongoing spend collection.
***
### Subscription CRUD (v0.3.7)
```diff theme={null}
- GET/POST/DELETE /api/v1/subscriptions
```
Replaced by:
| Old | New |
| ------------------------------ | ---------------------------------------------------- |
| `POST /api/v1/subscriptions` | `POST .../users` + OpenMeter checkout |
| `DELETE /api/v1/subscriptions` | `DELETE .../plans?planId=` |
| `GET /api/v1/subscriptions` | `GET .../plans` or `GET .../users/{id}/subscription` |
***
### Balance gate `expiryTtl`
The signer now receives `expiryTtl` explicitly. If you cached gate decisions, verify your cache TTL assumptions match the new value.
***
## v0.2.x breaking changes
### Hosted signer proxy removed (`/api/signer/*`)
**Affected routes (all return `410 Gone`):**
| Route | Removed |
| ----------------------------------------- | ------- |
| `POST /api/signer/generate-live-payment` | v0.2.x |
| `POST /api/signer/sign-orchestrator-info` | v0.2.x |
| `POST /api/signer/sign-byoc-job` | v0.2.x |
| `GET /api/signer/discover-orchestrators` | v0.2.x |
### Why it was removed
The hosted `/api/signer/*` HTTP proxy was a synchronous pass-through between your backend and the go-livepeer DMZ. It created a PymtHouse-hosted bottleneck on the signing hot path and was incompatible with direct DMZ deployments.
### Migration
**Step 1:** Fetch the remote DMZ URL and webhook URL for your app:
```http theme={null}
GET /api/v1/apps/{clientId}/signer/routing
Authorization: Basic base64(m2m_id:m2m_secret)
```
Response includes `dmzUrl` and `webhookUrl`.
**Step 2:** Use `@pymthouse/builder-sdk/signer/server` to proxy requests directly to the DMZ:
```ts theme={null}
import { createDirectSignerProxyHandler } from "@pymthouse/builder-sdk/signer/server";
import { createPmtHouseClientFromEnv } from "@pymthouse/builder-sdk/env";
const client = createPmtHouseClientFromEnv();
const routing = await client.getSignerRouting();
const handler = createDirectSignerProxyHandler({
client,
dmzUrl: routing.dmzUrl,
});
```
**Step 3:** Set up the go-livepeer identity webhook using `@livepeer/clearinghouse-identity-webhook`:
```ts theme={null}
import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol";
import { createLegacyWebhookConfigFromEnv } from "@livepeer/clearinghouse-identity-webhook/legacy-env";
export async function POST(request: Request) {
return handleAuthorize(request, createLegacyWebhookConfigFromEnv(process.env));
}
```
See [Signer routing](/integration/signer-routing) for the full setup guide.
`POST /api/signer/device/exchange` is **not** removed — it is an active SDK helper for device token → signer JWT exchange. Only the `generate-live-payment`, `sign-orchestrator-info`, `sign-byoc-job`, and `discover-orchestrators` proxy routes are gone.
***
### Synchronous signed-ticket ingest removed
**Affected route:**
| Route | Status |
| --------------------------------------------------- | ---------- |
| `POST /api/v1/apps/{clientId}/usage/signed-tickets` | `410 Gone` |
### Why it was removed
Synchronous HTTP ingest on the signing hot path added latency and created a PymtHouse-side bottleneck. Production metering is now **asynchronous**: go-livepeer emits `create_signed_ticket` events to Kafka (`livepeer-gateway-events`); the OpenMeter collector consumes Kafka and writes CloudEvents to OpenMeter/Konnect.
### Migration
**No direct replacement for synchronous ingest from your backend.** Metering is handled by the go-livepeer DMZ and Kafka collector automatically when signing requests are processed through the DMZ.
**Diagnostic-only ingest** remains available at:
```http theme={null}
POST /api/v1/ingest/events
Authorization: Bearer INGEST_SHARED_SECRET
```
This endpoint is for monitoring and diagnostics only — it does **not** write authoritative billing usage to OpenMeter.
To verify usage is flowing, query the Usage API after signing requests:
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage?groupBy=pipeline_model" | jq .
```
***
### Subscription CRUD removed (v0.2.x)
**Affected routes:**
| Route | Status | Replacement |
| ------------------------------ | -------------------------------------- | -------------------------------------------------------- |
| `POST /api/v1/subscriptions` | `410 Gone` | Use `POST .../plans` + OpenMeter checkout |
| `DELETE /api/v1/subscriptions` | `410 Gone` | Use `DELETE .../plans?planId=` |
| `GET /api/v1/subscriptions` | Deprecated (returns legacy cache rows) | Use `GET .../plans` or `GET .../users/{id}/subscription` |
Manage subscriptions through the plans CRUD API (dashboard session) or the OpenMeter billing checkout flow. See [Plans](/integration/plans) and [Allowances](/integration/allowances).
***
### Deprecated `credits` alias
**Affected routes:**
| Route | Status | Replacement |
| ----------------------------- | ------------------- | -------------------------------- |
| `GET .../users/{id}/credits` | Deprecated alias | `GET .../users/{id}/allowances` |
| `POST .../users/{id}/credits` | Removed / redirects | `POST .../users/{id}/allowances` |
The `credits` endpoints re-export the `allowances` endpoints. The `POST` route has been removed from PymtHouse. Update all references:
```diff theme={null}
- GET /api/v1/apps/{clientId}/users/{externalUserId}/credits
+ GET /api/v1/apps/{clientId}/users/{externalUserId}/allowances
- POST /api/v1/apps/{clientId}/users/{externalUserId}/credits
+ POST /api/v1/apps/{clientId}/users/{externalUserId}/allowances
```
SDK:
```diff theme={null}
- await client.getUserCredits(externalUserId)
+ await client.getUserAllowances(externalUserId)
- await client.grantUserCredits(externalUserId, input)
+ await client.grantUserAllowance(externalUserId, input)
```
***
### App manifest routes deprecated
**Affected routes:**
| Route | Status |
| -------------------------------------- | ---------------------------------------- |
| `GET /api/v1/apps/{clientId}/manifest` | Deprecated — returns fail-open stub only |
| `PUT /api/v1/apps/{clientId}/manifest` | Deprecated |
The manifest API was used to configure discoverable pipeline/model combinations (subtractive exclusions). The signing hot path no longer enforces capability restrictions; `GET .../manifest` returns a fail-open stub (`capabilities: []`) rather than a resolved list. Manage capability exclusions through the **Plans UI** instead.
**SDK methods deprecated:**
| Method | Replacement |
| ---------------------------------- | ----------- |
| `getAppManifest({ ifNoneMatch? })` | Plans UI |
| `parseAppManifestResponse(raw)` | N/A |
| `computeManifestRevision(parsed)` | N/A |
***
### Deprecated discovery profiles
**Affected routes:**
| Route | Status |
| -------------------------------------------- | -------------------------------------- |
| `GET/POST .../discovery-profiles` | Legacy — still functional, not removed |
| `GET/PUT/DELETE .../discovery-profiles/{id}` | Legacy — still functional, not removed |
Discovery profiles are a legacy mechanism for expressing pipeline/model capability sets on plans. They remain functional for backward compatibility but new integrations should use the **Plans UI** for network capability management.
***
## Summary table
| Route | Status | Replacement |
| ----------------------------------------------- | ---------------- | ---------------------------------------------- |
| `GET /api/v1/auth/validate` | Removed | `POST /api/v1/auth/validate` with `{ key }` |
| `GET/POST/DELETE /api/v1/apps/{clientId}/keys` | Replaced | `…/users/{externalUserId}/keys` |
| `POST .../auth/api-key/signer-session` | Replaced | `POST .../oidc/token` (RFC 8693) |
| `POST .../auth/api-key/token` | Replaced | `POST .../oidc/token` (RFC 8693) |
| `GET /api/v1/apps/{clientId}/usage*` | Deprecated alias | `GET /api/v1/builder/apps/{clientId}/usage*` |
| `GET /api/v1/apps/{clientId}/discovery/pricing` | Removed | `GET .../signer/routing` |
| `POST /api/signer/generate-live-payment` | `410 Gone` | `createDirectSignerProxyHandler` + DMZ |
| `POST /api/signer/sign-orchestrator-info` | `410 Gone` | Direct DMZ |
| `POST /api/signer/sign-byoc-job` | `410 Gone` | Direct DMZ |
| `GET /api/signer/discover-orchestrators` | `410 Gone` | Plans UI |
| `POST .../usage/signed-tickets` | `410 Gone` | Kafka async metering (no replacement) |
| `POST /api/v1/subscriptions` | `410 Gone` | Plans CRUD + checkout |
| `DELETE /api/v1/subscriptions` | `410 Gone` | Plans CRUD |
| `POST .../users/{id}/credits` | Removed | `POST .../users/{id}/allowances` |
| `GET .../users/{id}/credits` | Deprecated alias | `GET .../users/{id}/allowances` |
| `getUserCredits()` SDK method | Deprecated | `getUserAllowances()` |
| `grantUserCredits()` SDK method | Deprecated | `grantUserAllowance()` |
| `ingestSignedTicket()` SDK method | Legacy | No direct replacement (Kafka handles metering) |
| `GET .../manifest` | Deprecated | Plans UI |
| `PUT .../manifest` | Deprecated | Plans UI |
| `getAppManifest()` SDK method | Deprecated | Plans UI |
# Device flow
Source: https://docs.pymthouse.com/integration/device-flow
Authenticate users from CLI tools, set-top boxes, and limited-input devices using RFC 8628 device authorization with optional third-party initiate login.
The **device authorization grant** (RFC 8628) lets a CLI, SDK, or any input-constrained device authenticate a user without requiring a browser on the same machine. The device displays a short code or URL; the user completes login on any browser they have available.
PymtHouse extends the standard RFC 8628 flow with optional **third-party initiate login** (OIDC Core §4): unauthenticated users can be redirected to your own login UI instead of PymtHouse's default login page.
## When to use device flow
Use this pattern when:
* Your integration runs as a CLI tool, background daemon, or terminal app.
* The user's device has limited or no browser access at the point of authentication.
* You want to bridge an existing IdP session (NaaP / Option B) into PymtHouse without requiring a second login.
For browser-based authentication, use [Interactive login](/integration/interactive-login) instead.
## Prerequisites
* A **public** OIDC client (`app_…`) with the `device_code` grant enabled.
* If using third-party initiate: `device_third_party_initiate_login` must be enabled on the public client, and an `initiate_login_uri` must be registered.
* If completing the device grant from your backend (Option B): a **confidential M2M client** (`m2m_…`) with `users:token` or `device:approve` scope.
## The full flow
```mermaid theme={null}
sequenceDiagram
participant Device as CLI / device
participant User as Browser (any device)
participant RP as Your backend (Option B only)
participant OP as PymtHouse
Device->>OP: 1. POST /oidc/device/auth
OP-->>Device: device_code, user_code, verification_uri_complete
Device->>User: Display code or URL
User->>OP: 2. Open verification_uri_complete
OP-->>User: 302 → initiate_login_uri (if third-party initiate enabled)
User->>RP: 3. Login at your IdP
RP->>OP: 4. RFC 8693 token exchange (binds device grant)
OP-->>RP: 200 bound
Device->>OP: 5. Poll POST /oidc/token (grant_type=device_code)
OP-->>Device: access_token, id_token
```
## Step 1 — Request a device code
```bash theme={null}
curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=${PUBLIC_CLIENT_ID}" \
-d "scope=openid" \
"${BASE_URL}/api/v1/oidc/device/auth"
```
**Response:**
```json theme={null}
{
"device_code": "Ag_EE…long…",
"user_code": "ABCD-EFGH",
"verification_uri": "https://pymthouse.com/oidc/device",
"verification_uri_complete": "https://pymthouse.com/oidc/device?user_code=ABCD-EFGH&client_id=app_…&iss=https%3A%2F%2F…",
"expires_in": 600,
"interval": 5
}
```
**`verification_uri` vs `verification_uri_complete`:**
| Field | Format | Use |
| --------------------------- | -------------------------------------------------------- | ------------------------------------------------- |
| `verification_uri` | Short URL, easy to type | Display when the user will type the code manually |
| `verification_uri_complete` | Full URL with `user_code`, `client_id`, `iss` pre-filled | Use for clickable links, QR codes, and deep-links |
## Step 2 — Display the code to the user
Show either the short URL with the `user_code` to type in, or present the `verification_uri_complete` as a clickable link or QR code.
```
Open https://pymthouse.com/oidc/device
and enter the code: ABCD-EFGH
Or scan the QR code / click this link:
https://pymthouse.com/oidc/device?user_code=ABCD-EFGH&…
```
## Step 3 — Poll for the token
Begin polling the token endpoint at the `interval` specified in the response. Do **not** poll faster than the interval — the server will respond with `slow_down` and increase the interval.
```bash theme={null}
while true; do
RESPONSE=$(curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=${DEVICE_CODE}" \
-d "client_id=${PUBLIC_CLIENT_ID}" \
"${BASE_URL}/api/v1/oidc/token")
ERROR=$(echo "${RESPONSE}" | jq -r '.error // empty')
if [ -z "${ERROR}" ]; then
echo "Authenticated!"
ACCESS_TOKEN=$(echo "${RESPONSE}" | jq -r '.access_token')
break
elif [ "${ERROR}" = "authorization_pending" ]; then
sleep 5
elif [ "${ERROR}" = "slow_down" ]; then
sleep 10
else
echo "Error: ${ERROR}"
break
fi
done
```
**Poll response codes:**
| `error` value | Meaning | Action |
| ----------------------- | --------------------------------- | ----------------------------------------------------- |
| `authorization_pending` | User has not yet completed login. | Wait and retry after `interval` seconds. |
| `slow_down` | You are polling too fast. | Increase the polling interval by at least 5 seconds. |
| `access_denied` | User denied the request. | Abort and surface the error. |
| `expired_token` | `device_code` has expired. | Restart the flow with a new device code request. |
| *(no error)* | Authentication complete. | Read `access_token` and `id_token` from the response. |
## Third-party initiate login (Option B / NaaP)
When `device_third_party_initiate_login` is enabled on the public client, unauthenticated users who open `verification_uri_complete` are redirected to your registered **`initiate_login_uri`** with:
```
GET https://yourapp.example/login/initiate
?iss=https%3A%2F%2Fpymthouse.com%2Fapi%2Fv1%2Foidc
&target_link_uri=https%3A%2F%2Fpymthouse.com%2Fapi%2Fv1%2Foidc%2Fdevice%3F…
&login_hint=
```
Your `initiate_login_uri` endpoint must:
1. **Validate `iss`** against your discovery document. Reject if it does not equal the expected issuer.
2. **Validate `target_link_uri`** — ensure it points to your PymtHouse origin and the `/oidc/device` path. Reject open redirects.
3. Complete the user's login at your own IdP.
4. Call `POST {issuer}/token` with an RFC 8693 token exchange to bind the device grant (see [Token exchange — device completion](/integration/token-exchange#device-completion)).
5. Show an approval confirmation page or redirect to `target_link_uri`.
The `initiate_login_uri` is loaded from the database for the `client_id`. The endpoint does **not** accept an arbitrary `initiate_login_uri` query parameter. This is intentional to prevent open-redirect attacks.
### Security requirements for your initiate login endpoint
* Use **HTTPS** in production. HTTP on `localhost` is permitted for local development only.
* Apply **CSRF protection** on any form that triggers your IdP login.
* The OP sets a short-lived per-client cookie so that a failed relying-party round-trip does not loop redirects indefinitely.
## Implied consent
When the user opens `verification_uri_complete` with a pre-filled `user_code`, PymtHouse skips the secondary authorization confirmation step after a successful lookup — the user already authenticated at your site. This improves UX by avoiding double-confirmation for users who completed login through the third-party initiate flow.
## Key design decisions
1. **`verification_uri_complete` carries `iss` alongside `user_code`.** Including the issuer in the URL allows the device verification page to validate that the `user_code` was issued by this deployment and not by a phishing URL. It also enables the third-party initiate redirect to carry context without requiring a server-side lookup.
2. **Redirect target is database-loaded, not URL-provided.** Accepting an arbitrary `initiate_login_uri` query parameter would allow a crafted device-auth link to redirect any user to an attacker-controlled URL. Loading the URI from the client registration prevents this class of open-redirect vulnerability.
3. **Third-party device login must be explicitly opt-in.** Defaulting to a redirect to the relying party would silently change the user experience for every device session. Requiring explicit opt-in means the impact of enabling the feature is deliberate and visible.
4. **RFC 8628 polling semantics are enforced server-side.** `slow_down` responses enforce back-off at the server rather than trusting clients to self-regulate. This protects the token endpoint under high load or misbehaving clients.
## Implementation tasks
* Validate that your device code flow handler parses and acts on every polling error code — particularly `slow_down` and `expired_token`.
* If using third-party initiate, register `initiate_login_uri` as an HTTPS URL; verify it strictly matches `iss` from discovery before trusting any payload.
* Enable CSRF protection on your `initiate_login_uri` handler.
* Do not display `device_code` to the user — it is a server-side opaque token. Display only `user_code` and the verification URLs.
* After binding the device grant with RFC 8693 (Option B), verify the CLI poll returns a successful token before showing the "device approved" page to the browser.
# Integration patterns
Source: https://docs.pymthouse.com/integration/integration-patterns
Three complete integration recipes — SaaS app with per-user billing, CLI with device login, and metered billing setup. Pick the one that matches your architecture.
Every PymtHouse integration is some combination of three primitives: **machine auth** (your backend talks to PymtHouse), **user provisioning** (you register end-users), and **JWT issuance** (you get signed tokens that gate AI requests). What varies is how users get into the system and how you charge them.
## Setup: install the SDK and configure env vars
All TypeScript examples below use the official Builder SDK. Install it first:
```bash theme={null}
npm install @pymthouse/builder-sdk
# Node ≥ 20 required
```
Set these four required environment variables in your backend. **Never expose the M2M credentials client-side.** The fifth variable is optional and applies to local development only.
```bash theme={null}
PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc
PYMTHOUSE_PUBLIC_CLIENT_ID=app_… # starts with app_
PYMTHOUSE_M2M_CLIENT_ID=m2m_… # starts with m2m_
PYMTHOUSE_M2M_CLIENT_SECRET=pmth_cs_…
# Local dev only — set to 1 when issuer uses http://
PYMTHOUSE_ALLOW_INSECURE_HTTP=
```
See the [Builder SDK reference](/integration/sdk) for the full API, subpath exports, and method signatures.
Then construct the client once per request lifecycle (server-side only):
```ts theme={null}
// lib/pymthouse.ts
import "server-only";
import { PmtHouseClient } from "@pymthouse/builder-sdk";
export function createClient() {
return new PmtHouseClient({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
});
}
```
`createPmtHouseClientFromEnv` from `@pymthouse/builder-sdk/env` reads these same variables automatically but may not handle `PYMTHOUSE_ALLOW_INSECURE_HTTP`. Use the explicit constructor above for local development to ensure `allowInsecureHttp` is set correctly.
***
Pick the pattern that matches your architecture:
* [Pattern 1: SaaS app — per-user sessions](#pattern-1-saas-app-per-user-sessions) — Users log into your web app; each user gets their own metered allowance.
* [Pattern 2: CLI tool — device flow](#pattern-2-cli-tool-device-flow) — Users authenticate your CLI with their browser; no password prompts.
* [Pattern 3: Metered billing — charge users](#pattern-3-metered-billing-charge-users) — Collect payment, set usage limits, and sell plan upgrades.
***
Pattern 1: SaaS app — per-user sessions
**When to use:** Your app has logged-in users. You want to issue a fresh JWT for each user session and track usage per user for billing.
**What you'll have after:** Every API request carries a user-scoped JWT. Usage is attributed to individual users. You can show per-user billing dashboards.
### Architecture
```
User logs in → your backend authenticates → POST /users (upsert) → POST /users/{id}/token → JWT → AI request
```
### Step 1: Mint a user JWT (lazy-provision pattern)
Use `ensureUserAndMintToken` — it attempts the mint first and auto-provisions the user on `404 / not_found`:
```ts theme={null}
// lib/pymthouse.ts
import "server-only";
import { PmtHouseClient } from "@pymthouse/builder-sdk";
export function createClient() {
return new PmtHouseClient({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
});
}
/**
* Mint a user access JWT. Auto-provisions the user on first call.
* scope defaults to "sign:job" server-side.
*/
export async function mintUserToken(externalUserId: string): Promise {
const client = createClient();
const result = await client.ensureUserAndMintToken({ externalUserId });
return result.access_token;
}
```
The lazy pattern means you never need a separate sign-up hook — users are provisioned on their first request. If you prefer eager provisioning (e.g. to pre-allocate balance), call `upsertAppUser` explicitly on sign-up:
```ts theme={null}
// Eager provisioning (optional — e.g. on account creation)
await client.upsertAppUser({ externalUserId: userId });
// email and status are optional; PymtHouse accepts externalUserId alone
```
```bash theme={null}
# curl equivalent — eager provision
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d "{\"externalUserId\": \"${USER_ID}\"}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users"
# curl equivalent — mint JWT (scope defaults to sign:job)
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{}' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users/${USER_ID}/token" \
| jq -r '.access_token'
```
### Step 2: Gate requests on balance
Check the user's balance before allowing the request. This prevents over-billing and gives you a clean error surface.
```ts theme={null}
export async function assertUserHasBalance(userId: string) {
const client = createClient();
const balance = await client.getUsageBalance(userId);
if (!balance.hasAccess) {
throw new Error("Insufficient balance. Please upgrade your plan.");
}
}
```
### Step 3: Show usage in your dashboard
```ts theme={null}
// Per-user usage breakdown for a specific user
const client = createClient();
const usage = await client.fetchUsageForExternalUser({
externalUserId: "user-123",
startDate: "2026-06-01T00:00:00.000Z",
endDate: "2026-06-30T23:59:59.999Z",
});
console.log("Requests:", usage.requestCount);
console.log("Fee (wei):", usage.feeWei);
// App-wide breakdown across all users
const appUsage = await client.getUsage({ groupBy: "user" });
```
### Next.js example: API route
```ts theme={null}
// app/api/sign/route.ts
import "server-only";
import { PmtHouseError } from "@pymthouse/builder-sdk";
import { createClient, mintUserToken } from "@/lib/pymthouse";
export async function POST(req: Request) {
const { userId } = await getSession(req); // your auth
// Check balance before signing
const client = createClient();
const balance = await client.getUsageBalance(userId);
if (!balance.hasAccess) {
return Response.json({ error: "Insufficient balance" }, { status: 402 });
}
// Mint user JWT (auto-provisions on first call)
const accessToken = await mintUserToken(userId);
// Forward to AI service with the JWT
const result = await callAiService(accessToken);
return Response.json(result);
}
```
### Signer sessions (Option A — clearinghouse direct mint)
If your integration talks directly to the remote signer DMZ rather than a gateway, use `mintUserSignerToken` from `@pymthouse/builder-sdk/signer/server`. This mints a short-lived signer JWT and returns the user's balance in one call:
```ts theme={null}
import "server-only";
import { mintUserSignerToken, createSignerTokenManager } from "@pymthouse/builder-sdk/signer/server";
// One-shot mint (no caching)
const signerToken = await mintUserSignerToken({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
externalUserId: "user-123",
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
});
// signerToken.jwt — use as Bearer token at the DMZ
// signerToken.balanceUsdMicros — current balance
// signerToken.expiresAt — Unix timestamp
// With TTL caching across requests (recommended for production)
const tokenManager = createSignerTokenManager({
mint: async (publicClientId, externalUserId) =>
mintUserSignerToken({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
externalUserId,
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
}),
});
```
See [Signer routing](/integration/signer-routing) for the DMZ URL, webhook config, and identity webhook setup.
***
Pattern 2: CLI tool — device flow
**When to use:** You're building a CLI, daemon, or any tool that runs in a terminal. Users should authenticate with their browser, not type in API keys.
**What you'll have after:** Users run `my-cli login`, open a URL in their browser, and the CLI receives a signed session token without ever touching credentials.
### Architecture
```
CLI starts device flow → user opens URL → user logs in at your site → your backend mints JWT + binds grant → CLI polls and receives token
```
### Step 1: CLI requests a device code
```bash theme={null}
RESPONSE=$(curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=${PUBLIC_CLIENT_ID}" \
-d "scope=openid" \
"${BASE_URL}/api/v1/oidc/device/auth")
DEVICE_CODE=$(echo $RESPONSE | jq -r '.device_code')
USER_CODE=$(echo $RESPONSE | jq -r '.user_code')
VERIFY_URL=$(echo $RESPONSE | jq -r '.verification_uri_complete')
echo ""
echo "Open this URL in your browser to log in:"
echo " ${VERIFY_URL}"
echo ""
echo "Or go to ${BASE_URL}/oidc/device and enter: ${USER_CODE}"
```
### Step 2: CLI polls for the result
Start polling immediately and wait for the user to complete login:
```bash theme={null}
EXPIRES_IN=$(echo "$RESPONSE" | jq -r '.expires_in // 600')
DEADLINE=$(( $(date +%s) + EXPIRES_IN ))
while true; do
if [ "$(date +%s)" -ge "$DEADLINE" ]; then
echo "Device code expired. Run login again."
exit 1
fi
POLL=$(curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=${DEVICE_CODE}" \
-d "client_id=${PUBLIC_CLIENT_ID}" \
"${BASE_URL}/api/v1/oidc/token")
ERROR=$(echo "$POLL" | jq -r '.error // empty')
if [ -z "$ERROR" ]; then
ACCESS_TOKEN=$(echo "$POLL" | jq -r '.access_token // empty')
if [ -n "$ACCESS_TOKEN" ]; then
echo "✓ Logged in"
break
fi
sleep 5
elif [ "$ERROR" = "authorization_pending" ]; then
sleep 5
elif [ "$ERROR" = "slow_down" ]; then
sleep 10
else
echo "Login failed: $ERROR"
exit 1
fi
done
```
```ts theme={null}
// SDK equivalent (TypeScript CLI)
import { pollDeviceToken } from "@pymthouse/builder-sdk/device";
const token = await pollDeviceToken({
tokenEndpoint: `${process.env.PYMTHOUSE_ISSUER_URL}/token`,
deviceCode,
clientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
interval: 5,
});
```
### Step 3: Your backend binds the device grant (Option B)
When a user authenticates at your login page, your backend binds the pending device grant so the CLI poll receives the token. `approveDeviceLogin` handles upsert + mint + RFC 8693 exchange in one call:
```ts theme={null}
// Your login callback handler (server-side)
import { createClient } from "@/lib/pymthouse"; // your factory from Setup above
export async function handleDeviceApproval(userId: string, userCode: string) {
const client = createClient();
await client.approveDeviceLogin({
externalUserId: userId,
userCode, // the ABCD-EFGH code the CLI received
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
});
// The CLI's next poll will now return access_token
}
```
```bash theme={null}
# curl equivalent of what approveDeviceLogin does under the hood
# (two-step: mint JWT then bind grant)
# Step A: mint user JWT
USER_JWT=$(curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{ "scope": "sign:job" }' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users/${USER_ID}/token" \
| jq -r '.access_token')
# Step B: bind the device grant
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=${USER_JWT}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "resource=urn:pmth:device_code:ABCD-EFGH" \
"${BASE_URL}/api/v1/oidc/token"
```
The `user_code` (e.g. `ABCD-EFGH`) is what the CLI receives and shows to the user. The `device_code` is a server-side opaque token — never display it. Pass `user_code` in the `resource` parameter when binding the grant.
### Requirements checklist
Before this works end-to-end:
* [ ] Public client (`app_…`) has `device_code` grant enabled
* [ ] `device_third_party_initiate_login` enabled on the public client
* [ ] `initiate_login_uri` registered to your login handler's HTTPS endpoint
* [ ] M2M client has `users:token` or `device:approve` scope
***
Pattern 3: Metered billing — charge users
**When to use:** You want to charge users based on their AI usage. You offer a free tier, paid plans, and want to collect payment via Stripe.
**What you'll have after:** Users auto-enroll in a free Starter plan. When they hit the limit, you can offer a paid plan and take payment via Stripe Connect.
### How billing works
```
User makes AI request → signed ticket emitted → Kafka → OpenMeter → usage meter updated
↓
balance decrements automatically
↓
when plan limit reached: hasAccess = false
```
Billing is **async and metering-based** — you do not need to hook into each individual request. The signer records usage automatically.
### Step 1: Every new user gets a free Starter plan
No action needed. PymtHouse auto-subscribes new users to the Starter plan on first provision. The Starter allowance is `$5.00` by default.
To change the Starter allowance for all new users:
```bash theme={null}
curl -sS -X PUT \
-b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
-d '{"includedUsdMicros":"10000000"}' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/starter-plan"
```
```ts theme={null}
// Check a user's current balance
const balance = await pmth.getUsageBalance("user-123");
// { hasAccess: true, balanceUsdMicros: "5000000", consumedUsdMicros: "0" }
```
### Step 2: Create a paid plan
Create a plan with a monthly subscription fee and included usage allowance. Done from the dashboard or via session-authenticated API.
```bash theme={null}
curl -sS -X POST \
-b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro",
"type": "subscription",
"priceAmount": "49.00",
"priceCurrency": "USD",
"includedUsdMicros": "50000000",
"billingCycle": "monthly",
"status": "active"
}' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/plans"
```
Publishing with `status: active` syncs the plan to OpenMeter automatically.
### Step 3: Connect Stripe for payment collection
To collect payment from users, connect a Stripe account:
```bash theme={null}
# Start Stripe Connect flow (returns a hosted Stripe URL)
curl -sS -X POST \
-b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/billing/stripe/connect"
# → { "url": "https://connect.stripe.com/..." }
```
Complete the Stripe onboarding at the returned URL. Once `charges_enabled` and `details_submitted` are true, paid plan checkout is unlocked.
Point a Stripe webhook at `POST /webhooks/stripe` with `STRIPE_WEBHOOK_SECRET` to keep Connect status in sync automatically.
### Step 4: Let users upgrade and pay
Trigger a checkout session when a user wants to upgrade:
```bash theme={null}
curl -sS -X POST \
-b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
-d "{\"planId\": \"${PLAN_ID}\", \"externalUserId\": \"user-123\", \"successUrl\": \"https://yourapp.com/billing/success\"}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/billing/checkout"
# → { "checkoutUrl": "https://checkout.stripe.com/..." }
```
Redirect the user to `checkoutUrl`. After payment, their subscription in OpenMeter is updated automatically.
### Step 5: Manual credit top-ups
Grant additional balance outside of the plan subscription — useful for support credits, trials, or promotions:
```ts theme={null}
await pmth.grantUserAllowance("user-123", {
amountUsdMicros: "5000000", // $5.00
source: "manual",
});
```
```bash theme={null}
curl -sS -X POST \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{"amountUsdMicros":"5000000","source":"manual"}' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users/user-123/allowances"
```
### Step 6: Show users their billing summary
Pull the full billing snapshot for your billing dashboard page:
```ts theme={null}
// App-level billing summary (your dashboard)
const billing = await fetch(`${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/billing`, {
headers: { Authorization: `Basic ${btoa(`${M2M_ID}:${M2M_SECRET}`)}` }
}).then(r => r.json());
console.log("Total fee (USD):", Number(billing.cycle.usage.networkFeeUsdMicros) / 1e6);
console.log("Overage:", billing.cycle.overage.overageWei);
```
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/billing" | jq '.cycle.usage'
```
### Activation gate modes
PymtHouse can automatically block new user provisioning or paid plan checkouts until Stripe Connect is ready. Set `ACTIVATION_GATE_MODE` in your environment:
| Mode | Effect |
| ----------------- | ----------------------------------------------------------- |
| `off` (default) | No blocking — expose activation status via API only |
| `log` | Log what would be blocked, but allow everything |
| `enforce_revenue` | Block paid plan checkout/change without Connect |
| `enforce` | Also block new user provisioning when owner wallet is empty |
***
## Related guides
* [User management](/integration/user-management) — full CRUD for provisioned users
* [Allowances](/integration/allowances) — balance reads, top-ups, and grant history
* [Billing summary](/integration/billing) — response field reference and USD micro handling
* [Plans](/integration/plans) — plan creation, OpenMeter sync, and phase-out
* [Device flow](/integration/device-flow) — full device authorization reference
* [Token exchange](/integration/token-exchange) — RFC 8693 device binding and signer session exchange
# Interactive login
Source: https://docs.pymthouse.com/integration/interactive-login
Authenticate end-users with PymtHouse using the OAuth 2.0 authorization code flow with PKCE.
Interactive login uses the **OAuth 2.0 authorization code flow** (RFC 6749 §4.1) to authenticate an end-user in a browser. Public clients must use **PKCE** (Proof Key for Code Exchange, RFC 7636). Confidential server-side clients must authenticate at the token endpoint using their client secret.
## When to use interactive login
Use this pattern when you need:
* A user to authenticate directly through PymtHouse's login UI.
* An ID token or access token tied to the authenticated user session.
* RP-initiated logout (RFC 6749, OIDC Core) to return users to your app after sign-out.
For headless, CLI, or limited-input device scenarios, use [Device flow](/integration/device-flow) instead.
## Prerequisites
* A registered **public** OIDC client (`app_…`) with `authorization_code` grant enabled.
* A registered `redirect_uri` for your application.
* For public clients: PKCE is **required**.
* For confidential server-side clients: client secret is **required** at the token endpoint.
Read endpoints from OIDC discovery at `{issuer}/.well-known/openid-configuration` rather than hard-coding paths.
## The authorization code flow
```mermaid theme={null}
sequenceDiagram
participant User as Browser / user agent
participant RP as Your app (relying party)
participant OP as PymtHouse (OIDC provider)
RP->>OP: 1. Redirect to authorization endpoint
OP->>User: Login UI
User->>OP: Credentials / consent
OP-->>RP: 2. Redirect back with code + state
RP->>OP: 3. POST /token — exchange code for tokens
OP-->>RP: access_token, id_token, refresh_token
```
## Step 1 — Redirect to the authorization endpoint
Construct the authorization URL with the required parameters and redirect the user's browser.
```
GET {issuer}/auth
?response_type=code
&client_id=app_yourClientId
&redirect_uri=https%3A%2F%2Fyourapp.example%2Fcallback
&scope=openid
&state=
&code_challenge=
&code_challenge_method=S256
```
**PKCE parameters** (required for public clients):
| Parameter | Value |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `code_verifier` | Cryptographically random string, 43–128 chars, URL-safe characters. Store in session. |
| `code_challenge` | `BASE64URL(SHA256(ASCII(code_verifier)))` |
| `code_challenge_method` | `S256` |
**`state` parameter:** Generate a random, opaque value per authorization request and store it in the user's session. Verify it exactly on the callback to prevent CSRF attacks (RFC 6749 §10.12).
**Scopes.** Request only scopes registered on the client. Common values:
| Scope | Purpose |
| --------- | -------------------------------- |
| `openid` | Required to receive an ID token. |
| `profile` | Basic identity claims. |
| `email` | Email address claim. |
### Example: generate PKCE in Node.js
```typescript theme={null}
import crypto from 'crypto';
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
const { verifier, challenge } = generatePKCE();
// Store `verifier` in the user session.
// Pass `challenge` in the authorization request.
```
## Step 2 — Handle the callback
PymtHouse redirects the user agent back to your `redirect_uri` with a short-lived authorization `code` and the `state` value you sent.
```
GET https://yourapp.example/callback
?code=
&state=
```
Validate the response before proceeding:
1. **Verify `state`** matches the value you stored in the session. Reject if not.
2. **Verify no `error` parameter** is present. Surface the `error_description` to your logging system if present.
## Step 3 — Exchange the code for tokens
```bash theme={null}
curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=${AUTHORIZATION_CODE}" \
-d "redirect_uri=https://yourapp.example/callback" \
-d "client_id=${PUBLIC_CLIENT_ID}" \
-d "code_verifier=${CODE_VERIFIER}" \
"${BASE_URL}/api/v1/oidc/token"
```
For **confidential server-side clients**, add `client_secret` to the body (or use HTTP Basic auth with `client_id:client_secret` — RFC 7617). Do not send `code_verifier` if you are using a confidential client without PKCE.
**Successful response:**
```json theme={null}
{
"access_token": "eyJ...",
"id_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "pmth_rt_..."
}
```
## Token validation
Validate the `id_token` before creating a user session:
1. Verify the signature using the JWKS published at `{issuer}/jwks`.
2. Verify `iss` matches your configured issuer URL.
3. Verify `aud` contains your `client_id`.
4. Verify `exp` has not passed.
5. Verify `nonce` (if you sent one in the authorization request) to prevent token replay.
Most OIDC client libraries (e.g., `openid-client` for Node.js) handle this automatically when you pass the issuer and client id during initialization.
## Refresh tokens
Refresh tokens (`pmth_rt_…`) allow your app to obtain new access tokens without re-authenticating the user. Exchange a refresh token at the token endpoint:
```bash theme={null}
curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=${REFRESH_TOKEN}" \
-d "client_id=${PUBLIC_CLIENT_ID}" \
"${BASE_URL}/api/v1/oidc/token"
```
Treat refresh tokens as high-value secrets: store them server-side, rotate them on use (PymtHouse uses rotation by default), and revoke them on explicit logout.
## RP-initiated logout
When a user signs out of your app, also terminate the PymtHouse session. Use the `end_session_endpoint` from discovery:
```
GET {issuer}/session/end
?post_logout_redirect_uri=https%3A%2F%2Fyourapp.example%2Flogout-success
&id_token_hint=
&state=
```
`post_logout_redirect_uri` must be pre-registered on the client.
## Error handling
| `error` value | Meaning | Action |
| --------------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
| `access_denied` | User denied consent. | Show a friendly message; do not retry automatically. |
| `invalid_request` | Malformed authorization request. | Log the `error_description`; fix the client-side parameter. |
| `invalid_grant` | Code already used, expired, or `redirect_uri` mismatch. | Restart the authorization flow. |
| `unauthorized_client` | The client is not authorized for this grant type. | Check client registration; contact the platform admin. |
## Key design decisions
1. **PKCE is mandatory for public clients.** Without PKCE, authorization codes intercepted by a malicious app or redirect-hijack could be exchanged for tokens by an attacker. RFC 7636 closes this by binding the code to a secret the attacker cannot know.
2. **`state` prevents CSRF.** A missing or incorrectly validated `state` parameter has historically been the most common CSRF vector in OAuth implementations (RFC 6749 §10.12). Treat validation as non-negotiable.
3. **`end_session_endpoint` for single sign-out.** Closing only your local session while leaving the OP session open allows another tab or app to silently re-authenticate the user. RP-initiated logout ensures the OP session terminates alongside your app session.
## Implementation tasks
* Use an established OIDC client library (e.g., `openid-client`, `next-auth`, Passport.js `openid-connect`) rather than implementing the flow manually. Libraries handle signature verification, PKCE, and token validation correctly by default.
* Register every `redirect_uri` your app uses — including localhost for development — in the client configuration. Unregistered URIs are rejected by the token endpoint with `invalid_grant`.
* Store the `code_verifier` in a server-side session or a secure, `HttpOnly` cookie, never in the URL or `localStorage`.
* Implement the full `state` round-trip: generate before redirect, verify on callback, invalidate after single use.
* Enable refresh-token rotation and revoke refresh tokens on explicit logout to limit the impact of token theft.
# Machine access
Source: https://docs.pymthouse.com/integration/machine-access
Authenticate a backend service with PymtHouse using the OAuth 2.0 client credentials grant or HTTP Basic auth.
Machine access is the authentication pattern for server-to-server calls: your backend presents its M2M client credentials directly to PymtHouse without user involvement. This follows the **OAuth 2.0 client credentials grant** (RFC 6749 §4.4) and the **HTTP Basic authentication scheme** (RFC 7617) for Builder API calls.
## When to use machine access
Use this pattern when your backend needs to:
* Provision or update users via the Builder API.
* Mint user-scoped JWTs for end-users already known to your system.
* Read aggregated usage data from the Usage API.
* Complete a device authorization on behalf of a user (RFC 8693 token exchange).
* Manage owner wallets, subscriptions, and billing state (see [Builder M2M Payments API](/integration/payments)).
**Usage API paths require HTTP Basic auth.** Bearer access tokens are rejected on `/api/v1/builder/apps/{clientId}/usage*` and the legacy `/api/v1/apps/{clientId}/usage*` aliases. Use HTTP Basic for all usage queries.
Do **not** use this pattern for interactive user login flows. For those, see [Interactive login](/integration/interactive-login) or [Device flow](/integration/device-flow).
## Prerequisites
* A confidential M2M client (`m2m_…` id and `pmth_cs_…` secret). See [Client model](/integration/client-model).
* The M2M client has been granted the scopes required by the endpoint you are calling.
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
```
## Option A: Client credentials grant
Exchange your credentials for a short-lived Bearer token, then use that token for subsequent API calls. This is the preferred pattern when making multiple API calls in the same request lifecycle, because it decouples token acquisition from the API call.
### 1. Obtain a machine token
The token endpoint is published in OIDC discovery under `token_endpoint`. For convenience it is stable at `{issuer}/token`.
```bash theme={null}
MACHINE_TOKEN=$(curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${M2M_ID}" \
-d "client_secret=${M2M_SECRET}" \
-d "scope=users:write users:token" \
"${BASE_URL}/api/v1/oidc/token" | jq -r '.access_token')
```
**Scope selection.** Request only the scopes your call sequence requires. The full list of available M2M scopes is in [Client model — M2M scopes](/integration/client-model#m2m-client-scopes).
**Token lifetime.** Machine tokens are short-lived. Cache and reuse the token for the duration of a request batch, then discard it. Do not persist machine tokens across application restarts — just acquire a new one.
### 2. Call the Builder API with Bearer auth
```bash theme={null}
curl -sS \
-H "Authorization: Bearer ${MACHINE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{ "externalUserId": "user-123", "email": "alice@example.com", "status": "active" }' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users"
```
## Option B: HTTP Basic auth
For single calls where acquiring a separate token adds unnecessary latency, pass the M2M credentials directly using HTTP Basic auth (RFC 7617). PymtHouse accepts Basic auth on all Builder API endpoints, including billing and usage routes.
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{ "externalUserId": "user-123", "email": "alice@example.com", "status": "active" }' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users"
```
The tenant boundary is enforced identically in both authentication modes: the `clientId` in the URL path must match the authenticated confidential client's associated app. A valid credential from a different app's M2M client returns `404`.
## Choosing between Bearer and Basic
| | Client credentials (Bearer) | HTTP Basic |
| ----------------------- | -------------------------------- | ---------------------------- |
| **Round trips** | 2 (token + API) | 1 |
| **Best for** | Batching multiple API calls | Single, isolated calls |
| **Credential exposure** | Token (short-lived) per API call | Raw credentials per API call |
| **Standard** | RFC 6749 §4.4 | RFC 7617 |
For high-throughput backends making many calls in sequence, acquire one token and reuse it for the batch. For low-frequency automations and operational scripts, Basic auth is simpler.
## Error responses
| Status | Condition |
| ------------------ | ------------------------------------------------------------------------------------- |
| `400 Bad Request` | Malformed token request body, unknown `grant_type`, or unsupported `scope`. |
| `401 Unauthorized` | Invalid `client_id`, wrong `client_secret`, or expired Bearer token. |
| `403 Forbidden` | Valid credentials, but the client does not have the required scope for this endpoint. |
| `404 Not Found` | Valid credentials, but the M2M client does not belong to the app in the URL path. |
## Rotating client secrets
Rotate M2M secrets through the app credentials endpoint in the developer dashboard or admin API. After rotation:
1. Update the secret in your backend's secret manager.
2. Redeploy or restart the service.
3. Do **not** rotate the public client secret — public clients must remain secretless.
## Key design decisions
1. **Basic auth is supported alongside Bearer.** Confidential server-to-server clients are common in automation tooling where adding a token exchange step adds operational friction. Supporting both modes simplifies bootstrapping and scripting without compromising security, since the credential type (M2M secret) carries the same privilege either way.
2. **Tenant boundary on path, not query parameter.** Enforcing `clientId` as a URL path segment rather than a query parameter makes the tenant scope visible and cache-key-safe. The route handler resolves the OAuth `client_id` to an internal record before any query, keeping the public API free of internal IDs.
3. **Short-lived machine tokens, not long-lived API keys.** Using the standard client credentials flow means PymtHouse does not need a separate API-key issuance system. Short lifetimes limit the blast radius of a compromised token without requiring explicit revocation infrastructure.
## Implementation tasks
* Store `M2M_ID` and `M2M_SECRET` in your backend secret manager (e.g., HashiCorp Vault, AWS Secrets Manager, Vercel environment variables). Do not commit them to source control.
* Implement a simple in-process token cache: acquire a machine token once per request batch, reuse it, and let it expire naturally rather than building explicit refresh logic.
* For HTTP Basic auth calls, ensure your HTTP client encodes the credentials correctly (`base64(client_id + ":" + client_secret)`). Most libraries handle this via a `auth` or `user`/`password` field.
* Test that Basic auth calls to a different app's `clientId` return `404`, not `403` — this verifies that the tenant boundary is enforced, not just that the credentials are valid.
# App manifest
Source: https://docs.pymthouse.com/integration/manifest
Read and update your app's network capability manifest — the set of pipeline/model combinations discoverable through PymtHouse. Supports ETag caching and SDK helpers.
The **app manifest** defines which pipeline/model combinations are discoverable for your app through the PymtHouse network. It is expressed as a **subtractive** allowlist: the full NaaP pipeline catalog minus any `excludedCapabilities` you configure is what integrators and end-users can discover.
**`GET .../manifest` behavior change:** The GET endpoint currently returns a **fail-open stub** (`capabilities: []`, `excludedCapabilities: []`, `manifestVersion: "empty"`) rather than the fully-resolved capability list. `PUT .../manifest` still resolves and returns the full manifest after writing exclusions. The manifest is not enforced on the signing hot path — direct DMZ signing does not consult it.
## Endpoints
```http theme={null}
GET /api/v1/apps/{clientId}/manifest
PUT /api/v1/apps/{clientId}/manifest
```
### Authentication
| Method | Auth |
| ------ | -------------------------------------------- |
| `GET` | M2M Basic auth or provider dashboard session |
| `PUT` | Provider dashboard session with edit rights |
***
## GET manifest
```http theme={null}
GET /api/v1/apps/{clientId}/manifest
Authorization: Basic base64(m2m_id:m2m_secret)
```
Currently returns a **fail-open stub**. The signing hot path does not enforce capability restrictions; when the enforcement cache was removed, the GET stub was introduced to avoid breaking integrators that depend on the empty-capabilities signal:
```json theme={null}
{
"capabilities": [],
"excludedCapabilities": [],
"manifestVersion": "empty"
}
```
### ETag caching
The GET endpoint supports conditional requests via `If-None-Match`:
```http theme={null}
GET /api/v1/apps/{clientId}/manifest
If-None-Match: "a1b2c3d4e5f6..."
```
When the manifest has not changed, the server returns `304 Not Modified` with no body. Cache the `ETag` from the response header and send it on subsequent requests to avoid re-downloading an unchanged manifest.
```bash theme={null}
# First fetch — capture ETag
ETAG=$(curl -sS -i \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/manifest" \
| grep -i etag | awk '{print $2}' | tr -d '\r')
# Conditional fetch
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "If-None-Match: ${ETAG}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/manifest"
# Returns 304 if unchanged
```
SDK:
```ts theme={null}
import { createPmtHouseClientFromEnv } from "@pymthouse/builder-sdk/env";
const client = createPmtHouseClientFromEnv();
// First fetch
const { manifest, etag } = await client.getAppManifest();
// Conditional fetch
const result = await client.getAppManifest({ ifNoneMatch: etag });
if (result.notModified) {
// Use cached manifest
}
```
***
## PUT manifest (update exclusions)
Update the `excludedCapabilities` on the app's Network Price plan. The response re-resolves the full manifest after writing:
```http theme={null}
PUT /api/v1/apps/{clientId}/manifest
Content-Type: application/json
```
Request body:
```json theme={null}
{
"excludedCapabilities": [
{ "pipeline": "text-to-image", "modelId": "*" },
{ "pipeline": "audio-to-text", "modelId": "openai/whisper-large-v3" }
]
}
```
| Field | Description |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `excludedCapabilities` | Array of `{ pipeline, modelId }` objects to exclude from discovery. `modelId: "*"` removes all models for that pipeline. An empty array clears all exclusions (full catalog discoverable). |
Response mirrors the `GET` shape: `{ capabilities, excludedCapabilities, manifestVersion }` with the re-resolved list.
**Returns `409 Conflict`** if the new exclusions would hide pipeline/models that a custom billing plan still has capability bundles for. Remove the conflicting capability bundles from the plan first, or relax the exclusions.
```bash theme={null}
export SESSION_COOKIE="next-auth.session-token=..."
curl -sS -X PUT \
-b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
-d '{
"excludedCapabilities": [
{ "pipeline": "text-to-image", "modelId": "*" }
]
}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/manifest"
```
***
## Manifest version
The `manifestVersion` field is a SHA-256 prefix (24 hex characters) over the sorted `capabilities` and `excludedCapabilities`. Use it for cache-busting and change detection — if the version matches your cached value, no downstream update is needed.
SDK helper:
```ts theme={null}
import {
parseAppManifestResponse,
computeManifestRevision,
} from "@pymthouse/builder-sdk";
const parsed = parseAppManifestResponse(rawManifestJson);
const revision = computeManifestRevision(parsed);
```
***
## Fail-open behavior
* **`capabilities: []`** → no restriction. An empty capabilities list means the full catalog is discoverable (fail-open). Integrators must not interpret an empty array as "nothing allowed".
* **`503 Service Unavailable`** → returned when the NaaP pipeline catalog cannot be loaded on the server side (only during `PUT`, which needs to validate and resolve exclusions).
***
## Key design decisions
1. **Fail-open stub on GET.** The in-process enforcement cache was removed because it failed closed on replicas or after restarts, rejecting otherwise-valid signing requests. The signing hot path no longer consults the manifest; capability scoping is expressed through exclusions in the dashboard and surfaced via the manifest API for informational/discovery purposes only.
2. **Subtractive exclusion model.** Starting from the full NaaP catalog and subtracting exclusions makes it safe to add new pipelines/models to the catalog without requiring integrators to update an allowlist.
3. **Conflict guard on PUT.** Preventing exclusions that would hide capabilities still referenced by billing plans keeps the billing and discovery configuration consistent.
## Related guides
* [Plans](/integration/plans) — custom billing plans reference manifest capabilities
* [Builder SDK](/integration/sdk) — `getAppManifest`, `parseAppManifestResponse`, `computeManifestRevision`
# Builder M2M Payments API
Source: https://docs.pymthouse.com/integration/payments
Manage owner wallets, subscriptions, billing state, and end-user payments from your backend using M2M HTTP Basic auth — no dashboard session required.
The Builder M2M Payments API gives your backend full programmatic control over billing — the same operations available in the PymtHouse dashboard, accessible via M2M HTTP Basic auth. Use it to embed prepaid top-up flows, subscription upgrades, and billing dashboards directly in your product.
All billing and usage Builder routes return **`404 Not Found`** for any auth or tenant-mismatch failure. This is deliberate anti-enumeration, not a routing bug.
## API surface overview
Routes are divided into three audiences:
| Surface | Paths | Credential |
| ----------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Builder (M2M)** | `/api/v1/apps/{clientId}/billing/…`, `/api/v1/builder/apps/{clientId}/usage*` | M2M HTTP Basic (`m2m_…` + `pmth_cs_…`), or Bearer machine token on most non-usage routes |
| **End-user** | `/api/v1/apps/{clientId}/me/…` or `/api/v1/user/usage*` | Bare `pmth_*` API key, user JWT, or signer JWT |
| **Internal** | `/api/v1/internal/…` | PymtHouse dashboard session only |
OpenAPI specs:
| Surface | Spec | Docs UI |
| --------------------------- | ----------------------------------- | --------------------------- |
| Public (Builder + End-user) | `GET /api/v1/openapi.json` | `GET /api/v1/docs` |
| Internal | `GET /api/v1/internal/openapi.json` | `GET /api/v1/internal/docs` |
**Usage API auth is stricter than the rest of the Builder surface.** `/api/v1/builder/apps/{clientId}/usage*` and the legacy `/api/v1/apps/{clientId}/usage*` aliases require **HTTP Basic**. Bearer access tokens are rejected on those paths.
## Prerequisites
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId"
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
```
The M2M credential must be the app's configured M2M OIDC client. Anything else returns `404`.
***
## Canonical billing state
Before building any billing UI or gating user actions, read the canonical spend posture for your app. This single endpoint is the source of truth used by the signer, dashboard, and mint — so a rejection and a read can never disagree.
```http theme={null}
GET /api/v1/apps/{clientId}/billing/state
Authorization: Basic base64(m2m_id:m2m_secret)
```
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/state"
```
Response:
```json theme={null}
{
"status": "active",
"reason": null,
"nextAction": null,
"balance": {
"currency": "USD",
"prepaidCredits": "12.50",
"autoDebitAvailable": true,
"includedUsage": "5.00"
}
}
```
| Field | Values | Description |
| ------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `status` | `active` \| `overage` \| `at_risk` \| `blocked` | Current spend posture. |
| `reason` | string \| null | Machine-readable denial reason when blocked (e.g. `owner_payment_method_required`). |
| `nextAction` | string \| null | Actionable instruction for the owner when spend is blocked. |
| `balance` | object | Funding breakdown including prepaid credits, auto-debit availability, and included usage. |
**Poll this endpoint instead of inferring solvency from a failed sign.** A `status: active` response means the next request will be processed.
***
## Owner wallet
Prepaid wallet management over M2M Basic. All wallet operations are scoped to the app owner.
**Base path:** `/api/v1/apps/{clientId}/billing/wallet`
| Method | Path | Description |
| ------ | ------------------- | ----------------------------------------------------------------------- |
| `GET` | `…/wallet` | Balance, payment-method-on-file, Pay-Per-Use behavior, settlement order |
| `POST` | `…/wallet/top-up` | Payment-mode Stripe Checkout for a **$1–$10,000** top-up |
| `GET` | `…/wallet/invoices` | Past platform invoices (paginated) |
| `GET` | `…/payment-methods` | Attached cards (brand + last4 only) |
| `POST` | `…/payment-methods` | Setup-mode Stripe Checkout to attach the auto-debit card |
### Read the wallet
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/wallet"
```
Response includes:
```json theme={null}
{
"balanceUsdMicros": "12500000",
"paymentMethodOnFile": true,
"settlement": {
"order": "credits_then_auto_debit"
},
"payPerUse": {
"description": "Pay-per-use — charged at every $10.00 of usage (credits first)."
}
}
```
`settlement.order = "credits_then_auto_debit"` means prepaid credits are consumed first, then the auto-debit rail.
### Top up the wallet
Returns a Stripe Checkout URL for the owner to complete. The checkout amount must be between $1 and $10,000.
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X POST \
-H "Content-Type: application/json" \
-d '{"amountUsd": 50, "successUrl": "https://yourapp.com/billing/success"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/wallet/top-up"
```
Credits land in the wallet when Stripe fires the `checkout.session.completed` webhook. Top-up is **idempotent on the Checkout session id** — Stripe retries and duplicate webhook deliveries credit exactly once.
### Attach a payment card
Returns a Stripe setup-mode Checkout URL. After the owner completes it, promote the card to the default to enable auto-debit:
```bash theme={null}
# 1. Get setup Checkout URL
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X POST \
-H "Content-Type: application/json" \
-d '{"successUrl": "https://yourapp.com/billing/card-added"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/payment-methods"
# 2. After redirect, set the card as default
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X PATCH \
-H "Content-Type: application/json" \
-d '{"ensureDefault": true}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/payment-methods/{paymentMethodId}"
```
**Attaching a card is not the same as enabling auto-debit.** After a setup Checkout completes, you must explicitly set the card as default with `PATCH { ensureDefault: true }` before it will be used for overage collection or subscription charges.
### List invoices
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/wallet/invoices?page=1&pageSize=20"
```
Returns `{ items, page, pageSize, totalCount }`.
***
## Subscription management
Owner Paid subscription switching is available over M2M Basic. Use these endpoints to list available plans, upgrade, downgrade, or cancel — without a dashboard session.
| Method | Path | Description |
| -------- | ------------------------------------------------------------- | ------------------------------------------------------ |
| `GET` | `/api/v1/apps/{clientId}/billing/tiers` | Selectable Owner Paid tiers |
| `GET` | `/api/v1/apps/{clientId}/billing/subscription` | Live plan, pending downgrade, payment-method readiness |
| `PUT` | `/api/v1/apps/{clientId}/billing/subscription` | Upgrade or switch tiers (requires `confirm: true`) |
| `DELETE` | `/api/v1/apps/{clientId}/billing/subscription` | Schedule downgrade to Sandbox Starter at end of cycle |
| `DELETE` | `/api/v1/apps/{clientId}/billing/subscription/pending-change` | Cancel a pending downgrade |
### List available tiers
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/tiers"
```
### Upgrade to a paid tier
Upgrading is a two-step consentful flow: attach a card, then confirm the upgrade separately.
```bash theme={null}
# Step 1: Attach a payment card (see wallet section above)
# Step 2: Upgrade (explicit confirm required)
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X PUT \
-H "Content-Type: application/json" \
-d '{"planKey": "pymthouse_owner_paid", "confirm": true}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/subscription"
```
The `confirm: true` field is required. Omitting it returns a preview without making changes.
### Cancel at end of cycle
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X DELETE \
-H "Content-Type: application/json" \
-d '{"confirm": true}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/subscription"
```
Schedules a downgrade to Sandbox Starter at the end of the current billing cycle. To revert before the cycle ends:
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X DELETE \
-H "Content-Type: application/json" \
-d '{"confirm": true}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/subscription/pending-change"
```
### Payment methods on the owner wallet
| Method | Path | Description |
| -------- | ------------------------------------------------------ | --------------------------------------- |
| `GET` | `/api/v1/apps/{clientId}/billing/payment-methods` | List attached cards |
| `POST` | `/api/v1/apps/{clientId}/billing/payment-methods` | Setup-mode Checkout |
| `PATCH` | `/api/v1/apps/{clientId}/billing/payment-methods/{id}` | Set default (`{ ensureDefault: true }`) |
| `DELETE` | `/api/v1/apps/{clientId}/billing/payment-methods/{id}` | Unlink a card |
***
## Overage gate and soft-negative ceiling
When a user's prepaid balance reaches \$0, spending can continue if the right payment rail is available. This avoids hard stops mid-stream.
| `billingMode` | Overage requires |
| -------------- | ------------------------------------------------------------------------------------ |
| `owner_rollup` | Owner on a Paid tier **and** a default chargeable payment method on the owner wallet |
| `merchant` | Connect end-user default payment method **and** an overage-capable plan |
Continued spend is bounded by a **soft-negative debt ceiling** configured on your app:
* `0` means no ceiling (unlimited overage).
* Any positive value must be \*\*≥ $2**. Values below $2 are rejected because sub-\$0.50 invoices cannot be collected through Stripe.
* Hitting the ceiling denies with `debt_ceiling_reached`.
Collection is invoice-based (never a PaymentIntent from PymtHouse):
* A fire-and-forget trigger invoices accumulated usage before the ceiling is reached.
* Billing profiles use **anchored daily** collection, capping unbilled exposure to \~24h.
### Force collection now
Trigger immediate invoicing of accumulated unbilled usage. Idempotent within the trigger cooldown:
```bash theme={null}
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X POST \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/collect"
```
Returns `{ "collected": true }` on success, or `{ "skipped": true, "reason": "rate_limited" }` within the cooldown period. Returns `{ "skipped": true, "reason": "below_floor" }` when unbilled debt is below the \$0.50 Stripe minimum.
***
## Pay-Per-Use plans
Pay-Per-Use (`type: "usage"`) plans charge when accumulated usage crosses a threshold — credits first, then auto-debit — rather than on a billing cycle.
The threshold is set on the plan as `chargeThresholdUsd` (e.g. `10.00` charges at every \$10 of accumulated usage). The resolved behavior is returned in the wallet summary as display copy:
```
"Pay-per-use — charged at every $10.00 of usage (credits first)."
```
Pay-Per-Use plans keep a nominal internal monthly billing cycle for OpenMeter compatibility. Do not surface this cycle to your users — the threshold is the only charging semantic that matters.
See [Plans](/integration/plans) for creating and configuring Pay-Per-Use plans.
***
## End-user billing
Manage invoices and payment methods for individual end-users in your app.
| Method | Path | Description |
| ------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET` | `/api/v1/apps/{clientId}/users/{externalUserId}/invoices` | Invoice list (`{ items, page, pageSize, totalCount }`) |
| `GET` | `/api/v1/apps/{clientId}/users/{externalUserId}/invoices/{invoiceId}/hosted-url` | Stripe hosted invoice URL and PDF link |
| `GET` | `/api/v1/apps/{clientId}/users/{externalUserId}/payment-methods` | List attached cards |
| `POST` | `/api/v1/apps/{clientId}/users/{externalUserId}/payment-methods` | Setup-mode Checkout (does not change plan) |
| `POST` | `/api/v1/apps/{clientId}/users/{externalUserId}/subscription/change` | Switch plan; paid targets may return a Connect `checkoutUrl` |
```bash theme={null}
# List end-user invoices
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/invoices"
# Get hosted invoice URL
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/invoices/{invoiceId}/hosted-url"
```
Free, Starter, and draft plan targets are never revenue-gated. Migrating users off a phased-out paid plan continues working after switching to `owner_rollup`.
***
## Error codes
Billing denials use **RFC 9457 problem details** (`Content-Type: application/problem+json`) with a machine-readable `code` field:
| Condition | Status | `code` |
| --------------------------------------------- | ------ | ------------------------------- |
| Owner wallet empty, no payment method | `402` | `owner_payment_method_required` |
| Per-app end-user cap reached | `403` | `end_user_cap_reached` |
| Paid plan / checkout without Connect | `403` | `stripe_connect_required` |
| Connect started, capabilities not yet granted | `403` | `stripe_connect_pending` |
| Unbilled debt at soft-negative ceiling | — | `debt_ceiling_reached` |
| Collect called inside trigger cooldown | — | `rate_limited` |
Auth or tenant-match failures on Builder billing routes always return **`404 Not Found`**, never `401`/`403`.
***
## Stripe Connect
Stripe Connect is used for merchant-mode end-user invoicing and checkout. Builder M2M accepts Basic auth for wallet and subscription operations; Connect OAuth itself is initiated from a provider dashboard session.
| Method | Path | Auth | Description |
| -------- | ------------------------------------------------ | ----------------------------- | ------------------------------------------------------------------------ |
| `GET` | `/api/v1/apps/{clientId}/billing/stripe` | Provider session or M2M Basic | Stripe Connect status |
| `POST` | `/api/v1/apps/{clientId}/billing/stripe/connect` | App owner or platform admin | Start Stripe Connect OAuth flow |
| `DELETE` | `/api/v1/apps/{clientId}/billing/stripe` | App owner or platform admin | Disconnect Stripe |
| `POST` | `/api/v1/apps/{clientId}/billing/checkout` | Provider session | End-user checkout: `{ planId, externalUserId, successUrl?, cancelUrl? }` |
Merchant-mode settlement runs through OpenMeter Custom Invoicing on the Connect rail. Owner rollup stays on the platform Stripe app. Your tenants never receive direct OpenMeter access — all mutations and reads go through Builder API routes.
***
## Full example: embed a billing dashboard
```bash theme={null}
# 1. Check spend posture
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/state"
# 2. Show wallet balance and recent invoices
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/wallet"
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/wallet/invoices"
# 3. Let the owner top up
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X POST \
-H "Content-Type: application/json" \
-d '{"amountUsd": 20, "successUrl": "https://yourapp.com/billing?topup=success"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/wallet/top-up"
# 4. List available tiers and upgrade
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/tiers"
curl -sS -u "${M2M_ID}:${M2M_SECRET}" -X PUT \
-H "Content-Type: application/json" \
-d '{"planKey": "pymthouse_owner_paid", "confirm": true}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/billing/subscription"
```
For the aggregate billing summary (cycle totals, timeline, overage), see [Billing summary](/integration/billing).
# Plans
Source: https://docs.pymthouse.com/integration/plans
List, create, update, sync, and delete billing plans for your app. Plans define included allowances, overage rates, capability bundles, and OpenMeter sync state.
Plans define pricing tiers for your app. The active plan and subscription window drive overage in the [billing summary](/integration/billing). Published plans are synced to OpenMeter, which handles authoritative usage metering and retail rate cards.
## Authentication
Plan routes use **provider dashboard session** auth only: a logged-in session whose user is the app owner, a platform admin, or a `providerAdmins` team member. **Confidential M2M / Basic auth is not accepted** on plan mutation routes.
`GET /plans` also accepts M2M Basic auth for integrators who need to read plan configuration.
**POST**, **PUT**, and **DELETE** additionally require **`canEditProviderApp`** (same session, with edit rights).
## Base path
```http theme={null}
/api/v1/apps/{clientId}/plans
```
`{clientId}` is the public `app_…` OAuth client id.
***
## List plans
```http theme={null}
GET /api/v1/apps/{clientId}/plans
```
Returns every plan for the app, each with nested **capabilities** (pipeline/model bundles).
### Query parameters
| Parameter | Type | Description |
| ------------ | ---- | ------------------------------------------------------------------------------------------- |
| `apiVersion` | `2` | When `2`, returns plans as `BillingProduct` DTOs with additional sync metadata (see below). |
### Default response shape (no `apiVersion`)
Each plan includes: `id`, `clientId`, `name`, `type` (`free` | `subscription` | `usage`), `priceAmount`, `priceCurrency`, `status`, `isNetworkDefault`, `isStarterDefault`, `includedUnits`, `includedUsdMicros`, `overageRateWei`, `billingCycle`, `openmeterPlanId`, `lastSyncedAt`, `syncError`, and `capabilities` (array).
### Billing API v2 response (`apiVersion=2`)
Returns `{ apiVersion: 2, products: [BillingProduct…] }`.
```json theme={null}
{
"apiVersion": 2,
"products": [
{
"id": "plan-uuid",
"name": "Pro",
"type": "subscription",
"status": "active",
"includedUsdMicros": "10000000",
"overageRateUsd": "0.0000015",
"billingCycle": "monthly",
"openmeterPlanId": "om_plan_...",
"lastSyncedAt": "2026-04-01T00:00:00.000Z",
"syncError": null,
"sync": {
"status": "synced",
"syncedAt": "2026-04-01T00:00:00.000Z"
},
"capabilities": [
{
"pipeline": "text-to-image",
"modelId": "stabilityai/sdxl",
"effectiveRetailRateUsd": "0.0000020"
}
]
}
]
}
```
| Field | Description |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `includedUsdMicros` | Subscription usage allowance in USD micros (e.g. `10000000` = \$10.00). |
| `overageRateUsd` | Plan-level retail USD per network USD-micro (decimal string, e.g. `0.0000015` = 50% markup). Synced to OpenMeter usage rate cards. |
| `openmeterPlanId` | OpenMeter plan key; `null` until first sync. |
| `lastSyncedAt` | ISO 8601 timestamp of last successful OpenMeter sync. |
| `syncError` | Last sync error string, or `null` if sync succeeded. |
| `capabilities[].effectiveRetailRateUsd` | Per pipeline/model retail override (decimal USD per micro). |
***
## Create a plan
```http theme={null}
POST /api/v1/apps/{clientId}/plans
Content-Type: application/json
```
| Field | Required | Notes |
| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | Yes | Display name. Reserved names (`Network Price` and the internal default) are rejected. |
| `type` | No | Defaults to `free`. `subscription` requires `includedUnits` and `overageRateWei` (non-negative integer strings). `usage` enables Pay-Per-Use threshold charging. |
| `includedUsdMicros` | No | USD micros allowance for OpenMeter subscription entitlement. |
| `overageRateUsd` | No | Retail USD rate per USD micro for OpenMeter rate cards. |
| `chargeThresholdUsd` | No | **Pay-Per-Use only.** Charge when accumulated usage reaches this USD amount. Valid range: `$0 < x ≤ $1,000,000`, two decimal places. Required for `type: "usage"` plans with threshold charging. |
| `priceAmount`, `priceCurrency` | No | Defaults `0` / `USD`. |
| `billingCycle` | No | `"monthly"` (default). For Pay-Per-Use plans, a nominal monthly cycle is kept internally for OpenMeter compatibility — do not surface it to users. |
| `status` | No | `active` or `inactive`; default `active`. Publishing with `status: active` triggers OpenMeter sync when `OPENMETER_URL` is configured. |
| `capabilities` | No | Array of `{ pipeline, modelId, slaTargetScore?, slaTargetP95Ms?, maxPricePerUnit?, retailRateUsd? }`. Each entry must reference discoverable (non-excluded) pipeline/model combinations. |
**201 Created** body: `{ "id": "" }`.
`is_network_default` cannot be set on custom plans. The Network Price plan is managed separately via the Plans UI.
***
## Update a plan
```http theme={null}
PUT /api/v1/apps/{clientId}/plans
Content-Type: application/json
```
Body must include **`id`** (plan UUID). Omitted fields keep existing values. If **`capabilities`** is present, it **replaces** all bundles for that plan; omit the key to leave bundles unchanged. `PUT` on the Network Price plan id returns `400` — edit exclusions via the Plans UI.
**200 OK**: `{ "success": true }`.
***
## Delete a plan
```http theme={null}
DELETE /api/v1/apps/{clientId}/plans?planId=
```
Removes the plan and its capability bundles. Prefer **`status: inactive`** via PUT if subscribers might still reference the plan. Deleting the Network Price default plan returns `409`.
**200 OK**: `{ "success": true }`. **404** if the plan is missing or not owned by this app.
***
## Sync a plan to OpenMeter
```http theme={null}
POST /api/v1/apps/{clientId}/plans/{planId}/sync
```
Auth: provider dashboard session with edit rights.
Triggers an explicit OpenMeter plan sync for the specified plan. Use this when a plan's rate cards or included allowance have drifted from OpenMeter, or after changing pricing configuration outside the normal publish flow.
**200 OK**: `{ "success": true, "openmeterPlanId": "om_plan_...", "syncedAt": "..." }`.
***
## Starter plan
Every app has a **Starter** plan (`isStarterDefault: true`) separate from custom billing plans. It carries an `includedUsdMicros` allowance and is automatically subscribed to by new end-users.
Providers update the Starter allowance separately from the plans list:
```http theme={null}
GET /api/v1/apps/{clientId}/starter-plan
PUT /api/v1/apps/{clientId}/starter-plan
```
`PUT` body: `{ "includedUsdMicros": "5000000" }`. This triggers an immediate OpenMeter plan sync.
The Starter plan also appears in the `GET /plans` list (with `isStarterDefault: true`) but cannot be deleted or replaced by a custom plan.
***
## Errors
| Status | Typical cause |
| ------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `400` | Missing `name`, invalid `subscription` billing fields, malformed `capabilities`, or attempting to `PUT` the Network Price plan. |
| `403` | Session cannot edit this app. |
| `404` | Unknown `clientId` or plan id. |
| `409` | Attempting to delete the Network Price default plan, or exclusion conflicts with existing capability bundles. |
***
## OpenMeter sync behavior
When a plan is published with `status: active` and `OPENMETER_URL` is configured:
1. A plan keyed `{clientId}:{planId}` is created or updated in OpenMeter.
2. Flat subscription fee, included allowance on `network_fee_usd_micros`, and usage rate cards are provisioned.
3. Per-capability `retailRateUsd` entries create filtered OpenMeter features and rate cards.
4. Stale `openmeterPlanId` values are recreated automatically when OpenMeter returns plan-not-found.
Stripe Connect is for invoicing/checkout, not for provisioning plans in OpenMeter.
***
## Examples
Call **GET/POST/PUT/DELETE** from a **trusted context** that holds the provider dashboard session cookie.
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId"
export SESSION_COOKIE="next-auth.session-token=..."
# List plans (default)
curl -sS -b "${SESSION_COOKIE}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/plans" | jq .
# List plans (billing v2)
curl -sS -b "${SESSION_COOKIE}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/plans?apiVersion=2" | jq .
# Create a free plan
curl -sS -X POST -b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
-d '{"name":"Free","type":"free"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/plans"
# Sync a plan to OpenMeter
curl -sS -X POST -b "${SESSION_COOKIE}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/plans//sync"
# Update Starter plan allowance
curl -sS -X PUT -b "${SESSION_COOKIE}" \
-H "Content-Type: application/json" \
-d '{"includedUsdMicros":"10000000"}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/starter-plan"
```
Do not expose plan mutation to end users; keep session cookies server-side or use the in-app UI.
## Owner Paid subscription switching
Switching an app's Owner Paid subscription is available over Builder M2M Basic — no dashboard session required. See [Builder M2M Payments API → Subscription management](/integration/payments#subscription-management) for the full reference.
## Implementation reference
Canonical contract: [`pymthouse` `docs/builder-api.md`](https://github.com/pymthouse/pymthouse/blob/main/docs/builder-api.md) (Billing API → Plans). Code: `src/app/api/v1/apps/[id]/plans/route.ts`, `src/app/api/v1/apps/[id]/plans/[planId]/sync/route.ts`, `src/app/api/v1/apps/[id]/starter-plan/route.ts`.
# Builder SDK
Source: https://docs.pymthouse.com/integration/sdk
TypeScript client for the PymtHouse Builder API, Usage API, and OIDC issuer. Wraps M2M auth, user provisioning, token minting, signer integration, and billing in a single typed package.
`@pymthouse/builder-sdk` is the official TypeScript/npm SDK for PymtHouse app builders. It wraps:
* **Confidential Builder and Usage REST APIs** (M2M auth, user provisioning, billing)
* **OIDC protocol flows** (token mint, exchange, device login, JWT verify) via [`oauth4webapi`](https://github.com/panva/oauth4webapi)
* **Signer integration** (direct DMZ proxy and identity webhook for go-livepeer)
* **Client-safe utilities** (config checks, device-initiate validation, formatting, plan pricing math)
## Install
```bash theme={null}
pnpm add @pymthouse/builder-sdk
# or
npm install @pymthouse/builder-sdk
```
Node ≥ 20 required.
## Quick start
### From environment variables (recommended for server use)
```ts theme={null}
import { createPmtHouseClientFromEnv } from "@pymthouse/builder-sdk/env";
const client = createPmtHouseClientFromEnv();
const discovery = await client.getDiscovery();
```
### Explicit construction
```ts theme={null}
import { PmtHouseClient } from "@pymthouse/builder-sdk";
const client = new PmtHouseClient({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
});
```
## Environment variables
The `env` subpath reads these variables. Set them in your backend environment or secret manager.
| Variable | Value | Notes |
| ----------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |
| `PYMTHOUSE_ISSUER_URL` | `https://pymthouse.com/api/v1/oidc` | Must match `iss` in issued tokens. |
| `PYMTHOUSE_PUBLIC_CLIENT_ID` | `app_…` | Public OIDC client id. Used in device/browser flows and JWT claims. |
| `PYMTHOUSE_M2M_CLIENT_ID` | `m2m_…` | Confidential M2M client id. Server-side only. |
| `PYMTHOUSE_M2M_CLIENT_SECRET` | `pmth_cs_…` | M2M client secret. Server-side only. Rotate via the credentials endpoint. |
The `@pymthouse/builder-sdk/env` subpath throws immediately if imported in a browser context (detects `globalThis.window`). In Next.js, add `import "server-only"` in any file that re-exports `createPmtHouseClientFromEnv` to enforce the server-only constraint at build time.
## `PmtHouseClient` method reference
All REST calls use HTTP Basic auth against `{issuerOrigin}/api/v1/apps/{publicClientId}/…`.
### Discovery & OIDC
| Method | Description |
| --------------------------------- | --------------------------------------------------------- |
| `getDiscovery()` | Fetch OIDC discovery document (5-min cache). |
| `verifyIssuer(iss)` | Validate an issuer URL against this client's issuer. |
| `issueMachineAccessToken(scope?)` | Client credentials grant; returns a machine access token. |
### User management
| Method | Description |
| ----------------------------------- | ------------------------------------------------------------------- |
| `listAppUsers()` | `GET .../users` — list provisioned users. Requires `users:read`. |
| `upsertAppUser(input)` | `POST .../users` — create or update a user. Requires `users:write`. |
| `deleteAppUser({ externalUserId })` | `DELETE .../users` — deactivate a user. |
### Token minting & exchange
| Method | Description |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mintUserAccessToken(input)` | `POST .../users/{id}/token` — short-lived JWT. `scope` defaults to `sign:job`. Requires `users:token`. Throws `PmtHouseError({ status: 404, code: "not_found" })` if the user has not been provisioned. |
| `ensureUserAndMintToken(input)` | Same as `mintUserAccessToken` but auto-provisions the user on `404 / not_found` via `upsertAppUser` + retry. Use this for lazy provisioning instead of calling the two methods manually. |
| `exchangeForSignerSession({ userJwt, resource? })` | RFC 8693: user JWT → opaque `pmth_*` signer session. |
| `mintUserSignerSessionToken(input)` | Mint user JWT + exchange in one call. |
| `mintSignerSessionForExternalUser(input)` | Eager upsert + mint + exchange in one call. Always upserts the user first; use `ensureUserAndMintToken` when you only need a JWT without the signer exchange. |
| `createSignerSessionToken({ userJwt? })` | Exchange or fall back to machine token. |
| `exchangeApiKeyForUserAccessToken({ apiKey })` | `POST .../auth/api-key/token` — API key → short-lived JWT. |
| `exchangeApiKeyForSignerSession({ apiKey, facadeUrl? })` | API key → opaque signer session. |
### Device flow
| Method | Description |
| ----------------------------------------------- | ----------------------------------------------- |
| `parseDeviceApprovalRedirect(searchParams)` | Parse initiate-login redirect parameters. |
| `completeDeviceApproval({ userJwt, userCode })` | RFC 8693 token exchange to bind a device grant. |
| `approveDeviceLogin(input)` | Full Option B device approval workflow. |
### Usage & billing
| Method | Description |
| ------------------------------------------- | ---------------------------------------------------------- |
| `getUsage(input?)` | `GET .../usage` with groupBy/retail filters. |
| `fetchUsageForExternalUser(input)` | BFF-style `scope=me` usage rollup for a single user. |
| `getUsageBalance(externalUserId)` | `GET .../usage/balance` — entitlement balance. |
| `getUserAllowances(externalUserId)` | `GET .../users/{id}/allowances`. |
| `grantUserAllowance(externalUserId, input)` | `POST .../users/{id}/allowances`. |
| `getUserSubscription(externalUserId)` | `GET .../users/{id}/subscription`. |
| `listBillingProducts()` | `GET .../plans?apiVersion=2` — returns `BillingProduct[]`. |
| `syncBillingProduct(planId)` | `POST .../plans/{id}/sync` — explicit OpenMeter sync. |
### Signer routing
| Method | Description |
| -------------------- | --------------------------------------------------------------- |
| `getSignerRouting()` | `GET .../signer/routing` — DMZ URL, webhook URL, metering mode. |
## Subpath exports
| Import | Runtime safety | Purpose |
| -------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@pymthouse/builder-sdk` | Node + Edge | `PmtHouseClient`, usage helpers, manifest parsers, token helpers, error types |
| `@pymthouse/builder-sdk/env` | **Node only** (throws in browser) | `createPmtHouseClientFromEnv`, `getPymthouseBaseUrl` |
| `@pymthouse/builder-sdk/config` | Edge-safe | `isPymthouseConfigured`, `readPymthouseEnv`, URL helpers |
| `@pymthouse/builder-sdk/tokens` | Edge-safe | Signer session TTL constants, JWT shape helpers, `parseSignerSessionExchange` |
| `@pymthouse/builder-sdk/device` | Node + Edge | `pollDeviceToken` — RFC 8628 device code polling |
| `@pymthouse/builder-sdk/device-initiate` | Edge-safe | `validateDeviceInitiateLogin`, `extractDeviceApprovalFromTargetLink` |
| `@pymthouse/builder-sdk/verify` | Node + Edge | `verifyJwt` — RFC 9068 JWT validation via JWKS |
| `@pymthouse/builder-sdk/format` | Edge-safe | `formatWeiToEth`, `formatWeiToUsd` — wei display formatting |
| `@pymthouse/builder-sdk/plan-pricing` | Edge-safe | `markupPercentToRetailRateUsd`, `applyRetailRateToNetworkMicros`, plan pricing math |
| `@pymthouse/builder-sdk/signer/server` | **Node only** | `mintUserSignerToken`, `createSignerTokenManager`, `createDirectSignerProxyHandler`, `forwardDirectSignerRequest` |
| `@pymthouse/clearinghouse-identity-webhook` | Node | Root re-export of all subpaths below |
| `@pymthouse/clearinghouse-identity-webhook/protocol` | Node | `handleAuthorize` — go-livepeer identity webhook handler |
| `@pymthouse/clearinghouse-identity-webhook/verifiers` | Node | `createOidcVerifier`, `createApiKeyVerifier`, `createEndUserVerifierFromEnv`, `splitCompositeApiKey` |
| `@pymthouse/clearinghouse-identity-webhook/balance-gate` | Node | `createBalanceGate` — live balance check inside the identity webhook |
## Common usage patterns
### Construct the client
```ts theme={null}
// Explicit constructor (recommended — handles all env vars including PYMTHOUSE_ALLOW_INSECURE_HTTP)
import { PmtHouseClient } from "@pymthouse/builder-sdk";
const client = new PmtHouseClient({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
});
// Convenience factory — reads the same env vars automatically.
// allowInsecureHttp is inferred from http: issuer URL or PYMTHOUSE_ALLOW_INSECURE_HTTP=1.
import { createPmtHouseClientFromEnv } from "@pymthouse/builder-sdk/env";
const client = createPmtHouseClientFromEnv();
```
### Mint a user JWT (lazy provisioning)
```ts theme={null}
// ensureUserAndMintToken — auto-provisions on 404, no eager upsert needed
const { access_token } = await client.ensureUserAndMintToken({
externalUserId: "user-123",
// scope defaults to "sign:job" when omitted
});
// mintUserAccessToken — throws PmtHouseError({ status: 404, code: "not_found" })
// if the user hasn't been provisioned yet. Use when you provision eagerly on sign-up.
const { access_token } = await client.mintUserAccessToken({
externalUserId: "user-123",
});
```
### Mint a signer session for the DMZ
```ts theme={null}
// mintSignerSessionForExternalUser — eager upsert + mint + RFC 8693 exchange in one call
// Returns SignerSessionToken (opaque pmth_* bearer)
const session = await client.mintSignerSessionForExternalUser({
externalUserId: "user-123",
});
// session.access_token — opaque pmth_… bearer for the remote signer DMZ
// mintUserSignerToken (from signer/server) — M2M client_credentials mint returning
// CachedSignerToken with jwt, balanceUsdMicros, lifetimeGrantedUsdMicros, expiresAt.
// Use createSignerTokenManager for TTL caching across requests (recommended for production).
import { mintUserSignerToken, createSignerTokenManager } from "@pymthouse/builder-sdk/signer/server";
```
### Check usage balance before signing
```ts theme={null}
const balance = await client.getUsageBalance("naap-user-123");
if (!balance.hasAccess) {
throw new Error("Insufficient balance");
}
```
### Approve a device login (Option B)
```ts theme={null}
await client.approveDeviceLogin({
externalUserId: "naap-user-123",
userCode: "ABCD-EFGH",
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID,
});
```
### Usage BFF rollup
```ts theme={null}
import { summarizeUsageForExternalUser } from "@pymthouse/builder-sdk";
const usage = await client.getUsage({ groupBy: "user", startDate, endDate });
const summary = summarizeUsageForExternalUser(usage, "naap-user-123");
// summary.requestCount, summary.feeWei
```
### Next.js server-only guard
```ts theme={null}
// lib/pymthouse-server.ts
import "server-only";
export {
createPmtHouseClientFromEnv,
getPymthouseBaseUrl,
} from "@pymthouse/builder-sdk/env";
```
Import `createPmtHouseClientFromEnv` only from this wrapper in Route Handlers or Server Actions.
## Related guides
* [Client model](/integration/client-model) — env variable naming and the two-client pattern
* [API keys](/integration/api-keys) — exchanging `pmth_*` keys for JWTs
* [Allowances](/integration/allowances) — entitlement balance and manual top-ups
* [Signer routing](/integration/signer-routing) — direct DMZ proxy and identity webhook
* [Token exchange](/integration/token-exchange) — RFC 8693 and Option A/B flows
# Signer routing
Source: https://docs.pymthouse.com/integration/signer-routing
Integrate directly with the go-livepeer remote signer DMZ. Fetch routing config, proxy signing requests, and handle the identity webhook using @pymthouse/builder-sdk/signer/server and @livepeer/clearinghouse-identity-webhook.
Direct signer integration uses:
1. **`GET .../signer/routing`** — fetch the DMZ URL and webhook URL for your app.
2. **`@pymthouse/builder-sdk/signer/server`** — proxy signing requests directly to the remote signer DMZ with JWT minting.
3. **`@livepeer/clearinghouse-identity-webhook`** — handle go-livepeer identity webhook calls (`POST /authorize`) to authenticate end-users.
## Token lifecycle
The following diagram shows how a signing request flows from the app backend through PymtHouse to the remote signer DMZ:
```mermaid theme={null}
sequenceDiagram
participant App as App backend
participant PM as PymtHouse Builder API
participant DMZ as Remote signer DMZ
participant OP as OIDC issuer
App->>PM: GET .../signer/routing
PM-->>App: { dmzUrl, webhookUrl, meteringMode }
App->>PM: Mint user JWT (Builder API /users/{id}/token)
PM-->>App: short-lived JWT (sign:job scope)
App->>OP: RFC 8693 exchange → opaque signer session
OP-->>App: pmth_… signer session token
App->>DMZ: Forward signing request (Bearer pmth_…)
DMZ->>App: POST /authorize (webhook)
App-->>DMZ: { auth_id, externalUserId }
DMZ-->>App: Signed ticket
```
***
## Fetch signer routing config
```http theme={null}
GET /api/v1/apps/{clientId}/signer/routing
Authorization: Basic base64(m2m_id:m2m_secret)
```
Returns the remote DMZ URL, JWKS URL, webhook URL, and metering mode for the app.
Response:
```json theme={null}
{
"dmzUrl": "https://dmz.example.com",
"jwksUrl": "https://pymthouse.com/api/v1/oidc/jwks",
"webhookUrl": "https://your-app.example/authorize",
"meteringMode": "kafka"
}
```
| Field | Description |
| -------------- | --------------------------------------------------------------------------------------- |
| `dmzUrl` | The remote signer DMZ base URL. Forward signing requests here. |
| `jwksUrl` | PymtHouse JWKS endpoint for the DMZ to validate JWTs. |
| `webhookUrl` | The identity webhook URL configured on the go-livepeer DMZ (`-remoteSignerWebhookUrl`). |
| `meteringMode` | `"kafka"` (async Kafka collector) or `"direct"`. |
SDK:
```ts theme={null}
const routing = await client.getSignerRouting();
// routing.dmzUrl, routing.webhookUrl
```
***
## Direct DMZ proxy (`@pymthouse/builder-sdk/signer/server`)
Use `createDirectSignerProxyHandler` to build an HTTP handler in your backend that:
1. Mints a user JWT (or signer session) via the Builder API or OIDC.
2. Forwards the original signing request to the remote DMZ with the JWT as the Bearer token.
3. Streams the response back to the caller.
```ts theme={null}
import { createDirectSignerProxyHandler, createSignerTokenManager } from "@pymthouse/builder-sdk/signer/server";
import { createPmtHouseClientFromEnv } from "@pymthouse/builder-sdk/env";
const client = createPmtHouseClientFromEnv();
const routing = await client.getSignerRouting();
const handler = createDirectSignerProxyHandler({
client,
dmzUrl: routing.dmzUrl,
// Optional: cache signer tokens to reduce round-trips
tokenManager: createSignerTokenManager({ client }),
});
// In your HTTP framework:
export async function POST(request: Request) {
return handler(request);
}
```
### Low-level helpers
For custom forwarding logic:
```ts theme={null}
import {
forwardDirectSignerRequest,
mintUserSignerToken,
} from "@pymthouse/builder-sdk/signer/server";
// Mint a signer JWT for a specific user
const token = await mintUserSignerToken(client, {
externalUserId: "user-123",
scope: "sign:job",
});
// Forward to DMZ with the token
const response = await forwardDirectSignerRequest({
dmzUrl: routing.dmzUrl,
signerToken: token.access_token,
request: incomingRequest,
});
```
### Device and API key exchange handlers
For CLI device flows and API key integrations, builder-sdk provides purpose-built handlers:
```ts theme={null}
import {
createDeviceExchangeHandler,
createApiKeyExchangeHandler,
} from "@pymthouse/builder-sdk/signer/server";
// POST /api/signer/device/exchange — device token → signer JWT
const deviceExchange = createDeviceExchangeHandler({ client });
// POST /api/signer/api-key/exchange — API key → signer session via facade
const apiKeyExchange = createApiKeyExchangeHandler({
client,
facadeUrl: process.env.DASHBOARD_ORIGIN!,
});
```
***
## Identity webhook (`@livepeer/clearinghouse-identity-webhook`)
go-livepeer calls your identity webhook (configured via `-remoteSignerWebhookUrl`) for every signing request to verify the end-user's credentials and receive an `auth_id` for usage attribution. Set the flag to the exact path for your deployment:
| Deployment | `-remoteSignerWebhookUrl` |
| ------------------------------------ | ------------------------------------------------------ |
| **Embedded in PymtHouse** | `https:///webhooks/remote-signer` |
| **Standalone clearinghouse sidecar** | `https:///authorize` |
PymtHouse embeds the clearinghouse package at `POST /webhooks/remote-signer`. The same package runs as a standalone sidecar (listening at `POST /authorize`) in the clearinghouse compose stack.
```mermaid theme={null}
sequenceDiagram
participant Signer as go-livepeer DMZ
participant App as Your webhook handler
participant PM as PymtHouse
Signer->>App: POST /authorize { authorization, payload }
App->>PM: Verify JWT / resolve API key
PM-->>App: UsageIdentity { auth_id, externalUserId }
App-->>Signer: 200 { auth_id }
```
### Setup (embedded in your app)
```ts theme={null}
import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol";
import { createLegacyWebhookConfigFromEnv } from "@livepeer/clearinghouse-identity-webhook/legacy-env";
export async function POST(request: Request) {
return handleAuthorize(request, createLegacyWebhookConfigFromEnv(process.env));
}
```
### End-user auth modes
Set `IDENTITY_AUTH_MODE` when running the standalone sidecar (`api_key` or `oidc`). For embedded Pymthouse routes, `createLegacyWebhookConfigFromEnv` maps legacy `JWT_*` / `CLAIM_*` env vars to OIDC verification.
| Mode | Package import | Use case |
| ---------------------------- | -------------------------------------------- | ------------------------------------------- |
| **OIDC** (Pymthouse default) | `legacy-env` + `JWT_ISSUER` | PymtHouse-issued JWTs or Auth0/OIDC tokens |
| **API key** (sidecar) | `verifiers` + `createApiKeyVerifier` | Demo `sk_…` keys or custom key store |
| **OIDC** (sidecar) | `verifiers` + `createEndUserVerifierFromEnv` | Auth0 / generic OIDC with `OIDC_*` env vars |
#### API key mode (sidecar)
```ts theme={null}
import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol";
import { createApiKeyVerifier } from "@livepeer/clearinghouse-identity-webhook/verifiers";
const config = {
webhookSecret: process.env.WEBHOOK_SECRET!,
endUserAuth: createApiKeyVerifier({
issuer: process.env.IDENTITY_ISSUER!,
resolveApiKey: async (key) => (await lookup(key)) ?? null,
}),
};
export async function POST(request: Request) {
return handleAuthorize(request, config);
}
```
### Webhook environment variables
| Variable | Description |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| `WEBHOOK_SECRET` | Shared secret between go-livepeer DMZ and your webhook handler. Set on the DMZ via `-remoteSignerWebhookSecret`. |
| `JWT_ISSUER` | OIDC issuer URL for JWT validation (e.g. `https://pymthouse.com/api/v1/oidc`). |
| `JWT_AUDIENCE` | Expected `aud` claim in end-user JWTs. |
| `CLAIM_CLIENT_ID` | JWT claim to use as the client id (default `azp`; for Auth0 set to `azp`). |
***
## Security guidance
* `WEBHOOK_SECRET` authenticates that the signing request came from **your** go-livepeer DMZ instance. Rotate it if the DMZ is compromised.
* The end-user `EndUserAuthVerifier` authenticates the **user** making the signing request. Keep the two auth layers separate — webhook secret is transport; end-user auth is identity.
* Configure the go-livepeer DMZ with `-remoteSignerWebhookUrl` pointing to your webhook path (`/webhooks/remote-signer` when embedded in PymtHouse, `/authorize` for the standalone sidecar) and `-remoteSignerWebhookSecret` matching `WEBHOOK_SECRET`.
* The DMZ validates JWTs against the JWKS URL from `getSignerRouting()` — ensure `jwksUrl` is reachable from the DMZ host.
## Related guides
* [Token exchange](/integration/token-exchange) — minting JWTs and signer sessions
* [API keys](/integration/api-keys) — exchanging `pmth_*` keys for signer sessions
* [Builder SDK](/integration/sdk) — `createDirectSignerProxyHandler`, `getSignerRouting`
* [Deprecated routes](/integration/deprecated) — migration from the removed `/api/signer/*` proxy
# Token exchange
Source: https://docs.pymthouse.com/integration/token-exchange
Use OAuth 2.0 Token Exchange (RFC 8693) to complete device authorization, obtain a remote signer session, or mint a user signer token via the clearinghouse.
PymtHouse implements the **OAuth 2.0 Token Exchange** grant (RFC 8693) for three server-side operations:
1. **Device completion** — a backend binds a pending RFC 8628 device grant to an authenticated user, completing the CLI authentication flow without a second browser redirect.
2. **Remote signer session exchange** — a short-lived access token is exchanged for a long-lived opaque remote signer session token (`pmth_*`) scoped to `sign:job`.
3. **Clearinghouse signer mint (Option A)** — M2M client credentials with `sign:mint_user_token` scope mints a user-scoped signer JWT and allowance data in a single call without a prior Builder API user-token step.
Operations 1 and 2 use the same token endpoint (`POST {issuer}/token`) and the same `grant_type`, but with different `resource` values. Operation 3 uses `client_credentials` grant type.
## Common parameters (operations 1 & 2)
All RFC 8693 token exchange requests:
| Parameter | Value |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange` |
| `subject_token_type` | `urn:ietf:params:oauth:token-type:access_token` (JWT) **or** `urn:pymthouse:oauth:token-type:api_key` (bare/composite API key) |
| `subject_token` | A valid access token **or bare `pmth_*` API key** issued by this PymtHouse issuer |
Authentication: **M2M HTTP Basic auth** (`Authorization: Basic base64(m2m_id:m2m_secret)`) is required for JWT subject-token exchange operations and optional for API-key subject-token exchange.
Two endpoint forms are accepted:
| Endpoint | App resolution |
| ----------------------------------------- | ------------------------------------------------- |
| `POST /api/v1/oidc/token` | Resolved from the `subject_token` credential |
| `POST /api/v1/apps/{clientId}/oidc/token` | Path `{clientId}` must match the credential's app |
***
## Device completion (RFC 8693 + RFC 8628)
Use this operation in the **NaaP / Option B** flow: after the user authenticates at your backend, call the token endpoint to bind the pending device grant. The polling CLI receives its access token on the next poll.
### Prerequisites
* A confidential M2M client (`m2m_…`) with `device:approve` **or** `users:token` scope.
* `device_third_party_initiate_login` enabled on the **public** client.
* A user-scoped JWT for the **public** `app_…` client (minted via [User tokens](/integration/user-tokens)).
### Flow
```mermaid theme={null}
sequenceDiagram
participant Device as CLI / device (polling)
participant RP as Your backend
participant OP as PymtHouse
Note over RP: User has authenticated at RP's IdP
RP->>OP: Mint user JWT (Builder API /users/{id}/token)
OP-->>RP: user_jwt (sub = app-user, azp = app_…)
RP->>OP: POST /token — token exchange, resource = urn:pmth:device_code:ABCD-EFGH
OP-->>RP: 200 RFC 8693 response (device grant bound as side-effect)
Device->>OP: Poll POST /token (grant_type=device_code)
OP-->>Device: access_token ✓
```
### Request
```bash theme={null}
ISSUER="${BASE_URL}/api/v1/oidc"
M2M_ID="m2m_yourClientId"
M2M_SECRET="pmth_cs_yourSecret"
USER_JWT="eyJ..." # access_token from user-token mint, azp = public app_… client
USER_CODE="ABCD-EFGH" # code the CLI received in step 1 of device flow
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=${USER_JWT}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "resource=urn:pmth:device_code:${USER_CODE}" \
"${ISSUER}/token"
```
**`resource` format:** `urn:pmth:device_code:` — use the `user_code` (e.g. `ABCD-EFGH`), not the `device_code`. PymtHouse normalizes the code before lookup.
### Subject token requirements
The `subject_token` must be:
* A valid JWT issued by **this** PymtHouse issuer (signature verified against `{issuer}/jwks`).
* Issued to the **public** `app_…` client for the same app (`client_id` or `azp` claim = public client id).
* Not expired.
The M2M client's `allowed_scopes` must include `device:approve` or `users:token`.
### Response
```json theme={null}
{
"access_token": "pmth_signer_session_...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 86400
}
```
The binding of the device grant is a **side-effect** of this call. The CLI's next poll of the device code token endpoint will return the bound session.
SDK helper:
```ts theme={null}
await client.approveDeviceLogin({
externalUserId: "naap-user-123",
userCode: "ABCD-EFGH",
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID,
});
```
***
## API key signer session exchange
Use this operation to exchange a **bare `pmth_*` personal API key** directly for a signer session without first minting a user JWT. This is useful for CLI tools and personal-key workflows.
```bash theme={null}
ISSUER="${BASE_URL}/api/v1/oidc"
M2M_ID="m2m_yourClientId"
M2M_SECRET="pmth_cs_yourSecret"
API_KEY="pmth_..." # bare personal key
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=${API_KEY}" \
--data-urlencode "subject_token_type=urn:pymthouse:oauth:token-type:api_key" \
"${ISSUER}/token"
```
Both endpoint forms are supported. When using the app-scoped form, the path `{clientId}` must match the API key's app:
```bash theme={null}
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/oidc/token"
```
The response is the canonical **`SignerSession`** envelope:
```json theme={null}
{
"access_token": "pmth_signer_session_...",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "sign:job",
"signer_url": "https://signer.example/dmz",
"balanceUsdMicros": "4200000",
"lifetimeGrantedUsdMicros": "5000000"
}
```
| Field | Description |
| -------------------------- | --------------------------------------------------------------------- |
| `access_token` | Opaque signer session token. Pass as Bearer to the remote signer DMZ. |
| `signer_url` | DMZ signing endpoint (present when routing is configured). |
| `balanceUsdMicros` | Current spend balance. Gate access on this value. |
| `lifetimeGrantedUsdMicros` | Total lifetime allowance granted. |
Never pass a client secret (`pmth_cs_*`) as `subject_token`. Confidential clients authenticate via HTTP Basic, not as subject tokens.
***
## Signer session exchange (from user JWT)
Use this operation to exchange a short-lived user access token for a long-lived opaque remote signer session token (`pmth_*`).
### Prerequisites
* **HTTP Basic auth** with the confidential **M2M** client (`m2m_…` and secret).
* The M2M client's `allowed_scopes` must include **`users:token`**.
* The `subject_token` must already contain **`sign:job`** scope.
### Subject token binding
The `subject_token` must be a JWT from this issuer whose `client_id` or `azp` is either:
* The **public** `app_…` client for the same developer app as the authenticating M2M client (typical after interactive login or Builder user-token mint), or
* The same **M2M** `client_id` as the request (legacy `client_credentials` access token used as `subject_token`).
### Request
```bash theme={null}
ISSUER="${BASE_URL}/api/v1/oidc"
M2M_ID="m2m_yourClientId"
M2M_SECRET="pmth_cs_yourSecret"
ACCESS_TOKEN="eyJ..." # user access token with sign:job scope (azp = public app_…)
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=${ACCESS_TOKEN}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "scope=sign:job" \
"${ISSUER}/token"
```
Omit `resource`, or set `resource` to the issuer URL (`{issuer}`) if your client always sends a resource indicator (RFC 8707).
### Response
```json theme={null}
{
"access_token": "pmth_signer_session_longtoken...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "sign:job",
"signer_url": "https://signer.example/dmz",
"balanceUsdMicros": "4200000",
"lifetimeGrantedUsdMicros": "5000000"
}
```
SDK helpers:
```ts theme={null}
// Exchange a user JWT for a signer session
const session = await client.exchangeForSignerSession({ userJwt: accessToken });
// Mint user JWT + exchange in one call
const session = await client.mintUserSignerSessionToken({
externalUserId: "naap-user-123",
scope: "sign:job",
});
// Full workflow: upsert user + mint + exchange
const session = await client.mintSignerSessionForExternalUser({
externalUserId: "naap-user-123",
email: "user@example.com",
});
// session.accessToken is opaque pmth_…
```
***
## Clearinghouse signer mint (Option A)
M2M clients with `sign:mint_user_token` scope (automatically added when the public client has `sign:job`) can mint a user-scoped signer JWT and receive allowance balance information in a single `client_credentials` call. This avoids the separate Builder API user-token step.
### Request
```bash theme={null}
ISSUER="${BASE_URL}/api/v1/oidc"
M2M_ID="m2m_yourClientId"
M2M_SECRET="pmth_cs_yourSecret"
curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=${M2M_ID}" \
--data-urlencode "client_secret=${M2M_SECRET}" \
--data-urlencode "scope=sign:mint_user_token" \
--data-urlencode "external_user_id=naap-user-123" \
--data-urlencode "audience=livepeer-remote-signer" \
"${ISSUER}/token"
```
### Response
```json theme={null}
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "sign:job",
"balanceUsdMicros": "4200000",
"lifetimeGrantedUsdMicros": "5000000"
}
```
| Field | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------- |
| `access_token` | User-scoped JWT with `aud=livepeer-remote-signer`. Use as a Bearer token at the remote signer DMZ. |
| `balanceUsdMicros` | Current OpenMeter entitlement balance for the user in USD micros. |
| `lifetimeGrantedUsdMicros` | Total lifetime granted allowance for the user. |
`sign:mint_user_token` is automatically granted to M2M clients when the public sibling client has `sign:job` in its `allowed_scopes`. No manual scope configuration is required.
***
## Error responses
| Status | Condition |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `400 invalid_grant` | `subject_token` is expired, invalid signature, or wrong issuer. |
| `400 invalid_request` | Missing required parameter, or `resource` value not recognized. |
| `401 Unauthorized` | M2M / client credentials invalid. |
| `403 Forbidden` | M2M client lacks the required scope (`device:approve`, `users:token`, or `sign:mint_user_token`), or `subject_token` client mismatch. |
***
## Key design decisions
1. **Single token endpoint for all exchange types.** Routing device completion, signer session exchange, and signer JWT mint through `POST {issuer}/token` keeps the public surface minimal and consistent with RFC standards.
2. **`resource` as the dispatch discriminator.** `urn:pmth:device_code:` signals device completion; absence or the issuer URL routes to signer session exchange.
3. **Binding is a side-effect, not the primary response.** The CLI polls the standard device code endpoint rather than a proprietary callback, keeping device polling logic independent of the Option B backend.
4. **Option A (`sign:mint_user_token`)** minimizes round trips for clearinghouse integrators — one call returns both the signer JWT and the user's current balance.
## Implementation tasks
* For device completion: mint the user JWT via the Builder API **before** calling the token exchange.
* Validate that your backend stores the `user_code` from the device code response and passes it verbatim to the `resource` parameter.
* For signer session exchange, verify the `subject_token` contains `sign:job` before calling.
* For Option A: ensure the M2M client has `sign:mint_user_token` in `allowed_scopes` (automatically derived from public client's `sign:job`).
* Check `balanceUsdMicros` from the Option A response to gate access before forwarding to the DMZ.
* Do not retry a device completion exchange with the same `user_code` after success; the grant has already been bound.
* After obtaining a signer session or signer JWT, forward it to the remote signer DMZ — see [Signer routing](/integration/signer-routing).
# Troubleshooting
Source: https://docs.pymthouse.com/integration/troubleshooting
Common errors, diagnostic checklists, and fixes for PymtHouse integrations.
## Quick diagnostics
When something fails, work through this checklist first before diving into specific errors:
PymtHouse has **two client IDs** per app. Using the wrong one causes the most common integration errors.
| Prefix | Type | Used in |
| ------- | ---------------- | ----------------------------------------------------------------------------- |
| `app_…` | Public client | URL path `{clientId}`, device flow `client_id`, JWT `azp` / `client_id` claim |
| `m2m_…` | Confidential M2M | HTTP Basic auth `username`, `client_id` in token requests |
**Golden rule:** The path `{clientId}` and the JWT `client_id` / `azp` claim always use the **public** `app_…` id. HTTP Basic credentials always use the **M2M** `m2m_…` id.
The four required variables — and the clients they correspond to:
```bash theme={null}
echo $PYMTHOUSE_ISSUER_URL # must end in /api/v1/oidc
echo $PYMTHOUSE_PUBLIC_CLIENT_ID # must start with app_
echo $PYMTHOUSE_M2M_CLIENT_ID # must start with m2m_
echo $PYMTHOUSE_M2M_CLIENT_SECRET # must start with pmth_cs_
```
If `PYMTHOUSE_PUBLIC_CLIENT_ID` starts with `m2m_` (or vice versa), every flow will fail.
You must call `POST .../users` before calling `POST .../users/{id}/token`. If the user does not exist in PymtHouse, the token mint returns `404`.
***
## Error reference
### `400 invalid_scope`
**Where it appears:** Token endpoint (`POST /api/v1/oidc/token`) or user-token mint (`POST .../users/{id}/token`)
**Cause A — Public client missing the requested scope**
The scope you're requesting (e.g. `sign:job`) must be in the **public client's** `allowed_scopes`. The user-token mint validates the requested scope against the public client, not the M2M client.
Fix: Add the missing scope to the **public** `app_…` client's `allowed_scopes` in the dashboard.
**Cause B — M2M client missing `users:token`**
Minting user JWTs requires the M2M client to have `users:token` in its `allowed_scopes`.
Fix: Add `users:token` to the **M2M** `m2m_…` client.
**Cause C — Requesting `admin` scope**
`admin` scope is explicitly rejected on the user-token mint endpoint.
Fix: Remove `admin` from the requested scope.
**Diagnostic:**
```bash theme={null}
# Check what scopes the M2M client has by inspecting the token response
TOKEN_RESPONSE=$(curl -sS \
-d "grant_type=client_credentials" \
-d "client_id=${M2M_ID}" \
-d "client_secret=${M2M_SECRET}" \
-d "scope=users:token" \
"${BASE_URL}/api/v1/oidc/token")
echo "$TOKEN_RESPONSE" | jq . # look for "error": "invalid_scope" vs access_token
```
***
### `400 programmatic / per_user`
**Where it appears:** User-token mint or programmatic token flow
**Cause:** The public client's `allowed_scopes` must include `users:token` to enable programmatic (non-interactive) user token issuance.
Fix: Add `users:token` to the **public** `app_…` client's `allowed_scopes`. Note: this also switches the app's billing mode to **per-user**, so per-user usage attribution becomes available in the Usage API.
***
### `400 invalid_grant` on device poll
**Where it appears:** Token polling after device flow (`POST /api/v1/oidc/token` with `grant_type=device_code`)
**Message:** `grant request is invalid` after the browser completed login
**Cause:** The device grant's `accountId` could not be resolved. This happens when the `subject_token` used in the RFC 8693 device binding step has a `sub` that maps to an `app_users` row, but that row hasn't been linked to an `end_users` / `users` record via `findOrCreateAppEndUser`.
Fix: Ensure the `subject_token` in the device-completion call is minted via `POST .../users/{externalUserId}/token` (Builder API), **not** a raw `client_credentials` token. The Builder API mint creates the `end_users` linkage.
***
### `404 Not Found` on valid credentials
**Where it appears:** Any Builder API or Usage API endpoint
**Cause A — M2M client from a different app**
The M2M client's app does not match the `{clientId}` in the URL path. PymtHouse returns `404` (not `403`) for security — it does not reveal whether the path `clientId` exists.
Fix: Confirm `M2M_ID` and `PUBLIC_CLIENT_ID` belong to the same registered developer app.
**Cause B — Wrong client ID type in the URL**
Using `m2m_…` as the URL `{clientId}` instead of `app_…`.
Fix: All `/api/v1/apps/{clientId}/…` paths use the **public** `app_…` client id in the URL.
**Cause C — User not yet provisioned**
Calling `/users/{externalUserId}/token` before `/users` to provision the user.
Fix: Call `POST /users` with the `externalUserId` before minting tokens for that user.
***
### `402 owner_payment_method_required`
**Where it appears:** User provisioning (`POST /users`) or allowance grant
**Cause:** The app owner's wallet is empty and no payment method is attached. When `ACTIVATION_GATE_MODE=enforce`, new user provisioning is blocked until the owner adds a payment method.
Fix: Go to your billing settings and attach a payment method, or call:
```bash theme={null}
# Upgrade the owner account to paid
POST /api/v1/me/billing/upgrade-paid
```
***
### `403 end_user_cap_reached`
**Where it appears:** User provisioning (`POST /users`) or allowance grant
**Cause:** The app has reached its configured `endUserCap` limit on total provisioned users.
Fix: Increase the `endUserCap` on the app's billing settings, or contact the platform admin.
***
### `403 stripe_connect_required`
**Where it appears:** Paid plan checkout, plan change to a priced target
**Cause:** The app is in `enforce_revenue` or `enforce` activation gate mode, and Stripe Connect is not fully set up (either `charges_enabled` or `details_submitted` is false).
Fix: Complete the Stripe Connect onboarding. Check the current Connect status:
```bash theme={null}
curl -sS -b "${SESSION_COOKIE}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/billing/stripe" \
| jq '{ connectReady: .connectReady, chargesEnabled: .chargesEnabled }'
```
***
### `503 Service Unavailable` on usage/balance
**Where it appears:** `GET .../usage`, `GET .../usage/balance`, `GET .../users/{id}/allowances`
**Cause:** `OPENMETER_URL` is not configured, or the OpenMeter service is unreachable. All usage and allowance reads require OpenMeter.
Fix: Verify `OPENMETER_URL` and `OPENMETER_API_KEY` are set in your deployment environment. Check connectivity from the PymtHouse service to the OpenMeter host.
***
### Usage shows zero after AI requests
**Cause A — Async metering delay**
Usage is metered asynchronously via Kafka → OpenMeter collector. There is typically a 10–30 second delay between a signed ticket and it appearing in the Usage API.
Fix: Wait 30 seconds and query again.
**Cause B — `OPENMETER_URL` not configured**
If `openMeterConfigured: false` appears in the usage response, metering is not active.
Fix: Configure `OPENMETER_URL` and bootstrap OpenMeter meters with `npm run openmeter:bootstrap`.
**Cause C — `pipeline` + `modelId` missing from the request**
`groupBy=pipeline_model` aggregates from validated billing events. Events that arrive without `pipeline` + `modelId` metadata appear only in `totals`, not in `byPipelineModel`.
Fix: Ensure the gateway includes `paymentMetadataVersion` with `pipeline` and `modelId` in the payment payload. See the gateway payment metadata contract in the Builder API reference.
***
### `trial_credits_exhausted` from the signer
**Cause:** The user's `hasAccess` is `false` — their allowance balance is zero.
Fix: Check the balance, then either grant a top-up or direct the user to a plan upgrade:
```bash theme={null}
# Check balance
curl -sS -u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/usage/balance?externalUserId=user-123" \
| jq .hasAccess
# Grant a manual top-up ($5)
curl -sS -X POST -u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{"amountUsdMicros":"5000000","source":"manual"}' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users/user-123/allowances"
```
***
### JWT session decrypt errors (NextAuth)
**Symptom:** Repeated `JWT_SESSION_ERROR` or `JWEDecryptionFailed` in server logs
**Cause:** `NEXTAUTH_SECRET` changed or `.env.local` is overriding `.env`.
Fix:
1. Ensure `NEXTAUTH_SECRET` is stable across deployments.
2. Check that `.env.local` does not contain a conflicting `NEXTAUTH_SECRET`.
3. Clear browser cookies for the app origin and sign in again.
***
## Auth model reference
| Auth mode | Where to use | Credential |
| ----------------------- | ------------------------------------------- | --------------------------------------------- |
| HTTP Basic | Builder API, Usage API, Allowances, Balance | `base64(m2m_id:m2m_secret)` |
| Bearer (machine token) | Builder API, Usage API | Short-lived JWT from client credentials grant |
| Bearer (user JWT) | End-user usage routes, AI service requests | Short-lived JWT from user-token mint |
| Bearer (signer session) | Remote signer DMZ | Opaque `pmth_*` from RFC 8693 exchange |
| Provider session cookie | Plans, Billing settings, Stripe Connect | NextAuth session cookie |
HTTP Basic is **required** (not optional) for the canonical Usage API paths (`/api/v1/builder/apps/…/usage*`). Bearer machine tokens are not accepted there.
***
## Getting help
If you've worked through this guide and are still stuck:
1. Check the server logs — PymtHouse logs the specific validation that failed.
2. Verify your credentials against the OIDC discovery document: `GET {issuer}/.well-known/openid-configuration`
3. Use the interactive API docs at `GET /api/v1/docs` to test endpoints directly.
# Usage API
Source: https://docs.pymthouse.com/integration/usage-api
Query aggregated request counts, fee totals, and pipeline/model breakdowns for your app, powered by OpenMeter.
The Usage API is a read-only endpoint that exposes aggregated usage data for a developer application. It is backed by **OpenMeter** (`OPENMETER_URL` is required) — all responses include `"source": "openmeter"`. It is designed for **billing dashboards**, **cost analytics**, **per-user attribution**, and **pipeline/model breakdown** workflows.
All monetary values are expressed in **wei** as decimal strings, with USD micro equivalents available via `include=retail`.
## Authentication
Two auth modes are accepted. The tenant boundary is enforced identically in both: the `clientId` in the URL path must match the authenticated principal's app.
### Confidential client (recommended for server-to-server)
HTTP Basic auth with your M2M credentials:
```http theme={null}
GET /api/v1/apps/{clientId}/usage HTTP/1.1
Authorization: Basic base64(m2m_id:m2m_secret)
```
No additional scope is required beyond possessing valid M2M credentials — the endpoint only returns data for the authenticated client's own app.
### Provider dashboard session
A logged-in provider session whose user is the app's owner, a platform admin, or a `providerAdmins` team member may call the endpoint without Basic auth.
Requests that satisfy neither auth mode, or whose authenticated principal does not match the path `clientId`, receive **`404 Not Found`**. The endpoint deliberately does not distinguish "unauthenticated" from "not found" to avoid leaking app existence.
## Endpoint
```http theme={null}
GET /api/v1/apps/{clientId}/usage
```
### Path parameters
| Parameter | Type | Description |
| ---------- | ------ | -------------------------------------------------------------------------------------- |
| `clientId` | string | OAuth `client_id` of the developer app (`app_…`). Must match the authenticated client. |
### Query parameters
All query parameters are optional.
| Parameter | Type | Default | Description |
| ------------------ | -------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------- |
| `startDate` | ISO 8601 timestamp | — | Inclusive lower bound on record creation time. |
| `endDate` | ISO 8601 timestamp | — | Inclusive upper bound on record creation time. |
| `groupBy` | `none` \| `user` \| `pipeline_model` \| `daily_pipeline` | `none` | Grouping dimension for the response. |
| `userId` | string | — | Filter to a single user by their **internal** PymtHouse `endUserId`. Required for `daily_pipeline`. |
| `gatewayRequestId` | string | — | Filter billing events to a specific gateway request. May include `events` detail in the response. |
| `include` | `retail` | — | When `retail`, adds `endUserBillableUsdMicros` and fiat estimates when the active plan has retail rate cards. |
**Date format:** `Date.parse`-compatible strings are accepted (e.g. `2026-01-01T00:00:00.000Z` or `2026-01-01`). Invalid values return `400 Bad Request`.
**`userId` vs `externalUserId`:** The `userId` parameter accepts the **internal** PymtHouse user id (`endUserId`), not your system's `externalUserId`. Resolve an `externalUserId` to an `endUserId` via a prior `groupBy=user` response.
### `groupBy` values
| Value | Description |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `none` (default) | App-level totals only. |
| `user` | Adds `byUser[]` — one entry per distinct end-user. |
| `pipeline_model` | Adds `byPipelineModel[]` — aggregated from validated `usage_billing_events` rows that have a full `pipeline` + `modelId` constraint. |
| `daily_pipeline` | Adds `byDailyPipeline[]` — OpenMeter DAY windows per pipeline/model. Requires `userId` filter. |
## Response
### 200 OK
```json theme={null}
{
"clientId": "app_f4c21e7ac5f35d3e91bfad7f",
"source": "openmeter",
"period": {
"start": "2026-01-01T00:00:00.000Z",
"end": "2026-12-31T23:59:59.999Z"
},
"totals": {
"requestCount": 1423,
"totalFeeWei": "128750000000000000",
"totalFeeEth": "0.128750",
"networkFeeUsdMicros": "386250000",
"ownerChargeWei": "141625000000000000",
"ownerChargeUsdMicros": "424875000",
"platformFeeWei": "12875000000000000"
},
"byUser": [
{
"endUserId": "5d2b1234-uuid-...",
"externalUserId": "user-123",
"requestCount": 42,
"feeWei": "3750000000000000"
},
{
"endUserId": "unknown",
"externalUserId": null,
"requestCount": 7,
"feeWei": "625000000000000"
}
],
"byPipelineModel": [
{
"pipeline": "text-to-image",
"modelId": "stabilityai/sdxl",
"requestCount": 820,
"networkFeeUsdMicros": "246000000",
"endUserBillableUsdMicros": "369000000"
}
]
}
```
### Response fields
| Field | Type | Description |
| -------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------ |
| `clientId` | string | Echo of the path `clientId`. |
| `source` | `"openmeter"` | Always present. Indicates usage reads from OpenMeter meters. |
| `period.start` | string \| null | Echo of `startDate`, or `null` if omitted. |
| `period.end` | string \| null | Echo of `endDate`, or `null` if omitted. |
| `totals.requestCount` | integer | Total number of usage records matching the filter. |
| `totals.totalFeeWei` | string | Sum of all network fees in wei, as a base-10 decimal string. |
| `totals.totalFeeEth` | string | Decimal ETH equivalent of `totalFeeWei`. |
| `totals.networkFeeUsdMicros` | string | Transaction-time USD micros for network cost. |
| `totals.ownerChargeWei` | string | Network fee plus platform cut, in wei. |
| `totals.ownerChargeUsdMicros` | string | Transaction-time USD micros for owner charge. |
| `totals.platformFeeWei` | string | PymtHouse platform cut in wei. |
| `byUser` | array | Present only when `groupBy=user`. One entry per distinct user. |
| `byUser[].endUserId` | string | Internal PymtHouse user id, or `"unknown"` for unattributed records. |
| `byUser[].externalUserId` | string \| null | Your system's user identifier, when resolvable. |
| `byUser[].requestCount` | integer | Requests attributed to this user. |
| `byUser[].feeWei` | string | Network fees for this user in wei. |
| `byPipelineModel` | array | Present only when `groupBy=pipeline_model`. Aggregated from validated `usage_billing_events`. |
| `byPipelineModel[].pipeline` | string | Pipeline name (e.g. `text-to-image`). |
| `byPipelineModel[].modelId` | string | Model identifier (e.g. `stabilityai/sdxl`). |
| `byPipelineModel[].requestCount` | integer | Validated billing event count for this pipeline/model. |
| `byPipelineModel[].networkFeeUsdMicros` | string | Network cost in USD micros. |
| `byPipelineModel[].endUserBillableUsdMicros` | string \| null | Retail estimate (present when `include=retail` and the plan has rate cards). |
| `byDailyPipeline` | array | Present only when `groupBy=daily_pipeline`. OpenMeter DAY window breakdowns (requires `userId`). |
`totalFeeWei` and `feeWei` are decimal strings, not numbers. They can exceed `Number.MAX_SAFE_INTEGER`. Always parse them with a BigInt-capable library (e.g. `BigInt(feeWei)` in JavaScript, `viem`'s `formatEther` for display).
### The `"unknown"` bucket
Usage records without a resolvable `userId` are grouped under `endUserId: "unknown"` rather than silently dropped. This guarantees that `totals.requestCount` always equals the sum of `byUser[].requestCount` (including the `"unknown"` bucket) when `groupBy=user` is requested.
## Usage balance
Check a user's remaining entitlement balance before allowing access:
```http theme={null}
GET /api/v1/apps/{clientId}/usage/balance?externalUserId={externalUserId}
Authorization: Basic base64(m2m_id:m2m_secret)
```
Response:
```json theme={null}
{
"balanceUsdMicros": "4200000",
"hasAccess": true,
"remainingUsdMicros": "4200000",
"consumedUsdMicros": "800000",
"lifetimeGrantedUsdMicros": "5000000"
}
```
| Field | Description |
| -------------------------- | ------------------------------------------------------------------------------- |
| `balanceUsdMicros` | Current entitlement balance in USD micros from OpenMeter. |
| `hasAccess` | `true` when the user has remaining balance from their active plan subscription. |
| `remainingUsdMicros` | Remaining balance in USD micros. |
| `consumedUsdMicros` | Consumed balance in USD micros this period. |
| `lifetimeGrantedUsdMicros` | Total granted allowance (Starter + manual top-ups). |
See [Allowances](/integration/allowances) for granting additional balance.
## Examples
```bash theme={null}
export BASE_URL="http://localhost:3001" # or your production URL
export CLIENT_ID="app_yourClientId"
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
```
### App-level totals (all time)
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage" | jq .
```
### Per-user breakdown
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage?groupBy=user" | jq .
```
### Pipeline/model breakdown
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage?groupBy=pipeline_model" | jq .
```
### Pipeline/model breakdown with retail estimates
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage?groupBy=pipeline_model&include=retail" | jq .
```
### Month-to-date window
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage\
?startDate=2026-04-01T00:00:00.000Z\
&endDate=2026-04-30T23:59:59.999Z" | jq .
```
### Usage balance for a user
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage/balance?externalUserId=user-123" | jq .
```
### Filter to a single gateway request
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage?gatewayRequestId=job-abc123" | jq .
```
### Format wei as ETH in a shell script
```bash theme={null}
RESPONSE=$(curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/usage")
TOTAL_WEI=$(echo "${RESPONSE}" | jq -r '.totals.totalFeeWei')
node -e "const w = BigInt('${TOTAL_WEI}'); const eth = Number(w) / 1e18; console.log(eth.toFixed(6) + ' ETH')"
```
### SDK helper: usage by external user
```ts theme={null}
import { summarizeUsageForExternalUser } from "@pymthouse/builder-sdk";
const usage = await client.getUsage({ groupBy: "user", startDate, endDate });
const summary = summarizeUsageForExternalUser(usage, "user-123");
// summary.requestCount, summary.feeWei (wei string)
```
## Error responses
| Status | Condition |
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| `400 Bad Request` | `startDate` or `endDate` is not parseable by `Date.parse`. |
| `404 Not Found` | No authenticated principal, credentials valid but for a different app, or `clientId` does not resolve to a known app. |
## Security boundaries
* Tenant isolation is enforced by matching the authenticated client's app to the path `clientId`. A valid credential for a different app returns `404`.
* Provider sessions must be the app owner, a platform admin, or a recorded `providerAdmins` team member.
* No secrets, signer material, per-request payloads, or customer PII are returned.
* Confidential client secrets must stay server-side. Do not call this endpoint from the browser with Basic auth.
## Key design decisions
1. **OpenMeter-authoritative.** All usage reads come from OpenMeter meters (`network_fee_usd_micros`, `signed_ticket_count`). The `"source": "openmeter"` field is always present. Allowance balance reads use OpenMeter entitlement APIs, never Postgres.
2. **`pipeline_model` grouping from billing events.** The `groupBy=pipeline_model` dimension aggregates from validated `usage_billing_events` rows — records that have a full `pipeline` + `modelId` constraint from the gateway payment envelope. Records without this constraint appear only in `totals`.
3. **Async metering.** Signing hot-path performance is not impacted by metering writes. go-livepeer emits events to Kafka; the OpenMeter collector ingests them asynchronously.
4. **`404` for all auth and tenant-mismatch failures.** Collapsing `401`, `403`, and "wrong app" into `404` prevents enumeration of valid `client_id`s.
5. **Retail estimates are opt-in.** `include=retail` triggers retail rate computation from the active plan's rate cards. Authoritative invoicing remains OpenMeter after plan sync.
## Implementation tasks
* Parse `totalFeeWei` and `feeWei` with `BigInt` before any arithmetic.
* When displaying fees in your dashboard, convert from wei using a safe formatter (e.g. `viem`'s `formatEther`).
* For reconciliation workflows, always supply explicit `startDate`/`endDate` bounds.
* Use `groupBy=pipeline_model` to build per-pipeline attribution dashboards. Only requests with full `pipeline` + `modelId` constraints appear in this dimension.
* Check `getUsageBalance()` (or `GET .../usage/balance`) before allowing user actions that consume entitlement.
* Rotate M2M client secrets periodically via the credentials endpoint.
# User management
Source: https://docs.pymthouse.com/integration/user-management
Create, upsert, update, and deactivate users in your PymtHouse app tenant via the Builder API. Manage per-user API keys, allowances, and subscription state.
The Builder API exposes a set of user management endpoints scoped to your app tenant. These endpoints let your backend provision the user records that PymtHouse needs to issue user-scoped JWTs, attribute usage to individuals, and manage per-user billing entitlements.
All endpoints require a **confidential M2M client** for authentication. See [Machine access](/integration/machine-access) for the two auth patterns (Bearer token and HTTP Basic auth).
## Identity model
PymtHouse maps your user identifiers using two distinct id spaces:
| Identifier | Source | Stability | Use |
| ---------------- | ----------------------- | --------------------- | ---------------------------------------------------------------------------------------------- |
| `externalUserId` | Your system | You control it | Join key between your user system and PymtHouse. Pass in create/upsert requests and API paths. |
| `endUserId` | PymtHouse-assigned UUID | Stable after creation | Internal reference; returned by the Usage API for per-user attribution. |
Never construct Builder API paths with internal PymtHouse IDs — always use `externalUserId` in paths and request bodies.
## Base path
```
/api/v1/apps/{clientId}/users
```
`{clientId}` is the **public** `app_…` client id. The tenant boundary is enforced server-side: the authenticated M2M client must belong to the same app as the `clientId` in the path. A mismatch returns `404`.
## Prerequisites
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId" # public client id
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
```
Required M2M scopes per operation:
| Operation | Required scope |
| ----------------- | -------------- |
| List users | `users:read` |
| Create or upsert | `users:write` |
| Update | `users:write` |
| Deactivate | `users:write` |
| Mint user token | `users:token` |
| Per-user API keys | `users:write` |
***
## List users
```http theme={null}
GET /api/v1/apps/{clientId}/users
Authorization: Basic base64(m2m_id:m2m_secret)
```
Returns all provisioned users for the app tenant.
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users" | jq .
```
***
## Create or upsert a user
```http theme={null}
POST /api/v1/apps/{clientId}/users
Authorization: Basic base64(m2m_id:m2m_secret)
Content-Type: application/json
```
This operation is **idempotent**: sending the same `externalUserId` again updates the existing record rather than creating a duplicate. New users are automatically subscribed to the app's Starter plan.
### Request body
```json theme={null}
{
"externalUserId": "user-123",
"email": "alice@example.com",
"status": "active"
}
```
| Field | Type | Required | Description |
| ---------------- | ---------------------- | -------- | ------------------------------------------------------------------------------------------ |
| `externalUserId` | string | Yes | Your stable identifier for this user. Used as the join key; must be unique within the app. |
| `email` | string | No | User's email address. |
| `status` | `active` \| `inactive` | No | User status. Defaults to `active`. |
### Example
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{
"externalUserId": "user-123",
"email": "alice@example.com",
"status": "active"
}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users"
```
The upsert uses a database-level `ON CONFLICT DO UPDATE` to avoid duplicate-key races under concurrent provisioning. It is safe to call from multiple backend instances simultaneously. New users are auto-subscribed to the Starter plan on first provision.
***
## Update user attributes
```http theme={null}
PUT /api/v1/apps/{clientId}/users
Authorization: Basic base64(m2m_id:m2m_secret)
Content-Type: application/json
```
Update attributes on an existing user record. The request body follows the same shape as the create/upsert body.
```bash theme={null}
curl -sS -X PUT \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{
"externalUserId": "user-123",
"email": "alice-new@example.com"
}' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users"
```
***
## Deactivate a user
```http theme={null}
DELETE /api/v1/apps/{clientId}/users?externalUserId={externalUserId}
Authorization: Basic base64(m2m_id:m2m_secret)
```
Sets `status: inactive` on the user. Records are **not hard-deleted** — deactivation preserves the record for usage attribution and audit purposes. You can reactivate a deactivated user by calling POST/PUT with `status: active`.
```bash theme={null}
curl -sS -X DELETE \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users?externalUserId=user-123"
```
***
## Per-user API keys
Long-lived API keys can be issued per-user by your backend and exchanged for short-lived JWTs or signer sessions without repeating device login.
```http theme={null}
GET /api/v1/apps/{clientId}/users/{externalUserId}/keys
POST /api/v1/apps/{clientId}/users/{externalUserId}/keys
DELETE /api/v1/apps/{clientId}/users/{externalUserId}/keys
Authorization: Basic base64(m2m_id:m2m_secret)
```
**GET** returns the list of active API keys for the user.
**POST** creates a new key. Builder-minted keys use a **composite format** (`app_<24hex>_`) so that pathless callers can recover the app `client_id` from a single Bearer header. Response includes the full key (shown once only):
```json theme={null}
{
"keyId": "key-uuid",
"apiKey": "app_a1b2c3d4e5f6a1b2c3d4e5f6_pmth_...",
"createdAt": "2026-04-01T00:00:00.000Z"
}
```
**DELETE** with `?keyId=` revokes the key immediately.
**Personal keys** (issued directly to an app owner or via network self-serve) stay bare `pmth_`. Builder-minted per-user keys are composite so that the remote-signer identity webhook can recover `client_id` from the credential alone. Both key types can be exchanged for signer sessions — see [Token exchange](/integration/token-exchange).
Exchange a per-user API key for a signer session via RFC 8693:
```bash theme={null}
curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=app_a1b2c3d4e5f6..._pmth_..." \
--data-urlencode "subject_token_type=urn:pymthouse:oauth:token-type:api_key" \
"${BASE_URL}/api/v1/oidc/token"
```
See [Token exchange](/integration/token-exchange) for the full exchange flow.
***
## End-user billing
Manage invoices and payment methods for individual end-users via M2M Basic:
| Method | Path | Description |
| ------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `GET` | `.../users/{externalUserId}/invoices` | Invoice list (`{ items, page, pageSize, totalCount }`) |
| `GET` | `.../users/{externalUserId}/invoices/{invoiceId}/hosted-url` | Stripe hosted URL and PDF link |
| `GET` | `.../users/{externalUserId}/payment-methods` | List attached cards |
| `POST` | `.../users/{externalUserId}/payment-methods` | Setup-mode Checkout (does not change plan) |
| `POST` | `.../users/{externalUserId}/subscription/change` | Switch plan; paid targets may return a Connect `checkoutUrl` |
See [Builder M2M Payments API → End-user billing](/integration/payments#end-user-billing) for the full reference.
***
## App-level API keys
App-level keys (personal keys tied to the app owner) are managed separately:
```http theme={null}
GET /api/v1/apps/{clientId}/keys
POST /api/v1/apps/{clientId}/keys
DELETE /api/v1/apps/{clientId}/keys
```
Auth: provider dashboard session. Personal keys are bare `pmth_`. They are suitable for integrations where a single credential covers the entire app rather than individual users.
***
## User allowances and entitlements
Per-user USD micro allowances are managed via the allowances endpoint:
```http theme={null}
GET /api/v1/apps/{clientId}/users/{externalUserId}/allowances
POST /api/v1/apps/{clientId}/users/{externalUserId}/allowances
```
And balance is checked at:
```http theme={null}
GET /api/v1/apps/{clientId}/usage/balance?externalUserId={externalUserId}
```
See [Allowances](/integration/allowances) for the full reference.
***
## User subscription status
Read the OpenMeter subscription state for a specific end-user:
```http theme={null}
GET /api/v1/apps/{clientId}/users/{externalUserId}/subscription
Authorization: Basic base64(m2m_id:m2m_secret)
```
Returns the user's active plan subscription (Starter or a paid checkout plan), including status, period, and entitlement details.
***
## Bulk provisioning
There is no batch endpoint. For bulk provisioning, loop over your user set and call POST for each user. The upsert semantics make it safe to re-run the loop — already-provisioned users will be updated in place.
```bash theme={null}
while IFS=',' read -r external_id email; do
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d "{\"externalUserId\": \"${external_id}\", \"email\": \"${email}\", \"status\": \"active\"}" \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users"
done < users.csv
```
For high-volume initial imports, acquire one machine token (client credentials grant) and reuse it across the loop rather than re-authenticating per request.
***
## Error responses
| Status | Condition |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `400 Bad Request` | Missing `externalUserId`, malformed JSON, or invalid `status` value. |
| `401 Unauthorized` | Invalid M2M credentials or expired Bearer token. |
| `403 Forbidden` | M2M client lacks the required scope (`users:write` or `users:read`). |
| `404 Not Found` | `clientId` in the path does not match the authenticated M2M client's app, or `externalUserId` not found. |
***
## Key design decisions
1. **`externalUserId` as the join key, not an internal ID.** This eliminates the need for integrators to store PymtHouse-internal IDs and removes the risk of foreign-key coupling between two systems.
2. **Upsert semantics by default on POST.** Idempotent provisioning makes it safe to call from retry logic or concurrent workers.
3. **Soft delete only (`status: inactive`).** Hard-deleting a user record would orphan historical usage records. Keeping the record preserves the join between `usage_records.user_id` and `app_users`.
4. **Auto-Starter subscription on provision.** New users start with the Starter plan allowance immediately after `POST /users`, without requiring a separate subscription step.
## Implementation tasks
* Call POST with `externalUserId` during your user creation flow so the PymtHouse record is ready before the first JWT mint.
* Implement a lightweight reconciliation job that calls POST/PUT for users whose attributes have changed in your system.
* When deactivating users in your system, call DELETE on the PymtHouse side to prevent new JWT issuance.
* Ensure your `externalUserId` values are stable and unique within your app.
* Use `GET .../usage/balance` before allowing signed requests to verify the user has remaining entitlement.
* For high-concurrency environments, use the Bearer token pattern (one token per batch) rather than re-authenticating on each call.
# User-scoped JWTs
Source: https://docs.pymthouse.com/integration/user-tokens
Mint short-lived access tokens scoped to a specific end-user and capability via the Builder API.
The user-token endpoint lets your backend issue a short-lived JWT on behalf of a provisioned end-user. These tokens carry the user's identity as their `sub` and are scoped to a specific capability (e.g. `sign:job`). Downstream PymtHouse services validate this token before processing any user request.
## When to mint user tokens
Mint a user-scoped JWT when:
* Your backend needs to authorize an end-user for a PymtHouse service on their behalf.
* You are completing a device flow (RFC 8628) and need a `subject_token` for the RFC 8693 exchange.
* You want to pass a short-lived, user-attributable credential to your frontend or CLI rather than a long-lived secret.
## Prerequisites
* The user must already be provisioned in PymtHouse. See [User management](/integration/user-management).
* Your M2M client (`m2m_…`) must have `users:token` scope.
* The requested scope must be listed in the **public** client's `allowed_scopes` — not the M2M client's. For example, to request `sign:job`, the public `app_…` client must have `sign:job` in its `allowed_scopes`.
```bash theme={null}
export BASE_URL="https://pymthouse.com"
export CLIENT_ID="app_yourClientId" # public client id
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
```
## Endpoint
```http theme={null}
POST /api/v1/apps/{clientId}/users/{externalUserId}/token
Authorization: Basic base64(m2m_id:m2m_secret)
Content-Type: application/json
```
`{clientId}` is the **public** `app_…` client id. `{externalUserId}` is your system's user identifier as stored during provisioning.
## Request body
```json theme={null}
{ "scope": "sign:job" }
```
| Field | Type | Required | Description |
| ------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scope` | string | No | Space-separated list of scopes to include in the issued JWT. Defaults to `sign:job` if omitted. Must be a subset of the public client's `allowed_scopes`. |
`admin` is explicitly rejected regardless of scope configuration. User tokens can never escalate to administrative privilege.
## Example
```bash theme={null}
USER_JWT=$(curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{ "scope": "sign:job" }' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/token" \
| jq -r '.access_token')
echo "User JWT: ${USER_JWT:0:60}..."
```
**Response:**
```json theme={null}
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 300
}
```
## JWT claims
The issued JWT contains:
| Claim | Value | Notes |
| ------------------- | ------------------------------------------ | ----------------------------------------------- |
| `iss` | PymtHouse issuer URL | Verify against discovery. |
| `sub` | `app_users.id` (PymtHouse app-user row id) | Not the same as `externalUserId`. |
| `client_id` / `azp` | Public `app_…` client id | Used for tenant matching in RFC 8693 exchanges. |
| `scope` | Granted scopes | Subset of the public client's `allowed_scopes`. |
| `exp` | Expiry timestamp | Tokens are short-lived by design. |
`sub` is the PymtHouse internal app-user id, **not** your `externalUserId`. If you need to correlate the JWT back to your user, use the `client_id`/`azp` + your own session context rather than parsing `sub`.
## Passing the token to downstream services
Pass the JWT in a standard `Authorization: Bearer` header to any PymtHouse service that validates it:
```bash theme={null}
curl -sS \
-H "Authorization: Bearer ${USER_JWT}" \
"https://your-signer.example/sign"
```
The receiving service verifies the JWT signature against `{issuer}/jwks`, checks `exp`, and validates `scope` includes the required capability before processing.
## Scope validation flow
```mermaid theme={null}
flowchart LR
A[M2M client calls /users/id/token] --> B{M2M has users:token?}
B -- No --> C[403 Forbidden]
B -- Yes --> D{Requested scope ⊆ public client allowed_scopes?}
D -- No --> E[400 invalid_scope]
D -- Yes --> F[Issue JWT with requested scope]
```
The requested scope is validated against the **public** client's `allowed_scopes`, not the M2M client's. This means the M2M client cannot grant a user more capability than the public client's registration allows, regardless of what scopes the M2M client itself holds.
## Token lifetime and refresh
User-scoped JWTs are intentionally short-lived (seconds to minutes). There is no refresh token for programmatic user JWTs — your backend simply mints a new one when needed. This design:
* Limits the blast radius of a leaked user token.
* Keeps revocation implicit (expiry) rather than requiring an explicit revocation endpoint.
* Ensures the scope remains correct even if the public client's `allowed_scopes` changes.
## Device flow integration
When completing an RFC 8628 device grant via RFC 8693 token exchange, the user JWT you mint here becomes the `subject_token`:
```bash theme={null}
# 1. Mint user JWT
USER_JWT=$(curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/json" \
-d '{ "scope": "sign:job" }' \
"${BASE_URL}/api/v1/apps/${CLIENT_ID}/users/user-123/token" \
| jq -r '.access_token')
# 2. Bind device grant
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token=${USER_JWT}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
--data-urlencode "resource=urn:pmth:device_code:ABCD-EFGH" \
"${BASE_URL}/api/v1/oidc/token"
```
For the full end-to-end flow, see [Token exchange — device completion](/integration/token-exchange#device-completion).
## Error responses
| Status | Condition |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| `400 Bad Request` | Requested scope includes `admin`, or scope is not a subset of the public client's `allowed_scopes`. |
| `401 Unauthorized` | Invalid M2M credentials or expired Bearer token. |
| `403 Forbidden` | M2M client lacks `users:token`. |
| `404 Not Found` | `clientId` path does not match the M2M client's app, or `externalUserId` has not been provisioned. |
## Key design decisions
1. **Scope validation against the public client, not the M2M client.** The public `app_…` client represents the app's registration contract with the platform — what capabilities it is permitted to grant users. The M2M client is an operational credential. Validating the requested scope against the public client ensures users cannot receive capabilities that exceed the app's registration, even if the M2M client holds broader scopes.
2. **No `admin` scope in user tokens.** The user-token path is explicitly designed for end-user contexts. Administrative privilege cannot be embedded in a token that is intended to be passed to the user session or a client SDK. This is enforced at the route level, not as a policy check.
3. **`sub` is the app-user row id.** Using the PymtHouse internal `app_users.id` as `sub` makes the token's subject stable under email or `externalUserId` changes, and avoids leaking the integrator's internal user identifier in a standard JWT claim that may be logged or decoded by third-party services.
4. **Short lifetime, no refresh.** User JWTs are issued on demand by a backend that already holds the M2M credential. Making them short-lived with no refresh means the blast radius of a leaked token is bounded to its TTL. Re-minting is a single backend API call rather than a stateful refresh flow.
## Implementation tasks
* Provision the user with the Builder API before calling this endpoint. A `404` on the user-token path most commonly means the `externalUserId` was never provisioned, not that the credentials are wrong.
* Verify the requested scope is listed in the public client's `allowed_scopes` before calling — you will get a `400` otherwise, and surfacing that at call time makes debugging easier.
* Do not store user JWTs beyond the scope of a single request chain. Mint fresh tokens for each user session or device flow.
* In your JWT verification logic for downstream services, check `client_id` or `azp` against the known public `app_…` client id to confirm the token was issued for your app before trusting the `scope` claim.
# Quickstart
Source: https://docs.pymthouse.com/quickstart
Provision a user, mint a signed JWT, and verify usage tracking — all with working curl commands.
By the end of this guide your backend will be able to:
1. Authenticate as a machine client (server-to-server)
2. Provision an end-user in your app's tenant
3. Issue a signed, scoped JWT for that user
4. Gate a request on the user's entitlement balance
5. Query usage to confirm metering is working
**Time:** \~10 minutes\
**What you need:** `curl`, `jq`, and credentials from your PymtHouse app registration
## Prerequisites
You need three values from your registered developer app. If you don't have these yet, ask your platform admin or check your app's settings page.
```bash theme={null}
export BASE_URL="https://pymthouse.com" # or http://localhost:3001 for local dev
export PUBLIC_CLIENT_ID="app_yourClientId" # public client — appears in JWT claims
export M2M_ID="m2m_yourClientId" # M2M client — server-side only
export M2M_SECRET="pmth_cs_yourSecret" # M2M secret — server-side only
```
For local development, `BASE_URL` is `http://localhost:3001`. Your M2M and public client IDs are created automatically during app setup.
***
## Step 1 — Authenticate your backend
Exchange your M2M credentials for a short-lived machine token. This token authorizes Builder API calls on behalf of your app.
```bash theme={null}
MACHINE_TOKEN=$(curl -sS \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=${M2M_ID}" \
-d "client_secret=${M2M_SECRET}" \
-d "scope=users:write users:token" \
"${BASE_URL}/api/v1/oidc/token" | jq -r '.access_token')
echo "Token acquired: ${MACHINE_TOKEN:0:50}..."
```
You'll use this token for the next two steps. Machine tokens are short-lived — acquire a fresh one per request batch rather than persisting them.
`scope=users:write users:token` is the minimum needed for this guide. `users:write` lets you provision users; `users:token` lets you mint JWTs for them.
***
## Step 2 — Provision a user
Register a user in your app's tenant. Use your own identifier — PymtHouse calls this `externalUserId`. This call is **idempotent**: running it again updates the existing record instead of creating a duplicate.
```bash theme={null}
curl -sS \
-H "Authorization: Bearer ${MACHINE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"externalUserId": "user-123",
"email": "alice@example.com",
"status": "active"
}' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users" | jq .
```
A `200` response confirms the user exists. PymtHouse automatically subscribes new users to the app's **Starter plan** — they start with a default \$5.00 USD allowance for AI job requests.
***
## Step 3 — Issue a signed user JWT
Mint a short-lived access token scoped to this user. This is what you'll pass to AI services as proof that this user is authorized to make a request.
```bash theme={null}
USER_JWT=$(curl -sS \
-H "Authorization: Bearer ${MACHINE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{ "scope": "sign:job" }' \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/users/user-123/token" \
| jq -r '.access_token')
echo "User JWT: ${USER_JWT:0:60}..."
```
This JWT has:
* `sub` — the PymtHouse user record id
* `azp` / `client_id` — your public `app_…` client id
* `scope` — `sign:job` (gates this user to AI signing requests)
Pass it as `Authorization: Bearer ${USER_JWT}` to any PymtHouse-integrated service.
***
## Step 4 — Check the user's entitlement balance
Before dispatching an AI request, verify the user has remaining balance. This is the access gate.
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/usage/balance?externalUserId=user-123" | jq .
```
Response:
```json theme={null}
{
"hasAccess": true,
"balanceUsdMicros": "5000000",
"remainingUsdMicros": "5000000",
"consumedUsdMicros": "0",
"lifetimeGrantedUsdMicros": "5000000"
}
```
`hasAccess: true` means the user can proceed. Gate your request on this field. When the balance is exhausted, `hasAccess` becomes `false` and the signer will reject requests with `trial_credits_exhausted`.
***
## Step 5 — Query usage
After your first AI request goes through, verify it was metered:
```bash theme={null}
curl -sS \
-u "${M2M_ID}:${M2M_SECRET}" \
"${BASE_URL}/api/v1/apps/${PUBLIC_CLIENT_ID}/usage?groupBy=user" | jq .
```
Usage appears here after the OpenMeter collector ingests the signed-ticket event from the AI backend. There is a short async delay (typically under 30 seconds in production).
***
## What's next
You have the core flow working. Now wire it into your product:
Full working examples: SaaS app, CLI device flow, and metered billing. Pick the one that matches your architecture.
Replace the curl calls with one TypeScript client. `mintSignerSessionForExternalUser` handles upsert + mint + exchange in one call.
Add browser-based login for users authenticating from a CLI or terminal.
Connect Stripe, configure plans, and let users pay for usage beyond the Starter allowance.
### SDK equivalent
The entire quickstart in TypeScript using the Builder SDK. See [Builder SDK](/integration/sdk) for install instructions and the full method reference.
```ts theme={null}
import { PmtHouseClient, PmtHouseError } from "@pymthouse/builder-sdk";
const client = new PmtHouseClient({
issuerUrl: process.env.PYMTHOUSE_ISSUER_URL!,
publicClientId: process.env.PYMTHOUSE_PUBLIC_CLIENT_ID!,
m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID!,
m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET!,
// Set to true for local dev when issuer URL is http://
allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
});
// Steps 2–3: mint user JWT (auto-provisions on first call via 404-retry)
async function mintUserToken(externalUserId: string): Promise {
try {
const { access_token } = await client.mintUserAccessToken({ externalUserId });
return access_token;
} catch (err) {
if (err instanceof PmtHouseError && err.status === 404 && err.code === "not_found") {
await client.upsertAppUser({ externalUserId });
const { access_token } = await client.mintUserAccessToken({ externalUserId });
return access_token;
}
throw err;
}
}
// Step 4: check balance before dispatching
const balance = await client.getUsageBalance("user-123");
if (!balance.hasAccess) {
throw new Error("Insufficient balance — top up or upgrade plan");
}
// Step 3: mint the token
const userJwt = await mintUserToken("user-123");
console.log("User JWT:", userJwt.slice(0, 40) + "...");
```