Technical Documentation: unkeyed/unkey
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (README.md)
What is Unkey?
Unkey is the developer platform for modern APIs. We unify your infrastructure so you can ship faster: deploy APIs instantly, route traffic through global gateways, and understand usage — all in one place.
- Deploy — push an API to production in seconds, with zero infrastructure to manage
- Gateway — route, authenticate, and shape traffic through globally distributed gateways
- API Keys — issue, verify, and revoke keys with fast global verification
- Ratelimiting — globally consistent, durable ratelimiting for any identifier
- Permissions & RBAC — per-key permissions, roles, and fine-grained access control
- Analytics — usage, latency, and per-key insights across every request
- Audit logs — immutable history of every action across your workspace
License
Please find the license under the LICENSE file.
Security Issue
Please find what is in scope in the SECURITY file.
Pull requests
Unkey is not accepting external pull requests at this time.
Unkey has paused external code contributions while the team focuses on platform direction and stability. Pull requests opened by people outside the Unkey team will not be reviewed or merged.
Issues remain open for bug reports, feature requests, and documentation feedback. This repository remains public and source-available so you can read the code, fork it under the terms of the FSL-1.1-ALv2, and self-host. Unkey may revisit this policy in the future, and any change will be announced here.
Let's Talk
Authors
Index (`docs/index.mdx`)
title: Unkey Products
sources:
- docs/docs.json
boost: 3
mode: wide
Unkey issues and verifies API keys, enforces rate limits and usage quotas for any app with API Management and deploys entire apps from a GitHub repository or a container image with Compute. These offerings are flexible enough to be used individually or together.
<h2 className="unkey-product-column-title">Compute</h2>
<a className="unkey-product" href="/compute" aria-label="Explore Compute">
my-appProduction
01 / BuildReady
02 / DeployHealthy
From code to production.
Deploy from GitHub or a container image. Build, run, and route traffic
to your app with managed domains and a gateway. Use Compute on its own
or add API Management.
Explore Compute →
</a>
<h2 className="unkey-product-column-title">API Management</h2>
<a className="unkey-product" href="/api-management" aria-label="Explore API Management">
keys.verifyKeyValid
API key•••• ••••
codeVALID
Your API. Under control.
Issue and verify API keys, enforce rate limits, and meter usage from
any backend. Your app stays wherever you host it, with no dependency
on Compute.
Explore API Management →
</a>Api Management Index (`docs/api-management/index.mdx`)
title: API management
sidebarTitle: "Overview"
sources:
- svc/api/routes/register.go
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- web/internal/db/src/schema/keyAuth.ts
boost: 3
Use API Management to issue API keys for a public API and to verify them on each request. Call keys.createKey to issue a key, and call keys.verifyKey from your backend to check it. Your app can stay on the host you already use. Compute is the other product, and you don't need it for this.
Unkey stores only a hash of each key. Verification runs the checks you configure (expiry, credits, rate limits, permissions, IP allow lists) and returns one verdict. If you already have keys, Migrating keys explains how to import their hashes.
How this ties to Compute and Platform
Root keys authenticate your calls to the Unkey API, and both products share the CLI. You'll find both documented in Platform. If you also host your app on Unkey, the Compute gateway can verify keys for you with its key-auth policy. See API key authentication.
Compute Index (`docs/compute/index.mdx`)
title: Compute
sidebarTitle: "Overview"
sources:
- docs/docs.json
- svc/ctrl/services/app/create_app.go
- svc/ctrl/worker/deploy/doc.go
- cmd/deploy/control_plane.go
boost: 3
import { ProductLink } from "/snippets/product-link.jsx";
Compute deploys an app from a GitHub repository on every push by default, or from a container image you built yourself. Connect the repository, pick the branch, and each push becomes a ready.
You get a production and a preview
Get started
Concepts
Build
Configure
The official SDKs wrap the whole Unkey API, so projects, apps, environments, deployments, domains, gateway policies, and the GitHub install flow are reachable from the same client you use for keys.
Errors Overview (`docs/errors/overview.mdx`)
title: "Error codes"
sidebarTitle: "Overview"
sources:
- pkg/codes/codes.go
- pkg/codes/constants_gen.go
- svc/api/internal/middleware/errors.go
- svc/api/openapi/gen.go
- svc/frontline/middleware/observability.go
Every Unkey error has a code shaped like err:{system}:{category}:{specific}, and every code has a page here at /errors/{system}/{category}/{specific}. Each page tells you what the error means, shows an example response, and lists the likely causes and how to fix them.
Reading an error code
The first part tells you where the error came from.
| System | Meaning | Returned by |
|---|---|---|
user |
Your request can't be processed as sent: a malformed body, a query that breaks a rule, or a rate limit you went over. | The API at api.unkey.com |
unkey |
The API understood the request but couldn't complete it: authentication, permissions, a missing or conflicting resource, a failed precondition, or a failure on our side. | The API at api.unkey.com |
frontline |
The gateway in front of an app on Compute rejected a request or couldn't forward it. Often the person who sees it is calling your API, not you. | The gateway on your app's domains |
The second part groups codes by kind, and the third names the exact problem. Always read the whole code, because some names appear in two categories. err:unkey:authentication:key_not_found is about the root key you authenticated with. err:unkey:data:key_not_found is about a key you referenced in the request.
What the API error body looks like
The API returns errors as JSON:
meta.requestIdidentifies the request. Include it when you contact support.error.typeis the error's page in this catalog.error.statusrepeats the HTTP status.error.titleis a short label. Match onerror.type, never on the title.error.detailexplains what went wrong this time, often with the exact value or permission.
{
"meta": {
"requestId": "req_2c9a0jf23l4k567"
},
"error": {
"detail": "We could not find the requested key.",
"status": 404,
"title": "Not Found",
"type": "/errors/unkey/data/key_not_found"
}
}Validation failures (err:unkey:application:invalid_input) add an errors array. Each entry has a location, such as /required, and a message, such as missing property 'apiId'. Some entries also have a fix that says what to change.
{
"meta": {
"requestId": "req_8f7g6h5j4k3l2m1n"
},
"error": {
"detail": "POST request body for '/v2/keys.createKey' failed to validate schema",
"errors": [
{
"location": "/required",
"message": "missing property 'apiId'"
}
],
"status": 400,
"title": "Bad Request",
"type": "/errors/unkey/application/invalid_input"
}
}Don't guess the status from the code's name. Every err:unkey:data:*_not_found code is 404, but err:unkey:authentication:key_not_found and err:unkey:authentication:portal_session_not_found are 401, because what's missing is your credential.
A 5xx status means the failure is on our side, not in your request. Retry with backoff if the request is safe to repeat, and contact support with the request ID if it keeps happening.
What the gateway error body looks like
The gateway in front of apps on Compute returns a smaller body: a request ID, a code, and a message. Each error's page gives its status.
{
"meta": {
"requestId": "req_3k2j1h0g9f8e7d6c"
},
"error": {
"code": "err:frontline:client:invalid_key",
"message": "Authentication failed. The provided API key is invalid."
}
}You get this JSON when your request asks for it: an Accept header of application/json, application/*, or */* without text/html. Plain curl sends */*, so it gets JSON. Everything else, including a browser, gets an HTML page with the same code, message, and request ID.
Categories
Unkey errors
User errors
Gateway errors
Platform Index (`docs/platform/index.mdx`)
title: The Unkey platform
sidebarTitle: "Overview"
sources:
- docs/docs.json
- README.md
- LICENSE
- SECURITY.md
boost: 3
Hosted Unkey is the dashboard at app.unkey.com and the API at api.unkey.com. API management (keys and rate limits) and Compute (deploying and running APIs) share the workspace, billing, root keys, and CLI on this page, and both run on Unkey's infrastructure. You can fork the public source and run it yourself: code outside packages and internal/icons is FSL-1.1-ALv2, these docs have no self-hosting guide, and security fixes aren't backported, so run a current build of main or the latest release.
Set up what Compute and API Management share: your workspace and team, billing, security, root keys, the CLI, and the HTTP API.
Workspace
Billing
Security
CLI
API
Root keys
Self-host Unkey
These docs cover hosted Unkey: the dashboard at app.unkey.com and the API at api.unkey.com. Compute and API Management both run on Unkey's infrastructure, and there's no self-hosting guide in these docs.
The Unkey source code is public. Code outside the packages and internal/icons directories is licensed under FSL-1.1-ALv2, so you can fork it and run it yourself under those terms. If you self-host, run a current build of main or the latest release. Unkey applies security fixes there and doesn't backport them to older tags.
Hosted Unkey and a fork you run are different setups.
| Hosted Unkey | A fork you run | |
|---|---|---|
| Where it runs | Unkey's infrastructure, at app.unkey.com and api.unkey.com |
A host you operate. These docs have no self-hosting guide |
| Source you run | Unkey operates the service | The public repo. FSL-1.1-ALv2 outside packages and internal/icons |
| Which build | The hosted service | A current build of main or the latest release. Security fixes aren't backported to older tags |
Reference
Api Management Analytics Overview (`docs/api-management/analytics/overview.mdx`)
title: Analytics
sidebarTitle: "Overview"
sources:
- svc/api/routes/v2_analytics_get_verifications/handler.go
- svc/api/routes/v2_analytics_get_ratelimits/handler.go
- svc/api/routes/v2_analytics_get_gateway_requests/handler.go
- svc/api/routes/v2_analytics_get_runtime_logs/handler.go
- internal/services/analytics/service.go
- internal/services/analytics/executor.go
- pkg/clickhouse/query-parser/tables.go
import { ProductLink } from "/snippets/product-link.jsx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
analytics.getGatewayRequests and analytics.getRuntimeLogs query Compute tables and are documented there. The query language, restrictions, and quotas on the pages here apply to them too.
Every key SELECT and get rows back. You only ever see your own workspace's data. Use it to build usage dashboards, bill on verifications, find keys that are hitting limits, or answer a one-off question.
Get access
Ask [email protected] to turn on analytics for your workspace. There's no plan gate and no self-serve switch. Until it's on, the endpoints return HTTP 412 with err:unkey:data:analytics_not_configured. Once it's on, the restrictions and quotas apply.
The endpoints
There are four, one per data set. Each takes {"query": "<SQL>"} and returns data as an array of rows, keyed by the column names you selected.
| Endpoint | Public tables | Root key permission |
|---|---|---|
analytics.getVerifications |
key_verifications_v1 and its per-minute, per-hour, per-day, and per-month rollups |
api.*.read_analytics, or api.<api_id>.read_analytics for specific keyspaces |
analytics.getRatelimits |
ratelimits_v1 and its rollups |
ratelimit.*.read_analytics, or ratelimit.<namespace_id>.read_analytics for specific namespaces |
analytics.getGatewayRequests |
gateway_requests_v1 |
project.*.read_gateway_requests |
analytics.getRuntimeLogs |
runtime_logs_v1 |
project.*.read_runtime_logs |
A root key with read_analytics for specific keyspaces (or namespaces) instead of * only sees rows for those.
curl -X POST https://api.unkey.com/v2/analytics.getVerifications \
-H "Authorization: Bearer <root key>" \
-H "Content-Type: application/json" \
-d '{"query": "SELECT outcome, count() AS n FROM key_verifications_v1 WHERE time > toUnixTimestamp64Milli(now() - toIntervalDay(1)) GROUP BY outcome ORDER BY n DESC"}'{
"meta": { "requestId": "req_2c9a0jf23l4k567" },
"data": [
{ "outcome": "VALID", "n": 48213 },
{ "outcome": "RATE_LIMITED", "n": 1207 },
{ "outcome": "USAGE_EXCEEDED", "n": 88 }
]
}Public tables
key_verifications_v1 has one row per verification. key_verifications_per_minute_v1, _per_hour_v1, _per_day_v1, and _per_month_v1 are rollups, much faster for charts and totals. The ratelimits tables follow the same pattern. Any other table name fails with err:user:bad_request:invalid_analytics_table. Columns and types are on key_verifications tables and ratelimits tables.
Where to go next
You can run the same queries from the CLI with unkey api analytics get-verifications and unkey api analytics get-ratelimits. The CLI reference starts at get-verifications.
Api Management Authorization Overview (`docs/api-management/authorization/overview.mdx`)
title: Authorization
sidebarTitle: "Overview"
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/permissions/createPermission/V2PermissionsCreatePermissionRequestBody.yaml
- svc/api/openapi/spec/paths/v2/permissions/createRole/V2PermissionsCreateRoleRequestBody.yaml
- svc/api/routes/v2_keys_verify_key/handler.go
- internal/services/keys/validation.go
- pkg/rbac/rbac.go
- pkg/rbac/permissions.go
- web/internal/db/src/schema/rbac.ts
Verification tells you a key is real. Authorization tells you what it's allowed to do. Give each key permissions, directly or through roles. Then, when you verify the key, ask whether it has the permissions a request needs. If it doesn't, verification fails with code: INSUFFICIENT_PERMISSIONS and no rate limit or credits are used.
The pieces
A permission is a string you define, such as documents.read or billing:write. It has a readable name, a slug that keys and queries use, and an optional description. It means whatever your API decides it means.
A role is a named group of permissions, such as editor with documents.read and documents.write. A key with the role gets all its permissions, and changing the role changes every key that has it.
A key can have permissions, roles, or both. Its permissions are its own plus those from its roles. Verification returns them as data.permissions, and the roles as data.roles.
A permission query is what you send as permissions on keys.verifyKey: one slug, or slugs combined with AND, OR, and parentheses. Verification passes only if the key's permissions satisfy it.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_live_...", "permissions": "documents.write OR admin" }'Key permissions vs root key permissions
The permissions on this page belong to your users' keys, and your API decides what they allow. Root key permissions, such as api.*.create_key, control what your root keys can do on api.unkey.com. The two never mix. See Root key permissions.
When to check where
Send a permissions query when the answer depends only on the key: "can this key write documents?" Unkey answers as part of the verification. Check data.permissions in your own code when the answer also depends on your data: "can this key delete this document?" needs the document's owner, which Unkey doesn't know.
Next steps
Api Management Cookbook Index (`docs/api-management/cookbook/index.mdx`)
title: Cookbook
sidebarTitle: "Overview"
sources:
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
Each recipe solves one common problem from start to finish. They assume you already have a keyspace, a root key, and a request handler that calls keys.verifyKey. If you don't yet, start with a framework guide.
Api Management Get Started Quickstart (`docs/api-management/get-started/quickstart.mdx`)
title: Issue and verify your first key
sidebarTitle: "Quickstart"
sources:
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/apps/dashboard/lib/trpc/routers/api/create.ts
- web/internal/api/src/sdk/sdk.ts
- web/internal/api/src/sdk/keys.ts
- https://github.com/unkeyed/sdks/tree/main/api/go/v3
- https://github.com/unkeyed/sdks/blob/main/api/py/README.md
import { ProductLink } from "/snippets/product-link.jsx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Issue API keys for your public API with keys.createKey, then verify each request with keys.verifyKey. Both are HTTPS calls to api.unkey.com, from whatever language and host you already use. If you already have keys in your own database, Migrating keys shows how to import the hashes, so users keep the same keys and you stop storing the plaintext.
Create a keyspace, issue a key, and
The root key you create needs api.*.create_key and api.*.verify_key. Pick your language with the switcher. Only the steps that call the API change.
<Frame>
</Frame>```bash
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"name": "My first key"
}'
```
This is the only time you see the key. Copy `data.key` now and give it to your user.
```json
{
"meta": { "requestId": "req_..." },
"data": {
"keyId": "key_...",
"key": "..."
}
}
```
</Step>
<Step title="Verify the key">
This is the call your backend makes on every incoming request. There's no `apiId` field because the key alone identifies its keyspace.
```bash
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "<the key from the previous step>" }'
```
</Step>const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });
const { data } = await unkey.keys.createKey({
apiId: "api_...",
name: "My first key",
});
// Shown once. Store it or send it to your user now.
console.log(data.key);
```
</Step>
<Step title="Verify the key">
There's no `apiId` field because the key alone identifies its keyspace.
```typescript verify-key.ts
const { data } = await unkey.keys.verifyKey({ key: incomingKey });
if (!data.valid) {
// data.code says why: NOT_FOUND, EXPIRED, RATE_LIMITED, ...
throw new Error(`denied: ${data.code}`);
}
console.log(data.keyId);
```
</Step>import (
"context"
"fmt"
"os"
unkey "github.com/unkeyed/sdks/api/go/v3"
"github.com/unkeyed/sdks/api/go/v3/models/components"
)
func main() {
client := unkey.New(unkey.WithSecurity(os.Getenv("UNKEY_ROOT_KEY")))
res, err := client.Keys.CreateKey(context.Background(), components.V2KeysCreateKeyRequestBody{
APIID: "api_...",
Name: unkey.String("My first key"),
})
if err != nil {
panic(err)
}
// Shown once. Store it or send it to your user now.
fmt.Println(res.V2KeysCreateKeyResponseBody.Data.Key)
}
```
</Step>
<Step title="Verify the key">
There's no `apiId` field because the key alone identifies its keyspace.
```go verify.go
res, err := client.Keys.VerifyKey(ctx, components.V2KeysVerifyKeyRequestBody{
Key: incomingKey,
})
if err != nil {
return err
}
data := res.V2KeysVerifyKeyResponseBody.Data
if !data.Valid {
// data.Code says why: NOT_FOUND, EXPIRED, RATE_LIMITED, ...
return fmt.Errorf("denied: %s", data.Code)
}
```
</Step>with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
res = unkey.keys.create_key(api_id="api_...", name="My first key")
# Shown once. Store it or send it to your user now.
print(res.data.key)
```
</Step>
<Step title="Verify the key">
There's no `api_id` argument because the key alone identifies its keyspace.
```python verify_key.py
res = unkey.keys.verify_key(key=incoming_key)
if not res.data.valid:
# res.data.code says why: NOT_FOUND, EXPIRED, RATE_LIMITED, ...
raise PermissionError(f"denied: {res.data.code}")
```
</Step>How you read the verification response
Verification returns HTTP 200 even when the key is rejected. data.valid tells you whether the key passed. When it didn't, data.code says why.
{
"meta": { "requestId": "req_..." },
"data": {
"valid": true,
"code": "VALID",
"keyId": "key_...",
"name": "My first key",
"enabled": true
}
}The response gets more fields as you set more on the key, such as credits, ratelimits, meta, and identity. Verifying keys lists every field and every code value.
Next steps
If you host your app on Unkey, the gateway's key-auth policy can run this verification before a request reaches your code.
Api Management Identities Overview (`docs/api-management/identities/overview.mdx`)
title: Identities
sidebarTitle: "Overview"
sources:
- svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityResponseData.yaml
- svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/getIdentity/V2IdentitiesGetIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/listIdentities/V2IdentitiesListIdentitiesRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/deleteIdentity/V2IdentitiesDeleteIdentityRequestBody.yaml
- svc/api/openapi/spec/common/Identity.yaml
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/routes/v2_identities_create_identity/handler.go
- svc/api/routes/v2_identities_update_identity/handler.go
- svc/api/routes/v2_identities_delete_identity/handler.go
- svc/api/routes/v2_identities_get_identity/handler.go
- svc/api/routes/v2_identities_list_identities/handler.go
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- web/internal/db/src/schema/identity.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
An identity is a user, organization, or service account in your system that owns one or more keys. You name it with an externalId, usually that user's ID in your own database. Keys linked to an identity share its metadata and its rate limits, so a customer can't get around a limit by creating another key.
What an identity holds
Create an identity by issuing a key
The easiest way to create an identity is to pass externalId when you create a key (with keys.createKey or keys.migrateKeys). Unkey creates the identity if it doesn't exist and links the key. Most apps never call identities.createIdentity. Call it when you want metadata or shared limits in place before the first key exists, or when you manage identities from somewhere that doesn't issue keys, such as a billing webhook.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "externalId": "user_123" }'Verifying that key now returns the identity:
{
"meta": { "requestId": "req_..." },
"data": {
"valid": true,
"code": "VALID",
"keyId": "key_...",
"identity": { "id": "id_...", "externalId": "user_123", "meta": {} }
}
}To move a key to another identity, set externalId on keys.updateKey. Send null to unlink it.
You need identity.*.create_identity, identity.*.read_identity, identity.*.update_identity, or identity.*.delete_identity for those calls.
Create an identity
1 to 255 characters, ^[a-zA-Z0-9_.-]+$. A second identity with the same externalId fails with HTTP 409 err:unkey:data:identity_already_exists.
At most 100 top-level properties, and at most 1 MB once serialized to JSON. A larger object is rejected with HTTP 400 err:unkey:application:invalid_input.
Up to 50 limits, each with a unique name. Every entry needs name (3 to 128 characters), limit (1 or more), duration in milliseconds (1000 or more), and autoApply (required).
curl -X POST https://api.unkey.com/v2/identities.createIdentity \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "org_acme",
"meta": { "plan": "enterprise", "billingEmail": "[email protected]" },
"ratelimits": [ { "name": "requests", "limit": 1000, "duration": 60000, "autoApply": true } ]
}'The response carries identityId.
Find identities
identities.getIdentity takes identity (the id or externalId) and returns the fields above. An unknown value returns HTTP 404 err:unkey:data:identity_not_found.
identities.listIdentities takes limit (1 to 100, default 100), cursor, and an optional search. Search matches any part of the id or externalId, ignoring case. The response has data and pagination.
Update an identity
identities.updateIdentity takes identity (id or externalId) plus any of:
Replaces all metadata. Omit it to keep the current metadata, or send {} to clear it. The same 100-property and 1 MB limits apply.
Replaces the whole list. Limits you leave out are deleted, matching names are updated, and new names are added. The same bounds as create apply. A repeated name fails with HTTP 400 err:unkey:application:invalid_input. Leave it out to keep the current limits.
You can't rename an identity's externalId. Create a new identity and move the keys with keys.updateKey instead.
Delete an identity
identities.deleteIdentity takes identity (id or externalId). The keys aren't deleted, but they stop returning identity on verification and stop sharing its rate limits. You can reuse the externalId for a new identity.
From the dashboard
Open Identities in the sidebar to search, create, edit, or delete identities. Each identity's page shows the verification history of all its keys together.
Api Management Keyspaces Overview (`docs/api-management/keyspaces/overview.mdx`)
title: Keyspaces
sidebarTitle: "Overview"
sources:
- web/internal/db/src/schema/apis.ts
- web/internal/db/src/schema/keyAuth.ts
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/getApi/V2ApisGetApiResponseData.yaml
- svc/api/routes/v2_apis_create_api/handler.go
- svc/api/routes/v2_apis_delete_api/handler.go
- svc/api/routes/v2_keys_create_key/handler.go
- internal/services/keys/get.go
- web/apps/dashboard/lib/trpc/routers/api/create.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/_components/create-api-button.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/delete-api.tsx
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
A keyspace is a group of related API keys. Every key belongs to one keyspace, and you name it every time you create a key. It's not a running service and has no URL. The dashboard lists keyspaces under Keyspaces (APIs), and API endpoints call them APIs, as in apis.createApi.
When to create more than one
One keyspace is enough when all your keys share the same defaults and you tell them apart by metadata or identity. Create more when you want a hard boundary: different key prefixes per product, separate analytics per api.<api_id>.create_key can be limited to one keyspace.
Two identifiers
Each keyspace has two IDs, used in different places.
| Identifier | Format | Used by |
|---|---|---|
| API ID | api_... |
keys.createKey, apis.listKeys, apis.getApi, apis.deleteApi, and root key permissions such as api.<api_id>.verify_key |
| Keyspace ID | ks_... |
Analytics tables (key_space_id), the keyspaces filter on keys.verifyKey, and the customer portal |
Both appear on the keyspace's Settings page with a copy button.
Settings that apply to every key
These settings apply to every key in the keyspace. Keyspace settings covers each one.
| Setting | Effect |
|---|---|
| Default prefix | Prepended to new keys that do not pass their own prefix. |
| Default bytes | Random bytes used for new keys that do not pass their own byteLength; 16 unless changed. |
| Store encrypted keys | Allows keys to be created as recoverable; opt-in through support. |
| IP allow list | Verification rejects requests from any other client IP; exact match, not editable in the dashboard. |
| Delete protection | Blocks apis.deleteApi and the dashboard delete button while on. |
Create a keyspace
<Frame>
</Frame>```bash
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "payment-service-prod" }'
```
`apis.getApi` returns the `id` and `name` for an API ID you already have.Delete a keyspace
Deleting a keyspace makes every key in it verify as NOT_FOUND. In the dashboard, use the Delete Keyspace card in the settings danger zone and type the keyspace name and the confirmation phrase. From the API, call apis.deleteApi with the apiId. It needs api.*.delete_api or api.<api_id>.delete_api.
If delete protection is on, both fail with HTTP 412 err:unkey:application:protected_resource. Turn protection off on the settings page first. See Delete protection.
Next steps
Api Management Portal Overview (`docs/api-management/portal/overview.mdx`)
title: Developer portal
sidebarTitle: "Overview"
sources:
- svc/api/openapi/spec/common/Portal.yaml
- svc/api/openapi/spec/common/PortalBranding.yaml
- svc/api/openapi/spec/paths/v2/portal/createPortal/V2PortalCreatePortalRequestBody.yaml
- svc/api/openapi/spec/paths/v2/portal/createPortal/index.yaml
- svc/api/openapi/spec/paths/v2/portal/createSession/index.yaml
- cmd/api/portal/create_portal.go
- cmd/api/portal/create_session.go
- svc/api/openapi/spec/paths/v2/portal/updatePortal/V2PortalUpdatePortalRequestBody.yaml
- svc/api/routes/v2_portal_create_session/handler.go
- svc/api/routes/v2_portal_update_portal/handler.go
- svc/api/routes/v2_portal_delete_portal/handler.go
- web/internal/db/src/schema/portals.ts
- pkg/rbac/permissions.go
- pkg/codes/constants_gen.go
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
The developer portal is a hosted page where your users see their API keys, roll them over, and check their usage, without you building any of it. You keep the sign-in: your backend creates a short-lived session for a user who's already signed in, and redirects them to the portal. Each user only sees their own keys.
You can manage a portal for a keyspace in the dashboard, with the API, or with the CLI (create-portal, get-portal, update-portal, delete-portal, create-session). A portal for a deployed app can only be managed with the API or the CLI for now.
What a portal is bound to
A portal shows keys from one place. Set keyspaceId to show the keys in a keyspace. Or set appId to a Compute
A user only sees keys whose identity externalId matches the one in their session. Keys without an identity never show up in the portal, so attach identities to keys you want users to manage.
Fields
A handle for the portal in API calls, unique in your workspace. 3 to 64 lowercase letters, digits, and hyphens, not starting or ending with a hyphen. Your users never see it. You can use the slug or the pc_ ID wherever you name a portal. A taken slug, or a second portal for the same keyspace or app, fails with HTTP 409 err:unkey:data:portal_already_exists.
1 to 64 characters shown in the portal header and page titles. Change it any time.
The keyspace to serve. Mutually exclusive with appId.
The app to serve. Mutually exclusive with keyspaceId.
Whether new sessions can be created. Disabling also ends sessions that are already live.
https:// URL of the logo in the portal header, up to 500 characters. Your users' browsers load it directly, so that host sees their IP address. Set null on update to remove it.
Six-digit hex color (#6366f1) for buttons and accents. Set null on update to go back to the default.
When you read a portal, logoUrl and primaryColor come back under branding, and createdAt and updatedAt are Unix milliseconds.
Create a portal
Creating a portal needs portal.*.create_portal. Reading, updating, and deleting need read_portal, update_portal, or delete_portal, either as portal.*.<action> or scoped to one portal as portal..<action>. Creating sessions needs a separate permission, covered on Portal sessions.
curl -X POST https://api.unkey.com/v2/portal.createPortal \
-H "Authorization: Bearer <root key>" \
-H "Content-Type: application/json" \
-d '{
"slug": "acme-portal",
"displayName": "Acme",
"keyspaceId": "ks_1234abcd",
"primaryColor": "#6366f1"
}'You get the portal back with its pc_ ID. Next, create a session for a signed-in user and redirect them to the URL you get back.
Change or remove a portal
portal.updatePortal takes the same fields, all optional. Leave a field out to keep it. Pointing the portal at a different keyspace or app, or disabling it, ends every live session.
portal.deletePortal deletes the portal, ends all its live sessions, and frees the slug. Both write an audit log entry (portal.update or portal.delete).
Next steps
Api Management Ratelimiting Overview (`docs/api-management/ratelimiting/overview.mdx`)
title: Rate limiting with Unkey
sidebarTitle: "Overview"
sources:
- svc/api/routes/v2_ratelimit_limit/handler.go
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/openapi/spec/common/KeyCreditsRefill.yaml
- svc/frontline/internal/policies/ratelimit/headers.go
- svc/frontline/internal/policies/ratelimit/keyextract.go
- svc/api/openapi/spec/common/RemoteIpKey.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
- svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- web/internal/api/src/sdk/identities.ts
- svc/api/internal/middleware/authentication.go
- internal/services/ratelimit/doc.go
- internal/services/keys/validation.go
- pkg/rbac/permissions.go
- web/internal/api/src/sdk/ratelimit.ts
- web/internal/api/src/sdk/keys.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Per customer, Unkey counts on an identity, so extra keys don't add budget.
You can also limit per key, per endpoint with a named limit or a cost, and per IP with ratelimit.limit.
Monthly quotas are separate, as credits on the key.
On Compute, a gateway policy applies these limits before the request reaches your API.
Which one do I use
Use the row that matches what you want to count.
| You want to limit | What Unkey uses | Read more |
|---|---|---|
| Per customer | A rate limit on the identity, shared by every key with that externalId |
Shared rate limits |
| Per key | A named rate limit on the key, checked during keys.verifyKey |
Key and identity rate limits |
| Per endpoint | A named limit the route opts into, or a cost on that call. With no key, one namespace per route on ratelimit.limit |
Per-endpoint rate limits |
| Per IP | The IP as the identifier on ratelimit.limit. On Compute, a gateway policy can count by remote IP |
ratelimit.limit, Rate limit policy |
| Monthly quota | Credits on the key, with credits.refill.interval set to monthly |
Credits and refill |
Limit per customer
Create the identity with the shared limit, then create each key with the same externalId. keys.verifyKey counts all of those keys together. The client is @unkey/api {versions.tsApi}.
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });
await unkey.identities.createIdentity({
externalId: "cus_acme",
ratelimits: [
{ name: "requests", limit: 100, duration: 60000, autoApply: true },
],
});
await unkey.keys.createKey({ apiId: "api_...", externalId: "cus_acme" });
export async function handler(key: string) {
const { data } = await unkey.keys.verifyKey({ key });
if (data.valid) {
return new Response("ok");
}
if (data.code === "RATE_LIMITED") {
return new Response("Too many requests", { status: 429 });
}
return new Response("Unauthorized", { status: 401 });
}To override that customer's limit, call identities.updateIdentity with identity set to the same externalId and a new ratelimits array. The array replaces the whole list, so send every limit the customer should keep. See Per-user rate limits.
Unkey enforces per-user and per-customer rate limits, and a monthly request quota, on an API you host yourself. You don't run Redis or any other counter store. Attach both to an API key, or call ratelimit.limit with a user id, a customer id, or any other stable string.
A rate limit is a sliding window, such as 100 requests per minute. A monthly quota is credits on the key: set credits.refill.interval to monthly, and each successful keys.verifyKey spends from that balance. Credits are an exact total. Rate limit counts are close, and they can run a little high when traffic is split across regions. See Credits and refill.
Add a per-key limit
Create the key with a per-minute limit and a monthly credit refill, then verify it on each request. This limit counts that key alone. externalId still ties the key to one customer.
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });
await unkey.keys.createKey({
apiId: "api_...",
externalId: "cus_acme",
credits: {
remaining: 10000,
refill: { interval: "monthly", amount: 10000, refillDay: 1 },
},
ratelimits: [
{ name: "requests", limit: 100, duration: 60000, autoApply: true },
],
});
export async function handler(request: Request) {
const header = request.headers.get("authorization") ?? "";
const key = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header;
const { data } = await unkey.keys.verifyKey({ key });
if (data.valid) {
return new Response("ok");
}
if (data.code === "RATE_LIMITED") {
const resetMs = data.ratelimits?.find((limit) => limit.exceeded)?.reset ?? Date.now();
const retryAfter = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(retryAfter) },
});
}
if (data.code === "USAGE_EXCEEDED") {
return new Response("Monthly quota exceeded", { status: 429 });
}
return new Response("Unauthorized", { status: 401 });
}duration is the window in milliseconds. autoApply: true checks that limit on every verification. The monthly refill replaces the balance on refillDay at 00:00 UTC. It doesn't add to what's left.
For an endpoint with no API key, call ratelimit.limit with your own identifier instead. The same pattern is on ratelimit.limit and multiLimit.
What a limited request looks like
keys.verifyKey and ratelimit.limit return HTTP 200 when your user is over their limit. Read the body. keys.verifyKey sets data.valid to false and data.code to RATE_LIMITED (or USAGE_EXCEEDED when credits run out). Each checked limit is in data.ratelimits, with limit, remaining, reset, and exceeded. ratelimit.limit sets data.success to false and puts limit, remaining, and reset on data. reset is Unix milliseconds, when the current window ends.
Your server is the one that returns 429 Too Many Requests to the caller. RFC 6585 defines that status and says the response may include Retry-After, a number of seconds to wait. Set it from reset, and use at least 1 second. Unkey doesn't send Retry-After or X-RateLimit-* on these two endpoints for your user's limit, so copy limit, remaining, and reset onto your own response if you want those headers. X-RateLimit-Reset on the Compute gateway is Unix seconds, so divide reset by 1000 if you want to match it.
If the API runs on Compute, the gateway returns 429 itself. The response has Retry-After (seconds, never under 1), X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix seconds). See rate_limited.
Your server's own calls to api.unkey.com have a separate workspace limit. If you exceed it, Unkey returns HTTP 429 with RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the window ends), and Retry-After. Those headers describe your workspace's budget for the Unkey API, not your users' limits.
Choose a rate limit
Standalone ratelimit.limit |
Key and identity rate limits | |
|---|---|---|
| What you limit | Any string: a user ID, an IP address, an email domain, a tenant | A key, or every key of an identity together |
| Where it runs | Its own endpoint, whenever you call it | Inside keys.verifyKey, as one of its checks |
| Configuration | You send the limit and duration with every call | Named limits stored on the key or identity |
| Per-caller exceptions | Overrides on an identifier or wildcard pattern | Different limits per key or identity |
| Failure signal | success: false in the response |
code: RATE_LIMITED in the verification |
Use the standalone API when the endpoint has no key (sign-up, password reset) or when you want to limit something other than the key holder. Use key and identity limits when the request already carries a key, so the check happens during the verification you already make.
Both count requests the same way. See How rate limiting works.
A first rate limit
The root key needs ratelimit.*.limit, plus ratelimit.*.create_namespace the first time a namespace name is used.
curl -X POST https://api.unkey.com/v2/ratelimit.limit \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"namespace": "email.send",
"identifier": "user_123",
"limit": 10,
"duration": 60000
}'{
"meta": { "requestId": "req_..." },
"data": { "success": true, "limit": 10, "remaining": 9, "reset": 1704067260000 }
}The namespace email.send names what you're limiting. The identifier user_123 is who's being counted. Each user gets ten calls per minute, and the eleventh returns success: false until the window moves on. In the TypeScript SDK, the same call is unkey.ratelimit.limit({ namespace, identifier, limit, duration }).
Next steps
Api Management Sdks Overview (`docs/api-management/sdks/overview.mdx`)
title: SDKs
sidebarTitle: "Overview"
sources:
- docs/snippets/versions.mdx
- web/internal/api/src/sdk/sdk.ts
- web/internal/api/src/models/components/v3deploymentscreatedeploymentrequestbody.ts
- cmd/api/util/client.go
- cmd/api/deployments/create_deployment.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
- https://github.com/unkeyed/sdks/blob/49e455e0e6a3204f57ef325054438cf1bd7d6586/api/go/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
- https://www.npmjs.com/package/@unkey/nuxt
import { versions } from "/snippets/versions.mdx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Use an SDK to call the Unkey API from your code. We publish clients for TypeScript, Go, and Python that cover every endpoint, plus smaller TypeScript packages for one job each, such as verifying keys in a Hono route. The community maintains a Nuxt module. Every package that calls the API needs a root key.
Packages
| Package | Language | Current version | What it is |
|---|---|---|---|
| @unkey/api | TypeScript | {versions.tsApi} | Generated client for the whole API |
| @unkey/ratelimit | TypeScript | {versions.tsRatelimit} | Standalone rate limiting with timeouts and fallbacks |
| @unkey/hono | TypeScript | {versions.tsHono} | Hono middleware that verifies the bearer key |
| @unkey/nextjs | TypeScript | {versions.tsNextjs} | withUnkey wrapper for Next.js route handlers |
| @unkey/cache | TypeScript | {versions.tsCache} | Typed tiered cache with stale-while-revalidate |
| Go SDK | Go | {versions.goSdk} | Generated client, module github.com/unkeyed/sdks/api/go/v3 |
| Python SDK | Python | {versions.pySdk} | Generated client unkey.py, sync and async |
| Nuxt module | TypeScript | {versions.tsNuxt} | Community-maintained Nuxt integration |
The TypeScript, Go, and Python clients match the API reference, so method names and fields line up across languages. The Python client ({versions.pySdk}) covers API Management only. The TypeScript and Go clients also cover Compute.
Which package to use
@unkey/api, the Go SDK, or the Python SDK when you call several endpoints or want full types.@unkey/honoor@unkey/nextjswhen you only need to check incoming requests for a valid key. They callkeys.verifyKeyand give your handler the result.@unkey/ratelimitto rate limit user IDs or IP addresses without issuing keys. It adds a timeout and fallback so a network problem can't block your function.@unkey/cachefor typed caching in your own services. It doesn't call the Unkey API.
One client, both products
The TypeScript and Go clients cover Compute too, so the same Unkey instance that
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const result = await unkey.keys.verifyKey({ key: "prod_abc123" });
if (!result.data.valid) {
// result.data.code says why: NOT_FOUND, DISABLED, EXPIRED, RATE_LIMITED, ...
}import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const result = await unkey.deployments.createDeploymentV3({
project: "my-project",
app: "api",
environment: "production",
oci: { image: "ghcr.io/acme/api:v1.0.0" },
});
console.log(result.data.deploymentId);For Compute concepts such as projects, apps, and
Response shape
Every successful response has meta.requestId (quote it when you contact support) and data, the result. Errors are thrown or returned as typed errors per HTTP status. Each SDK page shows how to catch them. See API overview for the raw format.
Next steps
Api Management Security Overview (`docs/api-management/security/overview.mdx`)
title: API key security
sidebarTitle: "Overview"
sources:
- pkg/hash/sha256.go
- internal/services/keys/create.go
- svc/api/routes/v2_keys_verify_key/handler.go
- svc/api/routes/v2_keys_create_key/handler.go
- web/internal/db/src/schema/keyAuth.ts
- web/internal/db/src/schema/util/delete_protection.ts
- web/apps/dashboard/app/api/v1/github/verify/route.ts
- pkg/rbac/permissions.go
Here's how the API keys you issue are protected, and what you can do to keep them safe. For workspace security, such as how your team signs in, see Security.
Keys are stored as hashes
When you create a key, you get it back once and we keep only a hash. A copy of our database wouldn't give anyone a working key, and we can't show a key again. So show the key when it's created and let users roll it over. Don't promise to show it later. See Key storage.
Recovery is opt-in and separately permissioned
If your product must show a key more than once, we can turn on encrypted storage for a keyspace. Keys created with recoverable: true are then also stored encrypted with keys unique to your workspace. It's off by default. Creating a recoverable key needs the encrypt_key permission, and reading one back needs decrypt_key, so you can grant recovery narrowly. See Recovering keys for the trade-offs and Recoverable keys for the steps.
Verification doesn't leak
keys.verifyKey returns NOT_FOUND for several failures, including a root key without permission for the keyspace, so nobody can use it to find out which keys exist. Every verification is recorded in analytics, and verifications made with a root key are also in the audit log.
IP allow lists
A keyspace can have a list of IP addresses its keys can be verified from. Verification from anywhere else fails with FORBIDDEN. Use it for keys that should only be used from your own servers. See Keyspace settings.
Delete protection
With delete protection on, you can't delete a keyspace until you turn protection off, so one wrong call can't wipe out a production keyspace and all its keys. See Delete protection.
Root keys are scoped
A root key has only the permissions you give it, down to one keyspace. For example, api.<api_id>.verify_key lets a service verify keys in one keyspace and nothing else. Give each service its own root key with as few permissions as it needs, so a leaked key does little damage and is easy to replace. See Root key permissions.
Leaked credentials
GitHub's secret scanning recognizes root keys by their unkey_ prefix. If one is pushed to a public repository, every member of your workspace gets an email. The keys you issue to your users use your own prefixes, and GitHub doesn't scan for them, so delete or reroll a user's key yourself when you learn it leaked. Details are on GitHub secret scanning.
Everything is audited
Every change to keyspaces, keys, identities, roles, permissions, and portals is in the audit log, with who did it and from where. You can stream it to your own systems. See Audit logs.
Compute Build Overview (`docs/compute/build/overview.mdx`)
title: Builds
sidebarTitle: "Overview"
sources:
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/worker/deploy/build.go
- svc/ctrl/worker/deploy/railpack.go
- svc/api/openapi/spec/common/EnvironmentBuild.yaml
- svc/api/openapi/spec/common/DeploymentErrorCode.yaml
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- web/internal/db/src/schema/app_build_settings.ts
- web/apps/dashboard/lib/limits.ts
- svc/ctrl/worker/deploy/build_handler.go
- svc/ctrl/worker/cron/buildlimitsync/handler.go
A build turns the source of a git-connected
Only git-connected apps build. If you deploy a prebuilt image, there is nothing to build: the deployment records the image you named and runs it, and none of the settings on this page apply.
Choose how your app is built
There is one decision, and it is the Dockerfile path in your
Leave it empty, the default the dashboard calls Automatic (no Dockerfile), and we detect your language and toolchain from the root directory and build it for you. Set a path and we run a normal Docker build with that file, using the root directory as the build context. See Dockerfile builds.
To get an automatic build right:
- Pin your tool versions in the files your ecosystem already uses. Detection reads them to decide which toolchain to install, so a project that pins nothing can move versions underneath you between builds.
- In a monorepo, set the root directory to the app's own directory, so detection looks at the right project rather than the repository root.
- Set a build command when the detected one is wrong. It can be up to 1000 characters, and it is ignored when a Dockerfile is set.
If detection can't work out how to build your app, the deployment fails with "Unkey could not build this app automatically. For a monorepo, set the root directory to your app and a custom build command in settings, or review the build logs for details." The fix is one of the three above.
Why your build is waiting
A workspace runs one build at a time on every plan today, so a second deployment sits in pending until the first finishes. Deployments from a prebuilt image queue too, even though they don't build.
Production deployments jump ahead of preview deployments in the queue, so a hot-fix doesn't wait behind a backlog of pull request builds.
A deployment that has waited an hour for its turn fails rather than waiting longer, so a jammed queue surfaces instead of hanging. Deploy again once the queue has drained.
Why your build failed
A failed build ends the deployment as failed with the error code build_failed. Read the build output on the deployment's detail page in the dashboard, where it streams live and stays afterwards. Common causes are rewritten in plain language above the log, for example a Dockerfile that couldn't be read or a file the build referenced that doesn't exist, and the full log is always underneath.
Problems in your own source, such as a Dockerfile syntax error or a build command that exits non-zero, fail immediately. Retrying them without changing anything gives the same result.
Infrastructure blips are retried for you, up to five attempts and no longer than 30 minutes in total, whichever comes first. A build that takes unusually long and then fails has normally spent that time in retries.
Make builds faster, or skip them
Build layers are cached per project, up to 25 GB, with layers older than 7 days evicted. Repeat builds of a project whose dependencies haven't changed reuse that cache. A project you haven't deployed in over a week builds from scratch again.
Because every deployment produces its own image, promoting and rolling back never rebuild. They run an image that already exists, which is why a rollback is fast.
Two settings stop a push from building at all:
- Watch paths builds only when a changed file matches one of the environment's glob patterns, so a documentation-only commit doesn't start a build.
- Auto-deploy, turned off, stops pushes from deploying entirely, leaving you to deploy from the dashboard or the API when you choose.
A push that isn't built still appears as a skipped deployment with the reason, so you can tell the difference between a push we ignored and one we never saw. Both settings live on Build settings, and the order they're evaluated in is on GitHub integration.
Your environment variables are available while the build runs without being baked into the image. See Build-time secrets.
Next steps
Compute Gateway Overview (`docs/compute/gateway/overview.mdx`)
title: Gateway
sidebarTitle: "Overview"
sources:
- svc/frontline/routes/proxy/handler.go
- svc/frontline/internal/router/lookup.go
- svc/frontline/internal/db/queries/ingress_route_find_by_fqdn.sql
- svc/ctrl/worker/deploy/create.go
- svc/frontline/internal/policies/engine.go
- svc/frontline/internal/proxy/director.go
- svc/frontline/internal/proxy/forward.go
- svc/frontline/internal/proxy/headers.go
- svc/frontline/middleware/sanitize_headers.go
- svc/frontline/middleware/clickhouse_logging.go
- pkg/timing/entry.go
- svc/api/openapi/spec/common/Policy.yaml
import { ProductLink } from "/snippets/product-link.jsx";
Every
What policies can do
A policy has a name, an on/off switch, up to ten match expressions that pick which requests it applies to, and one rule. There are five kinds of rule:
| Type | What it does | On rejection |
|---|---|---|
| API key authentication | Checks an Unkey API key on the request and tells your app who's calling with the principal header. | 401, 403, or 429 |
| Rate limit | Counts requests per client IP, header, path, authenticated caller, or principal field, and rejects when the limit is used up. | 429 |
| Firewall | Blocks every request its match expressions select. | 403 |
| OpenAPI validation | Checks the path, query, headers, and body against the OpenAPI spec your deployment serves. | 400 |
| Logging | Saves headers, query data, and bodies of matched requests in the request log. | Never rejects |
See Gateway policies to add them from the dashboard, API, or CLI.
What happens to a request
- The request arrives at one of your domains, and the gateway finds the deployment it belongs to.
- The deployment's policies run in list order. Turned-off policies, and policies whose match expressions don't fit the request, are skipped.
- If a policy rejects the request, the caller gets a
4xxwith anerr:frontline:...code. The request never reaches your app and doesn't appear in your request log. See Gateway errors for every code. - If every policy passes, the gateway forwards the request to your app with the headers below.
What your app receives
The gateway keeps the original Host header and adds these:
| Header | Value |
|---|---|
X-Unkey-Request-Id |
The request ID. The caller sees the same value on the response and in error bodies. |
X-Unkey-Region |
The gateway's platform and region as ::<region>. |
X-Unkey-Frontline-Id |
The ID of the gateway that handled the request. |
X-Unkey-Principal |
Who's calling, as JSON. Only present when an API key authentication policy succeeded. |
X-Forwarded-For |
The client IP. |
X-Forwarded-Host |
The hostname the client requested. |
X-Forwarded-Proto |
Always https. |
Callers can't fake these. We remove any X-Unkey-* header a client sends before policies run. To get the deployment ID, read the UNKEY_DEPLOYMENT_ID environment variable.
The response to the caller includes X-Unkey-Request-Id, X-Unkey-Region, and X-Unkey-Frontline-Id. Rate limit headers are added when a rate limit applied.
Use keys from API Management
The API keys the gateway checks live in API Management. To require a key, create a keyspace and keys in API Management, then add the keyspace to an API key authentication policy. The same keys also work if your own code calls keys.verifyKey.
Create the keyspace and keys an authentication policy verifies against, and manage their permissions, rate limits, and credits.
Next steps
Compute Observe Overview (`docs/compute/observe/overview.mdx`)
title: Observability
sidebarTitle: "Overview"
sources:
- web/apps/dashboard/lib/navigation/leaves.ts
- svc/frontline/middleware/clickhouse_logging.go
- svc/frontline/routes/proxy/handler.go
- pkg/clickhouse/schema/022_frontline_requests_raw_v1.sql
- pkg/clickhouse/schema/036_frontline_requests_per_minute_v1.sql
- pkg/clickhouse/schema/037_frontline_requests_per_5m_v1.sql
- pkg/clickhouse/schema/038_frontline_requests_per_15m_v1.sql
- pkg/clickhouse/schema/039_frontline_requests_per_hour_v1.sql
- pkg/clickhouse/schema/040_frontline_requests_per_day_v1.sql
- pkg/clickhouse/schema/023_runtime_logs_raw_v1.sql
- pkg/clickhouse/schema/020_build_steps_v1.sql
- pkg/clickhouse/schema/021_build_step_logs_v1.sql
- pkg/clickhouse/schema/025_instance_checkpoints_v1.sql
- pkg/clickhouse/schema/027_instance_resources_per_minute_v1.sql
- pkg/clickhouse/schema/028_instance_resources_per_hour_v1.sql
- pkg/clickhouse/schema/033_instance_resources_per_15s_v1.sql
- pkg/clickhouse/schema/034_instance_resources_per_day_v1.sql
- pkg/clickhouse/schema/035_instance_resources_per_month_v1.sql
- pkg/clickhouse/schema/029_instance_events_raw_v1.sql
- pkg/clickhouse/user.go
- internal/services/analytics/executor.go
- web/apps/dashboard/lib/limits.ts
We record four kinds of data about your
- Every request the gateway passes to your app.
- Everything your instances print to stdout and stderr.
- CPU, memory, disk, and network for each instance.
- Each build step and its output.
Where to look in the dashboard
- Request and runtime logs: open a project and click Requests or Logs in the sidebar. Filter by app,
environment ,deployment , or region. - Metrics and build steps: open the app, click Deployments, and open a deployment. Its overview has the traffic and resource charts and the build steps. Its Network view shows regions and instances with live request rates.
Rejected requests aren't in the request log
The request log only has requests that reached your app. Requests rejected by a policy, or that the gateway couldn't route (an unknown hostname, or no running instance), aren't there. So your request log can show less traffic than your callers sent. See Request logs.
Retention
Each kind of data is kept for a fixed time, shown below. The dashboard can show all of it.
The analytics API has a second limit: your plan's log query range, 3 days on Starter, 7 on Pro, and 14 on Business. You can read back whichever is shorter. A query that reaches further fails with err:user:bad_request:query_range_exceeds_retention instead of returning partial results.
| Data | Stored for | Queryable |
|---|---|---|
| Request log rows | 7 days | Dashboard: 7 days. Analytics API: the shorter of 7 days and your plan's range |
| Per-minute request counts and latency percentiles | 14 days | Charts, no plan range |
| 5 and 15 minute request aggregates | 30 days | Charts, no plan range |
| Hourly request aggregates | 90 days | Charts, no plan range |
| Daily request aggregates | 365 days | Charts, no plan range |
| Runtime logs | 90 days from ingestion | Dashboard: 90 days. Analytics API: the shorter of 90 days and your plan's range |
| Build steps and build logs | 3 months | Dashboard only |
| 15 second resource metrics | 7 days | Charts, no plan range |
| Per-minute resource metrics | 30 days | Charts, no plan range |
| Hourly resource metrics | 90 days | Charts, no plan range |
| Daily resource metrics | 365 days | Charts, no plan range |
| Monthly resource metrics | 1825 days | Charts, no plan range |
| Raw resource samples | 95 days | Charts, no plan range |
| Instance events | 90 days | Charts, no plan range |
For example, on Starter the analytics API reaches back 3 days into runtime logs that are kept for 90. On Business it reaches 7 days of request logs, not 14, because that's how long they're kept. See Compute limits.
Query the data yourself
You can query request and runtime logs with SQL through the analytics API, using the gateway_requests_v1 and runtime_logs_v1 tables, to build your own dashboards or alerts. See Query gateway requests and runtime logs. Read build logs through the API with deployments.listBuildLogs. See Build logs. Resource metrics are only in the dashboard.
Platform Ai Coding Overview (`docs/platform/ai-coding/overview.mdx`)
title: AI coding
sidebarTitle: "Overview"
sources:
You can connect an AI coding assistant like Claude Code, Cursor, or VS Code to Unkey in two ways. It can act on your workspace through Unkey's MCP servers, and it can read these docs while it writes code for you.
Let your assistant act on Unkey
Unkey hosts two MCP servers. Add one to your assistant with a root key, and it can call the Unkey API for you.
- Unkey: every v2 API endpoint, so keys, keyspaces, identities, permissions, rate limits, analytics, portals, and Compute.
- Compute: only the Compute endpoints, for projects, apps, environments, deployments, domains, and gateway policies.
See Unkey MCP servers for the URLs, the root key to use, and example prompts.
Give your assistant the docs
- Docs MCP server: lets your assistant search these docs while it works. Its URL is below. You can also add it from the menu next to Copy page at the top of any page, which has Connect to Cursor, Connect to VS Code, Copy MCP Server, and Copy MCP install command.
- Markdown: Copy page copies the page as Markdown you can paste into a chat. Add
.mdto the end of any docs URL to get the same thing as plain text. - llms.txt: an index of every page, built for language models.
llms-full.txthas the full text of the docs in one file.
https://www.unkey.com/docs/mcp
https://www.unkey.com/docs/llms.txt
https://www.unkey.com/docs/llms-full.txtUse the CLI
Assistants that run terminal commands can also use the unkey CLI. Pass --output=json so they get the full API response. See Install the CLI.
Next steps
Platform Api Overview (`docs/platform/api/overview.mdx`)
title: HTTP API basics
sidebarTitle: "Overview"
sources:
- svc/api/openapi/openapi-generated.yaml
- svc/api/openapi/spec/common/Meta.yaml
- svc/api/openapi/spec/common/Pagination.yaml
- svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysResponseBody.yaml
- svc/api/internal/pagination/pagination.go
- svc/api/routes/v2_ratelimit_list_overrides/handler.go
- pkg/zen/auth.go
Compute and API Management share one HTTP API. Here's how to make a request, read the response, and page through lists.
Make a request
Send a POST with a JSON body to https://api.unkey.com/v2/{service}.{procedure}. There are no path or query parameters. The only GET routes are /v2/liveness, the health check, and /openapi.yaml and /reference, which serve the OpenAPI document and a browsable reference. See RPC naming convention for how endpoints are named.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer <root key>" \
-H "Content-Type: application/json" \
-d '{"key": "sk_live_..."}'Put a root key in the Authorization: Bearer header. See API authentication.
Read the response
Every response is a JSON object with meta. A success adds data, and a failure adds error instead.
{
"meta": { "requestId": "req_2c9a0jf23l4k567" },
"data": { "keyId": "key_1234abcd" }
}Page through lists
List endpoints use a cursor instead of page numbers. Each request takes an optional limit and cursor, and each response has a pagination object next to data. The example uses apis.listKeys, but every list endpoint works the same way.
Maximum items to return. For apis.listKeys it's 1 to 100, default 100. Most list endpoints default to 100, but ratelimit.listOverrides defaults to 50. Send limit if the page size matters to you.
The pagination.cursor value from the previous response, sent back unchanged. Leave it out for the first page. Cursors can expire, so don't store them long term or build them yourself.
Keep the other request fields the same on every page. If you change a filter partway through, the cursor no longer works.
Platform Cli Overview (`docs/platform/cli/overview.mdx`)
title: The unkey CLI
sidebarTitle: "Overview"
sources:
- build/cli/main.go
- cmd/api/root.go
- cmd/api/util/flags.go
- cmd/api/analytics/root.go
- cmd/auth/main.go
- cmd/deploy/main.go
- cmd/healthcheck/main.go
- pkg/cli/parser.go
- .goreleaser.yaml
The unkey CLI lets you do from your terminal what you'd do with the API. It covers both products. Nearly every API endpoint has a matching unkey api <group> <command>. The exception is the customer portal's end-user endpoints, which use a portal session instead of a root key.
There are also three top-level commands: unkey deploy deploys a prebuilt container image to Compute, unkey auth login stores a root key so you don't pass it on every call, and unkey healthcheck checks a URL from a script.
Command shape
Commands look like unkey <command> [subcommand] [flags] [arguments]. unkey --help, unkey <command> --help, and unkey help <command> print usage, and unkey --version prints the installed version.
Every unkey api command shares five flags for auth, output, and a raw request body. They're on CLI output and shared flags, along with flag syntax, so each command page only lists its own flags.
Command groups
unkey api has 14 groups, one per API service. Each command has its own page in the CLI section of the product it belongs to.
Compute
| Group | What it manages |
|---|---|
unkey api projects |
Create, read, update, and delete workspace projects |
unkey api apps |
Create, read, update, and delete apps within projects |
unkey api environments |
Environment variables and settings |
unkey api deployments |
Create and control deployments |
unkey api domains |
Create, read, |
unkey api gateway |
List, replace, and update gateway policies |
unkey api github |
GitHub App installations |
unkey api analytics |
get-gateway-requests and get-runtime-logs query gateway and runtime data |
API Management
Keyspaces, keys, identities, permissions and roles,
| Group | What it manages |
|---|---|
unkey api apis |
Keyspaces (the API namespaces keys belong to) |
unkey api keys |
Create, |
unkey api identities |
Identities that group keys |
unkey api permissions |
Permissions and roles |
unkey api ratelimit |
Rate limit checks and overrides |
unkey api portal |
Customer portals and their sessions |
unkey api analytics |
get-verifications and get-ratelimits query key and rate limit data |
analytics appears in both tables because two of its commands read Compute data and two read API Management data.
Top-level commands
| Command | Product | What it does |
|---|---|---|
unkey deploy |
Compute | Deploys a prebuilt container image to a project and waits until the deployment is active. Needs --project (or UNKEY_PROJECT), and a root key from --root-key or UNKEY_ROOT_KEY. It doesn't read the stored key. Reference: unkey deploy. |
unkey auth login |
Platform | Prompts for a root key and stores it in ~/.unkey/config.toml. Reference: unkey auth login. |
unkey healthcheck |
Compute | Sends one HTTP GET and exits 0 on a 200 response, or 1 otherwise. Useful in CI and monitoring scripts. Reference: unkey healthcheck. |
Next steps
Platform Root Keys Overview (`docs/platform/root-keys/overview.mdx`)
title: Root keys
sidebarTitle: "Overview"
sources:
- svc/api/routes/v2_root_keys_create_key/handler.go
- svc/api/routes/v2_root_keys_update_key/handler.go
- svc/api/routes/v2_root_keys_delete_key/handler.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/root-keys/page.tsx
- web/apps/dashboard/lib/trpc/routers/key/reroll/index.ts
- web/apps/dashboard/lib/trpc/routers/settings/root-keys/delete.ts
- web/apps/dashboard/components/api-keys-table/components/actions/components/rotate-key/rotate-key.constants.ts
- web/internal/keys/src/util.ts
- internal/services/caches/caches.go
import { DashboardScreenshot } from "/snippets/dashboard-screenshot.jsx";
A root key is the credential your services, CI jobs, and agents use to call Unkey through the API or CLI. It belongs to one workspace and has no permissions by default. You assign permissions to let it perform its task, such as deploying an app or verifying customer API keys.
Start with Root key permissions to decide what access the key needs. Then use Configure root keys to create it in the dashboard or API. This page explains how to store, rotate, and delete it.
A root key isn't one of the API keys you issue to your own users. You create and verify those through the API, using a root key. See API Management concepts.
Manage root keys on the workspace's Root Keys page. You need the admin role to manage them in the dashboard. Automation can manage root keys through the API with the required root key permissions.
What a root key looks like
A root key starts with unkey_, followed by random characters. It's shown to you only once (see Key storage). The bearer key identifies the workspace for API requests. Permission URNs also include that workspace's ID.
A root key has no credit limit or rate limit of its own. You can set an expiry when creating it through the API. Use short-lived keys for temporary jobs and agents.
Create a root key
Give each service, job, or agent its own root key. Choose the resource scope and actions in Root key permissions, then follow Configure root keys to create it in the dashboard or API.
Store the secret in your secret manager. You can't retrieve it again. The audit log records who created the key and which permissions it received.
Edit a root key
From the key's row menu, choose Edit root key to change its name or permissions. Saving replaces the complete permission set, so review all policies before saving. See Edit permissions for dashboard and API instructions.
Rotate a root key
Rotating gives you a new root key with the same name and permissions, and stops the old one after a grace period.
- Choose Rotate root key from the row menu.
- Pick a grace period: revoke immediately, 15 minutes, 1 hour, 6 hours, or 24 hours.
- Check the confirmation, click Rotate root key, then confirm the rotation.
- Copy the new key. It's shown once, like a new key.
- Update your services before the grace period ends.
When the grace period ends, requests with the old key fail with err:unkey:authorization:forbidden. If the old key was already set to expire sooner, it stops at that earlier time.
Rotate on a schedule that fits your security policy, and right away if a key has been exposed. With "revoke immediately", the old key stops working as soon as the new one exists.
Delete a root key
Choose Delete root key from the row menu and confirm. You can't undo this. The key can no longer authenticate after the deletion propagates. The audit log records the deletion. Delete keys for services you've retired instead of leaving them active.
If a root key leaks
Rotate it with "revoke immediately" or delete it, then remove the secret from wherever it was exposed. The key keeps working until you do.
If a root key is pushed to a public GitHub repository, GitHub secret scanning reports it and every member of the workspace gets an email saying where it was found.
Unkey stores root keys hashed, so it can't recover the secret, only identify which key ID a request used. That's another reason to create one key per service.
Permissions reference
Platform Security Overview (`docs/platform/security/overview.mdx`)
title: Security
sidebarTitle: "Overview"
sources:
- pkg/hash/sha256.go
- pkg/encryption/aes.go
- svc/api/routes/v2_keys_create_key/handler.go
- svc/vault/internal/vault/rpc_encrypt.go
- internal/services/keys/create_v1.go
- web/apps/dashboard/lib/auth/roles.ts
- web/apps/dashboard/lib/trpc/trpc.ts
- web/apps/dashboard/lib/trpc/routers/workspace/changeName.ts
- web/apps/dashboard/lib/trpc/routers/api/updateDeleteProtection.ts
- web/internal/db/src/schema/util/delete_protection.ts
- SECURITY.md
Unkey stores every API key and root key as a SHA-256 hash, so you can issue and verify keys without keeping a homegrown key table. A copy of the database isn't a list of working keys. This page covers that storage and what you can turn on around it.
How Unkey stores API keys
Unkey doesn't store the API key. On create, it hashes the full key string with SHA-256 and stores the hash as standard base64, not hex. Verification hashes the key your user sent and compares the two strings. There's no salt. The plaintext is returned once, in the create response, and isn't kept.
See the OWASP Secrets Management Cheat Sheet and the OWASP REST Security Cheat Sheet for hashing and storing secrets you only need to verify. SHA-256 here is a fast hash of a random key, not a password hash such as bcrypt or Argon2.
If a keyspace must show a key again, you opt in to an encrypted copy beside the hash. The copy is AES-256-GCM, the keyring is the workspace, and only a root key with decrypt permission can read the key back. Verification still uses the hash. See Key storage and Recovering keys.
Should you build your own API key system?
You can. What moves to Unkey is generating the random key, storing only the hash, and checking it on each request. keys.createKey issues the key. keys.verifyKey checks enabled, expiry, credits, rate limits, permissions, and the IP allow list. If you already have hashes and the format matches, Migrating keys covers importing them.
You still run your API, unless you deploy it on Compute. You still decide the HTTP status you return to your users. You stop maintaining the keys table, the hash comparison, and, if you use Unkey for them, the rate-limit counters and the monthly credit balance.
How Unkey protects the workspace
Two controls apply to the workspace itself, separate from how keys are stored.
Access is scoped. A root key only works in the workspace that issued it, and every endpoint needs a specific permission. Dashboard users are either an admin or a developer. A developer who tries an admin-only action gets This action requires admin privileges. See Team.
Changes are audited. Changes made in the dashboard appear in the audit log with who made them, the event, and a description. For example, renaming a workspace logs workspace.update. How long entries are kept depends on your plan. See Limits.
Security features you can use
Reporting a vulnerability
If you find a security issue, email [email protected] with steps to reproduce. Don't open a public issue or pull request. Reports about api.unkey.com and app.unkey.com are in scope. The docs site, the marketing site, and misconfigured self-hosted setups aren't. The full policy is in SECURITY.md.
Platform Workspace Overview (`docs/platform/workspace/overview.mdx`)
title: Workspaces
sidebarTitle: "Overview"
sources:
- web/internal/db/src/schema/workspaces.ts
- web/internal/id/src/generate.ts
- web/apps/dashboard/lib/trpc/routers/workspace/create.ts
- web/apps/dashboard/app/new/hooks/use-workspace-step.tsx
- web/apps/dashboard/lib/slugify.ts
- web/apps/dashboard/lib/limits.ts
- web/apps/dashboard/lib/support.ts
A workspace is the top-level container. Everything you create sits in exactly one. Whether you deploy an
Most teams need one workspace. Make a second when you need hard isolation: separate billing for two business units, or a client whose data must never sit next to yours.
What sits in a workspace
Everything on this site is scoped to a workspace: the team and roles, billing plans and limits, root keys, keyspaces and keys, and Compute projects with their apps and
Slug and ID
A workspace has two identifiers. They do different jobs.
The URL handle you choose when creating the workspace. It appears in every dashboard URL as app.unkey.com/<slug>/..., must be unique across Unkey, and can't be changed later. It's 3 to 64 characters of lowercase letters, digits, and hyphens, and can't start or end with a hyphen. The dashboard derives a slug from the workspace name as you type and lets you edit it before creating.
A generated identifier prefixed with ws_. Use it when an API call or a support request needs to name the workspace. Copy it from Settings > General. See Workspace settings.
Create a workspace
<Frame>
</Frame>Delete a workspace
The dashboard and the API don't offer workspace deletion. To close a workspace, email [email protected] from an address that belongs to a workspace admin. Make sure you cancel any paid plans under Settings > Billing so nothing renews while the request is handled.
Scripts Brands README (`docs/scripts/brands/README.md`)
Illustration brand marks
These marks identify integrations, not endorsements. Keep their original geometry and proportions. Full-color marks retain their source colors; monochrome marks use a light or dark treatment for contrast.
| Mark | Source |
|---|---|
| GitHub | Official brand toolkit, GitHub_Logos.zip, GitHub_Invertocat_Black.svg |
| Unkey, Docker, TypeScript, Go, Python | Existing web/internal/icons/src/icons/ assets |
| Hono | Official logo |
| Bun | Official logo |
| Next.js | Official website asset |
| FastAPI | Official icon |
| Express | Official favicon |
| Echo | Official favicon |
| Cloudflare | Official website asset |
The artwork generator embeds these local vectors, so card rendering does not depend on third-party requests. Cloudflare uses the standalone cloud symbol paths from its horizontal logo. Source files are retained here to make provenance and future brand updates reviewable.
Platform Workspace Audit Logs Overview (`docs/platform/workspace/audit-logs/overview.mdx`)
title: Audit logs
sidebarTitle: "Overview"
sources:
- pkg/auditlog/log.go
- pkg/auditlog/actors.go
- pkg/auditlog/events.go
- internal/services/auditlogs/insert.go
- svc/api/routes/v2_keys_verify_key/handler.go
- web/apps/dashboard/lib/limits.ts
- pkg/clickhouse/schema/025_audit_logs_raw_v1.sql
- web/apps/dashboard/app/(app)/[workspaceSlug]/audit/components/controls/components/logs-filters/index.tsx
The audit log records every change made through the API or the dashboard: who did it, what they did, what it touched, and from where. Use it to answer "who deleted that key?" or to show a compliance reviewer. Entries are written automatically, and nobody can edit or delete them.
What an entry contains
Each entry has:
- An event, such as
key.createorportal.session.exchange. See Audit log event types. - An actor, with a type, ID, and name. The type is
user(someone in the dashboard),rootkey(an API call),portalEndUser(one of your customers in a developer portal),github(a push or pull request on a connected repository), orsystem(Unkey itself). - The resources it touched, each with a type, ID, name, and optional details such as the fields that changed.
- The caller's IP address and user agent, and a readable description.
- A correlation ID that groups entries from one action, such as a key creation and the permissions it attached.
Every entry is in the unkey_mutations bucket, the only option in the dashboard's Bucket filter.
Which actions are logged
Every create, update, and delete is logged, for keyspaces, keys, identities, roles, permissions, rate limit namespaces and overrides, portals, every Compute resource, and workspace and team changes. Reads aren't logged, with two exceptions: key.verify when a root key calls keys.verifyKey, and ratelimit.limit when a root key checks a rate limit. Verifications by the gateway or a portal session aren't in the audit log. They're in analytics.
Reading the log
Open Audit log in the dashboard sidebar. Entries are newest first. Expand one to see its resources, description, IP address, and user agent. Filter by Events, Users, Root Keys, Bucket, and time range. Press F to open the filters and Q to reopen saved filters. Every workspace role can read the log, including viewers, but only admins see keys in the Root Keys filter.
Retention
Your plan sets how long entries are kept. Check the Audit log retention row on Settings > Limits. See Limits.
| Plan | Audit log retention |
|---|---|
| Free, no Compute plan | 3 days |
| API Pro, any tier | 14 days |
| Compute Starter | 7 days |
| Compute Pro | 14 days |
| Compute Business | 30 days |
| Enterprise | Custom |
No plan keeps audit logs longer than 90 days. To keep them longer, or in your own systems, stream them out with a log drain.
Web Apps Design Docs Index (`web/apps/design/docs/index.mdx`)
title: Overview sidebar: order: 0
.Mintlify Assistant (`docs/.mintlify/Assistant.md`)
You are the Unkey documentation assistant. Help developers use Unkey accurately, using these docs as your source of truth.
Tone
- Be friendly and guiding. Lead with the answer, then a short next step when it helps.
- Prefer plain language over jargon. When a Unkey term matters, use the glossary term and say which product it belongs to.
- Keep answers focused. Link to the relevant docs page instead of restating a whole guide.
Product context
Unkey has two separate products plus shared platform pages. A reader can use either product without the other. Always say which product an answer belongs to when it could be ambiguous.
- Compute: builds, deploys, and runs apps behind a gateway (projects, apps, environments, deployments, gateway policies).
- API Management: issues and verifies API keys, enforces rate limits, manages identities and permissions, and reports usage (keyspaces, keys, identities, credits).
- Platform (shared): workspace, billing, root keys, CLI, audit logs, and error codes.
Accuracy rules
- Only state product behavior, API shapes, limits, plans, and error codes that these docs support.
- If the docs do not clearly cover something, say so. Do not invent endpoints, fields, permissions, plan limits, or dashboard paths.
- Prefer citing or linking the page that supports your answer. When you are unsure between two meanings, ask a clarifying question or point to the glossary.
- Never invent or guess secret values, root keys, or API keys.
Terminology
Follow the glossary. These trips people up most:
- Root key vs API key: a root key (
unkey_…) authenticates you to the Unkey API and CLI. An API key is a credential you issue to users of your application and verify withkeys.verifyKey. - Workspace: top-level container (
ws_ID). Do not call it a project or organization. - Keyspace: API Management container for keys (called an API in endpoint names like
apis.createApi). - Project / app: Compute only. A project groups apps; an app is what you deploy.
- Environment: in Compute, production or preview. In API Management, an optional label on a key (
live/test) that does not change key behavior. - Rate limiting: three different features share this name — the standalone
ratelimit.limitAPI, key/identity rate limits checked on verify, and Compute gateway rate-limit policies. Say which one you mean. - Verify: usually
keys.verifyKeyin API Management. Compute also has custom-domain verification and gateway key-auth. - Role: dashboard roles (admin / developer) are not the same as API Management roles on keys.
- Tier vs plan: billing says "tier" for API Management and "plan" for Compute; they bill separately.
API shape
- Unkey API calls are HTTP
POSTtohttps://api.unkey.com/v2/{service}.{procedure}with a root key in theAuthorization: Bearerheader. - Root keys are workspace-scoped; you do not pass a workspace ID with the key.
- Error codes look like
err:{system}:{category}:{specific}and each has a page at/errors/{system}/{category}/{specific}.
Compute pricing
Explain Compute billing from these rules. Point people to unkey.com/pricing for current unit rates, plan fees, included credits, and the calculator at the bottom of that page. Do not invent rates that aren't on that page or in these docs.
Each Compute plan has a monthly fee and included usage credit. Usage above the credit is billed on:
- vCPU: average active virtual CPU per running instance. Billed on actual usage, not the configured ceiling. Idle time waiting on I/O is not active CPU.
- Memory: average memory used per running instance. Billed on actual usage, not the configured ceiling.
- Ephemeral disk: volume mounted per instance. Billed on the allocated size while the instance runs, up to 10 GiB per instance.
- Egress: total outbound network data transfer per month.
- Active / verified keys: distinct keys with at least one verification during the month through a Key Auth policy on Compute (Unkey Deploy). These bill to the Compute project, not an API Management keyspace.
Runtime settings are ceilings. Most people never pin 100% CPU or memory. If someone configures 1 vCPU but averages 0.5 vCPU, bill the 0.5. Work an example from the published per-second rates on the pricing page (for example, cost ≈ average_vCPU × seconds_running × vCPU_rate), then suggest the calculator for a full estimate. Preview deployments bill at the same rates as production. Unused included credits do not roll over.
Escalation
- When someone needs a human (account access, billing changes, enabling a support-gated feature, workspace deletion, or anything the docs say requires support), direct them to [email protected].
- You may mention Discord (https://unkey.com/discord) as a community option, but email support first for account and support-gated requests.
- For outages or degraded service, point to https://status.unkey.com.
Scope
- Stay on Unkey product and docs questions.
- Do not give legal, security-audit, or competitive advice beyond what these docs state.
- Do not invent comparisons to other vendors.
Platform Glossary (`docs/platform/glossary.mdx`)
title: Glossary
sources:
- web/internal/db/src/schema/keys.ts
- web/internal/db/src/schema/limits.ts
- web/internal/db/src/schema/workspaces.ts
- web/internal/db/src/schema/keyAuth.ts
- web/apps/dashboard/lib/auth/roles.ts
- pkg/hash/sha256.go
- web/apps/dashboard/lib/stripe/deployPlan.ts
Look up a term here. Some terms mean different things in different products, so each entry starts with the product that owns it.
Terms with more than one meaning
App
Compute. A service you deploy, inside a project. It has a GitHub repository or an image as its source, two environments, build and runtime settings, and a history of deployments. On this site, "app" always means this. When we mean the software you run anywhere, including outside Unkey, we say "your application".
Environment
Compute. Every app has two environments, production and preview, and each deployment belongs to one. Environment variables, runtime settings, and gateway policies are set per environment. API Management uses the same word for an optional label on a key, the key's environment field, so you can tag keys as live or test. The label doesn't change how the key behaves, and it has nothing to do with Compute environments.
Rate limiting
Three different features share this name:
- API Management: the standalone ratelimit API. You call
ratelimit.limitwith your own identifier and namespace and get an allow or deny. - API Management: key and identity rate limits. They're attached to a key or an identity and checked when the key is verified.
- Compute: the gateway rate limit policy. The gateway applies it to requests before they reach your app.
Verify
API Management. Key verification is keys.verifyKey: you send a key, and we tell you whether it's valid and, if not, why. Compute uses the word twice more: custom domain verification, where you prove you control a domain before the gateway serves it, and the gateway key-auth policy, which verifies keys on incoming requests so your app only sees verified callers.
Platform
Workspace
Platform. The top-level container for everything else. It has a slug used in dashboard URLs, a ws_ ID used in the API, and its own team, billing, and limits. Workspaces are fully separate from each other. See Workspaces.
Root key
Platform. The credential for calling the Unkey API and using the CLI. A root key belongs to one workspace, has a list of permissions, and is stored as a SHA-256 hash like every other key. See Root keys.
Role
Platform. Admin or developer, given to each dashboard user per workspace. Developers can work with every resource in the workspace. Admins can also manage members, rename the workspace, manage root keys, and change billing. These aren't the roles you attach to API keys in API Management, which group permissions for your own users. See Team.
Tier and plan
Platform. The billing page says "tier" for API Management (Free by default) and "plan" for Compute (Starter, Pro, or Business). They're billed separately to the same payment method. See Plans.
Limits
Platform. Your workspace's ceilings, shown under Settings > Limits: monthly API operations, log and audit log retention, log drains, team members, and Compute resources. Most come from your API tier and Compute plan. See Limits.
Delete protection
Platform. A setting on keyspaces, projects, and apps that blocks deletion while it's on. A delete attempt returns err:unkey:application:protected_resource. See Delete protection.
API Management
Keyspace
API Management. A container for API keys, called an API in endpoint names (apis.createApi). Each key belongs to one keyspace. A keyspace sets the default prefix and length for new keys, and has settings for encrypted key storage, an optional IP allow list, and delete protection.
Key
API Management. A credential you give to a user of your application and verify on each request. A key has a prefix and can have a name, metadata, an expiry, credits, rate limits, permissions and roles, and an identity. We store only its hash, unless it's a recoverable key.
Identity
API Management. One of your users or tenants, identified by your own externalId, that several keys can belong to. Rate limits on an identity are shared by all of its keys.
Credits
API Management. The number of uses a key has left. Each verification uses one, and credits can refill on a schedule. At zero, verification reports the key as out of usage.
Permission and role (keys)
API Management. Permissions are strings you define and attach to keys, directly or through roles. Verification can check that a key has a permission or matches a permission query. These are separate from dashboard roles.
Recoverable key
API Management. A key created with recoverable: true in a keyspace with encrypted storage turned on. We keep an encrypted copy, so you can read the key again later. See Key storage.
Compute
Project
Compute. A group of apps, usually one per codebase or product. Compute usage is billed and reported per project, and a project can have delete protection.
Deployment
Compute. One built version of an app, running in one environment. It's created from a git commit, an image, or an existing deployment. You can promote it or roll back to it, and it keeps the gateway policies that were in place when it was created.
Gateway
Compute. What sits in front of every deployment and receives its traffic. It handles HTTPS, routes requests by domain, and applies the environment's policies: key authentication, rate limiting, firewall rules, OpenAPI validation, and logging.
Gateway policy
Compute. A rule the gateway applies to requests for an environment. The key-auth policy uses a keyspace from API Management, which is where the two products connect.
Replica
Compute. One running instance of a deployment in a region. Autoscaling can add replicas up to your plan's replicas-per-region limit. See Limits.
Spend budget
Compute. A monthly dollar cap on Compute usage, set by an admin. It emails you at 50, 75, and 100 percent and can optionally stop your workloads at 100 percent. See Spend budget.
Scripts Pending Upstream (`docs/scripts/pending-upstream.txt`)
Endpoints present in svc/api/openapi/openapi-generated.yaml but not yet published
to the Speakeasy spec both API reference tabs render. Listing them in docs.json
fails `mint validate`, so they stay out of the reference until upstream publishes
them; docs-lint downgrades their coverage gap to a warning while they are here.
`mise run docs-check` prints the current pending set under "pending upstream publish".
/v2/workspace.getLimits
/v2/workspace.getUsage
Internal to the hosted portal and x-excluded on purpose; never listed.
/v2/portal.exchangeCode
/v2/portal.getVerifications
/v2/portal.listKeys
/v2/portal.rerollKey
GET /v2/liveness is published without x-codeSamples and `mint validate` fails to generate its page.
/v2/liveness
Snippets Cli Install (`docs/snippets/cli-install.mdx`)
import { versions } from "/snippets/versions.mdx";
npm install unkeyWithout Node, download the archive for your platform from the releases page and put the unkey binary on your PATH instead. Both paths ship the same binary, currently {versions.cli}.
Snippets Root Key Prereq (`docs/snippets/root-key-prereq.mdx`)
Snippets Versions (`docs/snippets/versions.mdx`)
export const versions = {
cli: "2.0.150",
tsApi: "2.5.1",
tsRatelimit: "2.1.4",
tsHono: "2.0.0",
tsNextjs: "2.0.0",
tsCache: "1.5.0",
tsNuxt: "1.1.15",
goSdk: "v3.0.1",
pySdk: "3.0.3",
};
Api Management Analytics Examples (`docs/api-management/analytics/examples.mdx`)
title: Analytics query examples
sidebarTitle: "Query examples"
sources:
- pkg/clickhouse/query-parser/validation.go
- pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
- pkg/clickhouse/schema/003_key_verifications_per_hour_v3.sql
- pkg/clickhouse/schema/004_key_verifications_per_day_v3.sql
- pkg/clickhouse/schema/006_ratelimits_raw_v2.sql
- pkg/clickhouse/schema/008_ratelimits_per_hour_v2.sql
Copy these queries to answer common questions. Send each as the query string of analytics.getVerifications or analytics.getRatelimits. You don't need a workspace filter.
Some of these reach back seven days or a calendar month, which is longer than some plans keep data. Check your retention first. If a query fails with err:user:bad_request:query_range_exceeds_retention, shorten the window.
Verifications
Total verifications in the last 24 hours
The per-hour rollup already sums verifications, so read sum(count) rather than count().
SELECT sum(count) AS verifications
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUROutcomes per hour for the last day
SELECT
time,
outcome,
sum(count) AS verifications
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
GROUP BY time, outcome
ORDER BY time, outcomeValid versus rejected, per day
sumIf lets you split one scan into several totals. Per-day time is a Date, so compare with today().
SELECT
time AS day,
sumIf(count, outcome = 'VALID') AS valid,
sumIf(count, outcome != 'VALID') AS rejected,
round(100 * valid / (valid + rejected), 2) AS valid_percent
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
GROUP BY day
ORDER BY dayUsage per customer this month
external_id is your ID for the identity behind each key, so it's a good way to bill.
SELECT
external_id,
sum(count) AS verifications,
sum(spent_credits) AS credits
FROM key_verifications_per_day_v1
WHERE time >= toStartOfMonth(today())
AND external_id != ''
GROUP BY external_id
ORDER BY verifications DESC
LIMIT 100Keys hitting rate limits or running out of credits
SELECT
key_id,
external_id,
sumIf(count, outcome = 'RATE_LIMITED') AS rate_limited,
sumIf(count, outcome = 'USAGE_EXCEEDED') AS usage_exceeded
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 1 DAY
GROUP BY key_id, external_id
HAVING rate_limited > 0 OR usage_exceeded > 0
ORDER BY rate_limited + usage_exceeded DESC
LIMIT 50Latency percentiles from the raw table
Latency stats have to come from the raw table. Raw time is in milliseconds, so convert the bound.
SELECT
round(avg(latency), 2) AS avg_ms,
round(quantile(0.5)(latency), 2) AS p50_ms,
round(quantile(0.99)(latency), 2) AS p99_ms
FROM key_verifications_v1
WHERE time >= toUnixTimestamp64Milli(now() - INTERVAL 1 HOUR)Verifications per tag
Tags are an array on each row. arrayJoin gives one row per tag, and has keeps rows with a specific tag.
SELECT
arrayJoin(tags) AS tag,
sum(count) AS verifications
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
GROUP BY tag
ORDER BY verifications DESCSELECT sum(count) AS checkout_calls
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
AND has(tags, 'endpoint:checkout')Hourly buckets from the raw table
When you need a dimension the rollups don't carry, such as region, bucket the raw rows yourself.
SELECT
toStartOfHour(fromUnixTimestamp64Milli(time)) AS hour,
region,
count() AS verifications
FROM key_verifications_v1
WHERE time >= toUnixTimestamp64Milli(now() - INTERVAL 6 HOUR)
GROUP BY hour, region
ORDER BY hour, regionGateway-verified versus API-verified
Keys verified by a Compute gateway key-auth policy carry source = 'gateway' and the app_id of the app that fronted them. Direct keys.verifyKey calls carry source = 'api' and an empty app_id.
SELECT
source,
app_id,
sum(count) AS verifications
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
GROUP BY source, app_id
ORDER BY verifications DESCRate limits
Pass rate per namespace over the last day
SELECT
namespace_id,
sum(passed) AS passed,
sum(total) AS total,
round(100 * passed / total, 2) AS pass_percent
FROM ratelimits_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
GROUP BY namespace_id
ORDER BY total DESCMost rejected identifiers
SELECT
identifier,
sum(total) - sum(passed) AS rejected
FROM ratelimits_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
GROUP BY identifier
HAVING rejected > 0
ORDER BY rejected DESC
LIMIT 25Checks that used an override
SELECT
override_id,
identifier,
countIf(passed) AS passed,
countIf(NOT passed) AS rejected
FROM ratelimits_v1
WHERE time >= toUnixTimestamp64Milli(now() - INTERVAL 6 HOUR)
AND override_id != ''
GROUP BY override_id, identifier
ORDER BY rejected DESCCombining tables
Use a CTE to answer a two-step question.
WITH top_customers AS (
SELECT external_id
FROM key_verifications_per_day_v1
WHERE time >= today() - INTERVAL 7 DAY
AND external_id != ''
GROUP BY external_id
ORDER BY sum(count) DESC
LIMIT 10
)
SELECT
time,
external_id,
sum(count) AS verifications
FROM key_verifications_per_hour_v1
WHERE time >= now() - INTERVAL 24 HOUR
AND external_id IN (SELECT external_id FROM top_customers)
GROUP BY time, external_id
ORDER BY time, external_idIf a query is rejected, the troubleshooting page maps each error to its fix.
Api Management Analytics Query Language (`docs/api-management/analytics/query-language.mdx`)
title: Analytics query language
sidebarTitle: "Query language"
sources:
- pkg/clickhouse/query-parser/parser.go
- pkg/clickhouse/query-parser/validation.go
- pkg/clickhouse/query-parser/tables.go
- pkg/clickhouse/query-parser/filter.go
- pkg/clickhouse/query-parser/limits.go
- pkg/clickhouse/query-parser/time_range.go
- pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
- pkg/clickhouse/schema/002_key_verifications_per_minute_v3.sql
Analytics queries are ClickHouse SQL. You can filter, group, aggregate, and join your own data. Anything not listed here is rejected with a 400 that names the problem.
What a query must be
A query is one SELECT statement with a FROM clause. An empty body or a second statement fails with err:user:bad_request:invalid_analytics_query. Anything that isn't a SELECT (INSERT, SHOW, DESCRIBE, and so on) fails with err:user:bad_request:invalid_analytics_query_type.
You can use WHERE, GROUP BY, HAVING, ORDER BY, LIMIT and OFFSET, WITH (CTEs), subqueries in FROM and IN (...), JOIN, UNION, and EXCEPT. The same rules apply to every nested SELECT.
These aren't allowed:
- A
SETTINGSclause. - Table functions such as
numbers(),url(), orremote(). INfollowed by a table name. WriteIN (SELECT ... FROM ...)instead.
Tables
Only the tables listed on the overview, and CTEs you define, can appear in FROM and JOIN. Any other table fails with err:user:bad_request:invalid_analytics_table. Aliases (FROM key_verifications_v1 AS v) work as usual.
Filters and limits added for you
You don't need a workspace filter. Every query only sees your workspace's rows, and only the keyspaces or namespaces your root key can read. Your own WHERE narrows it further. A query for a keyspace you can't see returns no rows, not an error.
Every SELECT is capped at your workspace's result row limit, 10,000,000 by default. A smaller LIMIT you write is kept.
Functions
Only the functions in this table are allowed. Names can be any case. Any other function fails with err:user:bad_request:invalid_analytics_function. Commonly missed ones include toUnixTimestamp, avgMerge, quantilesTDigestMerge, multiIf, argMax, topK, median, dateDiff, row_number, rank, lagInFrame, and every JSONExtract* except JSONExtractString. An allowed aggregate still works with OVER, as in sum(spent_credits) OVER (PARTITION BY key_id). Operators such as +, =, <, AND, OR, NOT, LIKE, IN, and BETWEEN always work.
| Family | Allowed functions |
|---|---|
| Aggregates | count, sum, avg, min, max, any, groupArray, groupUniqArray, uniq, uniqExact, quantile |
| Conditional aggregates and expressions | countIf, sumIf, if, case, coalesce |
| Date and time | now, now64, today, toDate, toDateTime, toDateTime64, toStartOfMinute, toStartOfHour, toStartOfDay, toStartOfWeek, toStartOfMonth, toStartOfQuarter, toStartOfYear, date_trunc, formatDateTime, fromUnixTimestamp64Milli, toUnixTimestamp64Milli |
| Intervals | toIntervalNanosecond, toIntervalMicrosecond, toIntervalMillisecond, toIntervalSecond, toIntervalMinute, toIntervalHour, toIntervalDay, toIntervalWeek, toIntervalMonth, toIntervalQuarter, toIntervalYear. ClickHouse's INTERVAL N UNIT literal, as in now() - INTERVAL 7 DAY, is syntax rather than a function and is also accepted. |
| Strings | lower, upper, substring, concat, length, trim, startsWith, endsWith |
| JSON | JSONExtractString |
| Math | round, floor, ceil, abs |
| Type conversion | toString, toInt32, toInt64, toFloat64 |
| Arrays | has, hasAny, hasAll, arrayJoin, arrayFilter |
If you need a function that isn't listed, tell us which one.
Working with time
In the raw tables, time is milliseconds since the Unix epoch (Int64), so convert when you compare with "now": time > toUnixTimestamp64Milli(now() - INTERVAL 1 HOUR). To group raw rows by hour, convert the other way: toStartOfHour(fromUnixTimestamp64Milli(time)). In the rollups, time is a DateTime (per-minute, per-hour) or a Date (per-day, per-month), so compare directly: time > now() - INTERVAL 7 DAY.
Queries only return data inside your plan's retention window. What happens when you ask for older data depends on how you write the time filter:
INTERVAL N UNIT, a literal date, ortoday(): a start time older than your retention fails witherr:user:bad_request:query_range_exceeds_retention.toIntervalDay(N)or no start time at all: the query runs and quietly returns only the rows inside retention.
For example, time >= now() - INTERVAL 400 DAY fails, while time >= now() - toIntervalDay(400) returns fewer rows with no error. Use INTERVAL N UNIT if you'd rather get an error than a short answer. Retention per plan is on Analytics restrictions and quotas.
Aggregate state columns
In the rollup tables, read count, spent_credits, total, and similar columns with sum(), never count(), because each row already sums many verifications. You can't read the rollups' latency_avg, latency_p75, and latency_p99 columns, because the -Merge functions they need aren't allowed. Use the raw table instead: avg(latency) and quantile(0.99)(latency). The table pages mark which columns are which.
Api Management Analytics Restrictions And Quotas (`docs/api-management/analytics/restrictions-and-quotas.mdx`)
title: Analytics restrictions and quotas
sidebarTitle: "Restrictions and quotas"
sources:
- pkg/clickhouse/query-parser/parser.go
- pkg/clickhouse/query-parser/validation.go
- pkg/clickhouse/query-parser/time_range.go
- pkg/clickhouse/user.go
- pkg/clickhouse/errors.go
- internal/services/analytics/executor.go
- internal/services/analytics/service.go
- svc/api/routes/v2_analytics_get_verifications/handler.go
- web/internal/db/src/schema/clickhouse_workspace_settings.ts
- web/apps/dashboard/lib/limits.ts
- pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
- pkg/clickhouse/schema/006_ratelimits_raw_v2.sql
- svc/api/internal/middleware/errors.go
Every analytics query runs under these limits. Most queries never hit them. When you do, find the error code here to see which limit you hit. For what SQL is allowed, see Analytics query language.
Query shape limits
These are checked before the query runs. Going over one returns HTTP 400, except nesting depth.
| Limit | Value | Error code |
|---|---|---|
| Query text | 16 KiB | err:user:bad_request:invalid_analytics_query |
Projected columns across all SELECTs |
64 | err:user:bad_request:invalid_analytics_query ("Analytics query projects too many columns") |
| Query complexity (parsed expression nodes) | 2,000 | err:user:bad_request:invalid_analytics_query ("Analytics query is too complex") |
| Nesting depth | 100 | The query fails when it runs, not before |
| Statements | exactly one SELECT with FROM |
invalid_analytics_query or invalid_analytics_query_type |
| Tables | public aliases only | err:user:bad_request:invalid_analytics_table |
| Functions | the allow-list | err:user:bad_request:invalid_analytics_function |
Execution limits
These apply while the query runs. Hitting one returns HTTP 422.
| Limit | Value | Error code |
|---|---|---|
| Execution time | 10 seconds | err:user:unprocessable_entity:query_execution_timeout |
| Memory per query | 1 GB | err:user:unprocessable_entity:query_memory_limit_exceeded |
| Result rows | 10,000,000. Also applied as a LIMIT on every SELECT. |
err:user:unprocessable_entity:query_rows_limit_exceeded |
| Encoded response size | 4 MiB | err:user:unprocessable_entity:query_memory_limit_exceeded ("Query result exceeds the maximum response size.") |
The 4 MiB response size is the limit people hit first. To stay under it, page with LIMIT and OFFSET, aggregate instead of selecting raw rows, or narrow the time window. NaN and infinity come back as null.
Quota per workspace
By default, each workspace can run 1,000 queries and 1,800 seconds of total query time per rolling hour. Going over either returns HTTP 429 with err:user:too_many_requests:query_quota_exceeded. Wait for the window to roll over, or contact [email protected] if you need more.
Retention
Queries only return rows inside your plan's log retention. If your time filter starts earlier than that, the query fails with HTTP 400 and err:user:bad_request:query_range_exceeds_retention, and the detail names the earliest date you can query. A filter written with toIntervalDay(N) instead of INTERVAL N DAY doesn't fail. It just leaves the older rows out. See Working with time.
| Plan | Log retention |
|---|---|
| Free, no Compute plan | 1 day |
| API Pro, any tier | 7 days |
| Compute Starter | 3 days |
| Compute Pro | 7 days |
| Compute Business | 14 days |
Plans agreed with sales have their own retention. Your actual value is the Log retention row on Settings > Limits. See Limits.
Each table also has a maximum age, whatever your plan:
- Verifications: raw, per-minute, and per-hour 90 days; per-day 365 days; per-month 3 years.
- Rate limits: raw one month; per-minute 7 days; per-hour 30 days; per-day 100 days; per-month 3 years.
Other errors
| Status | Code | Meaning |
|---|---|---|
| 412 | err:unkey:data:analytics_not_configured |
Analytics is not enabled for this workspace yet. See Analytics. |
| 403 | err:unkey:authorization:insufficient_permissions |
The root key lacks read_analytics (or read_gateway_requests / read_runtime_logs) for the endpoint. |
| 503 | err:unkey:data:analytics_connection_failed |
Analytics is temporarily unavailable. Retry with backoff. |
All errors use the standard API error format, and each error's type field links to its page.
Api Management Analytics Troubleshooting (`docs/api-management/analytics/troubleshooting.mdx`)
title: Analytics troubleshooting
sidebarTitle: "Troubleshooting"
sources:
- pkg/clickhouse/query-parser/parser.go
- pkg/clickhouse/query-parser/validation.go
- pkg/clickhouse/query-parser/tables.go
- pkg/clickhouse/query-parser/time_range.go
- pkg/clickhouse/errors.go
- pkg/clickhouse/user.go
- internal/services/analytics/service.go
- svc/api/routes/v2_analytics_get_verifications/handler.go
Start with the HTTP status:
- 200 with no rows: the query worked but matched nothing.
- 400: the query was rejected.
- 422 or 429: the query ran and hit a limit.
- 412: analytics isn't turned on.
- 403: the root key is missing a permission.
No rows came back
Check the time filter. Raw tables store time in milliseconds, so WHERE time >= now() - INTERVAL 1 DAY matches nothing. Write time >= toUnixTimestamp64Milli(now() - INTERVAL 1 DAY) instead. Rollup tables take DateTime or Date values directly.
Check IDs. key_space_id is the keyspace's ks_ ID, not the api_ ID. namespace_id is an rlns_ ID, not the namespace name. Values are case-sensitive, and outcome values are upper case (VALID, not valid).
Check retention. Data older than your plan's log retention is left out, often without an error. See Working with time.
Check the root key. If it has read_analytics for specific keyspaces or namespaces only, rows outside them are left out without an error.
The query was rejected (400)
| Detail | Cause | Fix |
|---|---|---|
| Invalid SQL syntax | The query couldn't be parsed. | Check quoting, commas, and parentheses. Remember that string literals use single quotes. |
Only SELECT queries are allowed (invalid_analytics_query_type) |
The statement is not a SELECT. |
Analytics is read-only; there is no INSERT, SHOW, or DESCRIBE. Columns are listed on the table pages. |
| Analytics queries must contain exactly one statement | Two statements, or a trailing statement after a semicolon. | Send one SELECT per request. |
| Query must have a FROM clause | SELECT 1 or a bare expression. |
Every query reads from a public table. |
Access to table 'x' is not allowed (invalid_analytics_table) |
A table that isn't one of the public names, a system or information_schema table, or a typo in a public name. |
Use the public names on the overview, such as key_verifications_v1. |
Function 'x' is not allowed (invalid_analytics_function) |
A function that isn't allowed, including row_number, rank, and every -Merge function. |
Rewrite with an allowed function; see Analytics query language. For latency, aggregate the raw table. |
| SETTINGS clauses are not allowed | A SETTINGS clause. |
Remove it. Execution limits are fixed per workspace. |
| Table-backed IN expressions are not supported | x IN some_table. |
Write x IN (SELECT x FROM some_table). |
| Analytics query projects too many columns | More than 64 selected columns across all SELECTs. |
Select fewer columns or split the query. |
| Analytics query is too complex | More than 2,000 parsed expression nodes. | Simplify; long IN lists are the usual culprit. |
| Analytics query exceeds the maximum length | The query text is over 16 KiB. | Shorten it, usually by replacing a long IN list with a subquery. |
| The query reads a column that is not available | SELECT * on gateway_requests_v1 or runtime_logs_v1. Only their documented columns can be read. |
Name the columns you want instead of *. They are listed on Gateway request and runtime log analytics. |
Cannot query data older than N days (query_range_exceeds_retention) |
A lower bound on time is before your retention window. |
Move the bound forward. The detail names the earliest allowed date. Retention per plan is on Analytics restrictions and quotas. |
The query ran but failed (422 or 429)
| Code | Fix |
|---|---|
query_execution_timeout |
The query exceeded 10 seconds. Switch from the raw table to a rollup, add or tighten the time bound, and aggregate rather than selecting raw rows. |
query_memory_limit_exceeded with "exceeds the maximum response size" |
The encoded result is over 4 MiB. Add LIMIT, aggregate, or page with LIMIT and OFFSET. |
query_memory_limit_exceeded otherwise |
The query needed more than 1 GB. groupArray over many rows and high-cardinality GROUP BY on the raw table are the common causes; aggregate on a rollup. |
query_rows_limit_exceeded |
More than 10,000,000 result rows. Aggregate or narrow. |
query_quota_exceeded (429) |
More than 1,000 queries or 1,800 seconds of execution in the current hour. Cache results on your side and retry after the window rolls over. |
Analytics isn't configured (412)
err:unkey:data:analytics_not_configured means analytics isn't turned on for your workspace. Email [email protected] with your workspace ID to turn it on. A 503 with analytics_connection_failed is different: analytics was briefly unavailable, so retry with backoff.
Permission denied (403)
insufficient_permissions names the permission the root key needs: api.*.read_analytics or api.<api_id>.read_analytics for verifications, ratelimit.*.read_analytics or ratelimit.<namespace_id>.read_analytics for rate limits. Grant it under Settings > Root Keys. See Root key permissions.
Numbers that look wrong
- A rollup total is far too low. You probably wrote
count()instead ofsum(count). Each rollup row sums many verifications. - A percentage is
null. It divided by zero. external_idis empty for many rows. Those keys have no identity. Group bykey_idinstead, or attach identities when you create keys.
Api Management Authorization Managing Key Permissions (`docs/api-management/authorization/managing-key-permissions.mdx`)
title: Managing key roles and permissions
sidebarTitle: "Managing key permissions"
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/setPermissions/V2KeysSetPermissionsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/addPermissions/V2KeysAddPermissionsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/removePermissions/V2KeysRemovePermissionsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/addRoles/V2KeysAddRolesRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/removeRoles/V2KeysRemoveRolesRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/setRoles/V2KeysSetRolesRequestBody.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_update_key/handler.go
- svc/api/routes/v2_keys_add_permissions/handler.go
- svc/api/routes/v2_keys_remove_permissions/handler.go
- svc/api/routes/v2_keys_set_permissions/handler.go
- svc/api/routes/v2_keys_add_roles/handler.go
- svc/api/routes/v2_keys_remove_roles/handler.go
- svc/api/routes/v2_keys_set_roles/handler.go
- web/apps/dashboard/components/api-keys-table/components/actions/keys-table-action.popover.constants.tsx
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Change the roles and permissions on a key at any time. Set them when you create the key, replace them with keys.updateKey, or add and remove them with six dedicated endpoints. Changes take about 10 seconds to reach verification. Verifying keys describes that timing.
All of these need api.*.update_key or api.<api_id>.update_key (creation needs create_key). Four of the incremental endpoints also need the matching rbac.* action: add_permission_to_key for keys.addPermissions, remove_permission_from_key for keys.removePermissions, both for keys.setPermissions, and add_role_to_key for keys.addRoles. keys.removeRoles and keys.setRoles need nothing beyond update_key.
keys.addPermissions and keys.setPermissions only create a new permission slug if the root key also has rbac.*.create_permission. Without it, an unknown slug fails with HTTP 403 err:unkey:authorization:insufficient_permissions. (keys.createKey and keys.updateKey create new slugs without that permission.) Roles are never created for you. An unknown role fails with HTTP 404 err:unkey:data:role_not_found.
At creation
keys.createKey accepts roles (up to 100 names, each 1 to 128 characters) and permissions (up to 1000 slugs, each 1 to 128 characters matching ^[a-zA-Z0-9_:\-\.\*]+$).
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"roles": ["editor"],
"permissions": ["reports.export"]
}'Replace everything
keys.updateKey takes the same two fields. Sending a list replaces it in full. Leaving a field out keeps it as it is.
Adjust incrementally
Each endpoint takes keyId and an array of permission slugs or role names (not IDs). The add and remove endpoints need at least one entry, so [] fails with HTTP 400. Send [] to keys.setPermissions or keys.setRoles to remove everything.
| Endpoint | Effect |
|---|---|
keys.addPermissions |
Attaches the listed permissions; ones already attached are left alone. |
keys.removePermissions |
Detaches the listed permissions; ones not attached are ignored. |
keys.setPermissions |
Replaces the direct permissions with the list; [] removes all. |
keys.addRoles |
Assigns the listed roles. |
keys.removeRoles |
Unassigns the listed roles. |
keys.setRoles |
Replaces the roles with the list; [] removes all. |
curl -X POST https://api.unkey.com/v2/keys.addPermissions \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_...", "permissions": ["documents.delete"] }'Every change writes audit events, such as authorization.connect_permission_and_key and authorization.disconnect_role_and_key, so you can see the history of a key's access.
Direct permissions versus roles
Removing a permission from a key doesn't help if one of its roles also grants it. To take a permission away completely, remove it from the key and from the key's roles, or remove the role. To change what a role contains, call permissions.setRolePermissions. That changes every key with the role. See Roles and permissions.
From the dashboard
Choose Manage roles and permissions from a key's actions menu, pick roles and permissions, and save. The Create key dialog has the same pickers in its permissions step.
Api Management Authorization Permission Queries (`docs/api-management/authorization/permission-queries.mdx`)
title: Permission queries
sources:
- pkg/rbac/parser.go
- pkg/rbac/lexer.go
- pkg/rbac/rbac.go
- pkg/rbac/doc.go
- svc/api/routes/v2_keys_verify_key/handler.go
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/index.yaml
- internal/services/keys/validation.go
- pkg/codes/user_request.go
- svc/api/routes/v2_keys_create_key/handler.go
Send a permission query in the permissions field of keys.verifyKey to check that a key has the permissions a request needs. A query combines permission slugs with AND, OR, and parentheses. If the key's permissions (its own plus its roles') don't satisfy it, verification fails with code: INSUFFICIENT_PERMISSIONS and no rate limit or credits are used.
Grammar
expression -> andExpression (OR andExpression)*
andExpression -> primary (AND primary)*
primary -> PERMISSION | "(" expression ")"A PERMISSION is a slug made of letters, digits, ., _, -, :, *, and /. AND and OR can be any case. AND is evaluated before OR, as in SQL, and parentheses change that. The whole query can be up to 1000 characters.
| Query | Passes when the key has |
|---|---|
documents.read |
documents.read |
documents.read AND documents.write |
both |
admin OR editor |
either |
admin OR documents.read AND documents.write |
admin, or both document permissions (AND groups first) |
(admin OR editor) AND billing:view |
billing:view plus one of the two |
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_live_...", "permissions": "(admin OR editor) AND billing:view" }'Matching is exact
Each slug in the query must match one of the key's permissions exactly, including case. There are no wildcards: a key with documents.* does not pass a query for documents.read. A query for documents.* only passes for a key that has a permission literally named documents.*.
To grant a family of permissions, attach them to a role and assign the role. See Roles and permissions.
Outcomes and errors
| Situation | Result |
|---|---|
| Expression is true for the key | Verification continues; data.permissions and data.roles list what the key holds. |
| Expression is false | HTTP 200, valid: false, code: INSUFFICIENT_PERMISSIONS. |
| Expression does not parse: unbalanced parentheses, empty parentheses, a dangling operator, or an illegal character | HTTP 400 err:user:bad_request:permissions_query_syntax_error with the position of the problem in detail. |
| Query is empty or longer than 1000 characters | HTTP 400. |
No permissions field |
No permission check; the response still lists the key's permissions and roles. |
Checking in your own code
When the decision needs data Unkey doesn't have, verify without a query and read data.permissions:
const { data } = await unkey.keys.verifyKey({ key: incomingKey });
if (!data.valid) {
return deny(data.code);
}
const permissions = data.permissions ?? [];
const document = await db.documents.find(documentId);
const canDelete =
permissions.includes("admin") ||
(permissions.includes("documents.delete") && document.ownerId === data.identity?.externalId);You can combine both: send a query for the part Unkey can decide, and use the returned list for the rest.
Api Management Authorization Roles And Permissions (`docs/api-management/authorization/roles-and-permissions.mdx`)
title: Roles and permissions
sources:
- svc/api/openapi/spec/paths/v2/permissions/createPermission/V2PermissionsCreatePermissionRequestBody.yaml
- svc/api/openapi/spec/paths/v2/permissions/createRole/V2PermissionsCreateRoleRequestBody.yaml
- svc/api/openapi/spec/paths/v2/permissions/setRolePermissions/V2PermissionsSetRolePermissionsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/permissions/listPermissions/V2PermissionsListPermissionsRequestBody.yaml
- svc/api/openapi/spec/common/Permission.yaml
- svc/api/openapi/spec/common/Role.yaml
- svc/api/routes/v2_permissions_create_permission/handler.go
- svc/api/routes/v2_permissions_create_role/handler.go
- svc/api/routes/v2_permissions_set_role_permissions/handler.go
- svc/api/routes/v2_permissions_delete_role/handler.go
- svc/api/routes/v2_permissions_delete_permission/handler.go
- svc/api/routes/v2_permissions_list_permissions/handler.go
- svc/api/routes/v2_permissions_list_roles/handler.go
- internal/services/caches/caches.go
- pkg/cache/cache.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/authorization/permissions/components/upsert-permission/upsert-permission.schema.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Create a permission for each action your API checks, and group them into roles for the access levels you offer. Permissions and roles belong to the workspace, so one set covers every keyspace. To attach them to keys, see Managing key roles and permissions.
The endpoints need the matching rbac.* permission: create_permission, read_permission, delete_permission, create_role, read_role, delete_role, and add_permission_to_role together with remove_permission_from_role for permissions.setRolePermissions.
Permissions
1 to 512 characters. A readable label shown in the dashboard.
1 to 128 characters matching ^[a-zA-Z0-9_:\-\.\*]+$, unique within the workspace. This is the string keys hold and queries check. documents.read and billing:write are both fine. An asterisk is allowed but is a literal character, not a wildcard. See Permission queries.
Up to 512 characters of internal documentation.
curl -X POST https://api.unkey.com/v2/permissions.createPermission \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Read documents", "slug": "documents.read", "description": "List and fetch documents" }'The response has permissionId (perm_...). A duplicate slug fails with HTTP 409 err:unkey:data:permission_already_exists.
permissions.getPermissiontakes the ID or slug.permissions.listPermissionstakeslimit(1 to 100, default 100),cursor(up to 1024 characters), andsearch(up to 256 characters, matched against ID, name, slug, or description, ignoring case).permissions.updatePermissiontakes the ID or slug inpermission, plus any ofname,slug, anddescription. Omitted fields stay unchanged, anddescription: nullremoves the description. It needsunkey:v1:<workspace_id>:projects//rbac/permissions/#write. A slug that another permission already uses fails with HTTP 409. Keys and roles keep the permission after a slug change. Key verification can return the old slug for a short time while caches refresh.permissions.deletePermissiondeletes the permission and removes it from every role and key.
Roles
1 to 128 characters, unique within the workspace. Keys refer to roles by name. Spaces are allowed.
Up to 512 characters.
Permission slugs to attach. Slugs that don't exist yet are created if the root key also has rbac.*.create_permission. Without it, an unknown slug fails with HTTP 403 err:unkey:authorization:insufficient_permissions. Leave it out or send [] for an empty role.
curl -X POST https://api.unkey.com/v2/permissions.createRole \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "editor",
"description": "Read and write documents",
"permissions": ["documents.read", "documents.write"]
}'The response has roleId (role_...). A duplicate name fails with HTTP 409 err:unkey:data:role_already_exists. permissions.getRole and permissions.listRoles return roles with their permissions. permissions.deleteRole deletes the role, and keys lose its permissions unless they get them another way.
permissions.updateRole takes the ID or name in role, plus any of name and description. Omitted fields stay unchanged, and description: null removes the description. It needs unkey:v1:<workspace_id>:projects//rbac/roles/<role_id>#write. A name that another role already uses fails with HTTP 409. Keys keep the role after a rename, but requests that name the role, such as keys.addRoles, must use the new name. Key verification can return the old name for a short time while caches refresh.
Change a role's permissions
permissions.setRolePermissions takes role (ID or name) and the full list of slugs. Anything not in the list is removed, and [] empties the role. (The older roleId field still works but is deprecated. Send one or the other.) New slugs are created under the same rule as createRole. Every key with the role picks up the change within about 10 seconds, and a few verifications just after that can still see the old permissions. Verifying keys describes the timing.
curl -X POST https://api.unkey.com/v2/permissions.setRolePermissions \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "role": "editor", "permissions": ["documents.read", "documents.write", "documents.delete"] }'Designing the set
- Name permissions after actions, not customers:
invoices.read,invoices.write,invoices.void. - Name roles the way you talk to customers:
viewer,editor,admin, or plan names. - Attach roles to keys, and save direct permissions for one-off grants. Then changing what "editor" means is one
setRolePermissionscall, not an update to every key. - Use a role for "everything under documents". An asterisk isn't a wildcard, so list each
documents.*permission in the role.
From the dashboard
Open Authorization in the sidebar. On the Permissions tab, create and edit permissions. On the Roles tab, create and edit roles, pick their permissions (or create new ones), and assign the role to keys.
Api Management Cookbook Endpoint Rate Limit (`docs/api-management/cookbook/endpoint-rate-limit.mdx`)
title: Per-endpoint rate limits
sources:
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
- internal/services/keys/validation.go
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Outcome: a burst of reads can't starve writes, and one expensive endpoint can't exhaust the budget of cheap ones, because each route checks its own
Named limits on the key
Give the key one limit per kind of request, with autoApply: false. Those limits are only checked when a verification names them, so each route names its own limit and the others aren't touched.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"ratelimits": [
{ "name": "reads", "limit": 1000, "duration": 60000, "autoApply": false },
{ "name": "writes", "limit": 100, "duration": 60000, "autoApply": false }
]
}'import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
export async function verifyFor(key: string, method: string) {
const name = method === "GET" ? "reads" : "writes";
const result = await unkey.keys.verifyKey({ key, ratelimits: [{ name }] });
return result.data;
}Naming a limit that isn't on the key or its identity fails the request with HTTP 412, so keep the names in one place in your code. To add a limit later, use keys.updateKey. Its ratelimits field replaces the whole list, so send every limit, not just the new one.
Weight expensive operations
cost is how much of the limit one call uses, 1 by default. If search costs 10, a client can make a tenth as many searches as list calls from the same limit.
To give a route its own limit without saving one on the key, pass both limit and duration in the entry. The name doesn't have to exist on the key, and the count is kept against the key, not the identity. If you pass only one of the two, it's ignored and the saved limit is used. See Inline limits for the minimum values.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_live_...", "ratelimits": [{ "name": "reads", "cost": 10 }] }'Alternative: a namespace per endpoint
When the caller has no API key, or you want the limit in code next to the route, call ratelimit.limit with a namespace named after the route. Each route sends its own numbers, and cost works the same way.
const rl = await unkey.ratelimit.limit({
namespace: "POST /v1/items",
identifier: userId,
limit: 100,
duration: 60_000,
cost: 1,
});
if (!rl.data.success) {
return Response.json({ error: "rate limited" }, { status: 429 });
}Namespaces are created on first use. To check a per-user and a per-endpoint limit on the same request, use ratelimit.multiLimit. data.passed is true only when every check passed.
Related
- Creating keys for the
ratelimitsfield and its bounds. - Verifying keys for the
ratelimitsrequest entries and the 412 on unknown names. - Standalone rate limiting and
multiLimitare documented under limit and multiLimit.
Api Management Cookbook Fastapi Auth (`docs/api-management/cookbook/fastapi-auth.mdx`)
title: FastAPI authentication
sources:
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/docs/sdks/keys/README.md
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Outcome: a route adds Depends(require_key()) or Depends(require_key("reports.read")), and the dependency X-RateLimit-* headers.
The root key needs api.*.verify_key. Install with pip install unkey.py fastapi uvicorn.
The dependency
require_key takes an optional permission query, so each route can ask for its own. FastAPI's HTTPBearer reads the header and returns 401 if it's missing.
Catch httpx.HTTPError as well as errors.UnkeyError. A timeout or connection failure raises an httpx error, and without catching it an outage becomes a 500 instead of a 503.
/* Detailed source-code truncated for AI context efficiency. */Routes
from typing import Annotated
from fastapi import Depends, FastAPI
from unkey.py import models
from .auth import require_key
app = FastAPI()
@app.get("/items")
def list_items(verification: Annotated[models.V2KeysVerifyKeyResponseData, Depends(require_key())]):
owner = verification.identity.external_id if verification.identity else None
return {"items": [], "owner": owner, "credits_left": verification.credits}
@app.get("/reports")
def reports(verification: Annotated[models.V2KeysVerifyKeyResponseData, Depends(require_key("reports.read"))]):
return {"reports": [], "key": verification.key_id}Headers set on the injected Response reach the client when the request succeeds. When the dependency raises an HTTPException, such as a 429, those headers are dropped, so pass them in the exception's headers argument if you need them on rejections too.
Async
For an async application, use async with Unkey(...) in the lifespan and await unkey.keys.verify_key_async(...) in an async def dependency. The rest of the code is unchanged.
Related
- Python guide for creating keys and the basic flow.
- Verifying keys for every response field.
Api Management Cookbook Go Middleware (`docs/api-management/cookbook/go-middleware.mdx`)
title: Go middleware for net/http, Gin, and Echo
sidebarTitle: "Go middleware"
sources:
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/keys.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/unkey.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/models/components/v2keysverifykeyresponsedata.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/README.md
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Outcome: one Verify function with small adapters for net/http, Gin, and Echo. Each reads the bearer key, checks an optional permission query, returns the right status, and puts the verification data on the request.
The root key needs api.*.verify_key. Install the SDK with go get github.com/unkeyed/sdks/api/go/v3, then the frameworks you use.
The shared core
/* Detailed source-code truncated for AI context efficiency. */Reason tells a caller who sent no key that the token is missing, instead of NOT_FOUND. Err is set only when the call to Unkey failed, so the adapters can return 503.
Adapters
import (
"context"
"net/http"
"github.com/unkeyed/sdks/api/go/v3/models/components")
type ctxKey struct{}
// Middleware verifies the bearer key and, when permissions is non-empty,
// requires the key to satisfy that permission query.
func (v *Verifier) Middleware(permissions string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
res := v.Verify(r.Context(), r.Header.Get("Authorization"), permissions)
if res.Err != nil {
http.Error(w, "authentication unavailable", res.Status)
return
}
if !res.Data.Valid {
http.Error(w, res.Reason, res.Status)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, res.Data)))
})
}
}
// FromContext returns the verification data stored by Middleware.
func FromContext(ctx context.Context) (components.V2KeysVerifyKeyResponseData, bool) {
d, ok := ctx.Value(ctxKey{}).(components.V2KeysVerifyKeyResponseData)
return d, ok
}
```go Gin
package auth
import (
"github.com/gin-gonic/gin"
)
func (v *Verifier) Gin(permissions string) gin.HandlerFunc {
return func(c *gin.Context) {
res := v.Verify(c.Request.Context(), c.GetHeader("Authorization"), permissions)
if res.Err != nil {
c.AbortWithStatusJSON(res.Status, gin.H{"error": "authentication unavailable"})
return
}
if !res.Data.Valid {
c.AbortWithStatusJSON(res.Status, gin.H{"error": res.Reason})
return
}
c.Set("unkey", res.Data)
c.Next()
}
}package auth
import (
"github.com/labstack/echo/v4"
)
func (v *Verifier) Echo(permissions string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
res := v.Verify(c.Request().Context(), c.Request().Header.Get("Authorization"), permissions)
if res.Err != nil {
return c.JSON(res.Status, map[string]string{"error": "authentication unavailable"})
}
if !res.Data.Valid {
return c.JSON(res.Status, map[string]string{"error": res.Reason})
}
c.Set("unkey", res.Data)
return next(c)
}
}
}Using it
verifier := auth.New(os.Getenv("UNKEY_ROOT_KEY"))
// net/http
mux := http.NewServeMux()
mux.Handle("/items", verifier.Middleware("")(http.HandlerFunc(listItems)))
mux.Handle("/admin", verifier.Middleware("admin.read")(http.HandlerFunc(admin)))
// Gin
r := gin.Default()
r.GET("/items", verifier.Gin(""), func(c *gin.Context) {
data := c.MustGet("unkey").(components.V2KeysVerifyKeyResponseData)
c.JSON(200, gin.H{"keyId": data.KeyID})
})
// Echo
e := echo.New()
e.GET("/items", listItemsEcho, verifier.Echo(""))To forward rate limit state to clients, read res.Data.Ratelimits. Each entry has Limit, Remaining, and Reset (Unix milliseconds) for one checked limit.
Related
- Go guide for creating keys and the full walk-through.
- Verifying keys for the fields on
V2KeysVerifyKeyResponseData.
Api Management Cookbook Per User Rate Limit (`docs/api-management/cookbook/per-user-rate-limit.mdx`)
title: Per-user rate limits
sources:
- svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
- svc/api/routes/v2_identities_create_identity/handler.go
- svc/api/routes/v2_identities_update_identity/handler.go
- svc/api/routes/v2_keys_create_key/handler.go
- pkg/rbac/permissions.go
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/setOverride/V2RatelimitSetOverrideRequestBody.yaml
- web/internal/api/src/sdk/ratelimit.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Outcome: a user who holds several keys gets one shared keys.verifyKey with no extra call, and users on a higher plan get a higher limit without a code change.
Put the limit on the identity
A limit on a key applies to that key alone. A limit on an identity is shared by all the user's keys. Create the identity with the limit, then create keys with the same externalId, and they're linked automatically. autoApply: true checks the limit on every verification.
The root key needs identity.*.create_identity for the first call, and api.*.create_key or api.<apiId>.create_key for the second.
curl -X POST https://api.unkey.com/v2/identities.createIdentity \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "user_123",
"meta": { "plan": "free" },
"ratelimits": [
{ "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
]
}'curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "externalId": "user_123" }'Your handler doesn't change. keys.verifyKey returns code: RATE_LIMITED when the shared limit is used up, and data.ratelimits has remaining and reset so you can send X-RateLimit-* headers.
const result = await unkey.keys.verifyKey({ key });
const rl = result.data.ratelimits?.find((r) => r.name === "requests");
const headers = rl
? {
"X-RateLimit-Limit": String(rl.limit),
"X-RateLimit-Remaining": String(rl.remaining),
"X-RateLimit-Reset": String(rl.reset),
}
: {};
if (!result.data.valid) {
return Response.json({ error: result.data.code }, { status: result.data.code === "RATE_LIMITED" ? 429 : 401, headers });
}When a user upgrades, call identities.updateIdentity with a new ratelimits array (needs identity.*.update_identity). It replaces the whole list, so send every limit the user should keep. All their keys pick up the change within about 10 seconds.
Alternative: the standalone ratelimit API
If you're limiting something other than a key holder (a logged-in session, an IP address, a tenant), use ratelimit.limit. The namespace names what you're limiting, the identifier is the user, and you send the limit with each request. The response is always HTTP 200, so read data.success.
For plans, use overrides. An override gives one identifier a different limit from the one in the request. Set one when a user upgrades and delete it when they downgrade. data.overrideId in the response shows when an override applied. The root key needs ratelimit.*.limit to check limits and ratelimit.*.set_override to create overrides.
curl -X POST https://api.unkey.com/v2/ratelimit.setOverride \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "namespace": "api.requests", "identifier": "user_123", "limit": 1000, "duration": 60000 }'Related
- Creating keys for the
externalIdandratelimitsfields. - Verifying keys for the
ratelimitsresponse entries. - Identities, standalone rate limiting, and overrides are documented under API Management.
Api Management Cookbook Tiered Subscriptions (`docs/api-management/cookbook/tiered-subscriptions.mdx`)
title: Tiered subscriptions
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_set_permissions/handler.go
- pkg/rbac/permissions.go
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Outcome: each plan is a small object in your code, a key carries its plan's limits, credits, permissions, and a meta.plan label, and upgrading a customer is one keys.updateKey call that takes effect within about ten seconds.
Define the tiers
Define each plan once and build every key from it. Rate limits cap how often, credits cap how much, permissions unlock features, and meta returns the plan name on every verification so your handlers don't need a database lookup.
export const PLANS = {
free: {
ratelimits: [{ name: "requests", limit: 60, duration: 60_000, autoApply: true }],
credits: { remaining: 1_000, refill: { interval: "monthly" as const, amount: 1_000, refillDay: 1 } },
permissions: ["items.read"],
},
pro: {
ratelimits: [{ name: "requests", limit: 600, duration: 60_000, autoApply: true }],
credits: { remaining: 100_000, refill: { interval: "monthly" as const, amount: 100_000, refillDay: 1 } },
permissions: ["items.read", "items.write", "exports.create"],
},
enterprise: {
ratelimits: [{ name: "requests", limit: 6_000, duration: 60_000, autoApply: true }],
credits: null,
permissions: ["items.read", "items.write", "exports.create", "webhooks.manage"],
},
};
export type Plan = keyof typeof PLANS;You don't have to create these permissions first. keys.createKey creates any that don't exist. keys.setPermissions does too, but only if the root key has rbac.*.create_permission; otherwise it returns 403. Don't use * in slugs, because it isn't a wildcard.
Create a key for a plan
import { Unkey } from "@unkey/api";
import { PLANS, type Plan } from "./plans";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
export async function createKeyFor(customerId: string, plan: Plan) {
const tier = PLANS[plan];
const created = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID ?? "",
externalId: customerId,
meta: { plan },
ratelimits: tier.ratelimits,
permissions: tier.permissions,
...(tier.credits ? { credits: tier.credits } : {}),
});
return created.data; // key (plaintext, show once) and keyId
}Read the plan during verification
Gate features with a permissions query so Unkey makes the decision. Use meta.plan only for softer choices, such as page size.
const result = await unkey.keys.verifyKey({ key, permissions: "exports.create" });
if (!result.data.valid) {
// INSUFFICIENT_PERMISSIONS here means "not on a plan that includes exports"
return Response.json({ error: result.data.code }, { status: result.data.code === "INSUFFICIENT_PERMISSIONS" ? 403 : 401 });
}
const plan = (result.data.meta as { plan?: string } | undefined)?.plan ?? "free";
const pageSize = plan === "enterprise" ? 1000 : 100;Change a customer's plan
keys.updateKey changes a key in place. Fields you leave out keep their value, ratelimits replaces the whole list, and credits: null removes the cap and any refill. Use keys.setPermissions for permissions. It replaces the key's permissions, so a downgrade removes what the higher plan granted.
Changes take about 10 seconds to reach verification. Credit changes apply right away. If a customer has several keys, put the plan's rate limits on their identity so all keys share one limit, as shown in Per-user rate limits.
Related
- Creating keys for every field and its bounds.
- Usage-based billing with credits for metering inside a plan.
- Roles and permissions are documented under Roles and permissions.
Api Management Cookbook Usage Billing (`docs/api-management/cookbook/usage-billing.mdx`)
title: Usage-based billing with credits
sidebarTitle: "Usage-based billing"
sources:
- svc/api/openapi/spec/common/KeyCreditsData.yaml
- svc/api/openapi/spec/common/KeyCreditsRefill.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyCredits.yaml
- svc/api/openapi/spec/paths/v2/keys/updateCredits/V2KeysUpdateCreditsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/ctrl/internal/db/queries/key_list_for_refill.sql
- svc/ctrl/internal/db/queries/key_refill_by_ids.sql
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Outcome: each customer gets a monthly allowance, heavy operations cost more than light ones, a customer who runs out gets a 402, and a purchased top-up works on the next request. Unkey does the counting.
Give the key a monthly allowance
credits.remaining is the balance and credits.refill resets it on a schedule. A monthly refill needs a refillDay (in shorter months it runs on the last day). A refill resets the balance to refill.amount rather than adding to it, so unused credits don't roll over.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"externalId": "customer_123",
"credits": {
"remaining": 10000,
"refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
}
}'Charge per operation
Each verification spends credits.cost, 1 by default. Set the cost per operation: a bulk export might cost 50 and a status check 0. A request that fails any other check, such as a rate limit or permission, spends nothing.
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const COST: Record<string, number> = {
"GET /v1/status": 0,
"GET /v1/items": 1,
"POST /v1/export": 50,
};
export async function meter(key: string, route: string) {
const result = await unkey.keys.verifyKey({
key,
credits: { cost: COST[route] ?? 1 },
tags: [`route=${route}`],
});
if (!result.data.valid) {
if (result.data.code === "USAGE_EXCEEDED") {
// data.credits is the unchanged balance; nothing was deducted.
return { ok: false as const, status: 402, remaining: result.data.credits ?? 0 };
}
return { ok: false as const, status: 401, remaining: result.data.credits };
}
// data.credits is the balance after this call. Surface it so clients can plan.
return { ok: true as const, remaining: result.data.credits, customer: result.data.identity?.externalId };
}Return the balance in a header such as X-Credits-Remaining so customers can see it. Analytics records the credits spent per key and identity, so you can build invoices from it.
Sell top-ups and change plans
keys.updateCredits changes the balance and leaves the refill schedule alone. Use increment when a customer buys credits, decrement to take some back, and set for a new balance. set with value: null makes the key unlimited, for example for an enterprise plan with no cap.
To change the monthly allowance, use keys.updateKey with a new credits.refill.amount. It applies at the next refill.
Related
- Credits and refill for every field and the refill schedule.
- Verifying keys for why credits are checked last.
- Tiered subscriptions for combining credits with limits and permissions per plan.
Api Management Get Started Concepts (`docs/api-management/get-started/concepts.mdx`)
title: Keyspaces, keys, identities, and root keys
sidebarTitle: "Concepts"
sources:
- web/internal/db/src/schema/apis.ts
- web/internal/db/src/schema/keyAuth.ts
- web/internal/db/src/schema/keys.ts
- web/internal/db/src/schema/identity.ts
- svc/api/routes/v2_keys_create_key/handler.go
- internal/services/keys/get.go
- pkg/rbac/permissions.go
- web/apps/dashboard/lib/trpc/routers/key/createRootKey.ts
To issue and verify API keys for a public API, use API Management. A keyspace holds the keys, keys.createKey issues one, and your server calls keys.verifyKey on each request. You don't build the hash lookup yourself.
API Management has four objects. A keyspace groups keys. A key is what your user sends you. An identity ties several keys to one user or organization. A root key is what you send to Unkey. Credits, rate limits, permissions, and analytics are all settings on, or views of, these four.
Keyspace
A keyspace is the container for related keys. You might have one per product, one per api_...) whenever you create a key.
A keyspace sets defaults for new keys (a prefix and a key length). It can also have delete protection and an IP allow list that applies to every key in it. It has a second identifier, the Keyspace ID (ks_...), which analytics and the customer portal use. You'll find both IDs on the keyspace's settings page. Keyspaces explains when to create more than one.
Key
A key is a random string, with an optional prefix, that you give to a user of your application. Unkey stores only a hash of the key, never the key itself, unless you turn on recoverable keys. On every request, your backend sends the key to keys.verifyKey and Unkey runs the checks set on it.
Every check is optional: whether the key is enabled, when it expires, how many credits it has left, which rate limits apply, and which permissions and roles it has. A key can also carry JSON metadata that comes back on every verification, so your backend can read a plan tier or feature flag without a database lookup. Creating keys lists each field with its bounds.
Identity
An identity is one user, organization, or service account in your system, named by an externalId you choose. Keys linked to an identity share its rate limits and metadata. Pass externalId when you create a key, and Unkey creates the identity if needed and links the key, so most apps never call the identity endpoints directly. Identities covers shared rate limits and metadata in detail.
Root key
A root key authenticates you, not your users. Every call to api.unkey.com sends a root key in the Authorization: Bearer header, and its permissions decide which calls succeed. Create root keys in the dashboard under Settings > Root Keys. Permissions such as api.*.create_key or api.<api_id>.verify_key apply to every keyspace or to one. See Root keys for how permissions are granted and rotated.
How the objects relate
Workspace
└── Keyspace (api_..., ks_...)
├── defaults: prefix, bytes, encrypted storage, IP allow list, delete protection
└── Key (key_...)
├── enabled, expires, credits, rate limits, permissions, roles, meta
└── Identity (externalId) shared rate limits and meta across keys
Root key (unkey_...) authenticates your calls; permissions name keyspaces by api_ idSo one request from your user involves two keys: their key, which they send to you, and your root key, which you send to Unkey to verify theirs. Your user's key never grants access to the Unkey API, and your root key should never leave your servers.
Next steps
Api Management Guides Bun (`docs/api-management/guides/bun.mdx`)
title: Bun guide
sidebarTitle: "Bun"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/internal/api/src/sdk/keys.ts
- web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect a Bun HTTP server with API keys. Bun.serve has one fetch handler, so you call a verify helper at the top of it, and keys.verifyKey decides whether the request goes on.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.```bash
bun add @unkey/api
```<CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```ts TypeScript
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const created = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID ?? "",
prefix: "sk_live",
externalId: "user_123",
meta: { plan: "free" },
});
// created.data.key is the plaintext, created.data.keyId the handle you keep
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).```/* Detailed source-code truncated for AI context efficiency. */
```bash
bun run server.ts
curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
```
</Step>
<Step title="Handle the outcome codes">
`keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:
| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
</Step>
<Step title="Next steps">
<Columns cols={2}>
<Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
<Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
<Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
</Columns>
</Step>
</Steps>
---
### Api Management Guides Cloudflare Workers (`docs/api-management/guides/cloudflare-workers.mdx`)
---
title: Cloudflare Workers guide
sidebarTitle: "Cloudflare Workers"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/internal/api/src/sdk/keys.ts
- web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect a Cloudflare Worker with API keys. The Worker's `fetch` handler calls `keys.verifyKey` on each request. Store the root key as a Worker secret, never in `wrangler.toml`.
<RootKeyPrereq />
<Steps titleSize="h3">
<Step title="Create a root key and a keyspace">
In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Export it as `UNKEY_ROOT_KEY` in your shell for the `curl` commands. You'll store it as a Worker secret in step 2. Never send it to a browser or mobile app.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
</Step>
<Step title="Install the SDK and store the root key">
`@unkey/api` (version {versions.tsApi}) runs on Workers with no polyfills.
```bash
npm install @unkey/api
npx wrangler secret put UNKEY_ROOT_KEY
```
Your code reads it as `env.UNKEY_ROOT_KEY`. If your Worker also creates keys, add the keyspace ID from step 1 as an `UNKEY_API_ID` variable in `wrangler.toml`. It isn't secret.
</Step>
<Step title="Create a key for a user">
Create a key when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.
Workers have no `process.env`, so read the secret and keyspace ID from the handler's `env` argument.
<CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```ts src/issue.ts
import { Unkey } from "@unkey/api";
interface Env {
UNKEY_ROOT_KEY: string;
UNKEY_API_ID: string;
}
// Call this from a route your own session check already protects.
export async function issueKey(env: Env, userId: string): Promise<string> {
const unkey = new Unkey({ rootKey: env.UNKEY_ROOT_KEY });
const created = await unkey.keys.createKey({
apiId: env.UNKEY_API_ID,
prefix: "sk_live",
externalId: userId,
meta: { plan: "free" },
});
// created.data.key is the plaintext, created.data.keyId the handle you keep
return created.data.key;
}
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).
</Step>
<Step title="Verify the key in the fetch handler">
`env` is only available inside the handler, so create the client there. The code below creates it once and reuses it.
```
/* Detailed source-code truncated for AI context efficiency. */```bash
npx wrangler dev
curl http://localhost:8787/ -H "Authorization: Bearer sk_live_..."
```
Prefer a router? The [Hono guide](/api-management/guides/hono) runs unchanged on Workers. Read the root key from `c.env.UNKEY_ROOT_KEY` instead of `process.env`.| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.Api Management Guides Express (`docs/api-management/guides/express.mdx`)
title: Express guide
sidebarTitle: "Express"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/internal/api/src/sdk/keys.ts
- web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect Express routes with API keys. A middleware reads the bearer token, calls keys.verifyKey, rejects bad keys with the right status, and puts the result on res.locals.unkey for your handlers.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.<CodeGroup>
```bash npm
npm install @unkey/api express
```
```bash pnpm
pnpm add @unkey/api express
```
</CodeGroup><CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```ts TypeScript
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const created = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID ?? "",
prefix: "sk_live",
externalId: "user_123",
meta: { plan: "free" },
});
// created.data.key is the plaintext, created.data.keyId the handle you keep
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).Keep `next()` outside the `try`. Otherwise an error in a route handler is caught here and answered as a 503 authentication failure.
```/* Detailed source-code truncated for AI context efficiency. */
```ts src/server.ts
import express from "express";
import { requireKey } from "./unkey";
const app = express();
app.get("/items", requireKey(), (_req, res) => {
res.json({ items: [], owner: res.locals.unkey.identity?.externalId });
});
app.post("/items", requireKey("items.write"), (_req, res) => {
res.status(201).json({ ok: true });
});
app.listen(3000);
```
```bash
curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
```
</Step>
<Step title="Handle the outcome codes">
`keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:
| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
</Step>
<Step title="Next steps">
<Columns cols={2}>
<Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
<Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
<Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
</Columns>
</Step>
</Steps>
---
### Api Management Guides Go (`docs/api-management/guides/go.mdx`)
---
title: Go guide
sidebarTitle: "Go"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/keys.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/unkey.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/models/components/v2keysverifykeyresponsedata.go
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/go/v3/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect a Go HTTP server with API keys, using the official Go SDK, `github.com/unkeyed/sdks/api/go/v3` (version {versions.goSdk}). For ready-made middleware for `net/http`, Gin, and Echo, see [Go middleware](/api-management/cookbook/go-middleware).
<RootKeyPrereq />
<Steps titleSize="h3">
<Step title="Create a root key and a keyspace">
In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
</Step>
<Step title="Install the SDK">
```bash
go get github.com/unkeyed/sdks/api/go/v3
```
Create the client once with `unkey.New(unkey.WithSecurity(rootKey))`. It's safe to share across goroutines.
</Step>
<Step title="Create a key for a user">
Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.
<CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```go Go
package main
import (
"context"
"fmt"
"log"
"os"
unkey "github.com/unkeyed/sdks/api/go/v3"
"github.com/unkeyed/sdks/api/go/v3/models/components"
)
func main() {
client := unkey.New(unkey.WithSecurity(os.Getenv("UNKEY_ROOT_KEY")))
prefix := "sk_live"
externalID := "user_123"
res, err := client.Keys.CreateKey(context.Background(), components.V2KeysCreateKeyRequestBody{
APIID: os.Getenv("UNKEY_API_ID"),
Prefix: &prefix,
ExternalID: &externalID,
Meta: map[string]any{"plan": "free"},
})
if err != nil {
log.Fatal(err)
}
if res.V2KeysCreateKeyResponseBody == nil {
log.Fatal("unkey: empty response body")
}
// res.V2KeysCreateKeyResponseBody.Data.Key is the plaintext, Data.KeyID the handle you keep
fmt.Println(res.V2KeysCreateKeyResponseBody.Data.KeyID)
}
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).
</Step>
<Step title="Verify the key in a handler">
The answer is in `res.V2KeysVerifyKeyResponseBody.Data.Valid` and `.Data.Code`. `V2KeysVerifyKeyResponseBody` is a pointer, so check it for `nil` even when `err` is `nil`. Use the `components.Code*` constants to switch on the outcome. An `error` means the call itself failed, not that the key is invalid.
```
/* Detailed source-code truncated for AI context efficiency. */```bash
go run . &
curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
```| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.Api Management Guides Hono (`docs/api-management/guides/hono.mdx`)
title: Hono guide
sidebarTitle: "Hono"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/internal/api/src/sdk/keys.ts
- web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/hono/README.md
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect Hono routes with API keys. The code works on Node, Bun, Deno, and Cloudflare Workers. Only how you read the root key changes. Use the @unkey/hono middleware to have verification handled for you, or write a few lines with @unkey/api to control every response.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.<CodeGroup>
```bash npm
npm install hono @unkey/hono @unkey/api
```
```bash bun
bun add hono @unkey/hono @unkey/api
```
</CodeGroup><CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```ts TypeScript
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const created = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID ?? "",
prefix: "sk_live",
externalId: "user_123",
meta: { plan: "free" },
});
// created.data.key is the plaintext, created.data.keyId the handle you keep
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).`onError` only runs for unexpected responses, such as a 502 from a proxy. A rejected root key, a throttled request, a 500, or a network failure is thrown instead, so catch those in Hono's `app.onError` if you want one response for every authentication failure.
<CodeGroup>
```ts src/index.ts (@unkey/hono)
import { Hono } from "hono";
import { type UnkeyContext, unkey } from "@unkey/hono";
const app = new Hono<{ Variables: { unkey: UnkeyContext } }>();
app.use(
"/items/*",
unkey({
rootKey: process.env.UNKEY_ROOT_KEY ?? "",
handleInvalidKey: (c) => c.json({ error: "invalid API key" }, 401),
onError: (c, err) => {
console.error("unkey", err.message);
return c.json({ error: "authentication unavailable" }, 503);
},
}),
);
app.get("/items/list", (c) => {
const verification = c.get("unkey");
return c.json({ items: [], owner: verification.data.identity?.externalId });
});
export default app;
```
```/* Detailed source-code truncated for AI context efficiency. */
</CodeGroup>
Keep `next()` outside the `try`. Otherwise an error in a route handler is caught here and answered as an authentication failure.
The middleware also takes `permissions` (a permission query every key must pass), `tags`, and `getKey` (for keys sent somewhere other than the `Authorization` header).
</Step>
<Step title="Handle the outcome codes">
`keys.verifyKey` returns HTTP 200 for every outcome. Check `data.valid`, then `data.code` for the reason, and return the matching status from your API:
| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.
</Step>
<Step title="Next steps">
<Columns cols={2}>
<Card title="Verifying keys" href="/api-management/keys/verifying-keys" icon="key">Every request field, the order of checks, and every response field.</Card>
<Card title="Credits and refill" href="/api-management/keys/credits-and-refill" icon="coins">Meter usage per key and refill balances on a schedule.</Card>
<Card title="Cookbook" href="/api-management/cookbook/index" icon="book">Copy-ready recipes for rate limits, billing, and subscription tiers.</Card>
</Columns>
</Step>
</Steps>
---
### Api Management Guides Nextjs (`docs/api-management/guides/nextjs.mdx`)
---
title: Next.js guide
sidebarTitle: "Next.js"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/internal/api/src/sdk/keys.ts
- web/internal/api/src/models/components/v2keysverifykeyresponsedata.ts
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/ts/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/nextjs/README.md
---
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect a Next.js App Router route handler with API keys. Your server creates keys for users, and every request to the route is checked with `keys.verifyKey` before your handler runs. Verification needs your root key, so do it in route handlers or server actions, never in client components.
<RootKeyPrereq />
<Steps titleSize="h3">
<Step title="Create a root key and a keyspace">
In the dashboard, go to **Settings > Root Keys** and create a root key with `api.*.create_api`, `api.*.create_key`, and `api.*.verify_key`. Put it in your server's `UNKEY_ROOT_KEY` environment variable. Never send it to a browser or mobile app.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.
</Step>
<Step title="Install the SDK">
`@unkey/api` (version {versions.tsApi}) is the client for the whole API. `@unkey/nextjs` (version {versions.tsNextjs}) wraps a route handler and verifies for you. You only need it for the wrapper in step 4.
<CodeGroup>
```bash npm
npm install @unkey/api @unkey/nextjs
```
```bash pnpm
pnpm add @unkey/api @unkey/nextjs
```
```bash bun
bun add @unkey/api @unkey/nextjs
```
</CodeGroup>
</Step>
<Step title="Create a key for a user">
Create a key from your backend when a user signs up or asks for one. `externalId` links the key to the user, so verification tells you who's calling. `meta` comes back on every verification. You get `data.key` only once: show it to the user and store only `data.keyId`.
<CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```ts TypeScript
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY ?? "" });
const created = await unkey.keys.createKey({
apiId: process.env.UNKEY_API_ID ?? "",
prefix: "sk_live",
externalId: "user_123",
meta: { plan: "free" },
});
// created.data.key is the plaintext, created.data.keyId the handle you keep
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).
</Step>
<Step title="Verify the key in a route handler">
The first handler calls `keys.verifyKey` directly, so you control the response. The second uses `withUnkey`, which reads the bearer token from the `Authorization` header, <Tooltip tip="Here: keys.verifyKey checking a key your user presented. Not domain verification and not the gateway policy.">verifies</Tooltip> it, and puts the result in `req.unkey`. Pass `handleInvalidKey` to choose the response for a bad key.
<CodeGroup>
```
/* Detailed source-code truncated for AI context efficiency. */```ts app/api/items/route.ts (@unkey/nextjs)
import { withUnkey } from "@unkey/nextjs";
import { NextResponse } from "next/server";
export const GET = withUnkey(
async (req) => {
// req.unkey holds the verification response; the key is already valid here
return NextResponse.json({ items: [], keyId: req.unkey?.data.keyId });
},
{
rootKey: process.env.UNKEY_ROOT_KEY ?? "",
handleInvalidKey: () => NextResponse.json({ error: "invalid API key" }, { status: 401 }),
onError: (_req, err) => {
console.error("unkey", err.message);
return NextResponse.json({ error: "authentication unavailable" }, { status: 503 });
},
},
);
```
</CodeGroup>
<Warning>
`withUnkey` only calls `onError` for unexpected responses, such as a 502 from a proxy. A rejected root key, a throttled request, a 500, or a network failure is thrown instead, so your route fails with an unhandled error rather than a 503. The hand-written version catches all of these and returns 503.
</Warning>
Test it with the key from step 3:
```bash
curl http://localhost:3000/api/items -H "Authorization: Bearer sk_live_..."
```| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.Api Management Guides Python (`docs/api-management/guides/python.mdx`)
title: Python guide
sidebarTitle: "Python"
sources:
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/createApi/V2ApisCreateApiResponseData.yaml
- pkg/rbac/permissions.go
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/docs/sdks/keys/README.md
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
import { versions } from "/snippets/versions.mdx";
Protect a Python API with API keys, using the official SDK, unkey.py (version {versions.pySdk}). The examples use FastAPI, but the client calls are the same in Flask or Django. For a fuller FastAPI setup, see FastAPI authentication.
Then create a keyspace, which holds your keys. Use **Keyspaces (APIs)** in the dashboard, or `apis.createApi`:
```bash create a keyspace
curl -X POST https://api.unkey.com/v2/apis.createApi \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "my-api" }'
```
Keep the `data.apiId` from the response. You need it to create keys. See [Root keys](/platform/root-keys/overview) for more on root keys.Create the client with `Unkey(root_key=...)`. It works as a context manager, and every method has an `_async` version for `asyncio`.<CodeGroup>
```bash curl
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "prefix": "sk_live", "externalId": "user_123", "meta": { "plan": "free" } }'
```
```python Python
import os
from unkey.py import Unkey
with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
created = unkey.keys.create_key(
api_id=os.environ["UNKEY_API_ID"],
prefix="sk_live",
external_id="user_123",
meta={"plan": "free"},
)
# created.data.key is the plaintext, created.data.key_id the handle you keep
print(created.data.key_id)
```
</CodeGroup>
Every field is described in [Creating keys](/api-management/keys/creating-keys).```python main.py
import os
import httpx
from fastapi import Depends, FastAPI, HTTPException, Request
from unkey.py import Unkey, errors
app = FastAPI()
unkey = Unkey(root_key=os.environ["UNKEY_ROOT_KEY"])
STATUS = {
"RATE_LIMITED": 429,
"USAGE_EXCEEDED": 402,
"FORBIDDEN": 403,
"INSUFFICIENT_PERMISSIONS": 403,
}
def require_key(request: Request):
auth = request.headers.get("authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing API key")
key = auth[len("Bearer "):]
try:
res = unkey.keys.verify_key(
key=key,
tags=[f"endpoint={request.url.path}", f"method={request.method}"],
)
except (errors.UnkeyError, httpx.HTTPError) as e:
# The call failed (bad root key, throttle, outage), not the user's key.
raise HTTPException(status_code=503, detail="authentication unavailable") from e
if not res.data.valid:
code = res.data.code
raise HTTPException(status_code=STATUS.get(code, 401), detail=code)
return res.data
@app.get("/items")
def list_items(verification=Depends(require_key)):
owner = verification.identity.external_id if verification.identity else None
return {"items": [], "owner": owner}
```
For an async app, create the client with `async with Unkey(...)` and call `await unkey.keys.verify_key_async(key=key)`.
```bash
uvicorn main:app --port 3000
curl http://localhost:3000/items -H "Authorization: Bearer sk_live_..."
```| `data.code` | Meaning | Return |
| --- | --- | --- |
| `VALID` | Every check passed. | continue |
| `NOT_FOUND` | No such key, or your root key may not verify this keyspace. | 401 |
| `DISABLED` | The key was disabled. | 401 |
| `EXPIRED` | The key's expiry passed. | 401 |
| `FORBIDDEN` | Client IP not on the keyspace allow list, or the workspace is disabled. | 403 |
| `INSUFFICIENT_PERMISSIONS` | The `permissions` query was not satisfied. | 403 |
| `RATE_LIMITED` | A <Tooltip tip="Here: limits enforced by keys.verifyKey on a key or identity, or by the standalone ratelimit API. Not a Compute gateway policy.">rate limit</Tooltip> on the key or its identity was exceeded. | 429 |
| `USAGE_EXCEEDED` | The key has no credits left. | 402 or 429 |
Remaining credits are in `data.credits`, and each checked rate limit is in `data.ratelimits` with `remaining` and `reset`. The call only fails with an HTTP error (which the SDKs throw) when the call itself is wrong, such as a bad root key or a malformed body. See [Verifying keys](/api-management/keys/verifying-keys) for every field.Api Management Identities Shared Rate Limits (`docs/api-management/identities/shared-rate-limits.mdx`)
title: Shared rate limits across keys
sidebarTitle: "Shared rate limits"
sources:
- svc/api/openapi/spec/paths/v2/identities/createIdentity/V2IdentitiesCreateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/identities/updateIdentity/V2IdentitiesUpdateIdentityRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/routes/v2_identities_create_identity/handler.go
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- internal/services/keys/get.go
- internal/services/keys/validation.go
- web/internal/api/src/sdk/identities.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
A rate limit on a key counts that key alone. If a user has three keys at 100 requests per minute each, they can make 300. Put the limit on the user's identity instead, and all their keys share the same 100. Here's how to set it up.
The root key needs identity.*.create_identity (or identity.*.update_identity for an existing identity), api.*.create_key, and api.*.verify_key.
<CodeGroup>
```bash cURL
curl -X POST https://api.unkey.com/v2/identities.createIdentity \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"externalId": "user_123",
"ratelimits": [
{ "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
]
}'
```
```typescript TypeScript
const { data } = await unkey.identities.createIdentity({
externalId: "user_123",
ratelimits: [
{ name: "requests", limit: 100, duration: 60_000, autoApply: true },
],
});
console.log(data.identityId);
```
</CodeGroup>
If the identity already exists, for example because a key was created with this `externalId` earlier, the call returns HTTP 409 `err:unkey:data:identity_already_exists`. Use `identities.updateIdentity` with the same `ratelimits` array instead.<CodeGroup>
```bash cURL
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "externalId": "user_123", "name": "Production" }'
```
```typescript TypeScript
await unkey.keys.createKey({ apiId: "api_...", externalId: "user_123", name: "Production" });
await unkey.keys.createKey({ apiId: "api_...", externalId: "user_123", name: "Staging" });
```
</CodeGroup><CodeGroup>
```bash cURL
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_..." }'
```
```typescript TypeScript
const { data } = await unkey.keys.verifyKey({ key: incomingKey });
if (!data.valid && data.code === "RATE_LIMITED") {
// the user exceeded 100 requests per minute across all their keys
}
console.log(data.identity?.externalId); // "user_123"
```
</CodeGroup>
`data.ratelimits` shows the limits that were checked, with `remaining` and `reset`.Several limits on one identity
Add more than one limit when different operations need different budgets. A limit with autoApply: false is only checked when a verification names it, so an expensive endpoint can have its own budget.
curl -X POST https://api.unkey.com/v2/identities.updateIdentity \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"identity": "user_123",
"ratelimits": [
{ "name": "requests", "limit": 500, "duration": 3600000, "autoApply": true },
{ "name": "tokens", "limit": 20000, "duration": 86400000, "autoApply": false }
]
}'Then spend 150 tokens on one call:
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_...", "ratelimits": [ { "name": "tokens", "cost": 150 } ] }'Both requests (auto-applied) and tokens are checked together. If either is exceeded, the verification returns RATE_LIMITED and neither is consumed.
Exceptions for one key
If a key has a limit with the same name as one on its identity, the key's limit wins for that key. Use this to give one integration a higher limit than the user's other keys. Details are in Key and identity rate limits.
Api Management Keys Creating Keys (`docs/api-management/keys/creating-keys.mdx`)
title: Creating keys
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyResponseData.yaml
- svc/api/openapi/spec/common/KeyCreditsData.yaml
- svc/api/openapi/spec/common/KeyCreditsRefill.yaml
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/config.go
- internal/services/keys/create.go
- web/internal/db/src/schema/keyAuth.ts
- web/internal/db/src/schema/keys.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Create a key with keys.createKey. The only required field is the keyspace's API ID. Everything else is optional and you can change it later with keys.updateKey. You get the key back once, in the response.
The root key needs api.*.create_key or api.<api_id>.create_key. Creating a recoverable key also needs api.*.encrypt_key or api.<api_id>.encrypt_key. Without it you get HTTP 404 err:unkey:data:api_not_found, not a permission error, so a missing grant looks like a missing API. See Root key permissions.
Request fields
The keyspace that owns the key, by API ID (api_...). 3 to 255 characters matching ^[a-zA-Z0-9_]+$. You can't move a key to another keyspace later.
1 to 16 characters matching ^[a-zA-Z0-9_]+$. Becomes the start of the key as _<random>, so users can tell keys apart in logs. When omitted, the key uses the keyspace's default prefix if one is set, and otherwise has no prefix. Don't put secrets or customer names in a prefix. It's visible wherever the key is.
1 to 255 characters. An internal label returned by keys.getKey, apis.listKeys, and keys.verifyKey. Unkey never shows it to your users.
16 to 255 random bytes, base58-encoded into the key. When omitted, the key uses the keyspace's default bytes value, or 16 if that's unset. 16 bytes gives 2^128 possibilities, and 32 is plenty for the most sensitive uses.
1 to 255 characters matching ^[a-zA-Z0-9_.-]+$: your identifier for the user, organization, or tenant that owns the key. Unkey creates an identity with this externalId if none exists and links the key to it, so every verification returns identity.externalId and any metadata or shared rate limits set on the identity. See Identities.
Arbitrary JSON returned in full on every verification. At most 100 top-level properties. Keep it small: it travels with every keys.verifyKey response, and the whole request body is capped at 10 MiB. Don't store secrets here. See Metadata and tags.
Up to 100 role names, each 1 to 128 characters. Every role must already exist in the workspace or the request fails. Roles bundle permissions, and the key gains all of them.
Up to 1000 permission slugs, each 1 to 128 characters matching ^[a-zA-Z0-9_:\-\.\*]+$. Added to the key on top of what its roles grant. Unlike roles, a slug that doesn't exist yet is created for you. An asterisk is a literal character, not a wildcard.
Unix timestamp in milliseconds, at most 4102444800000 (1 January 2100). After this instant verification returns code: EXPIRED. Omit for a key that never expires. See Key expiration.
Usage metering. credits.remaining (integer, 0 or more) is the number of verifications the key can still afford. credits.refill optionally restores it on a schedule with interval (daily or monthly), amount (1 or more), and refillDay (1 to 31, required for monthly). remaining must be set whenever refill is set. Omit the whole object for unlimited usage. See Credits and refill.
Up to 50 named rate limits, each with name (3 to 128 characters), limit (1 or more), duration in milliseconds (1000 or more), and autoApply (boolean, required). Limits with autoApply: true are checked on every verification. The others are checked only when a verification names them. See Key and identity rate limits.
Set to false to create the key in a disabled state. Verification returns code: DISABLED until you enable it. See Disabling and deleting keys.
Store an encrypted copy of the key so keys.getKey and apis.listKeys can show it later with decrypt: true. If the keyspace doesn't have encrypted storage turned on, you get HTTP 412 err:unkey:application:precondition_failed. If the root key lacks the encrypt permission, you get HTTP 404 err:unkey:data:api_not_found. See Recoverable keys.
Example
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"prefix": "sk_live",
"name": "Acme production",
"externalId": "org_acme",
"meta": { "plan": "pro" },
"expires": 1767225600000,
"credits": {
"remaining": 10000,
"refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
},
"ratelimits": [
{ "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
],
"permissions": ["documents.read", "documents.write"]
}'Response
{
"meta": { "requestId": "req_..." },
"data": {
"keyId": "key_...",
"key": "sk_live_..."
}
}keyId is the identifier you use for every later management call. key is the key itself, and this is the only time it's returned. Unkey keeps only a hash, plus an encrypted copy if you asked for a recoverable key. Send it to your user over a secure channel and don't log it.
Key format
A key looks like _<random>, or just <random> without a prefix. The random part is byteLength random bytes in base58. keys.getKey and apis.listKeys return a start field, _<first four characters>, so you can recognize a key without seeing all of it.
From the dashboard
The keyspace's Create key dialog has the same fields in six steps: general setup, rate limits, credits, expiration, permissions, and metadata. Two things differ from the API. Expiry must be at least two minutes in the future, and the general step has an meta if your backend needs the label.
Api Management Keys Credits And Refill (`docs/api-management/keys/credits-and-refill.mdx`)
title: Credits and refill
sources:
- svc/api/openapi/spec/common/KeyCreditsData.yaml
- svc/api/openapi/spec/common/KeyCreditsRefill.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyCredits.yaml
- svc/api/openapi/spec/paths/v2/keys/updateCredits/V2KeysUpdateCreditsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsData.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsRefill.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_update_key/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- internal/services/keys/validation.go
- internal/services/usagelimiter/limit.go
- internal/services/usagelimiter/redis.go
- svc/api/routes/v2_keys_update_credits/handler.go
- svc/ctrl/worker/cron/keyrefill/handler.go
- dev/k8s/charts/restate-cronjobs/values.yaml
- svc/ctrl/internal/db/queries/key_list_for_refill.sql
Credits cap how much a key can be used in total. (Rate limits cap how often.) A key starts with a balance, and each verification spends some of it. When the balance can't cover a request, the key stops working until you add more or a scheduled refill restores it. Use credits for "1,000 requests included" plans, trials, and pay-per-use APIs.
How spending works
A key has credits when credits.remaining is a number. If it's null or missing, the key is unlimited. Each keys.verifyKey call costs 1 unless the request sets credits.cost, any integer from 0 to 1,000,000,000,000.
Credits are checked last, so a key that fails any other check (disabled, expired, rate limited, missing permissions) doesn't lose credits. What happens next depends on the balance:
- Enough credits. The cost is deducted and the response's
creditsfield shows the new balance. - Not enough. Nothing is deducted, the verification fails with
code: USAGE_EXCEEDED, andcreditsshows the unchanged balance.
The balance never goes below zero. A cost of 0 checks the key without spending anything, so you can check a key or read its metadata for free.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_live_...", "credits": { "cost": 10 } }'{
"meta": { "requestId": "req_..." },
"data": { "valid": true, "code": "VALID", "keyId": "key_...", "credits": 990 }
}Verifications in every region spend from the same balance, so no spend is lost.
Add credits to a key
Set credits.remaining when creating the key, or later with keys.updateKey.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "credits": { "remaining": 1000 } }' Starting balance, 0 or more. On keys.updateKey, null removes the limit entirely and also clears any refill schedule. On keys.createKey, null isn't allowed, so omit credits instead.
Refill on a schedule
A refill resets the balance to a fixed amount on a schedule. It replaces the balance rather than adding to it: a key with 50 credits left and a refill of 1000 has 1000 afterward, not 1050. You must set credits.remaining in the same request.
daily or monthly.
The balance after each refill, 1 or more.
1 to 31. Required when interval is monthly. Leaving it out fails with HTTP 400 err:unkey:application:invalid_input. With daily, keys.updateKey rejects it with the same error and keys.createKey ignores it. If the month is shorter, the refill runs on its last day, so 31 works in February.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"credits": {
"remaining": 10000,
"refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
}
}'Refills run once a day at 00:00 UTC, whatever your customer's time zone. Daily keys refill every day, and monthly keys refill on their refillDay. A key that's already at or above its amount is left alone.
To stop refilling but keep the current balance, send credits.refill: null on keys.updateKey. To change the schedule, send the whole refill object again.
Change a balance directly
Use keys.updateCredits for top-ups, purchases, and refunds. It changes the balance and leaves the refill schedule alone.
The key to change.
set replaces the balance with value, increment adds value, and decrement subtracts value and clamps at zero.
0 or more. Required for increment and decrement. With set, null makes the key unlimited.
On a key with unlimited credits, increment and decrement fail with HTTP 400 err:unkey:application:invalid_input. To give an unlimited key a quota, send set first.
curl -X POST https://api.unkey.com/v2/keys.updateCredits \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_...", "operation": "increment", "value": 5000 }'The root key needs api.*.update_key or api.<api_id>.update_key for both keys.updateKey and keys.updateCredits. See Root key permissions.
Credits vs rate limits
| Credits | Rate limits | |
|---|---|---|
| Controls | Total usage | Usage per time window |
| Resets | Only by refill or updateCredits |
Automatically as the window slides |
| Failure code | USAGE_EXCEEDED |
RATE_LIMITED |
| Checked | Last, and spent only on success | Before credits |
Use both when you sell a quota and also want burst protection. The rate limit stops a client from burning the quota in a second, and credits enforce the quota. See Key and identity rate limits.
Api Management Keys Enable Disable Delete (`docs/api-management/keys/enable-disable-delete.mdx`)
title: Disabling and deleting keys
sources:
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/deleteKey/V2KeysDeleteKeyRequestBody.yaml
- svc/api/routes/v2_keys_update_key/handler.go
- svc/api/routes/v2_keys_delete_key/handler.go
- internal/services/keys/get.go
- internal/services/keys/status.go
- internal/services/caches/caches.go
- pkg/cache/cache.go
- web/apps/dashboard/components/api-keys-table/components/actions/keys-table-action.popover.constants.tsx
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
To stop a key from working, disable it or delete it. Disable when it might be temporary, like a failed payment or an investigation. The key keeps its settings and works again when you enable it. Delete when the key should never work again.
Disabling and enabling use keys.updateKey and need api.*.update_key or api.<api_id>.update_key. Deleting uses keys.deleteKey and needs api.*.delete_key or api.<api_id>.delete_key. See Root key permissions.
Disable or enable a key
In the dashboard, open the actions menu on the key's row or on its detail page and choose Disable Key or Enable Key. From the API, set enabled on keys.updateKey:
false suspends the key and true restores it. Omitting the field leaves the state unchanged.
curl -X POST https://api.unkey.com/v2/keys.updateKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_...", "enabled": false }'While a key is disabled, every verification returns valid: false with code: DISABLED, and it doesn't use up rate limits or credits.
{
"meta": { "requestId": "req_..." },
"data": { "valid": false, "code": "DISABLED", "keyId": "key_...", "enabled": false }
}Send "enabled": true to restore the key with all its settings as they were. You can also create a key disabled, with "enabled": false on keys.createKey, for example when access needs approval first.
Delete a key
In the dashboard, choose Delete key from the same actions menu. From the API, call keys.deleteKey:
The key to delete.
false, the default, deletes the key but keeps a record of it for your audit trail. true erases it completely, so the same key string could be issued again later. Use true for regulatory erasure requests. The dashboard always uses the default.
curl -X POST https://api.unkey.com/v2/keys.deleteKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_..." }'After deletion, verification returns code: NOT_FOUND and the key disappears from listings. The key.delete audit log event says whether it was permanent. Deletion can't be undone.
How quickly it takes effect
A disable, enable, or delete takes about 10 seconds to reach verification, and a few verifications just after that can still see the old state. You can't speed it up. If a key must stop working the instant you say so, your own service has to stop accepting it too.
Revoke everything a user owns
To find the keys linked to an identity, filter apis.listKeys by externalId, then delete them one by one.
let cursor: string | undefined;
do {
const { data, pagination } = await unkey.apis.listKeys({
apiId: "api_...",
externalId: "user_123",
cursor,
});
for (const key of data) {
await unkey.keys.deleteKey({ keyId: key.keyId });
}
cursor = pagination.hasMore ? pagination.cursor : undefined;
} while (cursor);Disable, delete, or reroll
| Situation | Use |
|---|---|
| Billing problem, investigation, pause | Disable, then enable later. |
| User left, key no longer needed | Delete. |
| Key leaked but the user should keep working | Reroll: a new key with the same configuration and a grace period for the old one. |
| Erasure request, or you must reuse the exact key string | Delete with permanent: true. |
Api Management Keys Expiration (`docs/api-management/keys/expiration.mdx`)
title: Key expiration
sidebarTitle: "Expiration"
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_update_key/handler.go
- internal/services/keys/get.go
- internal/services/keys/status.go
- internal/services/caches/caches.go
- pkg/cache/cache.go
- svc/ctrl/worker/cron/cron.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
Set an expiry to make a key stop working at a time you choose. After that time, every verification returns code: EXPIRED. Nothing else about the key changes, so you can extend an expired trial key and it keeps its metadata, credits, and permissions.
Set an expiry
Unix timestamp in milliseconds. The maximum is 4102444800000 (1 January 2100). On keys.updateKey, omitting the field keeps the current expiry and null removes it. A timestamp in the past is accepted and makes the key expired at once.
# expire 24 hours from now
EXPIRES=$(( $(date +%s) * 1000 + 86400000 ))
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d "{ \"apiId\": \"api_...\", \"expires\": $EXPIRES }"In the dashboard's Create key dialog, the expiry must be at least two minutes in the future.
What happens at expiry
Expiry is checked against Unkey's clock, not your server's. An expired key uses no rate limits or credits. The response looks like this:
{
"meta": { "requestId": "req_..." },
"data": {
"valid": false,
"code": "EXPIRED",
"keyId": "key_...",
"enabled": true,
"expires": 1704067200000
}
}The expires value is also returned on valid verifications, so your backend can warn a user that their key is about to lapse.
Expired keys are kept
Expired keys aren't deleted. They stay in the keyspace, show up in apis.listKeys and the dashboard, and keep returning EXPIRED. You can extend one and keep its analytics history. To remove expired keys, delete them yourself. See Disabling and deleting keys.
Extend or remove an expiry
# extend by 7 days from now
EXPIRES=$(( $(date +%s) * 1000 + 604800000 ))
curl -X POST https://api.unkey.com/v2/keys.updateKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d "{ \"keyId\": \"key_...\", \"expires\": $EXPIRES }"# make the key permanent
curl -X POST https://api.unkey.com/v2/keys.updateKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_...", "expires": null }'Extending an expired key takes about 10 seconds to apply, and a few verifications just after that can still return EXPIRED. Verifying keys explains the timing.
Expiry and rerolling
Rerolling a key copies its expiry to the new key and sets the old key to expire after the grace period you choose. In the dashboard you can't reroll an expired key, and the grace period can't go past the original expiry. The API applies the grace period as requested. See Rerolling keys.
Expiry compared with credits
An expiry ends access at a time. Credits end it after an amount of use. For a trial that ends after seven days or 1,000 requests, whichever comes first, set both. See Credits and refill.
Api Management Keys Looking Up Keys (`docs/api-management/keys/looking-up-keys.mdx`)
title: Looking up keys
sources:
- svc/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/whoami/V2KeysWhoamiResponseBody.yaml
- svc/api/openapi/spec/common/KeyResponseData.yaml
- svc/api/openapi/spec/common/KeyCreditsData.yaml
- svc/api/openapi/spec/common/RatelimitResponse.yaml
- svc/api/openapi/spec/common/Identity.yaml
- svc/api/routes/v2_keys_get_key/handler.go
- svc/api/routes/v2_keys_whoami/handler.go
- svc/ctrl/worker/cron/cron.go
- svc/ctrl/worker/cron/keylastusedsync/handler.go
- dev/k8s/charts/restate-cronjobs/values.yaml
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Look up a key's settings without verifying it or spending anything. Use keys.getKey when you have the key's ID, for example on a management screen. Use keys.whoami when you have the key itself, for example when a user pastes it into a support form. Both return the same fields, except keys.whoami can't return the decrypted key.
Both endpoints need api.*.read_key or api.<api_id>.read_key. Adding decrypt: true to keys.getKey additionally needs api.*.decrypt_key or api.<api_id>.decrypt_key. See Root key permissions.
By identifier
The key_... identifier from keys.createKey, a verification response, a listing, or the dashboard. 3 to 255 characters matching ^[a-zA-Z0-9_]+$.
Include the plaintext for a key created as recoverable. See Recoverable keys.
curl -X POST https://api.unkey.com/v2/keys.getKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_..." }'A key that doesn't exist, is deleted, or belongs to another workspace returns HTTP 404 err:unkey:data:key_not_found.
By plaintext
The full key including its prefix, 1 to 512 characters. Any change to it gives a not-found error.
curl -X POST https://api.unkey.com/v2/keys.whoami \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "sk_live_..." }'keys.whoami doesn't check whether the key is enabled, expired, or within its limits, and it doesn't record a verification. Use keys.verifyKey when you need a verdict.
Response fields
{
"meta": { "requestId": "req_..." },
"data": {
"keyId": "key_...",
"start": "sk_live_abc1",
"enabled": true,
"name": "Acme production",
"createdAt": 1704067200000,
"lastUsedAt": 1704153600000,
"expires": 1767225600000,
"meta": { "plan": "pro" },
"credits": {
"remaining": 9420,
"refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
},
"ratelimits": [
{ "id": "rl_...", "name": "requests", "limit": 100, "duration": 60000, "autoApply": true }
],
"permissions": ["documents.read", "documents.write"],
"roles": ["editor"],
"identity": { "id": "id_...", "externalId": "org_acme", "meta": {} }
}
}To page through many keys at once, use apis.listKeys, which returns the same objects. See Listing keys.
Api Management Keys Metadata And Tags (`docs/api-management/keys/metadata-and-tags.mdx`)
title: Metadata and tags
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/common/Identity.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_update_key/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- internal/services/keys/verifier.go
- pkg/clickhouse/schema/001_key_verifications_raw_v2.sql
- web/internal/db/src/schema/keys.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
Metadata is data you store on a key, like a plan tier. It comes back on every verification, so your backend doesn't need a database lookup. Tags are labels you send with each verification, like the endpoint called. They go only to analytics, so you can break usage down later.
Metadata
A JSON object with at most 100 top-level properties. Nesting is allowed. Set it on keys.createKey or replace it on keys.updateKey, where leaving it out keeps the current value and null removes it. Every keys.verifyKey response returns it as data.meta.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"meta": { "plan": "pro", "features": ["exports", "webhooks"], "billingEmail": "[email protected]" }
}'Keep metadata to what your request handler needs right away, like a plan tier, feature flags, or a tenant ID. Anything large slows every verification, and any service that calls keys.verifyKey can see it, so don't store secrets. To change one property, send the whole object again.
Metadata on an identity comes back separately as data.identity.meta. Put facts shared by all of a user's keys there, and key-specific facts on the key.
Tags
On keys.verifyKey. Up to 20 strings of 1 to 512 characters each. They're recorded with the verification and don't change the result.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "sk_live_...",
"tags": ["endpoint=/documents", "method=GET", "region=eu-west-1"]
}'Tags land in the tags column of the verifications table in analytics, so you can break usage down by endpoint, client version, or anything else. A key=value format makes them easy to filter. Don't put anything sensitive in a tag, because tags show up in analytics and the dashboard's verification logs. The key verifications table shows how to query them.
Which one to use
| Need | Use |
|---|---|
| Know the caller's plan or flags while handling the request | meta on the key, or meta on the identity if it is shared by all of a user's keys |
| Break usage reports down by endpoint, version, or region | tags on each verification |
| Tell live keys from test keys in your code | meta, since the dashboard environment label is not returned by verification |
Api Management Keys Migrating Keys (`docs/api-management/keys/migrating-keys.mdx`)
title: Migrating existing keys
sidebarTitle: "Migrating keys"
sources:
- svc/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeyData.yaml
- svc/api/openapi/spec/paths/v2/keys/migrateKeys/V2KeysMigrateKeysResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/routes/v2_keys_migrate_keys/handler.go
- svc/api/routes/v2_keys_verify_key/handler.go
- internal/services/keys/get_migrated.go
- internal/services/keys/db/models_generated.go
- pkg/hash/sha256.go
- web/internal/db/src/schema/keys.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Move homegrown API keys to Unkey by sending the hash of each key, never the key itself. Users keep the keys they already have. After the import, your backend calls keys.verifyKey instead of looking keys up in your own table.
After the move, you no longer store plaintext keys or write the hash comparison. If you switch those checks over too, you also stop running your own rate-limit counters and monthly usage counters. You still run your API, unless you deploy it on Compute, and you still return 401 and 429 to your users. Unkey stores a SHA-256 hash of the full key, base64 encoded, and compares that on verification. See How Unkey stores API keys.
Agree the hash format with Unkey before the import. The two formats are below.
Get a migration ID
Email [email protected] with your workspace ID, the system the keys come from, and how they're hashed. Support sends a migrationId. Two hash formats work today.
| Algorithm | What you send as hash |
Verification |
|---|---|---|
sha256 |
SHA-256 of the full key string, base64 encoded. This is Unkey's native format. | Works immediately with a plain keys.verifyKey; no migrationId needed at verify time. |
github.com/seamapi/prefixed-api-key |
SHA-256 of the key's long token only, hex encoded, as that library stores it. Keys look like prefix_shortToken_longToken. |
Send migrationId on keys.verifyKey until each key has been verified once. After that the key verifies without it. |
Keys hashed any other way, such as bcrypt or a salted hash, can't be imported from the hash alone. Tell support, and the migration needs another plan.
Hash the keys yourself
For sha256, this is the same function Unkey uses, so a key hashed this way works exactly like one Unkey created.
import { createHash } from "node:crypto";
function hashKey(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("base64");
}
hashKey("sk_live_abc"); // "5Q7m..." base64, not hexNever include plaintext keys in the export or the migration request.
keys.migrateKeys needs api.*.create_key or api.<api_id>.create_key. Verifying afterwards needs the usual api.*.verify_key. See Root key permissions.
Import the hashes
The identifier from support, 3 to 255 characters. An unknown ID returns HTTP 404 err:unkey:data:migration_not_found.
The keyspace to import into.
At least one entry. Each has a required hash (3 or more characters, in your migration's format) and the same optional fields as keys.createKey: name, externalId, meta, roles (up to 100), permissions (up to 1000), expires, enabled (default true), credits, and ratelimits (up to 50). The rules match Creating keys.
curl -X POST https://api.unkey.com/v2/keys.migrateKeys \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"migrationId": "<from support>",
"apiId": "api_...",
"keys": [
{
"hash": "xfV3...base64...=",
"name": "Acme production",
"externalId": "org_acme",
"meta": { "migratedFrom": "legacy" },
"ratelimits": [
{ "name": "requests", "limit": 1000, "duration": 60000, "autoApply": true }
]
}
]
}'Response
The response is HTTP 200 even when some hashes fail. A hash that already exists anywhere in Unkey, not just in your workspace, is skipped and listed under failed. Every other key is created and returned with its new keyId. Store it next to your own record of the key.
{
"meta": { "requestId": "req_..." },
"data": {
"migrated": [
{ "hash": "xfV3...base64...=", "keyId": "key_..." }
],
"failed": [
"duplicate...hash...="
]
}
}Send large migrations in batches and reconcile migrated against your export after each one.
Switch verification over
Point your backend at keys.verifyKey. For the sha256 algorithm nothing else changes. For the prefixed-api-key algorithm, include the migrationId in every verification during the roll-out:
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "prefix_short_long", "migrationId": "<from support>" }'After a key's first verification with migrationId, it works with or without the field. A key that hasn't been verified yet still needs it and returns NOT_FOUND without it, so keep sending migrationId until the fallback below goes quiet.
Run both systems during the switch
Verify against Unkey first. If a key comes back NOT_FOUND, check your old system and log it. Remove the fallback when the log goes quiet. Create new keys with keys.createKey so new users are never in the old system.
Api Management Keys Recoverable Keys (`docs/api-management/keys/recoverable-keys.mdx`)
title: Recoverable keys
sources:
- svc/api/openapi/spec/paths/v2/keys/createKey/V2KeysCreateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/getKey/V2KeysGetKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysRequestBody.yaml
- svc/api/openapi/spec/common/KeyResponseData.yaml
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_get_key/handler.go
- svc/api/routes/v2_keys_reroll_key/handler.go
- web/internal/db/src/schema/keyAuth.ts
- web/internal/db/src/schema/keys.ts
- web/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
A recoverable key is one you can show again after it's created. By default Unkey keeps only a hash of each key, so once keys.createKey returns it, nobody (including Unkey) can read it again. That's the safest setup and right for most apps: a user who loses a key gets a new one.
Use recoverable keys when showing a key again is worth the extra risk, such as an API playground that needs a working key, or a settings page where users expect to reveal their key. We store an encrypted copy and decrypt it only when you ask. Key storage explains how that copy is protected.
Opt the keyspace in
Recoverable keys need the keyspace's Store encrypted keys setting. It's off for new keyspaces and there's no dashboard toggle, so email support with the keyspace's API ID and we'll turn it on. Keys created before that can't be recovered.
Creating a recoverable key needs api.*.encrypt_key or api.<api_id>.encrypt_key in addition to the create permission. Reading one back needs api.*.decrypt_key or api.<api_id>.decrypt_key in addition to the read permission. Grant these only to root keys that need them. See Root key permissions.
A missing encrypt permission doesn't look like a permission problem. keys.createKey returns HTTP 404 err:unkey:data:api_not_found, "The specified API was not found." If you see this for an API ID you know is correct, check the root key's encrypt_key permission first.
Create a recoverable key
On keys.createKey. When true, an encrypted copy of the key is stored. Fails with HTTP 412 err:unkey:application:precondition_failed when the keyspace doesn't store encrypted keys ("This API does not support key encryption."), and with HTTP 404 err:unkey:data:api_not_found when the root key lacks encrypt_key.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "recoverable": true }'Rerolling a recoverable key produces a recoverable key, so the reroll needs the encrypt permission too.
Read the plaintext back
Both keys.getKey and apis.listKeys accept decrypt.
When true, every returned key that's recoverable has a plaintext field. Other keys come back without it, and the request doesn't fail.
curl -X POST https://api.unkey.com/v2/keys.getKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_...", "decrypt": true }'{
"meta": { "requestId": "req_..." },
"data": {
"keyId": "key_...",
"start": "sk_live_abc1",
"enabled": true,
"createdAt": 1704067200000,
"plaintext": "sk_live_..."
}
}decrypt: true fails with HTTP 412 when the key's keyspace doesn't store encrypted keys, and with a permission error when the root key lacks decrypt_key. Treat any response with plaintext like the original create response: send it over a secure channel, and never log or cache it.
Recoverable keys and rotation
If a key may have leaked, reroll it. Being able to read it again doesn't make it safe. See Rerolling keys.
Api Management Keys Rerolling Keys (`docs/api-management/keys/rerolling-keys.mdx`)
title: Rerolling keys
sources:
- svc/api/openapi/spec/paths/v2/keys/rerollKey/V2KeysRerollKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/rerollKey/V2KeysRerollKeyResponseData.yaml
- svc/api/routes/v2_keys_reroll_key/handler.go
- web/apps/dashboard/components/api-keys-table/components/actions/components/rotate-key/rotate-key.constants.ts
- web/apps/dashboard/lib/trpc/routers/key/reroll/index.ts
- web/apps/dashboard/components/api-keys-table/components/actions/keys-table-action.popover.constants.tsx
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Rerolling gives a key a new secret and keeps its settings. Unkey creates a new key with the same settings, and the old key keeps working for a grace period you choose. Use it for scheduled rotation, after a suspected leak, or when a user asks for a fresh key.
The root key needs api.*.create_key or api.<api_id>.create_key. When the original key is recoverable, it also needs api.*.encrypt_key or api.<api_id>.encrypt_key. See Root key permissions.
Request
The identifier of the key to reroll (key_...), not the key string itself.
Milliseconds from now until the old key stops working. The maximum is 4102444800000. The time is rounded up to the next whole minute, so expiration: 1 can leave the old key working for up to a minute. Only 0 revokes it immediately. Set it to null to keep the old key working; it keeps its current expiry, if it has one.
curl -X POST https://api.unkey.com/v2/keys.rerollKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "keyId": "key_...", "expiration": 86400000 }'Response
{
"meta": { "requestId": "req_..." },
"data": {
"keyId": "key_...",
"key": "sk_live_..."
}
}key is the new key, and this is the only time it's returned. keyId is the new key's ID. The old key keeps its own keyId until it expires.
What the new key copies
The new key copies the old key's enabled state, name, metadata, identity, expiry, credits and refill schedule, roles, permissions, and rate limits. If the old key was recoverable, so is the new one.
The new key gets a new keyId and secret. It keeps the old key's prefix, or uses the keyspace's default prefix if it had none. Its length is the keyspace's current default, or 16 bytes if none is set, so a key created with a custom byteLength can change length.
Analytics by identity carry on across the reroll. Analytics by keyId start fresh for the new key.
What happens to the original
The old key's expiry becomes now plus expiration, rounded up to the next minute. With null, the old key's expiry does not change. With 0, its next verification returns code: EXPIRED. The API applies the grace period even if the old key was due to expire sooner, so a reroll can extend its life. Check the old key's expires first if that matters.
The event is recorded in the audit log as key.reroll.
From the dashboard
Choose Rotate key from the key's actions menu. Pick a grace period: revoke immediately, 15 minutes, 1 hour, 6 hours, 24 hours (the default), or Do not expire original key. That last option keeps the old key working with its current expiry. The dashboard is stricter than the API: you can't rotate an expired key, and the grace period can't go past the key's original expiry. The new key is shown once.
Reroll or reissue
Reroll when the user should keep working without a break. keys.rerollKey can't change settings, so to change them too, update the new key with keys.updateKey afterward, or create a new key and delete the old one.
Api Management Keys Updating Keys (`docs/api-management/keys/updating-keys.mdx`)
title: Updating keys
sources:
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsData.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/UpdateKeyCreditsRefill.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyResponseBody.yaml
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/openapi/spec/common/KeyCreditsData.yaml
- svc/api/routes/v2_keys_update_key/handler.go
- svc/api/routes/v2_keys_update_key/three_state_test.go
- svc/api/routes/v2_keys_update_key/400_test.go
- svc/api/routes/v2_keys_create_key/handler.go
- pkg/auditlog/events.go
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Use keys.updateKey to change what a key can do without issuing a new one. The key string stays the same, so your users keep working. Only keyId is required.
For every other field:
- Leave it out to keep the current value.
- Send a value to replace it.
- Send
nullto clear it, for example to remove an expiry, a refill schedule, or an identity link.
The root key needs api.*.update_key or api.<api_id>.update_key. Without it you get HTTP 403 err:unkey:authorization:insufficient_permissions. A keyId that doesn't exist or belongs to another workspace returns HTTP 404 err:unkey:data:key_not_found. See Root key permissions.
Request fields
The key's ID (key_...), not the key string. 3 to 255 characters matching ^[a-zA-Z0-9_]+$.
1 to 255 characters. null removes the name.
1 to 255 characters matching ^[a-zA-Z0-9_.-]+$. Moves the key to the identity with that externalId, creating the identity in the keyspace's project if it doesn't exist yet. An existing identity in a different project is rejected with HTTP 404 err:unkey:data:identity_not_found. null unlinks the key from its identity. See Identities.
JSON with at most 100 top-level properties. It replaces the old metadata rather than merging. null removes it. See Metadata and tags.
Unix timestamp in milliseconds, at most 4102444800000 (1 January 2100). null makes the key permanent. A timestamp in the past is accepted and expires the key at once. See Key expiration.
credits.remaining (integer or null, 0 or more) and credits.refill (object or null). Unlike keys.createKey, neither is required, so you can change a schedule without resending the balance. See Credits and refill on update. See Credits and refill.
Up to 50 limits, each with name (3 to 128 characters), limit (1 or more), duration in milliseconds (1000 or more), and autoApply. The list replaces the key's limits rather than adding to them. null or [] removes them all. See Key and identity rate limits.
false suspends the key and true restores it. Not nullable. See Disabling and deleting keys.
Up to 100 role names, each 1 to 128 characters. The list replaces the key's roles. Not nullable, so send [] to detach them all.
Up to 1000 permission slugs, each 1 to 128 characters matching ^[a-zA-Z0-9_:\-\.\*]+$. The list replaces the key's direct permissions. Not nullable, so send [] to detach them all.
Credits and refill on update
The balance is credits.remaining and the schedule is credits.refill. Clearing one sometimes clears the other:
| Sent | Balance | Refill schedule |
|---|---|---|
credits omitted |
unchanged | unchanged |
credits: null |
cleared, key becomes unlimited | cleared |
credits.remaining: null |
cleared, key becomes unlimited | cleared |
credits.remaining: 500 |
set to 500 | unchanged |
credits.refill: null |
unchanged | cleared |
credits.refill: { ... } |
unchanged | replaced |
Removing the balance removes the schedule too. Removing the schedule keeps the balance, so you can stop a subscription from renewing without taking away credits the customer already paid for.
A refill object has interval (daily or monthly) and amount (1 or more). refillDay (1 to 31) is for monthly only. Sending it with daily, or leaving it out with monthly, fails with HTTP 400 err:unkey:application:invalid_input. (keys.createKey ignores refillDay with daily, so a body that works for create can fail here.)
To change only the balance, use keys.updateCredits.
Lists replace, they don't merge
ratelimits, roles, and permissions are each the full list you want the key to end up with. Send every entry you want to keep, not just the new ones.
- Rate limits are matched by
name. A limit you send keeps its ID and gets the new values. A limit you leave out is deleted. - Roles must already exist. The first one that doesn't fails the request with HTTP 404
err:unkey:data:role_not_found. - Permissions that don't exist yet are created for you. This list only replaces the key's direct permissions, not what its roles grant.
To add or remove one entry without resending the list, use keys.addPermissions, keys.removePermissions, keys.addRoles, or keys.removeRoles instead. Managing key permissions compares them.
Example
Move a key to the paid plan, extend it, and stop it expiring:
curl -X POST https://api.unkey.com/v2/keys.updateKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"keyId": "key_...",
"meta": { "plan": "pro" },
"expires": null,
"credits": {
"remaining": 10000,
"refill": { "interval": "monthly", "amount": 10000, "refillDay": 1 }
},
"ratelimits": [
{ "name": "requests", "limit": 1000, "duration": 60000, "autoApply": true }
]
}'Response
{
"meta": { "requestId": "req_..." },
"data": {}
}The response has no key data. Use keys.getKey to read the result.
When the change takes effect
An update applies in full or not at all, so an error leaves the key as it was.
Changes take about 10 seconds to reach verification, and a few verifications just after that can still see the old settings. A new credit balance applies right away. Verifying keys has the details.
Each update writes a key.update audit event, plus a permission.create event for each permission it created.
What you can't change
You can't change apiId, prefix, byteLength, or recoverable after a key is created. To replace the key string while keeping the same configuration, see Rerolling keys. To start over, create a new key and delete the old one.
Api Management Keys Verifying Keys (`docs/api-management/keys/verifying-keys.mdx`)
title: Verify API keys with Unkey
sidebarTitle: "Verifying keys"
sources:
- svc/api/routes/v2_keys_verify_key/handler.go
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyRequestBody.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyCredits.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/index.yaml
- internal/services/keys/get.go
- internal/services/keys/verifier.go
- internal/services/keys/validation.go
- internal/services/keys/status.go
- internal/services/caches/caches.go
- pkg/cache/cache.go
- internal/services/ratelimit/ratelimit.go
- pkg/rbac/parser.go
- svc/frontline/internal/policies/keyauth/executor.go
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Unkey checks the key your user sent by calling keys.verifyKey from your own server. One call runs expiry, an IP allow list when the keyspace has one, permissions, rate limits, and credits, then returns data.valid and a code. The HTTP status stays 200 when the key fails, and your server returns 401 or 429 to the caller. On Compute, the API key authentication policy runs the same checks at the gateway and returns 401, 403 or 429 itself.
What the check tells you
Read data.code. These are the outcomes you usually branch on. The full list, including the status to return, is under Code values.
| You need to know | What Unkey returns |
|---|---|
| Is the key allowed? | valid: true and code: VALID |
| Is it missing, disabled, or expired? | NOT_FOUND, DISABLED, or EXPIRED |
| Is the caller blocked? | FORBIDDEN when the client IP is off the keyspace allow list, or the workspace is disabled |
| Does it have permission? | INSUFFICIENT_PERMISSIONS when the permissions query fails |
| Is it over a rate limit? | RATE_LIMITED. That request doesn't spend credits |
| Has it used its quota? | USAGE_EXCEEDED when credits can't cover the cost |
| Should the gateway check the key? | The API key authentication policy runs the same checks and returns 401, 403, or 429 itself |
Verify an API key for your public API by calling keys.verifyKey with the key your user sent. This is the API Management endpoint. Use it when your own server, not the Compute gateway, decides whether the request is allowed.
Send the key exactly as your user sent it. Unkey runs the checks set on the key, plus any you add to the request, and returns one answer. The response is HTTP 200 for every outcome, including an invalid key, a rate limit, and a used-up credit balance. Read data.valid and data.code, not the status code. Your server then returns 401 or 429 to the caller. See Rate limiting for Retry-After.
The root key needs api.*.verify_key or api.<api_id>.verify_key for the key's keyspace. Without it you get a 200 with code: NOT_FOUND, not a permission error. A missing or invalid root key gets HTTP 401. See Root key permissions.
Request
The key your user sent, 1 to 512 characters, including its prefix. Any change to the string gives NOT_FOUND. You don't pass an API ID because the key already belongs to one keyspace.
1 to 5 keyspace IDs (ks_...), each up to 100 characters, matched exactly. The key must belong to one of them or the result is NOT_FOUND. Use it when one root key verifies several keyspaces but an endpoint should only accept keys from some of them. An empty array is HTTP 400.
A permission query, 1 to 1000 characters, such as documents.read AND (billing.read OR billing.write). AND binds tighter than OR, and parentheses group. Fails the verification with INSUFFICIENT_PERMISSIONS when the key's direct and role-derived permissions don't satisfy it. A malformed query is HTTP 400 err:user:bad_request:permissions_query_syntax_error. Asterisks inside slugs are matched literally. See Permission queries.
credits.cost (integer, 0 to 1,000,000,000,000) is how many credits this verification spends. Omitting the object spends 1 on keys that have credits configured and nothing on unlimited keys. cost: 0 checks the key without spending. See Credits and refill.
Rate limits to check on this call. Each entry names a limit configured on the key or its identity (name, 3 to 255 characters) and may pass cost (default 1). Naming a limit that doesn't exist on the key or its identity fails the whole request with HTTP 412 err:unkey:application:precondition_failed. An entry that also carries both limit and duration defines an inline, key-scoped limit for this call instead. Limits with autoApply: true are checked whether or not you list them.
An inline limit must be 1 or more and duration at least 1000 milliseconds. Values below that aren't rejected with an error. Instead the verification skips rate limiting entirely, including the auto-applied limits, and can still return VALID. Check these two values in your own code before you send them.
Up to 20 strings of up to 512 characters, recorded with the verification for analytics only. They never affect the verdict. See Metadata and tags.
Up to 256 characters. Lets Unkey match a key imported from another provider's hash format on its first use and convert it to the native hash. See Migrating existing keys.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "sk_live_...",
"permissions": "documents.read",
"credits": { "cost": 2 },
"ratelimits": [{ "name": "requests" }],
"tags": ["endpoint=/documents", "method=GET"]
}'Order of checks
Checks run in this order and stop at the first failure. The order decides which code you get, and whether a rate limit or credits get used up.
- Existence. The key must exist, its workspace and keyspace must not be deleted, and your root key must be allowed to verify keys in that keyspace. Anything else is
NOT_FOUND. - Keyspace restriction. If the request lists
keyspacesand the key's keyspace isn't one of them, the result isNOT_FOUNDwith no key details. - Workspace. A disabled workspace produces
FORBIDDEN. - Enabled. A key with
enabled: falseproducesDISABLED. - Expiration. A key whose
expiresis in the past producesEXPIRED. - IP allow list. If the keyspace has one, the request's client IP must be on it, or the result is
FORBIDDEN. - Permissions. If the request carries a
permissionsquery, the key must satisfy it, or the result isINSUFFICIENT_PERMISSIONS. - Rate limits. Auto-applied limits and limits named in the request are checked together. If any is exceeded, the result is
RATE_LIMITED, and the other limits aren't used up. - Credits. Only if everything above passed, the cost is deducted. Not enough credits gives
USAGE_EXCEEDEDand nothing is deducted.
So a rate-limited request doesn't spend credits, but a request that runs out of credits still counts against its rate limits.
Response
Code values
code |
Meaning | Typical HTTP status to return to your user |
|---|---|---|
VALID |
All checks passed. | proceed |
NOT_FOUND |
No such key; the key belongs to a deleted keyspace or a workspace your root key cannot verify for; or the request's keyspaces list excluded it. |
401 |
DISABLED |
The key has enabled: false. |
401 or 403 |
EXPIRED |
The key's expires has passed. |
401 |
FORBIDDEN |
Client IP not on the keyspace's allow list, or the workspace is disabled. | 403 |
INSUFFICIENT_PERMISSIONS |
The permissions query was not satisfied. |
403 |
RATE_LIMITED |
At least one checked rate limit is exceeded. | 429 |
USAGE_EXCEEDED |
Not enough credits for the requested cost. | 402 or 429 |
Why some failures say NOT_FOUND
Unkey never tells a caller that a key exists unless they're allowed to verify it. So you get a 200 with code: NOT_FOUND when the key belongs to another workspace, its keyspace was deleted, your root key has no verify permission for its keyspace, or your keyspaces list leaves it out. The response looks the same in every case. If you get NOT_FOUND for a key you know exists, check your root key's permissions first.
How quickly changes take effect
A change to a key (disable, delete, new expiry, new permissions or roles) takes about 10 seconds to reach verification, and a few verifications just after that can still see the old state. Plan incident runbooks around a little more than 10 seconds, not an instant cutoff.
Credit balances change instantly. Rate limit counts sync across regions within moments, so remaining is approximate. See How rate limiting works.
What gets recorded
Every verification, whatever its outcome, is recorded in analytics with its outcome, key, identity, tags, credits spent, and latency. Verifications made with a root key also produce a key.verify audit log event. See Analytics and Audit logs.
Api Management Keyspaces Listing Keys (`docs/api-management/keyspaces/listing-keys.mdx`)
title: Listing keys
sources:
- svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apis/listKeys/V2ApisListKeysResponseBody.yaml
- svc/api/openapi/spec/common/KeyResponseData.yaml
- svc/api/openapi/spec/common/Pagination.yaml
- svc/api/routes/v2_apis_list_keys/handler.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/filters.schema.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Use apis.listKeys to list the keys in a keyspace, up to 100 per page. It's handy for a "your API keys" screen, for finding every key a user owns, or for auditing a keyspace. It only returns the full key for recoverable keys, and only when you ask.
By default, keys are read from a replica, which may not reflect recent changes. Set revalidateKeysCache: true to read keys and their identities from the primary database instead, including when you filter by externalId. Primary reads can have higher latency, depending on your region.
The root key needs two permissions: api.*.read_key or api.<api_id>.read_key, and api.*.read_api or api.<api_id>.read_api. Adding decrypt: true additionally needs api.*.decrypt_key or api.<api_id>.decrypt_key. See Root key permissions.
A missing permission isn't a 403. You get HTTP 404 err:unkey:data:api_not_found, "The requested API does not exist or has been deleted." If you see this for an ID you know is correct, check both permissions on the root key.
Request
The keyspace to list, by API ID.
Keys per page, 1 to 100.
The pagination.cursor from the previous response. Omit for the first page.
Only keys linked to the identity with this exact externalId. Use it to list one user's keys.
Include plaintext for keys created with recoverable: true. Requires the decrypt permission and a keyspace with encrypted storage enabled. See Recoverable keys.
Read from the primary database to avoid replica lag. This can increase latency.
curl -X POST https://api.unkey.com/v2/apis.listKeys \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "apiId": "api_...", "externalId": "user_123", "limit": 50 }'Response
data is an array of key objects, and pagination tells you whether to continue.
{
"meta": { "requestId": "req_..." },
"data": [
{
"keyId": "key_...",
"start": "sk_prod_abc1",
"enabled": true,
"name": "Production key",
"createdAt": 1704067200000,
"expires": 1735689600000,
"meta": { "plan": "premium" },
"credits": { "remaining": 950 },
"identity": { "id": "id_...", "externalId": "user_123" }
}
],
"pagination": { "cursor": "...", "hasMore": true }
}Each item has the same fields keys.getKey returns. start is the prefix and first few characters, so a user can recognize the key. plaintext appears only when decrypt: true worked for that key. Looking up keys lists every field.
Paging
Send pagination.cursor back until hasMore is false. Cursors expire, so don't store them.
let cursor: string | undefined;
do {
const { data, pagination } = await unkey.apis.listKeys({
apiId: "api_...",
externalId: "user_123",
cursor,
});
for (const key of data) {
console.log(key.keyId, key.start);
}
cursor = pagination.hasMore ? pagination.cursor : undefined;
} while (cursor);In the dashboard
The keyspace's Keys tab shows the same list. You can filter by key ID, name, identity, and tags (is, contains, starts with, ends with). Select several rows to change their external ID, enable or disable them, or delete them in bulk.
Api Management Keyspaces Settings (`docs/api-management/keyspaces/settings.mdx`)
title: Keyspace settings
sidebarTitle: "Settings"
sources:
- web/internal/db/src/schema/keyAuth.ts
- web/internal/db/src/schema/apis.ts
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_reroll_key/handler.go
- svc/api/routes/v2_apis_delete_api/handler.go
- internal/services/keys/get.go
- internal/services/keys/validation.go
- svc/api/openapi/spec/paths/v2/apis/getApi/V2ApisGetApiResponseData.yaml
- web/apps/dashboard/lib/trpc/routers/api/create.ts
- web/apps/dashboard/lib/trpc/routers/api/query-api-key-details.ts
- web/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-prefix.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/default-bytes.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/settings/components/delete-protection.tsx
These settings apply to every key in a keyspace. Change them on the keyspace's Settings page in the dashboard. There's no API for this. For encrypted key storage and the IP allow list, contact support.
Name and identifiers
The label shown in the dashboard. You can change it any time.
Read-only, api_.... Pass it as apiId when creating or listing keys.
Read-only, ks_.... Used by analytics (key_space_id) and the customer portal.
Key generation defaults
These apply when a keys.createKey request leaves out prefix or byteLength.
Up to 16 characters. New keys without their own prefix look like _<random>. Rerolling a key with no prefix also uses it.
Random bytes in a new key when the request leaves out byteLength. The API accepts 16 to 255. Rerolled keys always use this value (or 16 if unset), not the original key's length.
Store encrypted keys
When on, you can create recoverable keys and read them back with decrypt: true. When off, keys.createKey with recoverable: true fails with HTTP 412 "This API does not support key encryption." There's no dashboard toggle. Contact support to turn it on. See Recoverable keys.
IP allow list
A comma-separated list of IP addresses, up to 512 characters. When set, a verification of any key in this keyspace fails with code: FORBIDDEN unless it comes from an IP on the list.
Matching is exact, for IPv4 and IPv6. CIDR ranges such as 10.0.0.0/8 don't work. A request with no client IP is also rejected with FORBIDDEN.
New keyspaces have no allow list. Contact support to set or change one. You can't see the list in the dashboard or the API, so if verifications return FORBIDDEN and you don't know why, ask support whether an allow list is set.
Delete protection
When on, Delete Keyspace in the dashboard and apis.deleteApi both fail with HTTP 412 err:unkey:application:protected_resource. Switch it on the Delete Protection card and type the keyspace name to confirm. The change shows in the audit log as api.update.
Protection covers the keyspace only. You can still delete keys inside it. Delete protection describes the same flag on the other resource types.
Delete keyspace
The Delete Keyspace card in the danger zone deletes the keyspace after you type its name and the confirmation word. Every key in it then verifies as NOT_FOUND. You can't undo this from the dashboard.
Api Management Portal End User Experience (`docs/api-management/portal/end-user-experience.mdx`)
title: What portal users can do
sidebarTitle: "End-user experience"
sources:
- web/apps/portal/src/routes/_portal/keys.tsx
- web/apps/portal/src/components/keys-page/keys-page.tsx
- web/apps/portal/src/components/keys-page/keys-page-model.ts
- web/apps/portal/src/lib/scopes.ts
- web/apps/portal/src/components/analytics/schema/analytics.schema.ts
- svc/api/routes/v2_portal_get_verifications/handler.go
- svc/api/openapi/spec/paths/v2/portal/createSession/V2PortalCreateSessionRequestBody.yaml
- svc/api/openapi/spec/paths/v2/portal/listKeys/V2PortalListKeysRequestBody.yaml
- svc/api/openapi/spec/paths/v2/portal/getVerifications/V2PortalGetVerificationsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/portal/getVerifications/V2PortalGetVerificationsDataPoint.yaml
- svc/api/routes/v2_portal_reroll_key/handler.go
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_delete_key/handler.go
Here's what your users see in the portal. The scopes on their session decide what they can do, and they only ever see their own keys and usage.
One page, shaped by scope
The portal is one Keys page. The scopes decide what's on it:
keys:readshows the key table. Without it the page is empty.keys:rerolladds a roll-over action to each key.analytics:readadds a time filter, usage numbers, and an outcomes chart above the table, plus a request count on each key.
If the token expires while the user is on the page, the portal shows "Your session has expired" with a link to the session's returnUrl.
Keys
The key table lists every key the user owns, with its name, status, creation date, and expiry. The full key is never shown.
With keys:reroll, the user can roll a key over. They pick a grace period, copy the new key (shown once), and the old key keeps working until the grace period ends.
Usage
With analytics:read, the page shows verification counts over a time range, broken down by outcome, for all the user's keys or one of them. The time ranges on offer fit your workspace's log retention.
Building your own portal front end
To build your own interface instead of using the hosted portal, call portal.listKeys, portal.rerollKey, and portal.getVerifications with the session's access token as a bearer token. They only return the session user's data.
List keys
portal.listKeys returns the user's keys, up to 100 per page (limit, default 100, with cursor and hasMore as on every list endpoint). There are no filters.
Roll a key over
portal.rerollKey takes the same body as keys.rerollKey: the key and an expiration grace period for the old key. Another user's key returns not found.
Read usage
portal.getVerifications takes startTime and endTime (Unix milliseconds, start included, end not) and an optional keyId for one key. A window longer than your log retention fails with err:user:bad_request:query_range_exceeds_retention.
The response has one series per key:
bucketMillisis the bucket size (a minute, hour, or day, based on the window).keyshas one entry per key with traffic in the window, with every bucket filled in (zeros included), in time order. Sum them for an account total.- Keys with no traffic are left out. An entry can be for a key that's since been deleted, so don't assume you can look it up in
portal.listKeys.
If more than 150 keys had traffic in the window, the request fails with per_key_breakout_too_large. Narrow the window or pass a keyId.
Each entry's data holds one point per bucket:
Asking for another user's key returns nothing.
Create and delete keys
keys.createKey and keys.deleteKey also accept a portal token, so your interface can let users create and delete their own keys. A new key always belongs to the session's user, whatever the request says. Deleting another user's key returns not found. These actions show in the audit log with a portalEndUser actor.
Api Management Portal Sessions (`docs/api-management/portal/sessions.mdx`)
title: Portal sessions
sidebarTitle: "Sessions"
sources:
- svc/api/routes/v2_portal_create_session/handler.go
- svc/api/routes/v2_portal_exchange_code/handler.go
- svc/api/routes/v2_portal_list_sessions/handler.go
- svc/api/openapi/spec/paths/v2/portal/createSession/V2PortalCreateSessionRequestBody.yaml
- svc/api/openapi/spec/paths/v2/portal/createSession/index.yaml
- svc/api/routes/v2_portal_update_portal/handler.go
- svc/api/routes/v2_portal_delete_portal/handler.go
- pkg/auth/portalrbac/portalrbac.go
- pkg/codes/constants_gen.go
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Sign a user into the developer portal from your backend, with no second login. After they sign in to your app, ask Unkey for a session for that user and redirect their browser to the URL you get back. Your root key never reaches the browser.
Sign a user in
Request
The portal's pc_ ID or slug.
The user's ID in your system, 1 to 256 characters. A session for user_123 shows the keys whose identity is user_123.
One or more of keys:read, keys:reroll, and analytics:read. Scopes decide what the user can see and do. See What portal users can do. keys:reroll and analytics:read each need keys:read too. Without it, the request fails with 400 and err:unkey:application:invalid_input.
Full URL, up to 500 characters, to send the user back to when they leave or the session expires. You set it per session, so different users can go back to different pages.
Create a preview session to try the portal yourself.
Permissions your root key needs
Your root key needs portal.*.create_portal_session or portal..create_portal_session. Without it, the request fails with 404, not 403.
It also needs a matching permission for each scope, on every keyspace the portal shows, either as * or for each api_ ID:
| Scope | Required on the root key |
|---|---|
keys:read |
read_key and read_api |
keys:reroll |
create_key, plus encrypt_key if the keyspace stores encrypted keys |
analytics:read |
read_analytics |
If the root key is missing one, the whole request fails with 403. A disabled portal can't create sessions at all.
What ends a session
A session ends when its 24 hour token expires, or when the portal is disabled, deleted, or pointed at a different keyspace or app. That usually takes effect within about 10 seconds, but can take up to 5 minutes. Re-enabling a disabled portal doesn't bring its sessions back. To log one end user out early, call portal.revokeSession with the portal and their externalId. It ends every live session they have on that portal.
To see who is signed in, call portal.listSessions with the portal. It returns one entry per end user with a session that has not expired or been revoked, including sessions whose portal URL was not opened yet, and each end user's sessions and scopes. Results are ordered by externalId, paginated with cursor, and can be filtered with a case-sensitive search prefix. It needs a grant on the portal's sessions: portal.*.create_portal_session, portal..create_portal_session, or the portal's sessions/* with read or write. Reading the portal itself is not enough. Without it the request fails with 404.
Creating, exchanging, and revoking sessions write portal.session.create, portal.session.exchange, and portal.session.revoke audit log entries. What the user does in the portal is logged under a portalEndUser actor.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | err:unkey:data:portal_not_found |
The portal does not exist, is not in your workspace, or your root key lacks create_portal_session for it. |
| 403 | err:unkey:authorization:insufficient_permissions |
A requested scope needs a keyspace permission the root key lacks. |
| 401 | err:unkey:authentication:portal_session_not_found |
On exchange: the code is expired, used, or unknown. On end-user endpoints: the token is expired or revoked. |
| 400 | err:unkey:application:invalid_input |
keys:reroll or analytics:read was requested without keys:read. |
| 400 | err:unkey:authentication:portal_token_missing |
An end-user endpoint was called without a portal token. |
Api Management Ratelimiting How It Works (`docs/api-management/ratelimiting/how-it-works.mdx`)
title: How rate limiting works
sidebarTitle: "How it works"
sources:
- internal/services/ratelimit/doc.go
- internal/services/ratelimit/ratelimit.go
- internal/services/ratelimit/interface.go
- internal/services/keys/validation.go
- svc/api/routes/v2_ratelimit_limit/handler.go
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- web/internal/api/src/sdk/ratelimit.ts
Unkey counts requests per identifier for you, so there's no counter store to run. Call ratelimit.limit with a user id or customer id, or put the limit on an API key and read it from keys.verifyKey. Both use the same sliding window.
When the identifier is over the limit, Unkey still returns HTTP 200. ratelimit.limit sets data.success to false. keys.verifyKey sets data.code to RATE_LIMITED. Your server returns 429 Too Many Requests to the caller, with Retry-After in seconds. reset in the body is Unix milliseconds. Header names Unkey itself sends are on Rate limiting.
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });
export async function handler(request: Request) {
const userId = request.headers.get("x-user-id") ?? "anonymous";
const result = await unkey.ratelimit.limit({
namespace: "api.requests",
identifier: userId,
limit: 100,
duration: 60_000,
});
if (!result.data.success) {
const retryAfter = Math.max(1, Math.ceil((result.data.reset - Date.now()) / 1000));
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(retryAfter) },
});
}
return new Response("ok");
}A monthly total on an API key is credits, not this window. See Credits and refill. The rest of this page explains how the count is computed.
How requests are counted
A limit is limit requests per duration milliseconds. Unkey uses a sliding window, so a caller can't use a full limit at the end of one window and another full limit right after. It counts the current window plus part of the previous one, based on how much of the current window is left.
previous window current window
|-------------------------------|-------------------------------|
^ now, 25% into the window
effective count = current + previous * (1 - 0.25)
allowed if effective count + cost <= limitFor example, with a limit of 100 per minute, a caller who made 100 requests at the end of one minute still counts as 75 fifteen seconds into the next (100 × 0.75), so the burst can't repeat right away. Denied requests don't count, so retrying while limited doesn't push the reset further away.
Change a limit mid-window
Unkey stores counts per namespace, identifier and duration. The limit isn't part of that key. Unkey compares the stored count with whatever limit arrives on each call, so a new number applies on the very next request.
| What you change | What happens to the count |
|---|---|
limit, with the same namespace, identifier and duration |
Usage so far in the window is kept and checked against the new limit. No reset, and no wait for the window to end. |
duration |
A separate counter starts at zero. Usage under the old window length isn't carried over. |
identifier or namespace |
A separate counter starts at zero. |
For example, a caller on 100 per 5 hours has used 100 when they upgrade to 1,000. If your code sends limit: 1000 with the same identifier and duration, their next request passes and remaining is about 899.
To give upgraded users higher limits right away, look up their plan when you make the call and send that plan's limit, keeping the identifier and duration the same. An override that changes only the limit behaves the same way. An override that changes duration starts a fresh count.
Make some requests cost more
Each check spends cost tokens, 1 by default, so a heavy operation can use more of the limit than a light one. With limit: 100, a caller can make 100 checks at cost 1, 20 at cost 5, or any mix. A cost of 0 reads the current state without spending anything.
When one call checks several limits, a request denied by one limit doesn't use up the others.
How exact the counts are across regions
Each region decides quickly using the traffic it has seen, and regions share counts within moments. In practice:
| Situation | What to expect |
|---|---|
| One region receives most of an identifier's traffic | Enforcement is tight. |
| Traffic is split across regions | A short burst can get through in more than one region before the counts catch up. |
| Part of our infrastructure is down | Limits stay on in each region, but a limit can let through more than usual until counts catch up. |
Rate limits protect you from bursts. They aren't an exact count. Use credits when you need an exact total. See Credits and refill.
What you get back
| Field | Meaning |
|---|---|
success |
Whether this request fit within the limit. In ratelimit.multiLimit the per-check field is passed. |
limit |
The limit that applied: yours, or an override's if one matched. |
remaining |
Tokens left in the current window, as an estimate; 0 when the check failed. |
reset |
Unix milliseconds when the current window ends. Capacity comes back gradually after this, not all at once. |
overrideId |
The override that supplied limit and duration, when one matched; empty otherwise. |
Trust success as the answer. Treat remaining as a close estimate, because another region may have accepted requests that aren't counted yet.
What happens on errors
ratelimit.limit and ratelimit.multiLimit reject an empty identifier, a limit below 1, or a duration under 1000 milliseconds. If the check can't run, they return an error, and your code decides whether to allow or deny.
keys.verifyKey is different. If the rate limit check can't run, the verification carries on as if the limits passed, so an outage can't lock your users out. The same happens with an inline limit below 1 or a duration under 1000 milliseconds, so check those values before you send them. See Verifying keys.
Api Management Ratelimiting Key And Identity Limits (`docs/api-management/ratelimiting/key-and-identity-limits.mdx`)
title: Key and identity rate limits
sidebarTitle: "Key and identity limits"
sources:
- svc/api/openapi/spec/common/RatelimitRequest.yaml
- svc/api/openapi/spec/common/RatelimitResponse.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/KeysVerifyKeyRatelimit.yaml
- svc/api/openapi/spec/paths/v2/keys/verifyKey/V2KeysVerifyKeyResponseData.yaml
- svc/api/openapi/spec/paths/v2/keys/updateKey/V2KeysUpdateKeyRequestBody.yaml
- svc/api/routes/v2_keys_verify_key/handler.go
- svc/api/routes/v2_keys_update_key/handler.go
- internal/services/keys/get.go
- internal/services/keys/validation.go
- internal/services/keys/verifier.go
- internal/services/ratelimit/ratelimit.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema.ts
- svc/api/openapi/spec/common/KeyCreditsRefill.yaml
- web/internal/api/src/sdk/keys.ts
Put a rate limit and a monthly request quota on each customer's API key, and keys.verifyKey enforces both. One call checks the key, the sliding-window limit, and the credit balance, with no Redis to run. To change a customer's numbers, call keys.updateKey. No deploy needed.
The rate limit is a named window on the key, such as 100 requests per 60,000 milliseconds. The monthly quota is credits. Set credits.refill.interval to monthly and the balance refills on refillDay. When the window is used up, data.code is RATE_LIMITED. When credits can't cover the request, data.code is USAGE_EXCEEDED. Unkey still returns HTTP 200, and your server returns the 429.
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });
export async function handler(request: Request) {
const header = request.headers.get("authorization") ?? "";
const key = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : header;
const { data } = await unkey.keys.verifyKey({ key });
if (!data.valid && data.code === "RATE_LIMITED") {
const resetMs = data.ratelimits?.find((limit) => limit.exceeded)?.reset ?? Date.now();
const retryAfter = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(retryAfter) },
});
}
if (!data.valid && data.code === "USAGE_EXCEEDED") {
return new Response("Monthly quota exceeded", { status: 429 });
}
return data.valid ? new Response("ok") : new Response("Unauthorized", { status: 401 });
}Retry-After is in seconds, and RFC 6585 defines both 429 and that header. reset on each checked limit is Unix milliseconds. keys.verifyKey doesn't set Retry-After or X-RateLimit-*. Which headers Unkey does send, including on the Compute gateway, is on Rate limiting.
A limit on an identity counts every key linked to that customer together. A limit on a key counts that key alone.
Define limits
You set limits with ratelimits on keys.createKey, keys.updateKey, identities.createIdentity, and identities.updateIdentity, up to 50 per key or identity. Each entry has:
3 to 128 characters, unique within the key or identity. You refer to the limit by this name at verification time.
Tokens per window, 1 or more.
Window length in milliseconds, 1000 or more.
true checks this limit on every verification. false checks it only when a verification names it. Required: leaving it out fails with HTTP 400 and missing property 'autoApply'.
curl -X POST https://api.unkey.com/v2/keys.createKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"apiId": "api_...",
"ratelimits": [
{ "name": "requests", "limit": 100, "duration": 60000, "autoApply": true },
{ "name": "exports", "limit": 5, "duration": 3600000, "autoApply": false }
]
}'On keys.updateKey and identities.updateIdentity, ratelimits replaces the whole list. Limits you leave out are deleted and matching names are updated. null on keys.updateKey removes them all. Leave the field out to keep limits as they are.
Check limits at verification
Auto-applied limits are always checked. To check any other limit, or to spend a custom cost, list it in the verification's ratelimits array.
The name of a limit on the key or its identity, 3 to 255 characters (saved limit names stop at 128; longer names only work for inline limits).
Tokens to spend on this limit for this verification, 0 or more.
With duration, defines an inline limit instead of referring to a configured one. See below.
Window in milliseconds for an inline limit. Send it together with limit.
curl -X POST https://api.unkey.com/v2/keys.verifyKey \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"key": "sk_live_...",
"ratelimits": [
{ "name": "exports" },
{ "name": "requests", "cost": 3 }
]
}'All listed limits are checked together. If any is exceeded, none is used up, the verification fails with code: RATE_LIMITED, and no credits are spent. Naming a limit that isn't on the key or its identity fails the whole request with HTTP 412 err:unkey:application:precondition_failed.
If the rate limit check can't run, the verification carries on as if the limits passed, so an outage never blocks valid keys.
Inline limits
An entry with both limit and duration is an inline limit, used for this call only. It counts against the key (never the identity), isn't saved, and has an empty id in the response. Use it when your code decides the numbers per endpoint.
{ "key": "sk_live_...", "ratelimits": [ { "name": "search", "limit": 20, "duration": 10000 } ] }What the response contains
Every checked limit appears in data.ratelimits with id, name, limit, duration, remaining, reset (Unix milliseconds), exceeded, and autoApply. If the key has an identity, data.identity.ratelimits lists all of the identity's limits, checked or not.
{
"meta": { "requestId": "req_..." },
"data": {
"valid": false,
"code": "RATE_LIMITED",
"keyId": "key_...",
"ratelimits": [
{ "id": "rl_...", "name": "requests", "limit": 100, "duration": 60000, "remaining": 0, "reset": 1704067260000, "exceeded": true, "autoApply": true }
]
}
}Key limits and identity limits
A limit on a key counts that key alone. A limit on an identity counts all its keys together, so five keys share one budget. If a key and its identity both have a limit with the same name, the key's limit wins, so you can give one key an exception to a shared limit. Shared rate limits across keys walks through the identity side.
From the dashboard
Set the same four fields in the Create key dialog's rate limit step, or with Edit ratelimit on a key or identity.
Api Management Ratelimiting Limit And Multi Limit (`docs/api-management/ratelimiting/limit-and-multi-limit.mdx`)
title: ratelimit.limit and multiLimit
sources:
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/limit/V2RatelimitLimitResponseData.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/limit/index.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/multiLimit/V2RatelimitMultiLimitRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/multiLimit/V2RatelimitMultiLimitCheck.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/multiLimit/V2RatelimitMultiLimitResponseData.yaml
- svc/api/routes/v2_ratelimit_limit/handler.go
- svc/api/routes/v2_ratelimit_multi_limit/handler.go
- internal/services/ratelimit/ratelimit.go
- internal/services/ratelimit/doc.go
- pkg/zen/middleware_metrics.go
- web/internal/db/src/schema/ratelimit.ts
- web/internal/api/src/sdk/ratelimit.ts
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Call ratelimit.limit to rate limit a public API by user id, customer id, or IP address. Unkey keeps the counters, so you don't run Redis. You send the limit and the window on each call, and you return HTTP 429 when data.success is false.
ratelimit.multiLimit runs up to 100 checks in one call, and counts them only if they all pass. The rules live in your code. To change the rule for particular identifiers without a deploy, use overrides. A monthly total on an API key is credits, not this window. See Key and identity rate limits.
import { Unkey } from "@unkey/api";
const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY });
export async function handler(request: Request) {
const userId = request.headers.get("x-user-id") ?? "anonymous";
const { data } = await unkey.ratelimit.limit({
namespace: "api.requests",
identifier: userId,
limit: 100,
duration: 60_000,
});
if (!data.success) {
const retryAfter = Math.max(1, Math.ceil((data.reset - Date.now()) / 1000));
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(retryAfter) },
});
}
return new Response("ok");
}The endpoint returns HTTP 200 either way. data.reset is Unix milliseconds. Retry-After is in seconds, which is what RFC 6585 allows on a 429. Unkey doesn't set Retry-After or X-RateLimit-* on this endpoint. The full header list, including what the Compute gateway sends, is on Rate limiting.
The root key needs ratelimit.*.limit or ratelimit.<namespace_id>.limit for every namespace checked. The first call with a new namespace name creates it, which also needs ratelimit.*.create_namespace.
Namespaces
A namespace names what you're limiting, such as email.send or api.requests. Identifiers are counted separately in each namespace. Refer to a namespace by name (1 to 512 characters, case-sensitive) or ID (rlns_...). A new name is created on first use. Rename or delete namespaces in the dashboard under Ratelimit. A check against a deleted namespace fails with HTTP 410 err:unkey:data:ratelimit_namespace_gone.
ratelimit.limit
Namespace name or ID, 1 to 512 characters.
Who or what you're counting, 1 to 512 characters: a user ID, IP address, tenant, or any stable string.
Maximum tokens per window, 1 or more. A matching override replaces it.
Window length in milliseconds, 1000 (one second) to 2592000000 (30 days). A matching override replaces it.
Tokens this request spends, 0 or more. 0 checks without counting.
curl -X POST https://api.unkey.com/v2/ratelimit.limit \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"namespace": "api.requests",
"identifier": "user_123",
"limit": 100,
"duration": 60000,
"cost": 5
}'Response
{
"meta": { "requestId": "req_..." },
"data": { "success": true, "limit": 100, "remaining": 95, "reset": 1704067260000 }
}Every check is recorded in rate limit analytics and writes a ratelimit.limit audit event. To keep a ratelimit.limit call out of analytics and your API request logs, send the header X-Unkey-Metrics: disabled. ratelimit.multiLimit ignores this header.
ratelimit.multiLimit
Send an array of 1 to 100 objects with the same fields as ratelimit.limit. Use it when one action must pass several limits, for example a per-user and a per-organization limit, or a request count and a token budget.
Each check must have a different combination of namespace, identifier and duration. A repeated combination returns a 400 error with invalid_input before any namespace is created.
curl -X POST https://api.unkey.com/v2/ratelimit.multiLimit \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '[
{ "namespace": "api.requests", "identifier": "user_123", "limit": 100, "duration": 60000 },
{ "namespace": "api.requests", "identifier": "org_acme", "limit": 1000, "duration": 60000 },
{ "namespace": "llm.tokens", "identifier": "user_123", "limit": 50000, "duration": 3600000, "cost": 1200 }
]'If any check fails, none of them count. A request that passes counts against every limit. Overrides apply per check. A deleted namespace in any check fails the whole call with err:unkey:data:ratelimit_namespace_gone.
Response
{
"meta": { "requestId": "req_..." },
"data": {
"passed": false,
"limits": [
{ "namespace": "api.requests", "identifier": "user_123", "passed": true, "limit": 100, "remaining": 99, "reset": 1704067260000 },
{ "namespace": "api.requests", "identifier": "org_acme", "passed": true, "limit": 1000, "remaining": 999, "reset": 1704067260000 },
{ "namespace": "llm.tokens", "identifier": "user_123", "passed": false, "limit": 50000, "remaining": 0, "reset": 1704070800000 }
]
}
}Nothing was counted, so remaining on the passing checks doesn't include this request.
Choosing limits
- Pick the window for the job. Short windows, such as 20 per 10 seconds, stop bursts. Longer windows, such as 1,000 per hour, work as quotas. Windows of a minute or more are more accurate across regions.
- Use
costfor expensive operations instead of a second namespace. - Keep identifiers stable. An identifier with a timestamp or request ID in it is never seen twice, so it limits nothing.
- Send plan limits per call. To raise limits when a user upgrades, send the new
limitwith the same identifier andduration. Usage already counted in the window carries over. See Change a limit mid-window. - Use an override when one customer needs a different number, instead of a code path.
Api Management Ratelimiting Overrides (`docs/api-management/ratelimiting/overrides.mdx`)
title: Rate limit overrides
sidebarTitle: "Overrides"
sources:
- svc/api/openapi/spec/paths/v2/ratelimit/setOverride/V2RatelimitSetOverrideRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/setOverride/V2RatelimitSetOverrideResponseData.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/getOverride/V2RatelimitGetOverrideRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/listOverrides/V2RatelimitListOverridesRequestBody.yaml
- svc/api/openapi/spec/paths/v2/ratelimit/deleteOverride/V2RatelimitDeleteOverrideRequestBody.yaml
- svc/api/openapi/spec/common/RatelimitOverride.yaml
- svc/api/routes/v2_ratelimit_set_override/handler.go
- svc/api/routes/v2_ratelimit_limit/handler.go
- pkg/match/wildcard.go
- pkg/db/queries/ratelimit_override_insert.sql
- web/internal/db/src/schema/ratelimit.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/ratelimits/[namespaceId]/_components/identifier-dialog.tsx
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
An override gives particular identifiers a different limit from the one your code sends. Your app keeps calling ratelimit.limit with its default numbers, and when the identifier matches an override, Unkey uses the override's numbers instead. Use it to raise a partner's limit or throttle an abuser without deploying.
Setting an override needs ratelimit.*.set_override or ratelimit.<namespace_id>.set_override. Reading and listing need ratelimit.*.read_override, and deleting needs ratelimit.*.delete_override.
Set an override
Namespace name or ID, 1 to 512 characters. Unlike ratelimit.limit, this doesn't create a missing namespace. It returns HTTP 404 err:unkey:data:ratelimit_namespace_not_found.
The exact identifier, or a pattern containing *, 1 to 512 characters. Case-sensitive.
Tokens per window for matching identifiers, 1 or more. Don't use 0 to ban someone: the API accepts it, but checks against it fail with a server error. Use 1 with a long duration instead.
Window length in milliseconds, 1000 or more. It may differ from the duration your code sends.
curl -X POST https://api.unkey.com/v2/ratelimit.setOverride \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"namespace": "api.requests",
"identifier": "org_acme",
"limit": 10000,
"duration": 60000
}'{
"meta": { "requestId": "req_..." },
"data": { "overrideId": "rlor_..." }
}Setting an override on an identifier that already has one replaces it. The change shows in the audit log as ratelimit.set_override. It takes up to about a minute to apply everywhere.
How a match is chosen
An override on the exact identifier always wins. If there isn't one, a matching wildcard override applies. In a pattern, * matches any run of characters (including none), and the pattern must match the whole identifier.
| Override | Matches | Does not match |
|---|---|---|
*@acme.com |
[email protected], @acme.com |
[email protected] |
enterprise:* |
enterprise:123, enterprise: |
pro:123 |
user_*_prod |
user_1_prod, user__prod |
user_1_staging |
*suspicious* |
anything containing suspicious |
With *@acme.com at 500 per minute and [email protected] at 10,000, [email protected] gets 10,000, everyone else at acme.com gets 500, and [email protected] gets the numbers your code sent. If two wildcards match the same identifier, there's no rule for which wins, so avoid overlapping wildcards in one namespace.
Read, list, and delete
ratelimit.getOverridetakesnamespaceandidentifierand returns the override that would apply, using the same matching rules:overrideId,identifier(the stored pattern),limit, andduration. If nothing matches, it returns HTTP 404err:unkey:data:ratelimit_override_not_found.ratelimit.listOverridestakesnamespace,limit(1 to 100, default 50), andcursor, and returns the same objects with pagination.ratelimit.deleteOverridetakesnamespaceandidentifier. Matching identifiers go back to your code's numbers (or another matching wildcard) within about a minute.
curl -X POST https://api.unkey.com/v2/ratelimit.deleteOverride \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{ "namespace": "api.requests", "identifier": "org_acme" }'From the dashboard
Under Ratelimit, open the namespace and its Overrides tab to add, edit, or delete overrides. You can also choose Delete Override on a row in the namespace's logs. In the dashboard, the identifier must be at least 2 characters and the limit can't go over 10,000. Overrides set through the API outside those bounds still work.
Patterns that work well
- Per-plan limits. Build identifiers as
${plan}:${userId}and set one wildcard override per plan (free:*,pro:*,enterprise:*). Update overrides from your billing webhooks when customers change plans. - Stop an attack. Set the identifier to a limit of
1over a long duration, and delete the override when the incident ends.
Api Management Sdks Go (`docs/api-management/sdks/go.mdx`)
title: Go SDK
sidebarTitle: "Go"
sources:
- go.mod
- cmd/api/util/client.go
- cmd/api/util/errors.go
- cmd/api/keys/verify_key.go
- cmd/api/keys/create_key.go
- cmd/api/ratelimit/limit.go
- cmd/api/deployments/create_deployment.go
- https://github.com/unkeyed/sdks/blob/49e455e0e6a3204f57ef325054438cf1bd7d6586/api/go/README.md
- https://github.com/unkeyed/sdks/blob/49e455e0e6a3204f57ef325054438cf1bd7d6586/api/go/go.mod
- https://pkg.go.dev/github.com/unkeyed/sdks/api/go/v3
import { versions } from "/snippets/versions.mdx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Use the Go SDK, github.com/unkeyed/sdks/api/go/v3 (current release {versions.goSdk}), to call the Unkey API from Go. The older v1 and v2 module paths are no longer updated.
Install
go get github.com/unkeyed/sdks/api/go/v3It needs a recent Go toolchain (currently Go 1.25).
Construct the client
import (
unkey "github.com/unkeyed/sdks/api/go/v3"
)
client := unkey.New(
unkey.WithSecurity(os.Getenv("UNKEY_ROOT_KEY")),
)WithSecurity sets your root key. To change retries for every call, add unkey.WithRetryConfig(retry.Config{...}). For one call, pass operations.WithRetries(...) as the last argument. Request and response types are in github.com/unkeyed/sdks/api/go/v3/models/components, and typed errors in .../models/apierrors.
Verify a key
import (
"errors"
"github.com/unkeyed/sdks/api/go/v3/models/components"
)
res, err := client.Keys.VerifyKey(ctx, components.V2KeysVerifyKeyRequestBody{
Key: "prod_abc123",
Permissions: unkey.String("documents.read"), // optional permission query
})
if err != nil {
return err
}
if res.V2KeysVerifyKeyResponseBody == nil {
return errors.New("unkey: empty response body")
}
data := res.V2KeysVerifyKeyResponseBody.Data
if !data.Valid {
// data.Code says why: NOT_FOUND, DISABLED, EXPIRED, RATE_LIMITED, ...
}The response field, here V2KeysVerifyKeyResponseBody, is a pointer, so check it for nil even when err is nil. Optional request fields are pointers or slices. Use helpers such as unkey.String and unkey.Bool for pointer values. An invalid key is a successful call with Valid false, not an error. The meaning of each field is covered in Verifying keys and the schema in keys.verifyKey.
Create a key
res, err := client.Keys.CreateKey(ctx, components.V2KeysCreateKeyRequestBody{
APIID: "api_1234abcd",
Prefix: unkey.String("prod"),
Name: unkey.String("Payment service"),
ExternalID: unkey.String("user_42"),
Permissions: []string{"documents.read"},
Enabled: unkey.Bool(true),
})
if err != nil {
return err
}
created := res.V2KeysCreateKeyResponseBody.Data
fmt.Println(created.KeyID)
fmt.Println(created.Key) // returned once; store it or hand it to the user nowByteLength, Meta, Roles, Expires, Credits, Ratelimits, and Recoverable are the remaining fields. Creating keys explains them and keys.createKey lists the schema.
Apply a rate limit
res, err := client.Ratelimit.Limit(ctx, components.V2RatelimitLimitRequestBody{
Namespace: "email.send",
Identifier: "user_42",
Limit: 10,
Duration: 60_000, // milliseconds
})
if err != nil {
return err
}
if !res.V2RatelimitLimitResponseBody.Data.Success {
// Data.Remaining and Data.Reset (Unix ms) tell the caller when to retry
}The endpoint is ratelimit.limit.
Create a deployment
The client covers Compute too. Name the project, app, and environment, and optionally set the source with one of Oci, Git, or Deployment. Leave all three out to use the app's default:
res, err := client.Deployments.CreateDeploymentV3(ctx, components.V3DeploymentsCreateDeploymentRequestBody{
Project: "my-project",
App: "api",
Environment: "production",
Oci: &components.DeploymentSourceOCI{Image: "ghcr.io/acme/api:v1.0.0"},
})
if err != nil {
return err
}
fmt.Println(res.V3DeploymentsCreateDeploymentResponseBody.Data.DeploymentID)The endpoint is deployments.createDeploymentV3. Don't use the CreateDeployment method. It calls the deprecated v2 endpoint. For projects, apps, and environments, see Projects, apps, and environments.
Handle errors
Every method returns a response or an error, never both. API errors have a type per status that you can match with errors.As. Anything else is an *apierrors.APIError.
import (
"errors"
"github.com/unkeyed/sdks/api/go/v3/models/apierrors"
)
res, err := client.Keys.GetKey(ctx, components.V2KeysGetKeyRequestBody{KeyID: "key_1234abcd"})
if err != nil {
var notFound *apierrors.NotFoundErrorResponse
var unauthorized *apierrors.UnauthorizedErrorResponse
var apiErr *apierrors.APIError
switch {
case errors.As(err, ¬Found):
fmt.Println("not found:", notFound.Error_.GetDetail())
case errors.As(err, &unauthorized):
fmt.Println("check the root key:", unauthorized.Error_.GetDetail())
case errors.As(err, &apiErr):
fmt.Println(apiErr.StatusCode, apiErr.Message)
default:
return err // transport failure
}
}The typed errors are BadRequestErrorResponse (400, whose Error_ is a components.BadRequestErrorDetails with a per-field GetErrors() list), UnauthorizedErrorResponse (401), ForbiddenErrorResponse (403), NotFoundErrorResponse (404), ConflictErrorResponse (409), GoneErrorResponse (410), PreconditionFailedErrorResponse (412), UnprocessableEntityErrorResponse (422), TooManyRequestsErrorResponse (429), InternalServerErrorResponse (500), and ServiceUnavailableErrorResponse (503). Each endpoint page in the API reference, for example keys.getKey, lists which ones apply.
Next steps
Api Management Sdks Python (`docs/api-management/sdks/python.mdx`)
title: Python SDK
sidebarTitle: "Python"
sources:
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/README.md
- https://github.com/unkeyed/sdks/blob/3bb124498270d8aff36e358dc2e0010e9991869a/api/py/pyproject.toml
- https://pypi.org/project/unkey.py/
import { versions } from "/snippets/versions.mdx";
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Use unkey.py to call the Unkey API from Python. The current release is {versions.pySdk}, and it needs Python 3.10 or newer. Every method has a regular and an _async version. Methods are grouped as unkey.keys, unkey.apis, unkey.identities, unkey.permissions, unkey.ratelimit, unkey.analytics, and unkey.portal.
Install
uv add unkey.pypip install unkey.pypoetry add unkey.pyConstruct the client
import os
from unkey.py import Unkey
with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
...root_key is your root key. The client is a context manager that closes its connections on exit. In a long-running service, create one client and reuse it. To change retries, pass retry_config=RetryConfig(...) from unkey.py.utils to the client or to one call.
Verify a key
res = unkey.keys.verify_key(key="prod_abc123", permissions="documents.read")
if not res.data.valid:
print(res.data.code) # NOT_FOUND, FORBIDDEN, INSUFFICIENT_PERMISSIONS, USAGE_EXCEEDED, RATE_LIMITED, DISABLED, EXPIREDimport asyncio
import os
from unkey.py import Unkey
async def main():
async with Unkey(root_key=os.environ["UNKEY_ROOT_KEY"]) as unkey:
res = await unkey.keys.verify_key_async(key="prod_abc123")
print(res.data.valid)
asyncio.run(main())Request fields are snake_case keyword arguments (tags, permissions, credits, ratelimits). An invalid key isn't an exception: data.valid is false and data.code says why. See Verifying keys for what each check means and keys.verifyKey for the schema.
Create a key
res = unkey.keys.create_key(
api_id="api_1234abcd",
prefix="prod",
name="Payment service",
external_id="user_42",
permissions=["documents.read"],
enabled=True,
)
print(res.data.key_id)
print(res.data.key) # returned once; store it or hand it to the user nowThe remaining fields (byte_length, meta, roles, expires, credits, ratelimits, recoverable) are described in Creating keys, and the endpoint is keys.createKey.
Apply a rate limit
res = unkey.ratelimit.limit(
namespace="email.send",
identifier="user_42",
limit=10,
duration=60_000, # milliseconds
)
if not res.data.success:
... # res.data.remaining and res.data.reset (Unix ms) tell the caller when to retryThe endpoint is ratelimit.limit.
Handle errors
HTTP errors raise an errors.UnkeyError, with message, status_code, headers, body, and raw_response. Each status has its own subclass with a typed data attribute holding the API's meta and error fields.
from unkey.py import Unkey, errors
try:
res = unkey.keys.get_key(key_id="key_1234abcd")
except errors.NotFoundErrorResponse as e:
print(e.data.meta.request_id, e.data.error.detail)
except errors.UnkeyError as e:
print(e.status_code, e.message)The subclasses are BadRequestErrorResponse (400), UnauthorizedErrorResponse (401), ForbiddenErrorResponse (403), NotFoundErrorResponse (404), ConflictErrorResponse (409), GoneErrorResponse (410), PreconditionFailedErrorResponse (412), UnprocessableEntityErrorResponse (422), TooManyRequestsErrorResponse (429), InternalServerErrorResponse (500), and ServiceUnavailableErrorResponse (503). Network failures raise httpx.RequestError subclasses such as httpx.ConnectError and httpx.TimeoutException. Each endpoint page in the API reference, for example keys.getKey, lists which errors it can return.
Next steps
Api Management Security Recovering Keys (`docs/api-management/security/recovering-keys.mdx`)
title: Recovering keys
sources:
- web/internal/db/src/schema/keyAuth.ts
- svc/api/routes/v2_keys_create_key/handler.go
- svc/api/routes/v2_keys_get_key/handler.go
- svc/api/routes/v2_apis_list_keys/handler.go
- pkg/rbac/permissions.go
- pkg/hash/sha256.go
- pkg/encryption/aes.go
- svc/vault/internal/vault/rpc_encrypt.go
Unkey stores every API key as a SHA-256 hash of the full key string, standard base64, with no salt. Verification hashes the presented key and compares the hashes. Unkey doesn't keep the plaintext unless you turn on recovery for that key. For background, see the OWASP Secrets Management Cheat Sheet and the OWASP REST Security Cheat Sheet. SHA-256 here isn't a password hash.
Recoverable keys let you show a key again after it's created. That comes with extra risk, so turn recovery on only when you need it: a dashboard that must show a key again, a migration that moves keys, or a support flow that reads a key back for a customer. By default you see a key only once, when it's created, and that's the right setup for most products. For the steps, see Recoverable keys.
How the encrypted copy is stored
A recoverable key is still verified by its hash. Beside the hash, Unkey stores an AES-256-GCM ciphertext of the key. The data key is 32 bytes, the nonce is 12 bytes, and the keyring is the workspace, so another workspace can't decrypt it. keys.getKey and apis.listKeys decrypt it only when you pass decrypt: true, and only with a root key that has decrypt_key.
A key is only recoverable if its keyspace has Store encrypted keys turned on (ask [email protected]) and the key was created with recoverable: true after that. Older keys can't be recovered.
Two separate permissions
Creating a recoverable key needs api.*.encrypt_key or api.<api_id>.encrypt_key on top of create_key. Reading a key back needs api.*.decrypt_key or api.<api_id>.decrypt_key on top of read_key. Without decrypt_key, a root key can create and list keys but never read them back. Give decrypt_key only to the one service that needs to show keys, for the one keyspace that needs it.
Limiting the exposure
With recovery on, a copy of our database is no longer useless on its own. Someone who also got your workspace's encryption keys, or a leaked root key with decrypt_key, could read your keys. To limit that:
- Only turn on recovery for keyspaces that need it.
- Never pass
decrypt: truein code that serves end users. - Use the developer portal, where users roll their own keys instead of reading them back.
- Watch the audit log for unexpected
key.createentries in keyspaces with encryption on.
If rerolling meets your need, use it instead. The user gets a fresh key and nobody reads the old one.
Compute Build Build Secrets (`docs/compute/build/build-secrets.mdx`)
title: Build-time secrets
sources:
- svc/ctrl/worker/deploy/build.go
- svc/ctrl/worker/deploy/env_file.go
- svc/ctrl/worker/deploy/railpack.go
- svc/ctrl/worker/deploy/railpack_prepare.dockerfile.tmpl
- svc/ctrl/worker/deploy/deploy_handler.go
- pkg/validation/env_var.go
Your build can read every environment variable of the RUN step that uses them.
This is safer than Docker's ARG and ENV, which are saved in the image history where anyone with the image can read them.
Automatic builds
When the environment builds without a Dockerfile, every variable is set as an environment variable of the same name for your install and build commands, the way it would be in CI. Change a value and the next deployment rebuilds the steps that use it.
Dockerfile builds
In a Dockerfile build, your variables arrive as a .env file. Mount it in each RUN step that needs it:
FROM node:lts-alpine AS builder
ARG UNKEY_SECRETS_ID
WORKDIR /app
COPY . .
RUN npm ci
RUN --mount=type=secret,id=${UNKEY_SECRETS_ID},target=/run/secrets/.env \
set -a && . /run/secrets/.env && set +a && \
npm run build
FROM node:lts-alpine
WORKDIR /app
COPY --from=builder /app .
CMD ["node", "dist/index.js"]- Declare
ARG UNKEY_SECRETS_IDin every stage that mounts the file. Docker doesn't carryARGvalues acrossFROMlines. set -a && . /run/secrets/.env && set +aloads the variables for that oneRUNstep only. The file isn't saved in the layer.- Changing a variable reruns that step and every step after it in the stage. Steps before it stay cached.
The file uses standard .env syntax, one KEY=value per line. Values with spaces, quotes, $, or newlines are single-quoted, so PEM keys and JSON come through unchanged. If your framework reads .env files itself, point it at /run/secrets/.env and make sure its parser handles quoted multi-line values.
Troubleshooting
A build step keeps using an old value
Older Dockerfiles mount the secret with the fixed id env. That still builds, but changing a variable doesn't rerun the step, so the old value can stay cached. Change the mount to id=${UNKEY_SECRETS_ID}.
Cloning a private repository or installing a private package fails
We fetch only the repository being built, with read-only access. A RUN git clone of another private repository, a private submodule, or a private package registry isn't authenticated. Add the token it needs as an environment variable and use it in the step that needs it.
Next steps
Compute Build Dockerfile (`docs/compute/build/dockerfile.mdx`)
title: Dockerfile builds
sources:
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/worker/deploy/build.go
- svc/krane/internal/deployment/apply.go
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/routes/v2_environments_update_settings/handler.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/dockerfile-settings.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/root-directory-settings.tsx
By default, we build your app without a Dockerfile. Use your own Dockerfile when you need something automatic detection can't do, such as system packages, custom build stages, an unusual toolchain, or full control of the image.
Switch an environment to a Dockerfile
- Open the
app , go to App settings > Build, and find the Dockerfile row. - Pick a Dockerfile from the list of ones we found in your repository, or type its path. The path is relative to the root directory and can be 1 to 500 characters. If the file isn't on the tracked branch, you'll see "File not found on this branch".
- Deploy. The setting applies from the next deployment.
With the API, set dockerfile on environments.updateSettings.
Production and preview have separate build settings, so one
To go back to automatic builds, choose Automatic (no Dockerfile) in the dashboard, or set dockerfile to null in the API.
Set the build context
The root directory is the build context. COPY and ADD paths, and the Dockerfile path, are resolved from it. The default . is the repository root.
For a service in services/api, you have two options:
- Set the root directory to
services/apiand the Dockerfile path toDockerfile. - Keep the root at
.and set the Dockerfile path toservices/api/Dockerfile, if the build needs files from elsewhere in the repository.
The root directory must be a relative path like services/api. It can't start with / or ./, or contain .., backslashes, or spaces.
What your container must do
- Listen on
PORT. We set this environment variable to the environment's port (8080 by default), and it overrides anyENV PORTin your image. - Exit cleanly on the shutdown signal,
SIGTERMunless you change it, so instances can drain when they're replaced. - Keep local writes under 128 MiB. That's the cap on the container's own filesystem. Use the optional disk at
/datafor anything larger.
If your image's default command isn't what you want, set the Start command in runtime settings instead of keeping a second Dockerfile.
Why your Dockerfile build failed
A failed build ends the deployment as failed with the code build_failed. The deployment page shows a plain message for common causes, with the full build log underneath:
| What went wrong | Message you see |
|---|---|
| The Dockerfile path points at an empty file | The Dockerfile appears to be empty. Please verify the file path in settings. |
| The Dockerfile could not be read at the configured path | Dockerfile could not be read. Please check that the file path is correct in settings. |
| A file the Dockerfile references is missing from the build context | A file or directory referenced in the build was not found. Please check the root directory in settings. |
A COPY or ADD source is missing |
A file referenced in the Dockerfile was not found. Please check the root directory in settings. |
| The requested build target stage doesn't exist | The specified build target stage was not found. Please check the target name in settings. |
| The Dockerfile has a syntax error | Dockerfile has a syntax error. Please check the Dockerfile for typos. |
There is no FROM instruction |
Dockerfile has no valid build stage. Please add a FROM instruction. |
A FROM image reference is malformed |
A Docker image reference is invalid. Please check your FROM lines. |
| The base image has no build for the target platform | The base image does not support the target platform. Please use a multi-platform image or change the platform. |
An undeclared ARG is referenced |
Dockerfile references an undefined variable. Please check your ARG declarations. |
A RUN command exited non-zero |
A build command failed. Please check the build logs for details. |
Errors we don't recognize show the raw build error.
If your Dockerfile clones another private repository or a private submodule, it gets a 404 from GitHub. We only have read access to the repository being built. Vendor those dependencies, or fetch them with credentials passed as build-time secrets.
Next steps
Compute Build Github (`docs/compute/build/github.mdx`)
title: GitHub integration
sources:
- svc/api/openapi/spec/paths/v2/github/installApp/index.yaml
- svc/api/openapi/spec/common/AppGitCreateInput.yaml
- svc/api/openapi/spec/common/AppGitUpdateInput.yaml
- svc/api/openapi/spec/paths/v2/deployments/createDeployment/DeploymentSourceGit.yaml
- svc/ctrl/api/webhooks/github/github.go
- svc/ctrl/api/webhooks/github/push.go
- svc/ctrl/api/webhooks/github/pull_request.go
- svc/ctrl/worker/githubwebhook/handle_push.go
- svc/ctrl/worker/deployment/authorize_handler.go
- svc/ctrl/internal/db/github_repo_connection_list_deploy_context.sql_generated.go
- svc/ctrl/worker/githubstatus/init_handler.go
- svc/ctrl/worker/githubstatus/report_handler.go
- svc/ctrl/worker/githubstatus/comment.go
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/worker/deploy/domains.go
- svc/ctrl/worker/deploy/build.go
- pkg/match/watchpath.go
- web/internal/db/src/schema/github_app.ts
- web/apps/dashboard/lib/trpc/routers/github.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/components/disconnect-github.tsx
Connect a GitHub repository to an
Install the GitHub App
You install the Unkey GitHub App once per workspace, on a GitHub organization or personal account, and choose which repositories it can see.
- Start from any of these places in the dashboard: Connect GitHub when you create an app, the Repository row on an app's App settings > Build page, or workspace settings.
- Approve the installation on GitHub. GitHub sends you back to Unkey.
To do it with the API, call github.installApp with a root key that has workspace.*.install_github, and open the URL it returns.
You can install the app on more than one GitHub account. An installation belongs to one workspace only, so a second workspace can't use it.
Connect a repository to an app
Pick the repository when you create the app, or later under App settings > Build. From the CLI, pass --git to create-app or update-app.
You connect a repository per app, not per project. In a monorepo, two apps can connect to the same repository and use different build settings.
The app's default branch deploys to production. It starts as the repository's default branch on GitHub, and you can change it with apps.updateApp.
To stop deploying from pushes, click Disconnect repository in the danger zone of the app's App settings > General page. The dashboard warns that "Unkey will stop building pushes to this repository. Running deployments stay live."
What deploys when you push
Two things on GitHub create a deployment:
- A push to a branch. The first push of a new branch counts, which is how a branch gets its first preview. Pushing a tag or deleting a branch doesn't deploy.
- A pull request from a fork, when it's opened or gets new commits. Pull requests from branches in your own repository don't deploy again, because the push already did.
The branch picks the main if none is set) deploys to production. Every other branch, and every fork pull request, deploys to preview. If several apps are connected to the repository, one push creates one deployment per app.
Skip deploys with auto deploy and watch paths
Two build settings can stop a push from deploying. We check them in this order:
- Auto deploy off. The push is recorded as
skippedwith "Auto deploy is disabled for this environment." Watch paths aren't checked. - Watch paths set, and no changed file matches. The push is skipped with "Watch paths did not match any changed files." If a pattern isn't a valid glob, it's skipped with a message naming the pattern.
Otherwise, we create a deployment. Watch paths also apply to fork pull requests.
Patterns use ** globbing. src/** matches everything under src, **/*.go matches every Go file, and a pattern with no wildcard matches only that exact path.
Approve pull requests from forks
A fork pull request runs code from someone without write access to your repository, with your environment variables. So we never build it automatically.
- The deployment waits in
awaiting_approval. On the pull request, a failing check named Unkey Deploy Authorization says "Awaiting authorization from a project member" and links to the deployment. - A project member approves it in the dashboard. The build starts.
Pushes from people with write access to the repository don't need approval.
Fork previews get their own hostname prefix, [-<app>]-fork-<fork owner>-..., so you can't confuse them with your own branches. You can also deploy a fork yourself from the Create Deployment dialog, or with deployments.createDeployment by passing git.repository as owner/repo and a commitSha.
What you'll see on GitHub
Each deployment of a connected app reports back to GitHub:
- A GitHub Deployment on the commit, labeled
project - env(orproject/app - envwhen the app isn'tdefault), linking to the environment'sunkey.appdomain. Its status goes from "Deployment queued" to "Deploying to regions...", "Configuring routing...", and "Deployment is live", or to a failure that names the step that failed. - A pull request comment, when the branch has an open pull request or the deployment came from a fork. We keep one comment per pull request up to date, with a row per app and environment: status, a Visit Preview link, an Inspect link to the dashboard, and when it last changed.
- A commit status, only for fork deployments waiting for approval.
Troubleshooting: a push didn't deploy
Check these in order:
- The Unkey GitHub App is installed on the account that owns the repository, and has access to it.
- The repository is connected to the app under App settings > Build.
- Auto deploy is on for the environment the branch maps to (the Auto-deploy switch on the environment's page under App settings > Environments).
- The push changed a file that matches the watch paths, if any are set.
- The app's deployments list shows a
skippedorfailedrow with the reason.
Next steps
Compute Cli Deploy (`docs/compute/cli/deploy.mdx`)
title: unkey deploy
sidebarTitle: "deploy"
sources:
- cmd/deploy/main.go
- cmd/deploy/control_plane.go
- cmd/api/util/client.go
- svc/api/routes/v2_deploy_create_deployment/handler.go
- svc/api/routes/v2_deploy_get_deployment/handler.go
- docs/scripts/manifest.json
import RootKeyPrereq from "/snippets/root-key-prereq.mdx";
Deploy a prebuilt container image to an
Usage
unkey deploy <docker-image> --project=<project> --root-key=<root key> [flags]The image is required. Without it, the command exits with docker image is required. The image must already be in a registry we can pull from. Nothing is built.
Flags
Project slug the app belongs to. Falls back to UNKEY_PROJECT.
App slug within the project. Falls back to UNKEY_APP.
Slug of the production to deploy to production.
Keyspace whose keys the gateway accepts for this deployment. Falls back to UNKEY_KEYSPACE_ID. See API key authentication.
Git branch recorded on the deployment. If you leave the default and you're in a Git checkout on another branch, the CLI uses that branch.
Commit SHA recorded on the deployment. Defaults to the HEAD of your Git checkout. Outside a Git repository, no commit is recorded unless you pass this. If you pass a SHA other than HEAD, only the SHA is recorded, without the commit message or author.
Root key. Falls back to UNKEY_ROOT_KEY. Unlike the unkey api commands, unkey deploy doesn't read ~/.unkey/config.toml, so a key saved with unkey auth login doesn't work here.
API base URL. Falls back to UNKEY_API_BASE_URL. When empty, https://api.unkey.com is used. You don't normally need to set it.
The command doesn't accept --output, --config, or --body.
Required permissions
project.*.create_deployment or project..create_deployment to create the deployment, and project.*.read_deployment or project..read_deployment to follow it. These are project permissions. The unkey api deployments commands use different permissions, listed on each command's page. See Root key permissions.
What you'll see
The command prints a Deployment Progress header with the branch, commit, and image. Inside a Git repository, it records the branch, commit, message, author, and commit time on the deployment so the dashboard can show them, and marks the commit as dirty if you have uncommitted changes.
If the deployment can't be created, the command prints the error and exits 1. Common causes are a --project or --app that doesn't exist, an --env that isn't an environment of that app, a root key without create_deployment, an image reference we can't parse, or a --keyspace-id that isn't in your workspace.
Otherwise it follows the deployment until it finishes:
- Ready: prints
Deployment completed successfully, the deployment ID, the environment, and its hostnames, and exits 0. - Failed: prints the error message (or
Unknown deployment error) and exits 1, so a CI job fails. - Still going after 5 minutes: exits with
deployment timeout after 5 minutes. The deployment keeps going. Follow it withunkey api deployments get-deployment.
Examples
Deploy to the preview environment of the default app:
unkey deploy ghcr.io/acme/payments:1.4.2 --project=payments --root-key=unkey_xxxDeploy to production with the key from the environment, as in CI:
export UNKEY_ROOT_KEY=unkey_xxx
unkey deploy myregistry.io/payments-api:latest --project=payments --app=payments-api --env=productionDeploy behind API key authentication:
unkey deploy ghcr.io/acme/payments:1.4.2 --project=payments --app=payments-api --env=production --keyspace-id=ks_1234abcdRelated
Compute Cli Healthcheck (`docs/compute/cli/healthcheck.mdx`)
title: unkey healthcheck
sidebarTitle: "healthcheck"
sources:
- cmd/healthcheck/main.go
- docs/scripts/manifest.json
Send one HTTP GET request to a URL and exit 0 if the server answers 200, or exit 1 otherwise. The command needs no root key and never contacts the Unkey API. Use it as a small probe for CI pipelines, load balancer checks, and monitoring scripts, for example against the hostname of a deployment.
Usage
unkey healthcheck <url>The URL is required. Without it, the command exits 1 with you must provide a url like so: 'unkey healthcheck '. The command has no flags.
Behavior
Redirects are followed and there's no request timeout, so a server that never answers keeps the command waiting. A 200 response prints ✓ Healthcheck passed: returned 200 and exits 0. Any other status exits 1 with healthcheck failed with status code <code>. A connection error exits 1 with failed to perform healthcheck: followed by the underlying error.
Examples
Probe a deployment hostname:
unkey healthcheck https://payments-payments-api-production-acme.unkey.app/healthAlert when a service is down:
unkey healthcheck https://example.com/api/status || echo 'Service is down!'Gate a pipeline step:
unkey healthcheck http://localhost:8080/health && npm testCompute Concepts Deployments (`docs/compute/concepts/deployments.mdx`)
title: Deployments on Unkey
sidebarTitle: "Deployments"
sources:
- pkg/mysql/types/deployment_status.go
- pkg/mysql/types/deployment_desired_state.go
- svc/api/openapi/spec/common/Deployment.yaml
- svc/api/openapi/spec/common/DeploymentStatus.yaml
- svc/api/openapi/spec/common/DeploymentErrorCode.yaml
- svc/api/openapi/spec/paths/v2/deployments/createDeployment/V2DeploymentsCreateDeploymentRequestBody.yaml
- svc/api/openapi/spec/paths/v2/deployments/createDeployment/DeploymentSourceGit.yaml
- svc/ctrl/worker/deploy/create.go
- svc/ctrl/worker/deployment/cancel_handler.go
- svc/ctrl/worker/deploy/supersede.go
- svc/ctrl/worker/deploy/doc.go
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/worker/deploy/deployment_step.go
- svc/ctrl/worker/deploy/readiness.go
- svc/ctrl/worker/githubwebhook/handle_push.go
- pkg/deploy/deployfail/messages.go
- web/apps/dashboard/lib/collections/deploy/deployment-status.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/deployments/components/table/components/actions/deployment-action-eligibility.ts
- svc/ctrl/worker/deploy/build_handler.go
Unkey creates a deployment from a GitHub push, a container image, the dashboard, the CLI, or deployments.createDeployment. That deployment keeps the image, runtime settings, environment variables, and gateway policies it started with. With auto deploy on and no watch paths, the default branch becomes a production deployment and any other branch becomes a preview. The deployment can enforce API keys and rate limits from API management through its gateway policies.
How a deployment starts
The source you pass decides what gets built. For a GitHub push, auto deploy must be on. If you set watch paths, a changed file must match one of them, or Unkey records the push as skipped.
| How you ship | What Unkey creates |
|---|---|
| Push to the connected default branch | A production deployment |
| Push to any other branch | A preview deployment |
| Pull request from a fork | A preview deployment that waits in awaiting_approval until a project member approves it |
Prebuilt image (oci) |
A deployment of the tag or digest you set, or latest if you set neither. Later moves of that tag don't change it |
| Dashboard, CLI, or API, with no source | A git app builds its default branch. An image app deploys its default image |
A
Create a deployment
A push to a connected GitHub repository creates a deployment for you. With auto deploy on and no watch paths, Unkey deploys from GitHub on every push. The default branch becomes a production deployment, and any other branch becomes a preview deployment. Watch it on the deployment page. The status moves through pending, building, and deploying to ready, and the page lists the domains. See Deploy from GitHub and GitHub integration.
To deploy by hand, open the app's Deployments page and click Create deployment, or click Redeploy on an existing deployment. From the CLI or API, use unkey api deployments create-deployment or deployments.createDeployment with the project, app, and environment. With no source, a git app builds its default branch and an image app deploys its default image. To deploy something else, pass one source:
git: build from the connected repository. Setbranchfor the latest commit on a branch,commitShafor an exact commit, orrepositorywith acommitShato build a fork.oci: run a prebuilt image as it is. Give it a tag or a digest, or you getlatest. The deployment keeps the image the tag pointed to when you deployed, so moving the tag later doesn't change it.deployment: run an existing deployment again. A git app rebuilds the same commit, and an image app reuses the same image. This is what Redeploy does.
The dashboard shows a badge for how each deployment was started: GitHub, API, CLI, or dashboard.
Statuses
A deployment moves through these statuses. Six of them are final: ready, failed, skipped, stopped, superseded, and cancelled. The other seven mean it's still in progress.
stateDiagram-v2
[*] --> pending : push, API, CLI, or dashboard
[*] --> awaiting_approval : pull request from a fork
[*] --> skipped : auto deploy off, or no watch path matched
awaiting_approval --> pending : approved by a project member
awaiting_approval --> superseded : newer commit on the same branch
pending --> superseded : newer commit on the same branch
pending --> starting : its turn in the queue
state "In flight" as inflight {
starting --> building
building --> deploying : image ready
deploying --> network : instances running
network --> finalizing : domains assigned
}
finalizing --> ready
inflight --> failed
pending --> cancelled : cancel
awaiting_approval --> cancelled : cancel
inflight --> cancelled : cancel
ready --> stopped : stop, idle preview, or spend cap
stopped --> deploying : start a stopped preview deployment| Status | What's happening |
|---|---|
pending |
Waiting for its turn. See Why a deployment is waiting. |
awaiting_approval |
A pull request from a fork, held until a project member approves it. |
starting |
Its turn has come and it's getting started. |
building |
Building the image from your repository. A deployment from a prebuilt image has nothing to build, so it passes through quickly. |
deploying |
Starting instances in each of the environment's regions and waiting for them to become healthy. |
network |
Assigning the deployment's domains. The dashboard labels this Assigning Domains. |
finalizing |
Finishing up and, for production, making it the live deployment. |
A production deployment that stops being the live one also becomes stopped, after a 30-minute standby. See Production and preview.
The deployment's page shows each step with its start and end time as it runs. A deployment from a prebuilt image has no build log. See Build logs.
Why a deployment is waiting
A workspace runs one deployment at a time on every plan, from build through rollout. The next one sits in pending until the one ahead finishes. Deployments from a prebuilt image queue too, even though they don't build. Production deployments jump ahead of preview deployments, so a hot-fix doesn't wait behind a backlog of pull request builds.
A deployment that has waited an hour for its turn fails rather than waiting longer. Deploy again once the queue has drained. See Builds.
Why a deployment was superseded or skipped
If you push a second commit to a branch while the first commit's deployment is still pending or awaiting_approval, the first one becomes superseded and the newer commit builds instead. Once a deployment has left pending, it finishes even if newer commits arrive.
skipped means we saw the push but didn't build it. Either auto deploy is off for the environment, or no changed file matched the environment's watch paths. The deployments list shows the reason.
Awaiting approval
A pull request from a fork runs outside code with your environment variables, so it never builds on its own. It waits in awaiting_approval until a project member approves it in the dashboard. GitHub shows a check named Unkey Deploy Authorization on the commit while it waits. Pushes from people with write access don't need approval. See GitHub integration.
Cancel a deployment
To cancel a deployment that's still in progress, open its menu in the deployments list and click Cancel deployment. The current step is marked "Cancelled by user", the deployment ends as cancelled, and the next deployment in the queue can start. A finished deployment can't be canceled.
Rollouts across regions
During deploying, we wait for each region to reach its minimum number of instances. The deployment goes live once all regions but one are healthy, so one region that fails to start doesn't block the release. A single-region environment waits for that one region. If enough regions aren't healthy within 15 minutes, the deployment fails. See Regions.
Why a deployment failed
The deployment's page shows the step that failed and a message. In the API, a failed deployment has an error object with a code, the step that failed, and a message. The codes are:
| Code | Meaning |
|---|---|
no_schedulable_regions |
The environment has no region that can currently be scheduled. Configure at least one region. |
invalid_runtime_settings |
The port is outside 1 to 65535, the CPU is below 0.25 vCPU, or the memory is below 256 MiB. |
cpu_quota_exceeded |
Starting this deployment would push the workspace past its CPU limit across all running deployments. |
memory_quota_exceeded |
The same for memory. |
storage_quota_exceeded |
The same for ephemeral disk. |
build_failed |
The image build did not complete. The build log names the failing step. |
unknown |
We couldn't classify the failure. Read message. |
For the quota codes, we add what your running deployments already use to this deployment's CPU, memory, and disk at its maximum instance count in every region, and compare that to your workspace limits. So a wide autoscaling range counts at its maximum. Scale down or remove a deployment to make room.
What you can do with a deployment
You can promote and roll back production deployments, and stop and start preview deployments. See Production and preview. The dashboard also offers Redeploy for any deployment that's ready, stopped, failed, superseded, or cancelled, and Cancel deployment for anything in progress.
In the API, a deployment's availableActions field lists the actions that will work right now, so you can check before you call.
When the deployment isn't serving
What callers get depends on why:
- The deployment is stopped:
503withdeployment_offline. - It should be running, but no instance is up yet:
503withno_running_instances. - The workspace hit its Compute spend budget with the stop option on: every deployment returns
402withspend_limit_reacheduntil you raise or remove the budget.
Compute Concepts Instances And Autoscaling (`docs/compute/concepts/instances-and-autoscaling.mdx`)
title: Instances and autoscaling
sources:
- svc/ctrl/services/app/create_app.go
- svc/api/openapi/spec/common/EnvironmentRegion.yaml
- svc/api/openapi/spec/common/Replicas.yaml
- svc/api/openapi/spec/common/EnvironmentHealthcheck.yaml
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/routes/v2_environments_update_settings/handler.go
- svc/krane/internal/deployment/apply.go
- svc/krane/internal/deployment/consts.go
- svc/frontline/internal/router/routing.go
- svc/frontline/routes/proxy/handler.go
- web/internal/db/src/schema/limits.ts
- web/apps/dashboard/lib/limits.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/compute-fields.tsx
An instance is one running container of a
How many replicas you get
Each
The minimum is at least 1, so you can't scale to zero. The maximum is capped by your plan's replicas-per-region limit: 4 on Starter, 8 on Pro, and 16 on Business. Every region uses the same minimum and maximum.
One replica gives no availability guarantee within a region. Two replicas let the deployment keep serving while one instance is replaced, and Unkey keeps at most one instance of a deployment unavailable at a time during planned disruption. For a regional outage you need a second region. See Regions.
How autoscaling decides
When the maximum exceeds the minimum, Unkey watches average CPU across the deployment's instances in each region. It adds replicas when average CPU exceeds 80 percent of the requested CPU and removes them when load drops, waiting 60 seconds after a drop before scaling in so short spikes don't cause flapping.
CPU, memory, and disk
Runtime settings give each instance a CPU allocation in vCPUs (minimum 0.25, in steps of 0.25), a memory allocation in MiB (minimum 256, in steps of 256), and an optional ephemeral disk in MiB (steps of 512). The upper bounds are your workspace's per-instance limits on Limits, and the API returns 400 if you exceed them. The workspace-wide totals are checked when a deployment starts, as described under Deployments.
The allocation you set is the hard limit for the container. Half of it is guaranteed, and an instance can burst to its full allocation when there's spare capacity. The container's own writable filesystem is capped at 128 MiB. That's scratch space for logs and small files, not storage. When you configure ephemeral disk, it's mounted at /data and UNKEY_EPHEMERAL_DISK_PATH=/data is set so your code can find it. The disk starts empty with each instance and is deleted when the instance stops.
What the container sees
Unkey injects PORT with the configured port (default 8080) and a set of UNKEY_* variables naming the deployment, environment slug, region, instance, and the git commit, branch, repository, and commit message when the deployment came from git. Your environment variables are added alongside, and the injected variables win if the names collide. The container receives the configured shutdown signal (SIGTERM by default) when an instance is being replaced or scaled in.
Health probes
A health check is optional. When you configure one, Unkey probes the container over HTTP at the path you give with GET or POST, every intervalSeconds (default 10, at most 3600), with a timeoutSeconds (default 5), after an initialDelaySeconds (default 0). The same probe drives two decisions: an instance that hasn't passed yet receives no traffic, and an instance that fails failureThreshold consecutive probes (default 3) is restarted. A GET probe counts any status from 200 to 399 as a pass.
Without a health check, an instance is treated as ready as soon as its container is running, and it's only restarted if the process exits. A probe that checks downstream dependencies can therefore take your whole deployment out when a dependency blips. Probe the process, not the database.
How traffic reaches instances
The gateway in each region sends a request to a random running instance of the deployment in that region. If it can't connect to one, it tries another. An error after the request was sent is returned as-is rather than retried, so your handlers never run twice for one request. If no instance in the region is running, the gateway forwards the request to the nearest region that has one, and if none does, it answers 503 with no_running_instances.
Compute Concepts Production And Preview (`docs/compute/concepts/production-and-preview.mdx`)
title: Production and preview
sources:
- svc/api/openapi/spec/common/EnvironmentKind.yaml
- svc/api/openapi/spec/common/Deployment.yaml
- svc/api/openapi/spec/common/DeploymentAction.yaml
- svc/api/openapi/spec/paths/v2/deployments/promoteDeployment/V2DeploymentsPromoteDeploymentRequestBody.yaml
- svc/api/openapi/spec/paths/v2/deployments/rollbackDeployment/V2DeploymentsRollbackDeploymentRequestBody.yaml
- svc/api/openapi/spec/paths/v2/deployments/stopDeployment/V2DeploymentsStopDeploymentRequestBody.yaml
- svc/api/openapi/spec/paths/v2/deployments/startDeployment/V2DeploymentsStartDeploymentRequestBody.yaml
- svc/api/internal/deployment/actions.go
- pkg/deploy/deploygate/deploygate.go
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/worker/environment/promote_handler.go
- svc/ctrl/worker/environment/rollback_handler.go
- svc/ctrl/worker/deployment/stop_handler.go
- svc/ctrl/worker/deployment/wake_handler.go
- svc/ctrl/worker/cron/idlepreview/handler.go
- svc/ctrl/worker/cron/cron.go
- svc/ctrl/internal/db/github_repo_connection_list_deploy_context.sql_generated.go
- web/internal/db/src/schema/apps.ts
- web/internal/db/src/schema/frontline_routes.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/deployments/components/table/components/actions/deployment-action-eligibility.ts
Every
Where a push lands
For a git-connected app, a push to the default branch creates a production deployment. A push to any other branch, or a pull request from a fork, creates a preview deployment. When you deploy from the API, CLI, or dashboard, you pick the environment.
Production
The live deployment
An app has one live production deployment. The environment domain, the shorter live domain, and any verified custom domains all point at it. When a new production deployment reaches ready, it goes live and those domains move to it, unless the app is rolled back (see Rollback). The deployment it replaced keeps running for 30 minutes as a standby, then stops.
Promote
Promote in the dashboard, or deployments.promoteDeployment in the API, makes a ready production deployment the live one. Use it to ship a deployment that was built while the app was rolled back, or to move traffic to an earlier deployment on purpose. The deployment it replaces stops after 30 minutes.
You can promote a deployment when it's ready and not stopped, and the app already has a live deployment. Promoting the deployment that's already live only works while the app is rolled back. That's a confirm rollback: nothing moves, but the rolled-back state ends and new pushes go live again.
Rollback
Rollback in the dashboard, or deployments.rollbackDeployment in the API, moves traffic back to the deployment you pick. The domains move in one step and the app is marked as rolled back.
While an app is rolled back, new production deployments still build and reach ready, but they don't go live. So a push can't undo your rollback. To end the rolled-back state, promote the deployment you rolled back to (confirm rollback) or promote a newer one.
You can roll back to a deployment that's ready, not stopped, and not the live one. A stopped deployment, including an old live one after its 30 minute standby, can't be a rollback target. To get back to it, click Redeploy on it, wait for ready, and promote it if the app is rolled back. Or roll forward with a fixed commit.
You can't stop or start production deployments by hand, because stopping the live one would take your app offline.
Preview
Stop and start
Stop deployment in the dashboard, or deployments.stopDeployment in the API, stops a ready preview deployment and frees its resources. It shows stopped once no instance is left.
Wake deployment in the dashboard, or deployments.startDeployment in the API, starts a stopped preview deployment again. It goes through deploying and back to ready. You can't start one while the workspace is suspended by its Compute spend budget.
Automatic stops
We stop deployments for you in these cases. A stopped preview deployment can be started again at any time.
| Trigger | What stops | When |
|---|---|---|
| New live production deployment, or a promote | The previous live deployment | 30 minutes later |
| New preview deployment on a branch | The deployment that was on that branch domain | 1 minute later |
| Preview deployment gets no requests | That deployment | After 1 hour idle |
| You stop it | A ready preview deployment |
Right away |
What callers see during a switch
Promote and rollback don't restart anything. The domains move to the other deployment, and the old one keeps serving until they have. The switch reaches every region within a few seconds, so a few requests can still reach the old deployment right after. A request to a stopped deployment gets 503 with deployment_offline.
Compute Concepts Projects Apps Environments (`docs/compute/concepts/projects-apps-environments.mdx`)
title: Projects, apps, and environments
sources:
- svc/ctrl/services/project/create_project.go
- svc/ctrl/services/app/create_app.go
- pkg/deploy/projectgate/projectgate.go
- svc/api/openapi/spec/common/Project.yaml
- svc/api/openapi/spec/common/App.yaml
- svc/api/openapi/spec/common/Environment.yaml
- svc/api/openapi/spec/common/EnvironmentKind.yaml
- svc/api/openapi/spec/paths/v2/apps/createApp/V2AppsCreateAppRequestBody.yaml
- svc/api/openapi/spec/paths/v2/apps/updateApp/V2AppsUpdateAppRequestBody.yaml
- web/internal/db/src/schema/environments.ts
- web/apps/dashboard/lib/navigation/leaves.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/page.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/new/page.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/sections.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/page.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/build-settings.tsx
Your workspace holds projects. A project holds
Projects
A project is how you group apps. It has a name and a slug. The slug is what you pass to the API and the CLI, so it must be unique in your workspace. The slug default is reserved, so the API will return an error if you use it. A project can also carry delete protection, which blocks deletion until you turn it off. See Delete protection.
Creating a project creates nothing else. There's no default app. In the dashboard, creating a project opens the new-app flow. The API returns the project alone. Unlike platforms where a project is itself what you deploy, here the project is only a folder.
In the dashboard, the project level holds things that span apps: runtime Logs and gateway Requests for every app in the project, plus Project settings (name and delete).
Apps
An app is what you deploy. Two apps in one project build and deploy on their own. A monorepo with an API and a background worker is two apps on the same repository with different root directories. The project page lists apps. Create app adds another.
An app has a name, a slug unique within its project, and a source. The source is either a GitHub repository or a container image. The source kind is fixed when you create the app. For an image-sourced app, the dashboard's Build settings page shows only the image, because there's nothing to build.
For a git-connected app the tracked branch is defaultBranch, which defaults to the repository's default branch on GitHub. Pushes to that branch deploy to production. Every other branch deploys to preview. You can retarget the branch, replace the repository, or disconnect.
Environments
Creating an app also creates its two environments: production with kind production and preview with kind preview. You can't add a third environment or delete either of these. The environment slug is what you pass to the API alongside the project and app slugs.
In the dashboard, App settings > Environments lists both environments. Each one opens a page with its compute (regions, size, instances, and storage), its domains, and, for a git-connected app, its tracked branch and auto-deploy switch.
The kind decides how deployments behave:
productiondeployments serve the live domains, can be promoted and rolled back, and can't be stopped.previewdeployments can be stopped and started and are stopped automatically when idle.
Identifiers
Every Compute endpoint accepts either the ID or the slug for a project, app, or environment. A request looks like project: payments, app: api, environment: production, or uses the proj_, app_, and env_ IDs the list endpoints return. The dashboard URL carries the IDs.
Compute Concepts Regions (`docs/compute/concepts/regions.mdx`)
title: Regions
sources:
- web/internal/db/src/schema/regions.ts
- web/internal/db/src/schema/app_regional_settings.ts
- svc/ctrl/services/app/create_app.go
- svc/api/openapi/spec/common/EnvironmentRegion.yaml
- svc/api/openapi/spec/common/Replicas.yaml
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/routes/v2_environments_update_settings/handler.go
- svc/ctrl/worker/deploy/readiness.go
- svc/frontline/internal/router/routing.go
- web/apps/dashboard/lib/trpc/routers/deploy/environment-settings/get-available-regions.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/compute-fields.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/compute/status.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/compute-overview.tsx
A region is a place Unkey runs your instances. Each
Available regions
Unkey runs on AWS and uses AWS region names. Compute runs in four:
| Region | Location |
|---|---|
us-west-2 |
Oregon |
us-east-1 |
Northern Virginia |
eu-central-1 |
Frankfurt |
ap-southeast-1 |
Singapore |
We add regions over time. A region can also be closed to new deployments for a while, and the dashboard grays it out. Picking one anyway fails with Region 'us-east-1' is not available for scheduling., and a name that is not a region at all fails with Region 'eu-west-1' does not exist.
Set the regions for an environment
Production and preview have separate lists, so you can run production in three regions and preview in one.
In the dashboard, open the app, go to App settings > Environments, and open the environment. Click Configure on the Compute card and pick regions under Regions. Unschedulable regions are visible but not selectable, and you can't remove the last region. From the CLI or the API:
The list you send replaces the whole set. A region you leave out is dropped, so send every region you want, not just the new one. The rules the API enforces:
- Between 1 and 5 regions. An empty list is rejected, because an environment cannot have zero regions.
- No duplicates:
Region 'us-east-1' is listed more than once. - Every region needs the same
minandmax. Mixed bounds fail withAll regions must specify the same replica bounds; per-region autoscaling is not supported yet. minis at least 1 andmaxis at most your plan's replicas-per-region ceiling, which is on Limits. Outside that you getRegion 'us-east-1' replicas must be between 1 and 4.
How failover works
Requests reach whichever Unkey gateway is nearest to the caller. That gateway then decides where to send the request:
- If its own region has running instances of the deployment, it serves from there. This is the normal path and costs no extra hop.
- If not, it forwards to the nearest region that does have one, following a fixed proximity order.
- If none of the regions in that order has an instance, it forwards to any region that does.
- If no region has one, the request fails with
503andno_running_instances.
Only regions your environment is deployed to are candidates. Our gateway picks among the regions that have a running instance of that deployment, so a deployment that runs in one region never serves from another, and traffic never reaches a region you did not choose.
When your environment does span several of them, step 2 resolves in this order:
| Gateway region | Prefers, in order |
|---|---|
us-west-2 |
us-east-1, ap-southeast-1, eu-central-1 |
us-east-1 |
us-west-2, eu-central-1, ap-southeast-1 |
eu-central-1 |
us-east-1, us-west-2, ap-southeast-1 |
ap-southeast-1 |
us-west-2, us-east-1, eu-central-1 |
That ordering matters for data residency. eu-central-1 is the only European region today, so an environment that must keep traffic in the EU has to run there alone, and it has no regional redundancy. If Frankfurt goes down the deployment returns 503 rather than serving from elsewhere. Adding a second region for availability means accepting that requests can be served outside the EU.
A second region is what protects you from losing a region. Extra replicas in one region protect you from losing an instance, but if that region goes away, so does your app.
Failover is per request and automatic. There is nothing to configure and no order you can set: adding a region to the environment is what makes it a target.
Rollouts tolerate one bad region
When a deployment starts, Unkey waits for each region to reach the environment's minimum replica count, and releases once all but one region is healthy. A single-region environment waits for that region. A three-region environment goes live once two are up, and the third joins when it recovers. If enough regions are not healthy within 15 minutes, the deployment fails. See Deployments.
Choosing regions
Put a region near your users and a region near the data your app talks to. A request served far from your database pays that round trip on every call, which usually outweighs the latency you saved by being close to the user.
Two regions with a couple of replicas each is the minimum we advise for production. Preview environments rarely need more than one region, since they exist to be looked at rather than to survive an outage.
Compute Configure Build Settings (`docs/compute/configure/build-settings.mdx`)
title: Build settings
sources:
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/openapi/spec/common/EnvironmentBuild.yaml
- svc/api/routes/v2_environments_update_settings/handler.go
- svc/ctrl/services/app/create_app.go
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/worker/deploy/railpack.go
- pkg/match/watchpath.go
- web/internal/db/src/schema/app_build_settings.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/environment-provider.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/build-settings.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/dockerfile-settings.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/root-directory-settings.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/build-command-settings.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/build-settings/watch-paths-settings.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/[appId]/(overview)/settings/environments/[environment]/auto-deploy.tsx
Build settings control how a git-connected
Change build settings
Edit them under App settings > Build in the dashboard, with environments.updateSettings in the API, or with unkey api environments update-settings in the CLI. In the API, send only the fields you want to change. To read the current values, call environments.getEnvironment and look under build.
The Build page shows the production values, and saving there writes to every environment of the app. To give production and preview different build settings, use the API or CLI. Auto-deploy is the exception: each environment has its own switch, on its page under App settings > Environments.
A change applies from the next deployment. Existing deployments keep the settings they were built with.
Fields
Path to the Dockerfile, relative to the root directory, 1 to 500 characters. When it's unset (the default), we detect your language and build without a Dockerfile. Send null to go back to automatic builds. In the dashboard, the Dockerfile row lists Dockerfiles in your repository and shows "File not found on this branch" if the path is missing. See Dockerfile builds.
The directory we build from, 1 to 500 characters. Automatic detection looks here, and a Dockerfile build uses it as the build context. Use . or a relative path like services/api with letters, digits, ., _, and -. A path that starts with / or ./, or contains .., a backslash, or spaces, returns "Root directory must be a relative path like 'api' or 'services/api'." The dashboard suggests app directories it finds in your repository.
Replaces the build command automatic detection would run, 1 to 1000 characters, for example pnpm --filter api build. Ignored when a Dockerfile is set, and the dashboard marks the field "Not used with a Dockerfile." Send null to go back to the detected command.
Glob patterns that decide whether a push builds. Up to 10 patterns, each up to 500 characters. When the list is empty, every push builds. Otherwise a push builds only if a changed file matches a pattern. src/** matches everything under src, and **/*.go matches every Go file. A pattern without a wildcard matches only that exact path, so src isn't the same as src/**. Don't start a pattern with / or ./. An invalid pattern returns 400 when you save it. See GitHub integration.
Whether GitHub pushes create deployments in this environment. In the dashboard, it's the Auto-deploy switch under Branch tracking on the environment's page. When it's off, a push shows up as a skipped deployment with "Auto deploy is disabled for this environment." A common setup is auto deploy on for preview and off for production: every branch gets a preview, and you release to production on purpose from the dashboard, API, or CLI.
Example
curl -X POST https://api.unkey.com/v2/environments.updateSettings \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"project": "payments",
"app": "api",
"environment": "production",
"rootDirectory": "services/api",
"dockerfile": "Dockerfile",
"watchPaths": ["services/api/**", "packages/shared/**"]
}'Next steps
Compute Configure Environment Variables (`docs/compute/configure/environment-variables.mdx`)
title: Environment variables
sources:
- svc/api/openapi/spec/common/EnvironmentVariable.yaml
- svc/api/openapi/spec/common/EnvironmentVariableKind.yaml
- svc/api/openapi/spec/common/EnvironmentVariableInput.yaml
- svc/api/openapi/spec/paths/v2/environments/setEnvironmentVariables/V2EnvironmentsSetEnvironmentVariablesRequestBody.yaml
- svc/api/openapi/spec/paths/v2/environments/removeEnvironmentVariables/V2EnvironmentsRemoveEnvironmentVariablesRequestBody.yaml
- svc/api/routes/v2_environments_set_environment_variables/handler.go
- svc/api/routes/v2_environments_list_environment_variables/handler.go
- pkg/validation/env_var.go
- svc/ctrl/worker/deploy/env_file.go
- svc/krane/internal/deployment/apply.go
- svc/krane/internal/deployment/secrets.go
- web/internal/db/src/schema/app_environment_variables.ts
- web/apps/dashboard/lib/schemas/env-var.ts
- web/apps/dashboard/lib/trpc/routers/deploy/env-vars/make-sensitive.ts
- web/apps/dashboard/lib/trpc/routers/deploy/env-vars/rename.ts
- web/apps/dashboard/lib/collections/deploy/env-vars.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/env-file.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/variables-table/variables-table.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/variables-table/variable-rows.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/add/add-env-vars-fields.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/add/add-env-vars-button.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/new/panes/config-blocks.tsx
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/env-vars/list/env-var-edit-row.tsx
Environment variables give your DATABASE_URL. Every value is encrypted at rest.
Add variables in the dashboard
- Open the app and go to Environment variables.
- Click Add environment variable. A panel opens with a table of keys and values.
- Enter one or more key-value pairs. To import a
.envfile, drop it on the table, click Import .env, or paste its contents into a key cell. - Turn on Sensitive in a row for secrets.
- Pick the target Environment, or keep All environments to set the same value everywhere.
- Save, then redeploy. See When a change takes effect.
The .env import skips comments, strips surrounding quotes, and handles multi-line values such as PEM keys. If a key appears twice in the file, the last one wins. You can save up to 200 variables at once. If a key already exists in the target environment, nothing is saved, the row shows "A variable with this key already exists.", and the panel stays open. If you close the panel with unsaved rows, the dashboard asks before it discards them.
The saved list has one row for each variable in each environment. You can filter it by environment, search it by key, and sort it by name or newest. Click a row to edit its key, value, note, or sensitive setting.
While you set up a new app
In the new-app flow, you can also change the value of a saved variable or delete it. Sensitive rows and rows marked "Varies by environment" can only be deleted. Changes apply to every selected environment.
Recoverable or write-only
Every variable is either recoverable or writeonly. Both are encrypted the same way. The difference is whether you can ever see the value again:
recoverable: shown in the dashboard and returned byenvironments.listEnvironmentVariables. Use it for configuration you want to check later.writeonly: never shown or returned. You can only replace or delete it. Use it for API keys and other credentials.
The dashboard's Sensitive switch makes a variable writeonly, with the note "Hidden in the dashboard and the API after you save. Unkey encrypts every value." In the API, writeonly is the default.
Rename, mark sensitive, or delete
These actions are only in the dashboard:
- Make Sensitive turns selected
recoverablevariables intowriteonly. This can't be undone. - Rename changes the key in the edit form of a row. It changes the key in that row's environment only, and fails if the new name is already used there. It works for sensitive variables too.
- Delete removes a variable from the row's environment. Select several rows to delete them together.
When a change takes effect
A deployment keeps the variables it was created with, for its build and its instances. Editing a variable doesn't change anything that's already running. To apply a change, push or redeploy. The dashboard shows a redeploy banner after an edit.
Set variables with the API
environments.setEnvironmentVariables creates or overwrites the variables you send, in one atomic call: if any variable is invalid, nothing changes. Variables you don't send stay as they are.
An existing key is fully overwritten. If you leave out description or kind, they reset to empty and writeonly.
curl -X POST https://api.unkey.com/v2/environments.setEnvironmentVariables \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"project": "payments",
"app": "api",
"environment": "production",
"variables": [
{ "key": "LOG_LEVEL", "value": "info", "kind": "recoverable", "description": "Log verbosity" }
]
}'To replace the whole set, add prune: true. Every variable not in your list is deleted. prune: true with an empty list deletes them all.
environments.removeEnvironmentVariables deletes the keys you name, and ignores keys that don't exist. environments.listEnvironmentVariables returns each variable's key, kind, createdAt, and description, plus value for recoverable ones only.
Limits
| Rule | Limit |
|---|---|
| Key | 1 to 256 characters matching ^[A-Za-z_][A-Za-z0-9_]*$: letters, digits, and underscores, not starting with a digit. |
| Value | 1 to 16384 bytes of UTF-8. Multibyte characters count as more than one byte. Newlines inside the value are kept. The dashboard trims surrounding whitespace and rejects carriage returns. |
| Description | Up to 255 characters, optional. |
| Variables per API call | Up to 50 in one setEnvironmentVariables or removeEnvironmentVariables call. |
| Duplicate keys in one call | Rejected with 400. |
There's no limit on how many variables an environment can have.
Variables Unkey injects
At build time, your variables are available as described in Build-time secrets. At run time, they're set as environment variables in each instance, along with these:
| Variable | Value |
|---|---|
PORT |
The environment's configured port. Your app must listen on it. |
UNKEY_DEPLOYMENT_ID |
The deployment's ID. |
UNKEY_ENVIRONMENT_SLUG |
production or preview. |
UNKEY_REGION |
The region the instance runs in. |
UNKEY_GIT_COMMIT_SHA |
The commit the deployment was built from. Empty for image deployments. |
UNKEY_GIT_BRANCH |
The branch of that commit. Empty for image deployments. |
UNKEY_GIT_REPO |
The repository as owner/repo. Empty for image deployments. |
UNKEY_GIT_COMMIT_MESSAGE |
The commit message. Empty for image deployments. |
UNKEY_INSTANCE_ID |
The instance's own ID. |
UNKEY_EPHEMERAL_DISK_PATH |
/data, only when the environment has ephemeral disk. |
If one of your variables has the same name as one of these, ours wins. KUBERNETES_SERVICE_HOST and the other KUBERNETES_* variables some libraries look for are set to empty strings.
Next steps
Compute Configure Health Checks (`docs/compute/configure/health-checks.mdx`)
title: Health checks
sources:
- svc/api/openapi/spec/common/EnvironmentHealthcheck.yaml
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/routes/v2_environments_update_settings/handler.go
- svc/krane/internal/deployment/apply.go
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/runtime-settings/healthcheck/schema.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/runtime-settings/healthcheck/index.tsx
- web/apps/dashboard/lib/collections/deploy/environment-settings.ts
A health check tells us how to tell whether your
What the health check does
- An instance gets no traffic until it passes. So a deployment can wait for your app to finish starting before the first request arrives.
- An instance that fails
failureThresholdchecks in a row is restarted.
A GET check passes on any status from 200 to 399. Anything else, including a timeout, is a failure.
Without a health check, an instance is ready as soon as its container is running, and it's only restarted if the process exits. That's fine for apps that are ready as soon as they open the port. Add a check if startup takes a while, or if your process can be running but unable to serve.
Choose what to check
Check the process, not its dependencies. A failing check restarts the container, so a check that queries your database takes every instance down when the database has a bad minute. GET /healthz returning 200 is enough.
Add a health check
In the dashboard, go to App settings > Runtime and use the Health check group: pick GET or POST, enter the path under Endpoint, and set the Interval. Saving there applies the check to every environment of the app. In the API, set healthcheck in runtime settings with environments.updateSettings:
curl -X POST https://api.unkey.com/v2/environments.updateSettings \
-H "Authorization: Bearer $UNKEY_ROOT_KEY" \
-H "Content-Type: application/json" \
-d '{
"project": "payments",
"app": "api",
"environment": "production",
"healthcheck": {
"method": "GET",
"path": "/healthz",
"intervalSeconds": 10,
"timeoutSeconds": 3,
"failureThreshold": 3,
"initialDelaySeconds": 5
}
}'Like every runtime setting, it applies from the next deployment. To remove the check, clear the Endpoint path in the dashboard, or send "healthcheck": null.
Fields
Only method and path are required. The others use the defaults below.
The HTTP method. POST sends an empty body using wget inside your container, so your image must include wget.
The path to request on your app's port, 1 to 512 characters, starting with / and matching ^(/[\w\-]+)+(\.[\w]+)?$, for example /healthz.
Seconds between checks, 1 to 3600. The dashboard's Interval field takes whole seconds and starts at 10.
Seconds to wait for a response before the check fails, 1 to 3600. Only settable in the API. Saving from the dashboard keeps the current value, or sets 5 for a new check.
Failures in a row before the instance is restarted, 1 to 100. Only settable in the API. Saving from the dashboard keeps the current value, or sets 3 for a new check.
Seconds after the container starts before the first check, 0 to 3600. Only settable in the API. Saving from the dashboard keeps the current value, or sets 0 for a new check.
Next steps
Compute Configure Limits (`docs/compute/configure/limits.mdx`)
title: Compute limits
sidebarTitle: "Limits"
sources:
- web/internal/db/src/schema/limits.ts
- web/apps/dashboard/lib/limits.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/settings/limits/limit-groups.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/settings/limits/breach-banner.tsx
- svc/api/routes/v2_environments_update_settings/handler.go
- svc/api/routes/v2_environments_set_environment_variables/handler.go
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/openapi/spec/paths/v2/gateway/setPolicies/V2GatewaySetPoliciesRequestBody.yaml
- svc/ctrl/worker/deploy/deploy_handler.go
- svc/ctrl/services/customdomain/service.go
- pkg/deploy/deployfail/messages.go
- svc/ctrl/worker/deploy/build_handler.go
- svc/ctrl/worker/cron/buildlimitsync/handler.go
- svc/api/routes/v2_workspace_get_limits/handler.go
Your Compute plan sets how much CPU, memory, and disk your
Settings > Limits shows these limits with your current usage once you have a Compute plan. A limit you've reached shows the banner "You've reached a limit". To read the same limits and usage from code, call workspace.getLimits with a root key that has unkey:v1:{workspace_id}:limits#read.
Limits per plan
| Limit | Starter | Pro | Business | Checked |
|---|---|---|---|---|
| CPU per instance | 2 vCPU | 8 vCPU | 16 vCPU | When you save runtime settings |
| Memory per instance | 2 GiB | 8 GiB | 32 GiB | When you save runtime settings |
| Ephemeral disk per instance | 10 GiB | 10 GiB | 10 GiB | When you save runtime settings |
| Workspace CPU | 30 vCPU | 120 vCPU | 240 vCPU | When a deployment starts |
| Workspace memory | 60 GiB | 240 GiB | 480 GiB | When a deployment starts |
| Workspace ephemeral disk | 120 GiB | 480 GiB | 960 GiB | When a deployment starts |
| Replicas per region | 4 | 8 | 16 | When you save regions |
| Concurrent builds | 1 | 1 | 1 | While deployments are in flight |
| Custom domains per workspace | 1 | Unlimited | Unlimited | When you add a domain |
| Log query range | 3 days | 7 days | 14 days | On every analytics API query |
Without a Compute plan you can't deploy. See Compute plans. "Unlimited" custom domains has a very high cap to stop abuse. Need a higher limit on your plan? Ask [email protected].
Saving a size over your plan's limit
CPU, memory, and disk in runtime settings are checked when you save them. Go over and you get 400 with a message like "Memory per instance cannot exceed 2048 MiB. Contact [email protected] to increase it." The dashboard only offers sizes your plan allows, so you'll only see this error from the API or CLI.
A deployment fails with a quota error
Workspace CPU, memory, and disk are checked when a deployment starts deploying, because they depend on what's already running. We add up what all your running instances use, plus this deployment's CPU, memory, and disk at its maximum replicas in each region. If the total is over a limit, the deployment fails with cpu_quota_exceeded, memory_quota_exceeded, or storage_quota_exceeded and the message "We are unable to deploy this application as you have exceeded your CPU quota" (or Memory, or Storage).
Because the check uses maximum replicas, an environment with a wide autoscaling range counts at its maximum even while it runs at the minimum.
To make room, lower an
Replicas per region
Each region's max replicas can't go above your plan's limit, and min is at least 1. Go over and you get 400 with, on Starter, "Region 'x' replicas must be between 1 and 4." On every plan, all regions in an environment must use the same bounds, and an environment can have at most 5 regions.
Deployments wait their turn
Every plan runs one deployment at a time per workspace. Others wait in pending for up to an hour, production ahead of preview, and fail if their turn doesn't come. See Builds.
Custom domain limit reached
Adding a domain over your limit fails with err:unkey:limits:custom_domain_limit_exceeded. The dashboard shows "Custom domain limit reached" with links to your limits and to upgrade. Removing a domain frees up room.
Log query range
The log query range limits how far back an analytics API query can reach. It's separate from how long we keep the data, and you can read whichever is shorter:
- Request log rows are stored for 7 days, so a Business workspace reads at most 7 days of them even though its query range allows 14.
- Runtime logs are stored for about 90 days, so a Starter workspace reads at most 3 days of them even though the rows exist for far longer.
A query that reaches further back than your plan allows fails with err:user:bad_request:query_range_exceeds_retention instead of returning partial results. This only applies to the analytics API, not the dashboard's log views. See Observability for how long data is kept.
Limits that are the same on every plan
| What | Limit |
|---|---|
| Regions per environment | 5 |
| Watch paths | 10 patterns of up to 500 characters |
| Build command | 1000 characters |
| Container command | 10 arguments of up to 4096 characters |
| Environment variables per request | 50; key up to 256 characters, value up to 16384 bytes |
| Gateway policies per environment | 50, with up to 10 match expressions each |
What has no limit
There's no limit on the number of projects, apps, or deployments in a workspace, or on the number of environment variables in an environment. Every app has exactly two environments. Compute is billed by what runs, so the limits are on CPU, memory, and disk, not on how many things you create.
Next steps
Compute Configure Runtime Settings (`docs/compute/configure/runtime-settings.mdx`)
title: Runtime settings
sources:
- svc/api/openapi/spec/paths/v2/environments/updateSettings/V2EnvironmentsUpdateSettingsRequestBody.yaml
- svc/api/openapi/spec/common/EnvironmentRuntime.yaml
- svc/api/openapi/spec/common/EnvironmentRegion.yaml
- svc/api/openapi/spec/common/Replicas.yaml
- svc/api/openapi/spec/common/EnvironmentShutdownSignal.yaml
- svc/api/openapi/spec/common/EnvironmentUpstreamProtocol.yaml
- svc/api/routes/v2_environments_update_settings/handler.go
- svc/ctrl/services/app/create_app.go
- svc/ctrl/worker/openapi/scrape_handler.go
- svc/krane/internal/deployment/apply.go
- svc/krane/internal/deployment/consts.go
- svc/frontline/internal/proxy/transport.go
- pkg/deploy/deployfail/messages.go
- web/apps/dashboard/lib/limits.ts
- web/apps/dashboard/lib/compute/sizing.ts
- web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/apps/_components/settings/environment-provider.tsx
- web/apps/dashboard/app/(app)/[w