API keys
Mint, list, update, rotate and revoke the keys your customers present.
packages/api/src/resources/keys.controller.ts by packages/docs/tools/generate-reference.mjs. Every example is a real exchange recorded against the control plane by packages/api/src/testing/reference-examples.spec.ts, with ids from that run. Credentials in the formats this product issues (kr_, krsk_, krses_, JWTs) and values under credential-named fields (password, *_secret, *_token, *_key, lookup_hash) are elided at record time; the recorded file and every page of this site are swept for credential shapes (kr_, krsk_, krses_, JWTs, URLs carrying a password, password fields) before either is accepted. Do not edit by hand: src/reference.spec.ts regenerates it and fails on a difference.POST /v1/keys
Answers 201 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Body. Validated by this schema, from the control plane's own source:
const CreateKey = z.object({
project_id: z.uuid(),
tenant_id: z.uuid(),
env: Env,
name: Name.optional(),
scopes: Scopes.optional(),
meta: Meta.optional(),
/** Omitted inherits the project default; `[]` is "no limits on this key". */
rate_limits: RateLimits.optional(),
expires_at: Timestamp.optional(),
});Example.
curl https://keyring-api.belghalem.fr/v1/keys \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "test",
"name": "CI",
"scopes": [
"orders:read",
"orders:write"
]
}'HTTP/1.1 201 Created
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b916-78a6-801e-79f4ba8233fb",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "test",
"display_prefix": "kr_test_KVME4u",
"name": "CI",
"scopes": [
"orders:read",
"orders:write"
],
"meta": {},
"rate_limits": null,
"expires_at": null,
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.700Z",
"last_used_at": null,
"key": "kr_test_KVME4u…",
"key_shown_once": true
}Example: A live key with its own limits and an expiry.
curl https://keyring-api.belghalem.fr/v1/keys \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"name": "Production",
"scopes": [
"orders:read"
],
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"window_ms": 1000,
"algorithm": "sliding",
"scope": "key"
}
],
"expires_at": "2027-01-01T00:00:00Z"
}'HTTP/1.1 201 Created
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_-sPZ4f",
"name": "Production",
"scopes": [
"orders:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": "2027-01-01T00:00:00.000Z",
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.709Z",
"last_used_at": null,
"key": "kr_live_-sPZ4f…",
"key_shown_once": true
}Example: A test credential naming live.
curl https://keyring-api.belghalem.fr/v1/keys \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"name": "Production",
"scopes": [
"orders:read"
]
}'HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": {
"code": "forbidden",
"message": "A test secret key cannot manage live resources."
}
}GET /v1/keys
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Query. Validated by this schema, from the control plane's own source:
const ListKeys = z.object({
project_id: z.uuid().optional(),
tenant_id: z.uuid().optional(),
env: Env.optional(),
include_revoked: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
limit: z.coerce.number().int().min(1).max(200).default(50),
});Example.
curl https://keyring-api.belghalem.fr/v1/keys?project_id=01a0ade7-b8fb-7711-bb1a-7d1bd5962995&env=test \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "list",
"data": [
{
"object": "api_key",
"id": "01a0ade7-b916-78a6-801e-79f4ba8233fb",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "test",
"display_prefix": "kr_test_KVME4u",
"name": "CI",
"scopes": [
"orders:read",
"orders:write"
],
"meta": {},
"rate_limits": null,
"expires_at": null,
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.700Z",
"last_used_at": null
}
]
}GET /v1/keys/:id
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. id.
Example.
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b91e-75e6-a213-7dc943e17366 \
-H "Authorization: Bearer $KEYRING_SECRET_KEY"HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_-sPZ4f",
"name": "Production",
"scopes": [
"orders:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": "2027-01-01T00:00:00.000Z",
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.709Z",
"last_used_at": null
}PATCH /v1/keys/:id
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. id.
Body. Validated by this schema, from the control plane's own source:
const UpdateKey = z.object({
name: Name.nullable().optional(),
scopes: Scopes.optional(),
meta: Meta.optional(),
/** `null` puts the key back on the project default. */
rate_limits: RateLimits.nullable().optional(),
expires_at: Timestamp.nullable().optional(),
});Example.
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b91e-75e6-a213-7dc943e17366 \
-X PATCH \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production (EU)",
"scopes": [
"orders:read",
"refunds:read"
]
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_-sPZ4f",
"name": "Production (EU)",
"scopes": [
"orders:read",
"refunds:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": "2027-01-01T00:00:00.000Z",
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.709Z",
"last_used_at": null
}POST /v1/keys/:id/rotate
Rotation with an overlap window (report section 7.4). The successor is a new key -- a rotation that reused the material would not be a rotation -- and the predecessor keeps working until the window closes, so a fleet can pick the new key up without a synchronised restart.
The window shortens an existing expiry but never extends one: rotating a key that already expires in an hour must not buy it another day.
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. id.
Body. Validated by this schema, from the control plane's own source:
Report section 7.4: default 24 h, hard maximum 7 days.
const RotateKey = z.object({
overlap_hours: z.number().min(0).max(168).default(24),
});Example.
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b91e-75e6-a213-7dc943e17366/rotate \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"overlap_hours": 24
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b935-77fe-bc3a-7dd4ef3c8dbe",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_mPXMEX",
"name": "Production (EU)",
"scopes": [
"orders:read",
"refunds:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": null,
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"created_at": "2026-09-17T05:47:19.731Z",
"last_used_at": null,
"key": "kr_live_mPXMEX…",
"key_shown_once": true,
"previous_key": {
"object": "api_key",
"id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_-sPZ4f",
"name": "Production (EU)",
"scopes": [
"orders:read",
"refunds:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": "2026-09-18T05:47:19.731Z",
"revoked_at": null,
"revoked_reason": null,
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.709Z",
"last_used_at": null
}
}Example: Rotating a revoked key.
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b91e-75e6-a213-7dc943e17366/rotate \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"overlap_hours": 24
}'HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": {
"code": "conflict",
"message": "A revoked key cannot be rotated."
}
}POST /v1/keys/:id/revoke
Idempotent on purpose. Revocation is what a customer reaches for during an incident; making the second call fail is hostile at exactly the wrong moment. A repeat is not a mutation, so it writes no audit row.
Answers 200 on success.
Authentication.
- A
krsk_secret key or akrses_dashboard session, asAuthorization: Bearer.
Path parameters. id.
Query. Validated by this schema, from the control plane's own source:
Report section 2.6's revoke-and-wait, and the answer to the security-review
question every enterprise buyer asks. wait=true turns "we told your fleet"
into "your fleet told us", which is the difference between a promise and a
receipt.
const RevokeQuery = z.object({
wait: z
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
});Body. Validated by this schema, from the control plane's own source:
const RevokeKey = z.object({ reason: z.string().min(1).max(500).optional() });Example.
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b91e-75e6-a213-7dc943e17366/revoke \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Rotation complete"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_-sPZ4f",
"name": "Production (EU)",
"scopes": [
"orders:read",
"refunds:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": "2026-09-18T05:47:19.731Z",
"revoked_at": "2026-09-17T05:47:19.752Z",
"revoked_reason": "Rotation complete",
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": null,
"created_at": "2026-09-17T05:47:19.709Z",
"last_used_at": null
}Example: With a propagation receipt.
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b935-77fe-bc3a-7dd4ef3c8dbe/revoke?wait=true \
-X POST \
-H "Authorization: Bearer $KEYRING_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Customer offboarded"
}'HTTP/1.1 200 OK
Content-Type: application/json
{
"object": "api_key",
"id": "01a0ade7-b935-77fe-bc3a-7dd4ef3c8dbe",
"project_id": "01a0ade7-b8fb-7711-bb1a-7d1bd5962995",
"tenant_id": "01a0ade7-b909-7ae2-9477-aa287f446aad",
"env": "live",
"display_prefix": "kr_live_mPXMEX",
"name": "Production (EU)",
"scopes": [
"orders:read",
"refunds:read"
],
"meta": {},
"rate_limits": [
{
"id": "per-second",
"limit": 20,
"scope": "key",
"algorithm": "sliding",
"window_ms": 1000
}
],
"expires_at": null,
"revoked_at": "2026-09-17T05:47:19.763Z",
"revoked_reason": "Customer offboarded",
"quarantined_at": null,
"quarantine_reason": null,
"rotated_from": "01a0ade7-b91e-75e6-a213-7dc943e17366",
"created_at": "2026-09-17T05:47:19.731Z",
"last_used_at": null,
"propagation": {
"version": 9,
"complete": true,
"waited_ms": 253,
"acked_nodes": 1,
"total_nodes": 1,
"nodes": [
{
"node_id": "api-eu-1",
"env_scope": "all",
"version": 9,
"acked": true,
"last_seen_at": "2026-09-17T05:47:19.969Z"
}
]
}
}Shared validators
Defined once in packages/api/src/validation.ts and used by the schemas above.
const Env = z.enum(['live', 'test']);const Meta = z.record(z.string(), z.unknown());const Name = z.string().min(1).max(200);The same bounds migration 0008's keyring.rate_limits_valid checks, so a bad
set is a 400 with a field path rather than a 500 wrapping a constraint
violation. The database keeps the check regardless: this API is not the only
thing that will ever write that column, and a limit is an authorisation
bound.
window_ms has a floor of one second because these counters are one network
hop away (report section 3.2): a 100 ms window enforced across an RTT is a
number we cannot honestly claim to hold. The ceiling is 31 days, which is the
longest period anyone means by "per month".
const RateLimits = z
.array(
z.object({
id: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/),
limit: z.number().int().min(1).max(1_000_000_000),
window_ms: z.number().int().min(1_000).max(2_678_400_000),
algorithm: z.enum(['sliding', 'fixed']),
scope: z.enum(['key', 'tenant']),
}),
)
.max(8)
.refine(
(rules) => new Set(rules.map((rule) => rule.id)).size === rules.length,
// Two limits of one name share a counter and disagree about its bound, and
// `RateLimit-Policy` is a structured-field dictionary that cannot express
// the duplicate at all.
{ message: 'rate limit ids must be unique' },
);const Scopes = z.array(z.string().min(1).max(200)).max(64);const Timestamp = z.iso.datetime({ offset: true });