Skip to main content
The Customer Portal has not launched. This page is unlisted, documents an unreleased API, and is subject to change without notice.
The Customer Portal is a white-labeled web app you can offer to your end users. They get key management, usage analytics, and API documentation, without you building any UI. Authentication uses a Stripe-style flow: your backend creates a session, redirects the user to a URL carrying a single-use code, and the portal exchanges that code for an access token.

How it works

  1. Your backend authenticates the user in your own system
  2. Your backend calls POST /v2/portal.createSession with a root key that holds the required permissions (see Required permissions)
  3. You redirect the user to the returned portal URL, which carries the code
  4. The portal exchanges the code for a 24-hour access token
  5. The browser calls the Unkey API with that token in an httpOnly cookie

1. Create a portal

A portal serves exactly one app or one keyspace. Create it with portal.createPortal, naming the resource it serves:
The response carries the new portal’s ID. The app or keyspace you name must belong to your workspace, and it can back only one portal, so a second portal for the same resource is rejected as a conflict. displayName is what your end users see in the portal header and page titles, so it is the name of your product as they know it. It is not part of any URL, so you can change it whenever you like. The slug is a short, human-readable identifier like my-portal or billing-dashboard. You can pass either the slug or the portal’s ID wherever a portal is named. Slugs must be 3–64 characters, lowercase alphanumeric and hyphens only, cannot start or end with a hyphen, and cannot contain consecutive hyphens. A portal value that matches neither a slug nor an ID in your workspace returns a 404, so a typo and a portal that was never provisioned look the same. A portal is enabled on creation. Send "enabled": false to create it dormant and turn it on later. Optionally, customize branding with logoUrl and primaryColor. The logo must be an absolute https:// URL, and the colour a six-digit hex value like #6366f1. Read a portal back with portal.getPortal, by its ID or slug, or by the resource it serves:
Change it with portal.updatePortal, which touches only the fields you send. Omit a field to leave it alone; send null for a branding field to clear it.

Turning a portal off

Two operations affect end users who are already signed in, and they differ:
  • Disabling a portal ("enabled": false) stops new sessions from being minted. Sessions already in progress keep working until they expire.
  • Deleting a portal, or re-pointing it at a different app or keyspace with keyspaceId or appId, revokes its sessions. Those end users lose access on their next request rather than at token expiry.
Revocation is not instantaneous. Session lookups are cached for a few seconds and served from a slightly stale cache for a few minutes while they refresh, so a request already in flight can still succeed. If you need access cut off immediately, revoke the underlying keys as well.

2. Create a session

When your user wants to access the portal, create a session from your backend:
The response:
Treat url as a credential. The code it carries grants access to the end user’s portal session, so do not log it or store it. id is safe to log and to keep against your own records of the visit.

Required parameters

Optional parameters

3. Redirect your user

Send the user to the portal URL. The code it carries is valid for 15 minutes and can only be redeemed once.
The portal will:
  1. Exchange the code for a 24-hour access token
  2. Set it as an httpOnly cookie
  3. Redirect to the first visible tab based on the session’s scopes

Scopes and tabs

Scopes come from a fixed vocabulary. Every scope is bound to the end user in the session: keys:* applies only to keys that user owns within the portal’s keyspace, and analytics:read returns only that user’s own verification events. An end user can never see another identity’s keys or analytics. Tab visibility is derived from the scopes: The API requires at least one scope. An empty scopes array is rejected with HTTP 400, as is any value outside the vocabulary above.

Session lifecycle

Both credentials are stored only as hashes, so they cannot be recovered from Unkey. The code exists only in the URL you were given, and the access token only in the user’s cookie. Re-authenticating creates a new session with a fresh code rather than extending an existing one. When the access token expires:
  • If returnUrl was set on the session → redirects to {returnUrl}?reason=session_expired
  • Otherwise → shows a “Session expired” error page
returnUrl is set per session on portal.createSession, not once on the portal, so one portal can return each user to whichever page they came from.

Branding

The portal supports basic white-labeling: Logo URLs must be HTTPS.

Required permissions

Creating a portal session needs two things from your root key, and both are checked. First, permission to mint sessions for the portal:
Use portal.<portal_id>.create_portal_session to restrict a key to one portal. Second, a session can never carry a capability your root key does not itself hold. Each scope you request also requires the equivalent permission on the keyspace behind the portal: Requesting a scope you do not hold returns 403 for the whole request rather than a session with fewer capabilities, so a missing grant shows up immediately instead of as a portal that silently misses a tab. A root key without the portal session permission gets a 404, not a 403. That is deliberate: a caller who cannot mint for a portal is not told whether it exists, so a guessed slug reveals nothing. Managing portals is a separate set of permissions from minting sessions, so a key that provisions portals need not be able to act as one: Creation accepts only the wildcard form, because the portal’s ID does not exist yet when the request is authorized. Naming a keyspaceId or appId costs one more grant: read access to the resource you are pointing at. Creating a portal for a keyspace, or re-pointing an existing one, additionally requires api.*.read_api (or api.<api_id>.read_api for the keyspace’s owning API); the app equivalent is app.*.read_app. Deciding which resource a portal serves is what decides which keys its end users see, so a key that cannot read the resource cannot expose it either. Updates that name neither id need only the portal permission. Read, update, and delete answer a missing permission with a 404 for the same reason session minting does. Creation answers with a 403: there is no portal yet whose existence could leak, and a plain permission error is more useful than a not-found. You can grant these on the root key in the Unkey dashboard under Settings, Root Keys.

Error responses

The exchange deliberately does not distinguish between an unknown code, an expired one, and one that was already redeemed.
Last modified on August 24, 2026