Skip to main content
PymtHouse uses a two-client model for every interactive developer app. Understanding the split between a public client and a confidential M2M client — the scope table that governs both — and the role of opaque API keys is a prerequisite for correctly implementing any integration pattern.

The two-client model

Each developer app that needs both end-user and server-to-server access has two OIDC clients registered in PymtHouse: The two clients are siblings: developer_apps.oidc_client_id → public row; developer_apps.m2m_oidc_client_id → M2M row.
Never add a secret to the public client. Doing so would break device login because the token endpoint would then require the secret for every device poll request initiated by a CLI or SDK.

Why two clients instead of one?

OAuth 2.0 draws a hard line between public clients (those that cannot keep a secret, such as native apps and CLIs) and confidential clients (servers that can). A single client cannot satisfy both security requirements:
  • Device flow polling happens in a CLI or SDK — no place to store a secret.
  • Builder API calls must be authenticated to enforce the tenant boundary — a secret is required.
Separating them also means that rotating the M2M secret never affects active device sessions, and that a compromised public client_id does not expose any server-side credential.

API keys (pmth_*)

In addition to short-lived JWTs, PymtHouse issues long-lived opaque API keys prefixed pmth_*. These are used for:
  • M2M client secrets — The pmth_cs_… secret on the confidential client, used for HTTP Basic auth on Builder API calls.
  • Per-user API keyspmth_ak_… keys created via POST .../users/{externalUserId}/keys, exchangeable for short-lived JWTs or signer sessions.
  • App-level API keyspmth_ak_… keys tied to a subscription, created via POST .../keys.
API keys are long-lived and must be stored securely server-side. Exchange them for short-lived JWTs on demand rather than using them directly on the signing hot path. See API keys for the exchange flows.

Environment variables

Follow this naming convention when configuring your integration. Mistakes here are a common source of 400 invalid_scope and 401 errors.
The @pymthouse/builder-sdk/env subpath reads these exact variable names. See Builder SDK.

OAuth scopes

Scopes control what each client can request and what claims appear in issued tokens.

Public client scopes (allowed_scopes)

These scopes are configured on the public app_… client. They govern:
  1. End-user token claims — which claims appear in tokens issued to the user.
  2. Programmatic user JWT requests — scopes requested when calling the user-token mint endpoint are validated against this list, not the M2M list.
  3. Billing pattern — see below.

M2M client scopes (allowed_scopes)

These scopes gate server-side calls from the confidential m2m_… client. Grant only the minimum scopes the server-side flow requires.

Billing pattern

The presence of users:token in the public client’s allowed_scopes determines the billing mode for the app: This is derived from src/lib/allowed-scopes.ts → billingPatternFromAllowedScopesString. Add users:token to the public client only if your use case requires per-user billing data from the Usage API.

Client registration

Clients are created and managed through the developer dashboard or API. npm run oidc:seed initialises signing keys only — it does not create application clients. Ask the platform administrator if you need a client pair provisioned in a shared environment.

Key design decisions

  1. client_id as the URL tenant identifier. All Builder API paths use /api/v1/apps/{clientId}/… where clientId is the public app_… value. This avoids exposing internal database IDs and keeps the URL stable across secret rotations.
  2. Secrets on M2M only. Placing the secret exclusively on the M2M client allows the public client to be freely embedded in CLI binaries, native apps, and JavaScript SDKs without creating a secret-exposure risk.
  3. Scope policy on the public client governs JWT claims. Validating the requested scope for user-JWT minting against the public client means users cannot receive capabilities that exceed what the app registered for.
  4. Billing pattern inferred from scope. Using the presence of users:token in allowed_scopes rather than a separate flag means the billing mode is a direct consequence of the integration pattern rather than an independent setting.
  5. sign:mint_user_token auto-derived. Auto-adding this scope to M2M clients when the public client has sign:job prevents a configuration footgun where the public scope and M2M clearinghouse capability drift out of sync.

Implementation tasks

  • Register your app and both clients (public + M2M) before testing any endpoint.
  • Store PYMTHOUSE_M2M_CLIENT_ID and PYMTHOUSE_M2M_CLIENT_SECRET in your backend secret manager; never expose them to the browser, mobile app, or CLI.
  • Confirm that PYMTHOUSE_PUBLIC_CLIENT_ID is the app_… public id wherever it appears in device or browser flows.
  • Add only the scopes listed in the table above to each client; remove any default placeholder scopes added by tooling.
  • Rotate M2M secrets through the credentials endpoint; update the secret in your secret manager without touching the public client.
  • For per-user API key flows, use POST .../users/{externalUserId}/keys — see API keys.