diff --git a/README.md b/README.md
index f575801..07efaa3 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
Public integrator documentation for PymtHouse. Production URL: **https://docs.pymthouse.com**.
-This repository is the **Mintlify site root** (`docs.json` lives here). The main application and canonical contract markdown live in the **[pymthouse](https://github.com/eliteprox/pymthouse)** monorepo.
+This repository is the **Mintlify site root** (`docs.json` lives here). The main application and canonical contract markdown live in the **[pymthouse](https://github.com/pymthouse/pymthouse)** monorepo.
## Prerequisites
@@ -40,6 +40,10 @@ npm run validate
Canonical contract text for code review still lives in the pymthouse repo:
- `docs/builder-api.md`
-- `docs/naap-oidc-integration.md`
-When those files change, update the matching pages under `integration/` in this repository in the same release cycle (same PR on each repo, or coordinated merges).
+When that file changes, update the matching pages under `integration/` and `api-reference/` in this repository in the same release cycle (same PR on each repo, or coordinated merges).
+
+Machine-readable endpoints are also available on every deployment:
+
+- `GET /api/v1/openapi.json`
+- `GET /api/v1/docs` (Scalar UI)
diff --git a/api-reference/introduction.mdx b/api-reference/introduction.mdx
index 89ebb0f..e9c7751 100644
--- a/api-reference/introduction.mdx
+++ b/api-reference/introduction.mdx
@@ -3,7 +3,7 @@ title: API reference
description: How the PymtHouse HTTP APIs and OIDC surface fit together, and where to find endpoint-level documentation.
---
-PymtHouse exposes a **unified OpenID Connect issuer** plus **REST-style Builder and billing APIs** under a single base URL. This tab will collect machine-readable **OpenAPI** definitions as they become available; the live **authorization, token, and user-management** paths are also listed in your tenant's [OIDC discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) document so your client always tracks endpoint rotations.
+PymtHouse exposes a **unified OpenID Connect issuer** plus **REST-style Builder and billing APIs** under a single base URL. A machine-readable **OpenAPI 3.1 document** and interactive **Scalar UI** are served directly from your PymtHouse instance. The live **authorization, token, and user-management** paths are also listed in your tenant's [OIDC discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) document so your client always tracks endpoint rotations.
## Base URL and discovery
@@ -12,15 +12,32 @@ PymtHouse exposes a **unified OpenID Connect issuer** plus **REST-style Builder
Always resolve OAuth and OIDC URLs from discovery in production. Hard-coded paths are brittle when routes or hostnames change.
+## Live API reference
+
+Two self-documentation endpoints are available on every PymtHouse deployment:
+
+| Endpoint | Description |
+| --- | --- |
+| `GET /api/v1/openapi.json` | OpenAPI 3.1 document generated from scanned route handlers and per-route metadata. |
+| `GET /api/v1/docs` | Interactive Scalar API reference UI — browse, try requests, and inspect schemas. |
+
+OIDC issuer metadata remains at `{issuer}/.well-known/openid-configuration`. Signer session exchange is documented at `POST /api/v1/apps/{clientId}/oidc/token`. The OIDC provider token endpoint (`POST /api/v1/oidc/token`) covers standard OAuth grants only.
+
+
+ After adding route handlers in the pymthouse app, run `npm run openapi:generate` to refresh the inventory. CI runs `npm run check:openapi` to fail on metadata drift.
+
+
## Where to find endpoint documentation
-All endpoint guides currently live in the **Builder guides** tab:
+Narrative guides live in the **Builder guides** tab:
| Need | Go to |
| --- | --- |
| End-to-end auth flows, PKCE, device code, token exchange | [Builder guides](/quickstart) tab |
| **Usage API** (aggregated usage, billing analytics) | [Usage API](/integration/usage-api) in Builder guides |
| User management, app tokens, billing endpoints | [Builder guides](/integration/user-management) and related pages in that tab |
+| Interactive OpenAPI browser | `GET /api/v1/docs` on your PymtHouse instance |
+| Removed routes and migrations | [Deprecated routes](/integration/deprecated) |
## Next steps
diff --git a/index.mdx b/index.mdx
index 5e643f4..f05c00c 100644
--- a/index.mdx
+++ b/index.mdx
@@ -20,7 +20,7 @@ All integrations share the same identity model: every registered app gets a **pu
TypeScript client for the Builder API, OIDC issuer, and signer integration. Install in one line.
- Long-lived pmth_* keys for per-user and app-level integrations. Exchange for short-lived JWTs on demand.
+ Long-lived per-user `pmth_*` keys. Exchange at `POST .../oidc/token` for a SignerSession.
@@ -77,7 +77,7 @@ Choose the authentication pattern that matches your integration:
## Migration
- Migration guide for removed endpoints: signer proxy (410), synchronous ingest (410), and deprecated credits/subscriptions.
+ Migration guide for removed endpoints: auth/api-key/*, app-level keys, GET validate, signer proxy, and credits/subscriptions.
## Discovery
diff --git a/integration/api-keys.mdx b/integration/api-keys.mdx
index 8d389df..057d82a 100644
--- a/integration/api-keys.mdx
+++ b/integration/api-keys.mdx
@@ -1,21 +1,26 @@
---
title: API keys
-description: Long-lived opaque pmth_* API keys for app-level and per-user integrations. Exchange for short-lived JWTs or signer sessions.
+description: Long-lived per-user pmth_* API keys. Exchange them at the app-scoped OIDC token endpoint for SignerSession envelopes.
---
-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.
+PymtHouse issues **long-lived opaque API keys** for per-app users. Use them as the `subject_token` on the app-scoped token exchange endpoint when you need a short-lived `SignerSession` without repeating a full OAuth login.
-Two types of API keys exist:
+Do **not** mix credential types. Prefixes have fixed roles:
-| 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` |
+| Prefix | Role | RFC usage |
+| --- | --- | --- |
+| `pmth_` | Per-app-user **API key** (stored / hashed form) | `subject_token` on `POST /api/v1/apps/{clientId}/oidc/token` |
+| `app_.pmth_` | **Presented** API key (issuance + remote-signer Bearer) | Same as bare `pmth_*`; the `app_*` prefix routes the key to the correct app |
+| `pmth_cs_` | Confidential **M2M client secret** | HTTP Basic with `m2m_…` client id — never pass as `subject_token` |
+| `app_…` / `m2m_…` | Public / confidential OAuth client ids | Path params and token endpoint `client_id` |
+
+Newly issued keys are returned as `app_.pmth_` (RFC 6750-safe `.` separator). Bare `pmth_*` remains valid as `subject_token` when the path already supplies `{clientId}`.
+
+App-level keys at `GET`/`POST`/`DELETE /api/v1/apps/{clientId}/keys` were **removed**. Use per-user keys only. See [Deprecated routes](/integration/deprecated).
## 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.
+Issue long-lived API keys to specific end-users. These suit CLI tools and service accounts where the user authenticates once and stores the key.
### Create a key
@@ -24,7 +29,7 @@ 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.
+The full secret is returned **once only** at creation time. Store it securely.
```bash
export BASE_URL="https://your-pymthouse.example"
@@ -42,7 +47,7 @@ Response:
```json
{
"keyId": "key-uuid",
- "apiKey": "pmth_ak_abc123...",
+ "apiKey": "app_yourClientId.pmth_a1b2c3...",
"createdAt": "2026-04-01T00:00:00.000Z"
}
```
@@ -54,7 +59,7 @@ 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.
+Returns key IDs and creation timestamps. The secret value is never returned after creation.
### Revoke a key
@@ -67,37 +72,36 @@ Revocation is immediate. Any exchange request using the revoked key returns `401
---
-## App-level API keys
+## Exchange API key → SignerSession
-App-level keys are tied to a subscription and suitable for app-wide machine access without a specific user context.
+Exchange a `pmth_*` (or presented `app_….pmth_*`) API key for a clearinghouse-compatible **`SignerSession`** via RFC 8693 token exchange:
```http
-GET /api/v1/apps/{clientId}/keys
-POST /api/v1/apps/{clientId}/keys
-DELETE /api/v1/apps/{clientId}/keys
+POST /api/v1/apps/{clientId}/oidc/token
+Content-Type: application/x-www-form-urlencoded
```
-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
-POST /api/v1/apps/{clientId}/auth/api-key/token
-Authorization: Bearer pmth_ak_abc123...
-Content-Type: application/json
-```
+| Field | Value |
+| --- | --- |
+| `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange` |
+| `subject_token` | User API key (`pmth_*` or `app_….pmth_*`) |
+| `subject_token_type` | `urn:ietf:params:oauth:token-type:access_token` |
+| `audience` / `resource` | Optional; when set must match the configured signer audience |
-Optional request body:
+Optional HTTP Basic with the M2M client (`m2m_*` + secret). When omitted, the `subject_token` alone authenticates the exchange.
-```json
-{ "scope": "sign:job" }
+```bash
+API_KEY="pmth_a1b2c3..." # or app_yourClientId.pmth_a1b2c3...
+
+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=${API_KEY}" \
+ --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
+ "${BASE_URL}/api/v1/apps/${CLIENT_ID}/oidc/token"
```
-Response: OIDC token bundle.
+Response — canonical **`SignerSession`** envelope:
```json
{
@@ -105,71 +109,67 @@ Response: OIDC token bundle.
"token_type": "Bearer",
"expires_in": 300,
"scope": "sign:job",
- "externalUserId": "user-123"
+ "signer_url": "https://dmz.example.com",
+ "discovery_url": "https://discovery.livepeer.cloud",
+ "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "correlation_id": "…",
+ "balanceUsdMicros": "4200000",
+ "lifetimeGrantedUsdMicros": "5000000"
}
```
-```bash
-API_KEY="pmth_ak_abc123..."
+| Field | Description |
+| --- | --- |
+| `access_token` | Short-lived signer JWT for the remote DMZ. |
+| `signer_url` | Optional remote signer DMZ URL. |
+| `discovery_url` | Optional Livepeer network discovery URL (not OIDC metadata). |
+| `issued_token_type` | RFC 8693 issued token type when present. |
+| `correlation_id` | Optional request correlation id. |
+| `balanceUsdMicros` | Current OpenMeter entitlement balance (USD micros). |
+| `lifetimeGrantedUsdMicros` | Lifetime granted allowance (USD micros). |
-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"
-```
+The removed routes `POST .../auth/api-key/token` and `POST .../auth/api-key/signer-session` are gone — always use `POST .../oidc/token`.
-SDK:
-
-```ts
-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:
+SDK (when available in your `@pymthouse/builder-sdk` version):
```ts
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
+// session.access_token — signer JWT for the DMZ
```
+Prefer calling `POST .../oidc/token` directly if your SDK still documents a facade URL or the old `auth/api-key/*` paths.
+
---
-## Validate an API key (subscription-backed)
+## Validate an API key
-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:
+Validate a presented key with a JSON body (GET validation was removed):
```http
-GET /api/v1/auth/validate
-Authorization: Bearer pmth_ak_abc123...
+POST /api/v1/auth/validate
+Content-Type: application/json
+
+{ "key": "pmth_…" }
```
-Returns `{ valid: true, planId: "...", capabilities: [...] }` on success.
+Requires `BPP_VALIDATE_V2=1` on the PymtHouse deployment. See live schemas at `GET /api/v1/openapi.json` or the Scalar UI at `GET /api/v1/docs`.
---
## 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.
+- Store API keys in a secret manager, not in source code or committed environment files.
+- The secret is returned once at creation. If lost, revoke the key and create a new one.
+- Prefer short-lived JWTs on the signing hot path; exchange the stored API key on demand.
+- Never pass `pmth_cs_*` as `subject_token` — that prefix is only for M2M HTTP Basic auth.
- 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.
+- Use per-user keys (not the M2M client secret) 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
+- [Token exchange](/integration/token-exchange) — RFC 8693 device completion and signer session exchange
- [Signer routing](/integration/signer-routing) — using the signer session at the DMZ
-- [Builder SDK](/integration/sdk) — `exchangeApiKeyForUserAccessToken`, `exchangeApiKeyForSignerSession`
+- [Deprecated routes](/integration/deprecated) — removed `auth/api-key/*` and app-level `/keys` paths
diff --git a/integration/billing.mdx b/integration/billing.mdx
index 01788b0..7293477 100644
--- a/integration/billing.mdx
+++ b/integration/billing.mdx
@@ -141,7 +141,9 @@ For `free` plans, `overageUnits` and `overageWei` are always `"0"`.
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.
+New end-users are auto-subscribed to the Starter plan when provisioned (via `POST /users`, signer token mint, or Kafka collector ingest) if they have no existing subscription.
+
+**Stripe billing is not required for Starter access.** Apps without Stripe Connect linked (or with incomplete Stripe setup) still grant Free Starter access. PymtHouse recovers from Stripe-related setup errors during subscription provisioning rather than failing the user — Connect Stripe via `POST /api/v1/apps/{clientId}/billing/stripe/connect` when you are ready for paid plans and invoicing.
Providers can update the Starter allowance via:
diff --git a/integration/client-model.mdx b/integration/client-model.mdx
index ecd87f3..8fcbaea 100644
--- a/integration/client-model.mdx
+++ b/integration/client-model.mdx
@@ -29,15 +29,18 @@ OAuth 2.0 draws a hard line between public clients (those that cannot keep a sec
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_*`)
+## Credential types (do not mix)
-In addition to short-lived JWTs, PymtHouse issues **long-lived opaque API keys** prefixed `pmth_*`. These are used for:
+| Prefix | Role | RFC usage |
+| --- | --- | --- |
+| `pmth_` | Per-app-user **API key** (stored / hashed) | `subject_token` on `POST /api/v1/apps/{clientId}/oidc/token` |
+| `app_.pmth_` | **Presented** API key (issuance + remote-signer Bearer) | Same as bare `pmth_*`; prefix routes to the app exchange URL |
+| `pmth_cs_` | Confidential **M2M client secret** | HTTP Basic with `m2m_…` — never as `subject_token` |
+| `app_…` / `m2m_…` | Public / confidential OAuth client ids | Path params and token endpoint `client_id` |
-- **M2M client secrets** — The `pmth_cs_…` secret on the confidential client, used for HTTP Basic auth on Builder API calls.
-- **Per-user API keys** — `pmth_ak_…` keys created via `POST .../users/{externalUserId}/keys`, exchangeable for short-lived JWTs or signer sessions.
-- **App-level API keys** — `pmth_ak_…` keys tied to a subscription, created via `POST .../keys`.
+Per-user API keys are created with `POST .../users/{externalUserId}/keys`. Newly issued keys are returned as `app_.pmth_`. App-level keys at `/api/v1/apps/{clientId}/keys` were removed.
-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](/integration/api-keys) for the exchange flows.
+API keys are long-lived and must be stored securely server-side. Exchange them for a short-lived **`SignerSession`** via `POST /api/v1/apps/{clientId}/oidc/token` rather than using them as long-lived Bearer tokens on the signing hot path. See [API keys](/integration/api-keys).
## Environment variables
@@ -115,4 +118,4 @@ Clients are created and managed through the developer dashboard or API. `npm run
- 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](/integration/api-keys).
+- For per-user API key flows, use `POST .../users/{externalUserId}/keys`, then exchange at `POST .../oidc/token` — see [API keys](/integration/api-keys).
diff --git a/integration/deprecated.mdx b/integration/deprecated.mdx
index ca9785b..6536e09 100644
--- a/integration/deprecated.mdx
+++ b/integration/deprecated.mdx
@@ -1,9 +1,26 @@
---
title: Deprecated routes and migrations
-description: Migration guide for removed endpoints. The hosted signer proxy, synchronous signed-ticket ingest, and legacy subscription and credits endpoints have been replaced.
+description: Migration guide for removed endpoints in PymtHouse 0.3.x. Use the canonical replacements below.
---
-This page documents endpoints that have been **removed** (returning `410 Gone`) or **deprecated** in PymtHouse v0.2.x. Follow the migration steps to update your integration.
+This page documents endpoints that have been **removed** or **deprecated**. Follow the migration steps to update your integration to the PymtHouse **0.3.x** contract.
+
+---
+
+## Breaking changes (API cleanup)
+
+| Removed | Replacement |
+| --- | --- |
+| `GET /api/v1/auth/validate` | `POST /api/v1/auth/validate` with `{ "key": "pmth_…" }` (`BPP_VALIDATE_V2=1`) |
+| `GET` / `POST` / `DELETE /api/v1/subscriptions` | `POST /api/v1/apps/{clientId}/users`, `GET …/users/{externalUserId}/subscription`, `POST …/allowances` |
+| `POST /api/v1/apps/{clientId}/usage/signed-tickets` | Kafka `create_signed_ticket` → OpenMeter collector (no HTTP ingest) |
+| `GET` / `POST` / `DELETE /api/v1/apps/{clientId}/keys` | Per-user keys: `…/users/{externalUserId}/keys` |
+| `…/users/{externalUserId}/credits` | `…/users/{externalUserId}/allowances` |
+| Dashboard BFF `POST /api/pymthouse/keys/exchange` (not served by pymthouse) | `POST /api/v1/apps/{clientId}/oidc/token` |
+| `POST /api/v1/apps/{clientId}/auth/api-key/signer-session` | `POST /api/v1/apps/{clientId}/oidc/token` (form `subject_token=pmth_*`) |
+| `POST /api/v1/apps/{clientId}/auth/api-key/token` | `POST /api/v1/apps/{clientId}/oidc/token` or M2M `…/users/{externalUserId}/token` |
+
+M2M secret rotation remains at `POST /api/v1/apps/{clientId}/credentials` (provider session).
---
@@ -108,30 +125,63 @@ curl -sS \
---
-## Subscription CRUD removed
+## Auth validate: GET → POST
+
+| Route | Status | Replacement |
+| --- | --- | --- |
+| `GET /api/v1/auth/validate` | Removed | `POST /api/v1/auth/validate` with JSON `{ "key": "pmth_…" }` |
+
+Requires `BPP_VALIDATE_V2=1`. Inspect the live response schema at `GET /api/v1/openapi.json`.
-**Affected routes:**
+---
+
+## App-level API keys removed
| 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` |
+| `GET` / `POST` / `DELETE /api/v1/apps/{clientId}/keys` | Removed | `…/users/{externalUserId}/keys` |
-Manage subscriptions through the plans CRUD API (dashboard session) or the OpenMeter billing checkout flow. See [Plans](/integration/plans) and [Allowances](/integration/allowances).
+Issue and manage keys per user. Presented form is `app_.pmth_`. See [API keys](/integration/api-keys).
---
-## Deprecated `credits` alias
+## API key exchange routes removed
+
+| Route | Status | Replacement |
+| --- | --- | --- |
+| `POST .../auth/api-key/token` | Removed | `POST .../oidc/token` (API key as `subject_token`) or M2M `…/users/{id}/token` |
+| `POST .../auth/api-key/signer-session` | Removed | `POST .../oidc/token` (form `subject_token=pmth_*`) |
+| Dashboard BFF `POST /api/pymthouse/keys/exchange` | Not served by pymthouse | `POST /api/v1/apps/{clientId}/oidc/token` |
+
+```bash
+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=pmth_..." \
+ --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
+ "${BASE_URL}/api/v1/apps/${CLIENT_ID}/oidc/token"
+```
-**Affected routes:**
+---
+
+## Subscription CRUD removed
| Route | Status | Replacement |
| --- | --- | --- |
-| `GET .../users/{id}/credits` | Deprecated alias | `GET .../users/{id}/allowances` |
-| `POST .../users/{id}/credits` | Removed / redirects | `POST .../users/{id}/allowances` |
+| `POST /api/v1/subscriptions` | `410 Gone` | `POST .../users` (auto-Starter) + OpenMeter checkout / plans |
+| `DELETE /api/v1/subscriptions` | `410 Gone` | `DELETE .../plans?planId=` or subscription management via Plans |
+| `GET /api/v1/subscriptions` | Deprecated / removed | `GET .../plans` or `GET .../users/{id}/subscription` |
+
+See [Plans](/integration/plans) and [Allowances](/integration/allowances).
+
+---
-The `credits` endpoints re-export the `allowances` endpoints. The `POST` route has been removed from PymtHouse. Update all references:
+## Deprecated `credits` alias
+
+| Route | Status | Replacement |
+| --- | --- | --- |
+| `GET .../users/{id}/credits` | Deprecated alias | `GET .../users/{id}/allowances` |
+| `POST .../users/{id}/credits` | Removed | `POST .../users/{id}/allowances` |
```diff
- GET /api/v1/apps/{clientId}/users/{externalUserId}/credits
@@ -153,37 +203,33 @@ SDK:
---
-## App manifest routes deprecated
-
-**Affected routes:**
+## Network capability manifest
| Route | Status |
| --- | --- |
-| `GET /api/v1/apps/{clientId}/manifest` | Deprecated — returns fail-open stub only |
-| `PUT /api/v1/apps/{clientId}/manifest` | Deprecated |
+| `GET /api/v1/apps/{clientId}/manifest` | Still available — returns a fixed fail-open stub (`capabilities: []`, `manifestVersion: "empty"`) without NaaP resolution |
+| `PUT /api/v1/apps/{clientId}/manifest` | Still available — writes Network Price exclusions; response is the fully resolved manifest |
-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.
+The signing hot path no longer enforces capability restrictions from the manifest. Prefer the **Plans / Network Price** UI for exclusion management. `GET .../manifest` is still linked from `GET /api/v1/apps/{clientId}` (`links.manifest`) for integrators that poll the stub.
-**SDK methods deprecated:**
+**SDK methods:**
-| Method | Replacement |
+| Method | Notes |
| --- | --- |
-| `getAppManifest({ ifNoneMatch? })` | Plans UI |
-| `parseAppManifestResponse(raw)` | N/A |
-| `computeManifestRevision(parsed)` | N/A |
+| `getAppManifest({ ifNoneMatch? })` | Prefer Plans UI / Network Price for capability management |
+| `parseAppManifestResponse(raw)` | Legacy helper |
+| `computeManifestRevision(parsed)` | Legacy helper |
---
## 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.
+Discovery profiles remain for backward compatibility. New integrations should manage network capabilities via **Network Price** exclusions (`PUT .../manifest` or the Plans UI).
---
@@ -194,15 +240,19 @@ Discovery profiles are a legacy mechanism for expressing pipeline/model capabili
| `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 |
+| `GET /api/signer/discover-orchestrators` | `410 Gone` | Plans / Network Price UI |
+| `POST .../usage/signed-tickets` | `410 Gone` | Kafka async metering (no HTTP replacement) |
+| `GET /api/v1/auth/validate` | Removed | `POST /api/v1/auth/validate` |
+| `GET/POST/DELETE .../apps/{clientId}/keys` | Removed | `…/users/{id}/keys` |
+| `POST .../auth/api-key/token` | Removed | `POST .../oidc/token` or `…/users/{id}/token` |
+| `POST .../auth/api-key/signer-session` | Removed | `POST .../oidc/token` |
+| Dashboard BFF keys/exchange | Not on pymthouse | `POST .../oidc/token` |
+| `POST /api/v1/subscriptions` | `410 Gone` | Users + plans / 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 |
+| `GET .../manifest` | Fail-open stub | Network Price / Plans UI |
+| `PUT .../manifest` | Writes exclusions | Network Price / Plans UI |
diff --git a/integration/manifest.mdx b/integration/manifest.mdx
index 85d1abf..0501e53 100644
--- a/integration/manifest.mdx
+++ b/integration/manifest.mdx
@@ -1,12 +1,16 @@
---
title: App manifest
-description: Read and update your app's network capability manifest — the set of pipeline/model combinations discoverable through PymtHouse. Supports ETag caching and SDK helpers.
+description: Network capability manifest for pipeline/model exclusions. GET returns a fail-open stub; PUT writes Network Price exclusions. Not on the active Builder guides nav — see Plans and Deprecated routes.
---
-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.
+The **app manifest** routes expose network capability exclusions for your app (Network Price plan). The signing hot path does **not** enforce the manifest.
- **`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.
+ This page is kept for reference but is not linked in the main navigation. Prefer the **Plans / Network Price** UI for exclusion management. See [Deprecated routes](/integration/deprecated) and [Plans](/integration/plans).
+
+
+
+ **`GET .../manifest`:** Returns a fixed fail-open stub (`capabilities: []`, `excludedCapabilities: []`, `manifestVersion: "empty"`) without NaaP resolution. **`PUT .../manifest`** still updates Network Price exclusions and returns the fully resolved manifest. App metadata at `GET /api/v1/apps/{clientId}` may include `links.manifest`.
## Endpoints
diff --git a/integration/sdk.mdx b/integration/sdk.mdx
index 0265952..b7669bd 100644
--- a/integration/sdk.mdx
+++ b/integration/sdk.mdx
@@ -84,12 +84,15 @@ All REST calls use HTTP Basic auth against `{issuerOrigin}/api/v1/apps/{publicCl
| Method | Description |
| --- | --- |
| `mintUserAccessToken(input)` | `POST .../users/{id}/token` — short-lived JWT. Requires `users:token`. |
-| `exchangeForSignerSession({ userJwt, resource? })` | RFC 8693: user JWT → opaque `pmth_*` signer session. |
-| `mintUserSignerSessionToken(input)` | Mint user JWT + exchange in one call. |
+| `exchangeForSignerSession({ userJwt })` | RFC 8693 via `POST .../apps/{clientId}/oidc/token` — user JWT → `SignerSession`. |
+| `mintUserSignerSessionToken(input)` | Mint user JWT + app-scoped exchange in one call. |
| `mintSignerSessionForExternalUser(input)` | Upsert user + mint + exchange workflow. |
| `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. |
+| `exchangeApiKeyForSignerSession({ apiKey })` | API key as `subject_token` on `POST .../oidc/token` → `SignerSession`. |
+
+
+ Prefer the HTTP contract in [API keys](/integration/api-keys) and [Token exchange](/integration/token-exchange). Older SDK helpers that call `…/auth/api-key/token`, `…/auth/api-key/signer-session`, or a dashboard `facadeUrl` target routes that were removed in PymtHouse 0.3.x.
+
### Device flow
@@ -158,7 +161,7 @@ const session = await client.mintUserSignerSessionToken({
externalUserId: "naap-user-123",
scope: "sign:job",
});
-// session.access_token — opaque pmth_… bearer for the DMZ
+// session.access_token — signer JWT for the DMZ
// Option 3: upsert user + mint + exchange in one call
const session = await client.mintSignerSessionForExternalUser({
diff --git a/integration/signer-routing.mdx b/integration/signer-routing.mdx
index 5108e10..92e74c9 100644
--- a/integration/signer-routing.mdx
+++ b/integration/signer-routing.mdx
@@ -18,7 +18,6 @@ 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 }
@@ -26,10 +25,10 @@ sequenceDiagram
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->>PM: POST .../apps/{clientId}/oidc/token (RFC 8693)
+ PM-->>App: SignerSession (access_token = signer JWT)
- App->>DMZ: Forward signing request (Bearer pmth_…)
+ App->>DMZ: Forward signing request (Bearer signer JWT)
DMZ->>App: POST /authorize (webhook)
App-->>DMZ: { auth_id, externalUserId }
DMZ-->>App: Signed ticket
@@ -125,26 +124,19 @@ const response = await forwardDirectSignerRequest({
});
```
-### Device and API key exchange handlers
+### Device exchange handler
-For CLI device flows and API key integrations, builder-sdk provides purpose-built handlers:
+For CLI device flows, builder-sdk provides a purpose-built handler:
```ts
-import {
- createDeviceExchangeHandler,
- createApiKeyExchangeHandler,
-} from "@pymthouse/builder-sdk/signer/server";
+import { createDeviceExchangeHandler } 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!,
-});
```
+For API keys, exchange at `POST /api/v1/apps/{clientId}/oidc/token` with the key as `subject_token` (see [API keys](/integration/api-keys)). Do not use dashboard facade URLs or the removed `auth/api-key/*` routes.
+
---
## Identity webhook (`@pymthouse/builder-sdk/signer/webhook`)
diff --git a/integration/token-exchange.mdx b/integration/token-exchange.mdx
index 27cab9d..beab843 100644
--- a/integration/token-exchange.mdx
+++ b/integration/token-exchange.mdx
@@ -1,38 +1,26 @@
---
title: Token exchange
-description: 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.
+description: Use OAuth 2.0 Token Exchange (RFC 8693) for device completion, app-scoped SignerSession issuance, and clearinghouse Option A mint.
---
-PymtHouse implements the **OAuth 2.0 Token Exchange** grant (RFC 8693) for three server-side operations:
+PymtHouse implements **OAuth 2.0 Token Exchange** (RFC 8693) for three server-side operations. They use **different endpoints**:
-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.
+1. **Device completion** — bind a pending RFC 8628 device grant to an authenticated user via `POST {issuer}/token`.
+2. **Signer session exchange** — exchange a user JWT **or** per-user API key for a clearinghouse-compatible **`SignerSession`** via `POST /api/v1/apps/{clientId}/oidc/token`.
+3. **Clearinghouse signer mint (Option A)** — M2M `client_credentials` with `sign:mint_user_token` on `POST /api/v1/oidc/token` mints a user-scoped signer JWT and allowance data in one call.
-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` |
-| `subject_token` | A valid access token issued by this PymtHouse issuer |
-
-Authentication: **M2M HTTP Basic auth** (`Authorization: Basic base64(m2m_id:m2m_secret)`) is required for all token exchange calls.
+The global OIDC token endpoint (`POST /api/v1/oidc/token` or `{issuer}/token`) covers standard OAuth grants and device completion. **API-key and user-JWT SignerSession exchange** use the **app-scoped** route, not the global token endpoint.
---
## 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.
+Use this in the **NaaP / Option B** flow: after the user authenticates at your backend, call the **global** issuer 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.
+- `device_third_party_initiate_login` enabled on the **public** client where required.
- A user-scoped JWT for the **public** `app_…` client (minted via [User tokens](/integration/user-tokens)).
### Flow
@@ -46,7 +34,7 @@ sequenceDiagram
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
+ RP->>OP: POST {issuer}/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 ✓
@@ -54,6 +42,8 @@ sequenceDiagram
### Request
+HTTP Basic with the M2M client is **required** for device completion.
+
```bash
ISSUER="${BASE_URL}/api/v1/oidc"
M2M_ID="m2m_yourClientId"
@@ -71,30 +61,28 @@ curl -sS \
"${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.
+**`resource` format:** `urn:pmth:device_code:` — use the `user_code` (e.g. `ABCD-EFGH`), not the `device_code`.
### 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).
+- Issued to the **public** `app_…` client for the same app (`client_id` or `azp` = public client id).
- Not expired.
-The M2M client's `allowed_scopes` must include `device:approve` or `users:token`.
-
### Response
```json
{
- "access_token": "pmth_signer_session_...",
+ "access_token": "…",
"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.
+The binding of the device grant is a **side-effect** of this call. The CLI's next poll returns the bound session.
SDK helper:
@@ -108,79 +96,101 @@ await client.approveDeviceLogin({
---
-## Signer session exchange
+## Signer session exchange (app-scoped)
-Use this operation to exchange a short-lived user access token for a long-lived opaque remote signer session token (`pmth_*`).
-
-### Prerequisites
+Clearinghouse-compatible **`SignerSession`** issuance:
-- **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.
+```http
+POST /api/v1/apps/{clientId}/oidc/token
+Content-Type: application/x-www-form-urlencoded
+```
-### Subject token binding
+| Field | Value |
+| --- | --- |
+| `grant_type` | `urn:ietf:params:oauth:grant-type:token-exchange` |
+| `subject_token` | User access JWT **or** per-app-user API key (`pmth_*` / `app_….pmth_*`) |
+| `subject_token_type` | `urn:ietf:params:oauth:token-type:access_token` |
+| `audience` / `resource` | Optional; when provided must match configured signer audience (issuer URL, `SIGNER_TOKEN_AUDIENCE`, or legacy `livepeer-clearinghouse` / `livepeer-remote-signer`) |
-The `subject_token` must be a JWT from this issuer whose `client_id` or `azp` is either:
+Optional HTTP Basic with the M2M client. When omitted, the `subject_token` alone authenticates the exchange.
-- 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`).
+Do **not** use the global `{issuer}/token` endpoint for API-key SignerSession exchange. Do **not** call the removed `POST .../auth/api-key/signer-session` route.
-### Request
+### Example (API key as `subject_token`)
```bash
-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_…)
+CLIENT_ID="app_yourClientId"
+API_KEY="pmth_..."
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=${API_KEY}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
- --data-urlencode "scope=sign:job" \
- "${ISSUER}/token"
+ "${BASE_URL}/api/v1/apps/${CLIENT_ID}/oidc/token"
```
-Omit `resource`, or set `resource` to the issuer URL (`{issuer}`) if your client always sends a resource indicator (RFC 8707).
+### Example (user JWT after device flow or M2M user-token mint)
-### Response
+```bash
+USER_JWT="eyJ..."
+
+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=${USER_JWT}" \
+ --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
+ "${BASE_URL}/api/v1/apps/${CLIENT_ID}/oidc/token"
+```
+
+### Response (`SignerSession`)
```json
{
- "access_token": "pmth_signer_session_longtoken...",
- "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "access_token": "eyJ...",
"token_type": "Bearer",
- "expires_in": 86400
+ "expires_in": 300,
+ "scope": "sign:job",
+ "signer_url": "https://dmz.example.com",
+ "discovery_url": "https://discovery.livepeer.cloud",
+ "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "correlation_id": "…",
+ "balanceUsdMicros": "4200000",
+ "lifetimeGrantedUsdMicros": "5000000"
}
```
-SDK helpers:
+| Field | Description |
+| --- | --- |
+| `access_token` | Short-lived signer JWT for the remote DMZ. |
+| `signer_url` | Optional remote signer DMZ URL. |
+| `discovery_url` | Optional Livepeer network discovery URL (not OIDC metadata). |
+| `issued_token_type` | RFC 8693 issued token type when present. |
+| `correlation_id` | Optional request correlation id. |
+| `balanceUsdMicros` | Current OpenMeter entitlement balance (USD micros). |
+| `lifetimeGrantedUsdMicros` | Lifetime granted allowance (USD micros). |
+
+SDK helpers (paths may vary by SDK version — prefer the HTTP contract above):
```ts
-// 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.
+M2M clients with `sign:mint_user_token` (auto-added when the public client has `sign:job`) can mint a user-scoped signer JWT and receive allowance balance in a single `client_credentials` call on the **global** OIDC token endpoint.
### Request
@@ -196,7 +206,6 @@ curl -sS \
--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"
```
@@ -215,12 +224,14 @@ curl -sS \
| Field | Description |
| --- | --- |
-| `access_token` | User-scoped JWT with `aud=livepeer-remote-signer`. Use as a Bearer token at the remote signer DMZ. |
+| `access_token` | Short-lived signer JWT. 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. |
+For a follow-on RFC 8693 exchange after minting a **user** JWT (not Option A), use `POST /api/v1/apps/{clientId}/oidc/token`.
+
- `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.
+ `sign:mint_user_token` is automatically granted to M2M clients when the public sibling client has `sign:job` in its `allowed_scopes`.
---
@@ -229,26 +240,24 @@ curl -sS \
| 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. |
+| `400 invalid_grant` | `subject_token` is expired, invalid signature, wrong issuer, or invalid API key. |
+| `400 invalid_request` | Missing required parameter, or `resource` / `audience` not recognized. |
+| `401 Unauthorized` | M2M credentials invalid (when Basic is required or supplied). |
+| `403 Forbidden` | M2M client lacks the required scope, 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.
+1. **App-scoped route for SignerSession.** Clearinghouse-compatible exchange (JWT or API key) uses `POST /api/v1/apps/{clientId}/oidc/token` so `{clientId}` is explicit and Basic auth can be optional when the API key authenticates the call.
+2. **Global issuer token endpoint for device completion and Option A.** Device binding and `sign:mint_user_token` stay on `{issuer}/token` / `/api/v1/oidc/token`.
+3. **Binding is a side-effect for device completion.** The CLI polls the standard device code endpoint rather than a proprietary callback.
+4. **Option A minimizes round trips** for clearinghouse integrators — one call returns 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.
+- For device completion: mint the user JWT via the Builder API **before** calling `{issuer}/token`.
+- For SignerSession: call `POST /api/v1/apps/{clientId}/oidc/token` (not the global token endpoint, and not the removed `auth/api-key/*` routes).
+- Never pass `pmth_cs_*` as `subject_token`.
+- For Option A: rely on auto-derived `sign:mint_user_token` when the public client has `sign:job`.
- After obtaining a signer session or signer JWT, forward it to the remote signer DMZ — see [Signer routing](/integration/signer-routing).
diff --git a/integration/user-management.mdx b/integration/user-management.mdx
index da846f2..bd6f249 100644
--- a/integration/user-management.mdx
+++ b/integration/user-management.mdx
@@ -153,7 +153,7 @@ curl -sS -X DELETE \
## Per-user API keys
-Long-lived opaque API keys (`pmth_*`) can be issued per-user and exchanged for short-lived JWTs or signer sessions without repeating device login.
+Long-lived opaque API keys (`pmth_`, presented as `app_.pmth_`) can be issued per-user and exchanged for a **`SignerSession`** without repeating device login.
```http
GET /api/v1/apps/{clientId}/users/{externalUserId}/keys
@@ -164,43 +164,30 @@ Authorization: Basic base64(m2m_id:m2m_secret)
**GET** returns the list of active API keys for the user.
-**POST** creates a new key. Response includes the full `pmth_*` secret (shown once only):
+**POST** creates a new key. Response includes the full secret (shown once only):
```json
{
"keyId": "key-uuid",
- "apiKey": "pmth_ak_...",
+ "apiKey": "app_yourClientId.pmth_a1b2c3...",
"createdAt": "2026-04-01T00:00:00.000Z"
}
```
**DELETE** with `?keyId=` revokes the key immediately.
-Exchange a per-user API key for a short-lived JWT:
+Exchange a per-user API key for a `SignerSession` at the app-scoped OIDC token endpoint:
```http
-POST /api/v1/apps/{clientId}/auth/api-key/token
-Authorization: Bearer pmth_ak_...
-Content-Type: application/json
-
-{ "scope": "sign:job" }
-```
+POST /api/v1/apps/{clientId}/oidc/token
+Content-Type: application/x-www-form-urlencoded
-See [API keys](/integration/api-keys) for the full exchange flow.
-
----
-
-## App-level API keys
-
-App-level API keys (tied to a subscription) are managed separately:
-
-```http
-GET /api/v1/apps/{clientId}/keys
-POST /api/v1/apps/{clientId}/keys
-DELETE /api/v1/apps/{clientId}/keys
+grant_type=urn:ietf:params:oauth:grant-type:token-exchange&
+subject_token=pmth_...&
+subject_token_type=urn:ietf:params:oauth:token-type:access_token
```
-Auth: provider dashboard session. These keys are suitable for integrations where a single credential covers the entire app rather than individual users.
+App-level keys at `/api/v1/apps/{clientId}/keys` and `POST .../auth/api-key/*` were removed. See [API keys](/integration/api-keys) and [Deprecated routes](/integration/deprecated).
---
diff --git a/integration/user-tokens.mdx b/integration/user-tokens.mdx
index 86402e2..18b5fc3 100644
--- a/integration/user-tokens.mdx
+++ b/integration/user-tokens.mdx
@@ -82,11 +82,12 @@ The issued JWT contains:
| `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. |
+| `external_user_id` | Your system's user identifier | The `externalUserId` as stored during provisioning. Included on programmatic user JWTs for downstream attribution without a separate lookup. |
| `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`.
+ `sub` is the PymtHouse internal app-user id, **not** your `externalUserId`. Prefer the `external_user_id` claim for correlating the JWT back to your user, rather than parsing `sub`.
## Passing the token to downstream services