> ## Documentation Index
> Fetch the complete documentation index at: https://paymanai.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Keys and environments

> One secret, two environments, how credentials end.

## One secret

```ts theme={null}
import { PaymanConnect } from "@paymanai/connect";

const payman = new PaymanConnect();   // reads PAYMAN_APP_KEY
```

`PAYMAN_APP_KEY` is the only secret. Environment is parsed from the key prefix; app id, redirect URI, and console origin resolve from `GET /connect/apps/me`. Set `PAYMAN_BASE_URL` only when pointing away from production, such as a local stack (default `https://api.paygent.payman.ai`).

The key is shown once at registration and stored hashed. If it leaks, **Regenerate** it (Apps screen, or `POST /connect/apps/:id/regenerate-key`): the old key stops immediately, with no grace window, and the 90-day clock restarts. Your customers' grants are untouched on Payman's side.

Regenerating does mean one extra step. Every store encrypts its copies under a data key wrapped by your app key, so the new key cannot open a store the old one created so the store refuses to start rather than come up empty. Re-wrap once, with both keys in hand, before your app boots on the new key:

```ts theme={null}
import { rewrapDek } from "@paymanai/connect/stores/sqlite";

rewrapDek(process.env.OLD_APP_KEY, process.env.PAYMAN_APP_KEY);
```

Each store exports its own, taking whatever it needs to reach the data:

| Store                   | Import from                       | Call                                      |
| ----------------------- | --------------------------------- | ----------------------------------------- |
| `sqliteStore` (default) | `@paymanai/connect/stores/sqlite` | `rewrapDek(oldKey, newKey, { dataDir })`  |
| `sqlStore`              | `@paymanai/connect/stores/sql`    | `await rewrapDek(pool, oldKey, newKey)`   |
| `redisStore`            | `@paymanai/connect/stores/redis`  | `await rewrapDek(client, oldKey, newKey)` |

Grants are never re-encrypted: this rewrites one wrapped key. It is idempotent, so it is safe to leave in a deploy step that every instance runs. Nothing to do at all if you set an explicit `encryptionKey`: the app key never wrapped anything, so regenerating it changes nothing at rest.

One case has no old key to re-wrap with: an installation that used to hold an app key and now authenticates as a public client. The key that wrapped the data key is simply gone. There the embedded store renames itself aside, as `connect.sqlite.unreadable-<timestamp>` along with its `-wal` and `-shm` sidecars, and starts fresh. The cost is one reconnect, and the row it gave up was already unreadable. Nothing is ever deleted.

That recovery is for public clients only. For a confidential client an unwrappable store means you regenerated your app key, `rewrapDek` above is the right answer, and rebuilding silently would drop every customer's grant at once.

<Warning>
  The key is server-only. The constructor throws in a browser; an app key that reaches a client bundle is leaked, so regenerate it.
</Warning>

## No secret, for software you distribute

Everything above assumes a server, which can hold a secret. Software you hand to other people cannot. A key shipped inside a package is one shared credential for every install, readable by anyone who runs `npm view`.

For a CLI, a desktop app, or an AI host, register a **public client** and authenticate with OAuth 2.1 and PKCE instead of a key:

```ts theme={null}
import { PaymanConnect } from "@paymanai/connect";

const payman = new PaymanConnect({
  clientId: "your-published-client-id",
  environment: "sandbox",
  redirectUri: "http://127.0.0.1:8899/callback",
});
```

The SDK mints a fresh random verifier for every authorization, sends only its SHA-256 to the consent page, and produces the pre-image at the token exchange. Whoever intercepts the code off the redirect still cannot redeem it, because the verifier never left the process.

Three things follow from having no secret:

* **`clientId` and `appKey` are mutually exclusive.** A client that has a secret must authenticate with it. Passing both is a configuration error, not a fallback, because a client id is published and would otherwise make the secret optional for anyone who read it.
* **`environment` has to be stated.** The confidential path reads sandbox or live off the key prefix. A client id carries no such marker, and guessing wrong is how a sandbox install ends up pointed at real money. There is no default.
* **The store is encrypted under a per-installation secret**, 32 random bytes minted on first run and written beside the store with owner-only permissions. Never the client id, which is identical for every install on earth. Losing that file costs one reconnect.

Redirect URIs are still compared byte for byte, so every port you might bind has to be registered. Register a small range rather than one port if several copies could run on one machine.

<Note>
  Building a product for your own customers? Keep the app key. A secret is a stronger credential than a public client, and your server can hold one. Public clients exist for code that runs on somebody else's machine. [The MCP server](/mcp-server) is the worked example.
</Note>

## Sandbox and live

Every app is registered as sandbox or live, once, permanently. One base URL serves both environments; the key prefix decides.

```bash theme={null}
PAYMAN_APP_KEY=pgc_app_test_…   # sandbox
PAYMAN_APP_KEY=pgc_app_…        # live
```

|                  | Sandbox                | Live                          |
| ---------------- | ---------------------- | ----------------------------- |
| Key prefix       | `pgc_app_test_`        | `pgc_app_`                    |
| Grant prefix     | `pgc_grant_test_`      | `pgc_grant_`                  |
| Banks on consent | Durango demo bank only | Real providers, never Durango |

The interact responses name their environment in the `x-paygent-environment` header, and sandbox apps are labeled on the consent screen. Going live is a new registration with a live key; a sandbox app stays sandbox.

## Registration renewal

App registrations expire after 90 days. **Renew** (Apps screen, or `POST /connect/apps/:id/renew`) moves the clock without touching the key, works after expiry, and your grants resume with it. `await payman.doctor()` warns as the cliff approaches; run it in CI.

## Grants, and how connections end

A **grant** is one customer's consent to your app for one of their deployments, carried as the `pgc_grant_…` token in the `x-paygent-connect-grant` header. Through the SDK your code never sees one: the callback exchange stores it encrypted, and every read is keyed by `userId`.

| Ending                                         | Effect                                                                                                                                        |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Customer disconnects from their Payman console | Next call answers 401; the 401 is the notification. The SDK returns `needs_connection` with a fresh connect URL and fires `onCredentialLost`. |
| Grant expires after 90 days                    | Same path. `status()` reports `health: "expiring_soon"` from 14 days out.                                                                     |
| Your `disconnect()`                            | Local drop only. The consent stays live until the customer withdraws it.                                                                      |
| App revoked                                    | Every grant it issued stops at once. Customers must consent again through a newly registered app.                                             |

## Reading a 401

Every credential failure answers the same `401 invalid_connect_credentials`; `details.reason` appears only for your own credential, so a stolen half learns nothing.

| `details.reason`                 | Fix                                            |
| -------------------------------- | ---------------------------------------------- |
| `app_expired`                    | Renew the registration. Keep your grants.      |
| `app_revoked`                    | Register a new app. Grants are gone.           |
| `grant_expired`, `grant_revoked` | Offer the Connect button again.                |
| absent                           | Unknown or mismatched token. Nothing to learn. |

The SDK does this reading for you: grant-side 401s become `needs_connection` with the stale grant dropped, while app-fault reasons keep the grant. It never retries a 401.
