
# Node.js management SDK

`@keenauth/node` is the TypeScript SDK for the KeenAuth management API. It needs Node.js 22 or later and has no runtime dependencies. It is not published to npm yet.

The package has these entry points:

| Import | Use it for |
|---|---|
| `@keenauth/node` | The management API client, management tokens, pagination and errors |
| `@keenauth/node/webhooks` | Verifying webhook signatures, without loading the API client |
| `@keenauth/node/bff` | Sign-in for server apps: [Express](/docs/sdk/express) and [Fastify](/docs/sdk/fastify) |
| `@keenauth/node/middleware` | [Protecting your API](/docs/sdk/protect-api) with bearer tokens |
| `@keenauth/node/testing` | The [offline test kit](/docs/sdk/testing) |

## Create a client

With a secret or restricted API key:

```ts
import { KeenAuth } from "@keenauth/node";

const keenauth = new KeenAuth({
  baseUrl: "https://api.eu.keenauth.com/v1",
  apiKey: process.env.KEENAUTH_API_KEY!,
});

const user = await keenauth.users.getUser({ userId: "usr_01j9zzzzzzzzzzzzzzzzzzzz" });
```

`baseUrl` is your region's API address with `/v1`. It must use `https`; plain `http` is accepted only for `localhost`. The key is sent only in the `Authorization` header and only to that address. Redirects are not followed.

## Authenticate without an API key

A `management` application can call the API with a short-lived token instead of a static key. The application signs a client assertion with its private key (`private_key_jwt`, RFC 7523), and the SDK exchanges it at your environment's token endpoint.

1. In the dashboard, create an application of type `management` and a management client record with the permissions it needs (see [Create a management client](/docs/api/management/management-clients/create)).
2. Generate a key pair and register the public key as a `public_key` credential of the application. For example, for ES256: `openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out management-key.pem`.
3. Keep the private key in your secret store. Pass it to the SDK:

```ts
import { readFileSync } from "node:fs";
import { KeenAuth } from "@keenauth/node";

const keenauth = new KeenAuth({
  baseUrl: "https://api.eu.keenauth.com/v1",
  credentials: {
    issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
    clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
    privateKey: readFileSync("management-key.pem", "utf8"), // PEM, a private JWK or a KeyObject
    scope: ["users:read", "users:write"], // optional; default: the application's default scopes
  },
});
```

What the SDK does:

- It sends `grant_type=client_credentials` to `<issuer>/oauth2/token` with `resource` set to the management API identifier. The default is the origin of `baseUrl`, for example `https://api.eu.keenauth.com`; set `credentials.audience` to change it.
- Each request gets a new client assertion: `iss` and `sub` are the client ID, `aud` is the issuer, `jti` is random and the assertion is valid for 120 seconds. The `kid` header is the key's RFC 7638 thumbprint, which is how KeenAuth finds the key when the application has several (for key rotation).
- The algorithm follows the key: RSA keys sign with `RS256` (set `alg: "PS256"` for RSA-PSS), P-256 keys with `ES256` and Ed25519 keys with `EdDSA`. RSA keys must have at least 2048 bits.
- The access token is cached and refreshed 60 seconds before it expires. Concurrent calls share one refresh. If the API answers `401`, the cached token is dropped and the next call gets a new one.

A failed token request throws `TokenError` with the OAuth `code` (for example `invalid_client` or `invalid_scope`), `description` and `status`. A configuration error, such as a public key instead of a private key, throws when you create the client.

`apiKey` and `credentials` cannot be used together.

## Retries

The SDK retries a request at most 3 times in total when the API answers `429`, `502`, `503` or `504`, or when the connection fails. It retries only `GET`, `PUT` and `DELETE` requests and `POST` requests that carry an `Idempotency-Key`. A `PATCH` that carries `If-Match` is retried on `429` only: the rate limiter refuses a request before it runs, and `If-Match` keeps a change from being applied twice. Any other `PATCH` is never retried.

- If the response has a `Retry-After` header (seconds or an HTTP date), the SDK waits that long. A `Retry-After` longer than 60 seconds is not waited for: the SDK returns that response to you.
- Otherwise it waits 0.5 seconds, then 1 second, each with ±25% random jitter.
- `timeoutMs` (default 30 seconds) limits the whole call, retries included. If a wait would go past it, the SDK returns the last response instead.

The SDK adds a random `Idempotency-Key` to every `POST` whose operation accepts one, so a retried create does not create a second object. To make your own retries safe as well, pass the same key yourself:

```ts
await keenauth.users.createUser({
  userCreateRequest: { email: "ada@example.com" },
  idempotencyKey: "signup-7d1f0c2a-0d7e-4c55",
});
```

## Pagination

List endpoints return one page: `data` and `listMetadata` with the `before` and `after` cursors. `paginate` follows the cursors for you:

```ts
import { paginate } from "@keenauth/node";

for await (const user of paginate((p) => keenauth.users.listUsers(p), { limit: 100 })) {
  console.log(user.id);
  if (user.email === "ada@example.com") break; // stops without loading the next page
}
```

