Keyring

API keys

Mint, list, update, rotate and revoke the keys your customers present.

Generated from 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 a krses_ dashboard session, as Authorization: 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.

Request
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"
    ]
  }'
Response
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.

Request
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"
  }'
Response
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.

Request
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"
    ]
  }'
Response
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 a krses_ dashboard session, as Authorization: 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.

Request
curl https://keyring-api.belghalem.fr/v1/keys?project_id=01a0ade7-b8fb-7711-bb1a-7d1bd5962995&env=test \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY"
Response
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 a krses_ dashboard session, as Authorization: Bearer.

Path parameters. id.

Example.

Request
curl https://keyring-api.belghalem.fr/v1/keys/01a0ade7-b91e-75e6-a213-7dc943e17366 \
  -H "Authorization: Bearer $KEYRING_SECRET_KEY"
Response
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 a krses_ dashboard session, as Authorization: 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.

Request
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"
    ]
  }'
Response
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 a krses_ dashboard session, as Authorization: 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.

Request
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
  }'
Response
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.

Request
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
  }'
Response
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 a krses_ dashboard session, as Authorization: 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.

Request
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"
  }'
Response
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.

Request
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"
  }'
Response
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 });

On this page