Skip to main content

One secret

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:
Each store exports its own, taking whatever it needs to reach the data: 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.
The key is server-only. The constructor throws in a browser; an app key that reaches a client bundle is leaked, so regenerate it.

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:
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.
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 is the worked example.

Sandbox and live

Every app is registered as sandbox or live, once, permanently. One base URL serves both environments; the key prefix decides.
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.

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. 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.