- Without `before`, it walks forward with `after` until the cursor is `null`. With `before`, it walks backward.
- `limit` is the page size (1-100) and is sent with every request; the other parameters are kept as well.
- `paginatePages` gives you whole pages instead of items.
- A server that returns the same cursor twice stops the loop with `PaginationError`.

## Errors

An error response becomes a `ProblemError` (RFC 9457) with `status`, `code`, `title`, `detail`, `requestId` and field `errors`. Check `code`, not the message. Codes the SDK does not know stay available as strings.

```ts
import { ProblemError } from "@keenauth/node";

try {
  await keenauth.users.getUser({ userId });
} catch (e) {
  if (e instanceof ProblemError && e.code === "not_found") {
    // …
  }
}
```

A response without a problem document, such as an HTML page from a proxy, becomes the generated `ResponseError` with the raw `response`.

## Verify webhooks

KeenAuth signs webhooks with the Standard Webhooks v1 scheme. Verify the signature with the raw request body, before you parse it:

```ts
import express from "express";
import { Webhook, WebhookVerificationError } from "@keenauth/node/webhooks";

const webhook = new Webhook(process.env.KEENAUTH_WEBHOOK_SECRET!); // idp_whsec_…

const app = express();
app.post("/webhooks/keenauth", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = webhook.verify(req.body, req.headers);
  } catch (e) {
    if (e instanceof WebhookVerificationError) return res.status(400).send(e.kind);
    throw e;
  }
  // handle event.type, then answer quickly
  res.sendStatus(204);
});
```

- The SDK checks the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. The timestamp must be within 5 minutes of your server's clock, in either direction.
- During a secret rotation, pass both secrets: `new Webhook([newSecret, oldSecret])`. A request is accepted when any `v1` signature matches any secret.
- Signatures are compared in constant time.
- `e.kind` tells you what failed: `headers` (a header is missing), `timestamp` (not a number or outside the tolerance), `signature` (no signature matches) or `secret` (the configured secret is not a valid `idp_whsec_` value, a configuration error raised by `new Webhook`). Each kind also has its own class, such as `WebhookSignatureError`.
- `verify` returns the parsed JSON body. `verifyRaw` returns the verified bytes.

Webhooks can arrive more than once. Use `webhook-id` to ignore duplicates.

## Typed events and claims

`@keenauth/node/events` has a type for every customer event, generated from the public event schemas. `parseEvent` checks a verified body and returns `KeenAuthEvent | UnknownEvent`. After `isKnownEvent`, `switch (event.type)` narrows the event to one type. `verifyEvent` in `@keenauth/node/webhooks` does both steps:

```ts
import { verifyEvent } from "@keenauth/node/webhooks";
import { isKnownEvent } from "@keenauth/node/events";

const event = verifyEvent(webhook, req.body, req.headers); // WebhookVerificationError or EventParseError
if (!isKnownEvent(event)) {
  // A type this SDK version does not know yet. event.type and event.data.object.id are still typed.
  return res.sendStatus(204);
}
switch (event.type) {
  case "user.created":
    console.log(event.data.object.id);
    break;
  case "email_sending.suspended":
    console.log(event.data.object.reason); // "bounce_rate" | "complaint_rate" | (string & {})
    break;
}
```

- `parseEvent` accepts JSON text, bytes or an already-decoded value. It checks the envelope and the members the type's schema declares. A missing or wrong-typed member throws `EventParseError` with its path, such as `data.object.reason`.
- An unknown type, or a known type with a `schema_version` this SDK does not model, is an `UnknownEvent`. It is not an error.
- Members that the schema does not declare stay on the object.
- Enumerated fields are typed as the known values plus `string`, so a new value from the server still type-checks and parses.

`@keenauth/node/claims` types the claims of a verified token: `parseIdTokenClaims` (OpenID Connect Core) and `parseAccessTokenClaims` (RFC 9068). They check claim types but verify nothing, so call them on the `claims` that the verifier returns:

```ts
import { parseAccessTokenClaims } from "@keenauth/node/claims";

const at = await verifier.verifyAccessToken(token);
const claims = parseAccessTokenClaims(at.claims);
claims.client_id; // string
claims.org_roles; // string[] | undefined
claims["tenant_tier"]; // unknown: your own claims
```

A claim that does not fit throws `ClaimsParseError` with its name.

## Deprecation warnings

When a response carries a `Deprecation` (RFC 9745) or `Sunset` (RFC 8594) header, the SDK writes one warning per endpoint and header value for the life of the process. The warning names the method, the path with IDs replaced by `{id}` and the header values; it never contains tokens, query strings or user data. It never throws.

By default the warning goes to `process.emitWarning` with the type `KeenAuthDeprecationWarning`. To send it to your logger instead:

```ts
const keenauth = new KeenAuth({
  baseUrl: "https://api.eu.keenauth.com/v1",
  apiKey: process.env.KEENAUTH_API_KEY!,
  onWarning: (message) => logger.warn(message),
});
```

## Telemetry header

Every request carries `Idp-Client: keenauth-ts/<version> (node/<version>)`. It holds no personal data and cannot be turned off; KeenAuth uses it for support and to tell you about deprecated SDK versions.
