API keys

An API key (real prefix cat_pk_..., not cat_ak_...) is a long-lived bearer credential the runtime authenticates — the control plane never checks one. It's scoped to one customer (workspace). The full secret is returned exactly once, in the create response, and never again.

For the wire-level reference, see API → API keys.

Auth. GET/POST /v1/api-keys and DELETE /v1/api-keys/{key_id} all require the control plane's x-catentio-session header, which this SDK never sends. This is true even though the keys this namespace manages are Bearer credentials — that's because those keys are verified by the runtime when used elsewhere, not by the control plane route that manages them. See SDKs → Auth: what actually works.

Namespace

catentio.apiKeys — three methods:

catentio.apiKeys.list()
catentio.apiKeys.create(input?)
catentio.apiKeys.delete(id)

There is no get — the full secret is only ever revealed at create time. There is no update — rotate by delete + create.

Methods

apiKeys.list

Signature. catentio.apiKeys.list(token?): Promise<{ data: ApiKey[]; meta: { total: number } }>

Scoped automatically to the caller's customer id (from the session) — there's no cross-workspace listing. The SDK does not unwrap this response (see note below); you get the whole {data, meta} object.

import { CatentioSaasClient } from '@forjio/catentio-saas-node';

const catentio = new CatentioSaasClient({ session });

const { data: keys } = await catentio.apiKeys.list();
for (const k of keys) {
  console.log(k.id, k.name, k.key_prefix, k.last_used_at);
}

apiKeys.create

Signature. catentio.apiKeys.create(input?, token?): Promise<ApiKeyCreated>

Mints a new key. The full secret is in the response under secret and is the only time it's ever visible — store it immediately.

const created = await catentio.apiKeys.create({ name: 'CI runner — staging' });

console.log(created.secret);  // 'cat_pk_...' — store now, never seen again
console.log(created.id);      // an integer — store for later revocation

Input fields (real, per the runtime's create_api_key):

Field Type Notes
name string Required, 1–120 chars. The field is name, not label.
scopes object Accepted but ignored on this path — the control plane pops scopes from the body before forwarding to the runtime, so a key created through this SDK always gets {} (full access to the customer's own data). Scopes are not implemented in v1. There is no expiresAt/expiration field at all — keys don't expire.

Subject to a tier cap on the number of keys per customer; exceeding it returns 403 with detail.code = "plan_limit_exceeded".

apiKeys.delete

Signature. catentio.apiKeys.delete(id, token?): Promise<void>

The real path parameter is an integer key id (e.g. 42), not an opaque string like apk_.... The SDK's signature declares id: string; pass the id's string form (String(42)) — it's interpolated straight into the URL.

The control plane checks that the key belongs to the caller's own customer before revoking; if it doesn't, you get 403 with detail = "not_your_key". Revocation is a soft-delete (revoked_at is set); the row stays for audit. There's no expiry grace window — a revoked key stops authenticating immediately.

await catentio.apiKeys.delete('42');

Response shape

Wire responses never include an error key, so the SDK's envelope-unwrap (which only fires when a body has data, error, and meta) never triggers here. list() returns the raw {data, meta} object; create()/delete() return the bare object/void the route sends.

Types

Real key row (list's data items):

interface ApiKey {
  id: number;
  customer_id: string;
  key_prefix: string;        // e.g. 'cat_pk_AbC1' — first 12 chars of the real secret
  name: string;
  scopes: Record<string, unknown>;   // always {} today
  created_at: string;
  last_used_at: string | null;
  revoked_at: string | null;
}

interface ApiKeyCreated extends ApiKey {
  secret: string;   // full 'cat_pk_...' — present ONLY in the create response
}

There is no label, maskedKey, expiresAt, lastUsedAt (camelCase), createdAt (camelCase), or createdBy field — the wire format is snake_case, and expiration doesn't exist.

Common patterns

Mint a key, store it, never echo the secret

const created = await catentio.apiKeys.create({ name: 'github-actions-ci' });
await writeSecret('CATENTIO_API_KEY', created.secret);
console.log(`Stored key ${created.id} (${created.key_prefix})`);

Rotate a key

const next = await catentio.apiKeys.create({ name: 'CI runner — staging (rotated)' });
await deployToCI(next.secret);
await waitForCIRotation();
await catentio.apiKeys.delete(String(oldKeyId));

Find dead keys

const { data: keys } = await catentio.apiKeys.list();
const ninetyDaysAgo = Date.now() - 90 * 86_400_000;
const stale = keys.filter(k => !k.last_used_at || new Date(k.last_used_at).getTime() < ninetyDaysAgo);
for (const k of stale) {
  console.log(`Stale: ${k.name} (last used ${k.last_used_at ?? 'never'})`);
}

Errors

The real error body is FastAPI's {"detail": ...}. Because the wire response never has an error key, the SDK doesn't parse out a structured code/message for failures — you get CatentioError with a generic HTTP_ERROR code and the HTTP reason phrase as the message; the raw detail value lands in .details.

Status detail Cause
401 auth_required (etc.) Missing/invalid session — this SDK never sends x-catentio-session.
403 "not_your_key" delete() on a key that belongs to a different customer.
403 {"code": "plan_limit_exceeded", ...} create() past the plan's key-count cap.
404 "api key {id} not found" delete() on a key id that doesn't exist.
400 create() with a missing or >120-char name.

Next

  • API → API keys — HTTP-level reference.
  • SDKs — the auth reality that applies to every namespace, including this one.