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-keysandDELETE /v1/api-keys/{key_id}all require the control plane'sx-catentio-sessionheader, 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 likeapk_.... The SDK's signature declaresid: 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.