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

# Customer Portal

> Give your end users a white-labeled self-service portal for API key management, usage analytics, and docs, with a Stripe-style session auth flow.

<Warning>
  The Customer Portal has not launched. This page is unlisted, documents an
  unreleased API, and is subject to change without notice. It is not usable
  today: `portal.createSession` requires a portal on your workspace, and there is
  no way to create one yet.
</Warning>

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

```
Your Backend                    Unkey API                     Portal
    │                              │                            │
    ├─ POST /v2/portal.createSession ─►│                        │
    │◄──── id + portal URL ────────┤                            │
    │                              │                            │
    ├─ Redirect user to portal URL ─────────────────────────────►│
    │                              │◄─ POST /v2/portal.exchangeCode ────┤
    │                              │──────── access token ──────►│
    │                              │                            │
    │                              │◄── Direct API calls ───────┤
```

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](#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. Configure a portal

Enable the Customer Portal for your workspace in the Unkey dashboard.

<Note>
  Dashboard configuration UI is coming soon. During early access, reach out to the Unkey team to get your portal configured.
</Note>

When configuring your portal, you'll choose a slug: a short, human-readable identifier like `my-portal` or `billing-dashboard`. You can pass either that slug or the portal's ID when creating sessions.

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.

Optionally, you can customize branding with your logo and brand colors.

## 2. Create a session

When your user wants to access the portal, create a session from your backend:

<CodeGroup>
  ```bash cURL theme={"theme":"kanagawa-wave"}
  curl -X POST https://api.unkey.com/v2/portal.createSession \
    -H "Authorization: Bearer YOUR_ROOT_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "portal": "my-portal",
      "externalId": "user_123",
      "scopes": ["keys:read", "keys:reroll", "analytics:read"],
      "returnUrl": "https://app.example.com/settings/api-keys"
    }'
  ```

  ```typescript TypeScript theme={"theme":"kanagawa-wave"}
  const response = await fetch("https://api.unkey.com/v2/portal.createSession", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.UNKEY_ROOT_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      portal: "my-portal",
      externalId: "user_123",
      scopes: ["keys:read", "keys:reroll", "analytics:read"],
      // Optional, per session: where this user goes when they leave the portal.
      returnUrl: "https://app.example.com/settings/api-keys",
    }),
  });

  const { data } = await response.json();
  // data.id   is the session's identifier (not a credential)
  // data.url  is the portal URL carrying a single-use code, valid 15 minutes
  ```

  ```go Go theme={"theme":"kanagawa-wave"}
  // Use your preferred HTTP client
  body := map[string]any{
      "portal":     "my-portal",
      "externalId": "user_123",
      "scopes":     []string{"keys:read", "keys:reroll", "analytics:read"},
  }
  ```
</CodeGroup>

The response:

```json theme={"theme":"kanagawa-wave"}
{
  "meta": { "requestId": "req_..." },
  "data": {
    "id": "ps_xxx",
    "url": "https://portal.unkey.com/?code=pst_xxx"
  }
}
```

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

### Required parameters

| Parameter    | Type       | Description                           |
| ------------ | ---------- | ------------------------------------- |
| `portal`     | `string`   | The portal's slug or ID               |
| `externalId` | `string`   | Your user's identifier in your system |
| `scopes`     | `string[]` | Capabilities granted to the end user  |

### Optional parameters

| Parameter | Type      | Description                                                          |
| --------- | --------- | -------------------------------------------------------------------- |
| `preview` | `boolean` | Shows a "Preview mode" banner, useful for testing as a specific user |

```bash theme={"theme":"kanagawa-wave"}
curl -X POST https://api.unkey.com/v2/portal.createSession \
  -H "Authorization: Bearer YOUR_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "portal": "my-portal",
    "externalId": "user_123",
    "scopes": ["keys:read", "analytics:read"],
    "preview": true
  }'
```

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

```typescript theme={"theme":"kanagawa-wave"}
// In your backend route handler
return Response.redirect(data.url, 302);
```

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.

| Scope            | Grants                           |
| ---------------- | -------------------------------- |
| `keys:read`      | List their own keys              |
| `keys:create`    | Create keys                      |
| `keys:reroll`    | Roll a key they own              |
| `analytics:read` | Their own verification analytics |

Tab visibility is derived from the scopes:

| Tab           | Shown when           |
| ------------- | -------------------- |
| API Keys      | any `keys:*` scope   |
| Analytics     | `analytics:read`     |
| Documentation | any scope is present |

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

| Value         | Prefix | Lifetime   | Usage                                     |
| ------------- | ------ | ---------- | ----------------------------------------- |
| Session id    | `ps_`  | n/a        | Not a credential; identifies the session  |
| Exchange code | `pst_` | 15 minutes | Single-use, carried by the portal URL     |
| Access token  | `pat_` | 24 hours   | httpOnly cookie, sent on portal API calls |

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:

| Setting       | Default   |
| ------------- | --------- |
| Primary color | `#2563eb` |
| Logo          | None      |

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:

```plaintext theme={"theme":"kanagawa-wave"}
portal.*.create_portal_session
```

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:

| Requested scope  | Also requires                                                                      |
| ---------------- | ---------------------------------------------------------------------------------- |
| `keys:read`      | `api.*.read_key` and `api.*.read_api`                                              |
| `keys:reroll`    | `api.*.create_key`, plus `api.*.encrypt_key` if the keyspace stores encrypted keys |
| `keys:create`    | `api.*.create_key`, plus `api.*.encrypt_key` if the keyspace stores encrypted keys |
| `analytics:read` | `api.*.read_analytics`                                                             |

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.

You can grant these on the root key in the Unkey dashboard under Settings, Root Keys.

## Error responses

| Scenario                                                      | Status | Message                                                                        |
| ------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------ |
| Missing or invalid JSON body                                  | 400    | `Bad Request`                                                                  |
| Invalid root key                                              | 401    | `Unauthorized`                                                                 |
| Portal disabled                                               | 403    | `Portal is disabled.`                                                          |
| Root key lacks a permission for a requested scope             | 403    | `You do not have permission to grant the "<scope>" scope to a portal session.` |
| Portal's API no longer exists                                 | 403    | `Portal is not available: the API it uses no longer exists.`                   |
| Portal not found, or root key lacks portal session permission | 404    | `Portal not found.`                                                            |
| Invalid, expired, or already redeemed code                    | 401    | `Session is invalid, expired, or has already been used.`                       |

The exchange deliberately does not distinguish between an unknown code, an expired one, and one that was already redeemed.
