diff --git a/api-reference/introduction.mdx b/api-reference/introduction.mdx
index 89ebb0f..f193b5e 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 now 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,6 +12,21 @@ 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. |
+
+The OpenAPI document covers all 90+ Builder API operations, including deprecation markers for removed routes. OIDC issuer metadata remains at `{issuer}/.well-known/openid-configuration` (a virtual pointer to `POST /api/v1/oidc/token` appears in the OpenAPI document for cross-reference).
+
+
+ `GET /api/v1/openapi.json` is regenerated from route metadata at build time. After adding route handlers, 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:
@@ -21,6 +36,7 @@ All endpoint guides currently live in the **Builder guides** tab:
| 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 |
## Next steps
diff --git a/integration/api-keys.mdx b/integration/api-keys.mdx
index 8d389df..88ed546 100644
--- a/integration/api-keys.mdx
+++ b/integration/api-keys.mdx
@@ -11,7 +11,10 @@ Two types of API keys exist:
| --- | --- | --- | --- |
| **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` |
+
+
+ Do not mix credential types. Presenting a `pmth_cs_*` M2M client secret to `POST .../auth/api-key/token` or `POST .../auth/api-key/signer-session` returns **`400 invalid_request`** (not `401`). The API-key exchange routes accept only per-user `pmth_*` API keys as Bearer credentials — M2M secrets must be used with HTTP Basic auth for Builder API routes.
+
## Per-user API keys
@@ -67,18 +70,6 @@ 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
-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
@@ -131,32 +122,91 @@ const tokens = await client.exchangeApiKeyForUserAccessToken({
---
-## Exchange API key → signer session
+## Exchange API key → signer session (single call)
+
+Exchange a `pmth_*` API key directly for a signer session in one HTTP call using the canonical endpoint. This replaces the two-step pattern (API key → JWT → RFC 8693 exchange) and the deprecated dashboard BFF facade:
+
+```http
+POST /api/v1/apps/{clientId}/auth/api-key/signer-session
+Authorization: Bearer pmth_ak_abc123...
+Content-Type: application/json
+```
+
+Optional request body:
-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:
+```json
+{ "scope": "sign:job" }
+```
+
+Response — **`SignerSession` envelope:**
+
+```json
+{
+ "access_token": "pmth_signer_session_...",
+ "token_type": "Bearer",
+ "expires_in": 86400,
+ "scope": "sign:job",
+ "balanceUsdMicros": "4200000",
+ "lifetimeGrantedUsdMicros": "5000000",
+ "signer_url": "https://dmz.example.com",
+ "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
+}
+```
+
+| Field | Description |
+| --- | --- |
+| `access_token` | Opaque signer session bearer for the remote DMZ. |
+| `balanceUsdMicros` | Current OpenMeter entitlement balance for the user in USD micros. |
+| `lifetimeGrantedUsdMicros` | Total lifetime granted allowance for the user. |
+| `signer_url` | Direct DMZ URL for this app (optional; present when configured). |
+| `issued_token_type` | RFC 8693 token type indicator. |
+| `correlation_id` | Opaque tracing id (optional). |
+
+```bash
+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/signer-session"
+```
+
+SDK:
```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.balanceUsdMicros — user's current entitlement balance
+// session.signer_url — DMZ URL when available
```
+
+ Integrator and dashboard facades that previously forwarded requests to `POST /api/pymthouse/keys/exchange` (a BFF route outside PymtHouse) should migrate to call `POST .../auth/api-key/signer-session` on the PymtHouse issuer directly. Pass the `SignerSession` response unchanged to the caller.
+
+
---
## 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:
+To validate a Bearer `pmth_*` key and check the associated plan and capabilities, POST to the validation endpoint. This is used by integrations that need to gate access before processing a request:
```http
-GET /api/v1/auth/validate
-Authorization: Bearer pmth_ak_abc123...
+POST /api/v1/auth/validate
+Content-Type: application/json
+
+{ "key": "pmth_ak_abc123..." }
```
Returns `{ valid: true, planId: "...", capabilities: [...] }` on success.
+
+ The legacy `GET /api/v1/auth/validate` with a `Bearer` header has been removed. Use `POST /api/v1/auth/validate` with the key in the JSON body (`BPP_VALIDATE_V2=1`). See [Deprecated routes](/integration/deprecated) for the migration.
+
+
---
## Security guidance
diff --git a/integration/billing.mdx b/integration/billing.mdx
index 01788b0..65e7d86 100644
--- a/integration/billing.mdx
+++ b/integration/billing.mdx
@@ -143,6 +143,8 @@ Every app has a **Starter** plan created automatically (`isStarterDefault: true`
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.
+**Stripe billing not required for Starter access.** If your app does not yet have Stripe billing fully connected (no Stripe Connect account linked, or Stripe setup is incomplete), end-users still receive Free Starter access. PymtHouse detects Stripe-related setup errors and recovers gracefully rather than failing subscription provisioning — users are granted the Starter allowance even when the Stripe billing configuration is pending. Connect Stripe via `POST /api/v1/apps/{clientId}/billing/stripe/connect` when you are ready to enable paid plans and Stripe invoicing.
+
Providers can update the Starter allowance via:
```http
diff --git a/integration/deprecated.mdx b/integration/deprecated.mdx
index ca9785b..874c512 100644
--- a/integration/deprecated.mdx
+++ b/integration/deprecated.mdx
@@ -1,9 +1,99 @@
---
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. The hosted signer proxy, synchronous signed-ticket ingest, legacy subscription, credits, and auth/validate (GET) endpoints have been replaced.
---
-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** (returning `410 Gone` or `400 invalid_request`) or **deprecated** in PymtHouse v0.2.x. Follow the migration steps to update your integration.
+
+---
+
+## `GET /api/v1/auth/validate` removed
+
+**Affected route:**
+
+| Route | Status | Replacement |
+| --- | --- | --- |
+| `GET /api/v1/auth/validate` | Removed | `POST /api/v1/auth/validate` with `{ "key": "pmth_…" }` |
+
+### Why it was removed
+
+The `GET` form passed the API key in the `Authorization: Bearer` header, which made key validation semantically identical to a Bearer-authenticated request — with no request body for the key being validated. The `POST` form provides an explicit validation surface compatible with the BPP v2 validate shape.
+
+### Migration
+
+Replace `GET /api/v1/auth/validate` with a `POST` and move the key into the JSON body:
+
+```diff
+- GET /api/v1/auth/validate
+- Authorization: Bearer pmth_…
+
++ POST /api/v1/auth/validate
++ Content-Type: application/json
++
++ { "key": "pmth_…" }
+```
+
+The response shape (`{ valid, planId, capabilities }`) is unchanged. Enable `BPP_VALIDATE_V2=1` on your deployment to activate the `POST` route.
+
+---
+
+## App-level keys removed (`/api/v1/apps/{clientId}/keys`)
+
+**Affected routes:**
+
+| Route | Status | Replacement |
+| --- | --- | --- |
+| `GET /api/v1/apps/{clientId}/keys` | Removed | `GET .../users/{externalUserId}/keys` |
+| `POST /api/v1/apps/{clientId}/keys` | Removed | `POST .../users/{externalUserId}/keys` |
+| `DELETE /api/v1/apps/{clientId}/keys` | Removed | `DELETE .../users/{externalUserId}/keys?keyId=` |
+
+### Why it was removed
+
+App-level keys were tied to subscriptions rather than to individual users, making revocation coarse-grained and attribution impossible. Per-user keys (`/users/{externalUserId}/keys`) carry user identity through to the DMZ webhook and support fine-grained revocation.
+
+### Migration
+
+Issue and manage API keys through the per-user endpoints:
+
+```diff
+- POST /api/v1/apps/{clientId}/keys
++ POST /api/v1/apps/{clientId}/users/{externalUserId}/keys
+
+- GET /api/v1/apps/{clientId}/keys
++ GET /api/v1/apps/{clientId}/users/{externalUserId}/keys
+
+- DELETE /api/v1/apps/{clientId}/keys
++ DELETE /api/v1/apps/{clientId}/users/{externalUserId}/keys?keyId=
+```
+
+See [API keys](/integration/api-keys) for the full per-user key lifecycle.
+
+---
+
+## Dashboard BFF key exchange removed (`POST /api/pymthouse/keys/exchange`)
+
+**Affected route:**
+
+| Route | Status | Replacement |
+| --- | --- | --- |
+| `POST /api/pymthouse/keys/exchange` | Removed (external BFF route) | `POST /api/v1/apps/{clientId}/auth/api-key/signer-session` |
+
+### Why it was removed
+
+The BFF facade was an application-layer relay that proxied API key → signer session exchange through the dashboard backend. Routing this through a third-party relay added latency and a dependency on the dashboard deployment for a security-critical token exchange.
+
+### Migration
+
+Call the canonical PymtHouse endpoint directly:
+
+```diff
+- POST /api/pymthouse/keys/exchange (external dashboard BFF)
+
++ POST /api/v1/apps/{clientId}/auth/api-key/signer-session
++ Authorization: Bearer pmth_…
+```
+
+The response is the canonical **`SignerSession`** envelope: `access_token`, `token_type`, `expires_in`, `scope`, `balanceUsdMicros`, `lifetimeGrantedUsdMicros`, `signer_url` (optional), `issued_token_type`, `correlation_id` (optional). Pass this response shape unchanged to callers. See [API keys — Exchange API key → signer session](/integration/api-keys#exchange-api-key--signer-session-single-call).
---
@@ -191,6 +281,9 @@ Discovery profiles are a legacy mechanism for expressing pipeline/model capabili
| Route | Status | Replacement |
| --- | --- | --- |
+| `GET /api/v1/auth/validate` | Removed | `POST /api/v1/auth/validate` with `{ "key": "pmth_…" }` |
+| `GET/POST/DELETE /api/v1/apps/{clientId}/keys` | Removed | `…/users/{externalUserId}/keys` (per-user) |
+| `POST /api/pymthouse/keys/exchange` (BFF) | Removed | `POST .../auth/api-key/signer-session` on issuer |
| `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 |
diff --git a/integration/token-exchange.mdx b/integration/token-exchange.mdx
index 27cab9d..d9d45f3 100644
--- a/integration/token-exchange.mdx
+++ b/integration/token-exchange.mdx
@@ -145,17 +145,35 @@ curl -sS \
Omit `resource`, or set `resource` to the issuer URL (`{issuer}`) if your client always sends a resource indicator (RFC 8707).
-### Response
+### Response — `SignerSession` envelope
```json
{
"access_token": "pmth_signer_session_longtoken...",
- "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
- "expires_in": 86400
+ "expires_in": 86400,
+ "scope": "sign:job",
+ "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "balanceUsdMicros": "4200000",
+ "lifetimeGrantedUsdMicros": "5000000",
+ "signer_url": "https://dmz.example.com",
+ "correlation_id": "req_abc123"
}
```
+| Field | Description |
+| --- | --- |
+| `access_token` | Opaque signer session bearer for the remote DMZ. |
+| `balanceUsdMicros` | Current OpenMeter entitlement balance for the user in USD micros (optional). |
+| `lifetimeGrantedUsdMicros` | Total lifetime granted allowance for the user (optional). |
+| `signer_url` | Direct DMZ URL for this app (optional; present when configured). |
+| `issued_token_type` | RFC 8693 token type indicator. |
+| `correlation_id` | Opaque tracing id (optional). |
+
+
+ Integrator facades should pass the `SignerSession` response shape through unchanged. For the single-call API key → signer session path (no prior user JWT needed), use `POST .../auth/api-key/signer-session` instead. See [API keys](/integration/api-keys#exchange-api-key--signer-session-single-call).
+
+
SDK helpers:
```ts
@@ -174,6 +192,7 @@ const session = await client.mintSignerSessionForExternalUser({
email: "user@example.com",
});
// session.accessToken is opaque pmth_…
+// session.signerUrl — DMZ URL when available
```
---
diff --git a/integration/user-tokens.mdx b/integration/user-tokens.mdx
index 86402e2..a3d3197 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 requiring 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