The server-side SDK for Qeet ID: manage an organisation's people and access, verify webhooks and access tokens, and run OAuth flows from Node.js.
π Docs Β· π Quick start Β· π§© API Β· π Changelog Β· ποΈ Architecture
Important
Use it on servers only. It authenticates with a secret API key that must never reach a browser.
| π§ Typed client | One QeetID client with 38 resource namespaces and 266 API calls, each checked against the server's OpenAPI documents. Every request, response and error code is typed |
| π¨ Typed errors | A QeetIDError hierarchy: one class per HTTP status, SessionRevokedError, OAuthError, NetworkError, TimeoutError. Each carries a stable code and the requestId |
| π Safe retries | Exponential backoff with jitter and Retry-After, only for requests that can't act twice. Idempotency keys sent automatically on the nine creates the API makes idempotent |
| π Pagination | for await walks every item across pages; whole lists come as { data, truncated } |
| πͺ Webhooks | Timestamped HMAC-SHA256 verification, constant-time, two secrets during a rotation, a five-minute replay window, typed events |
| π Access tokens | Local ES256 verification against the issuer's signing keys, with a required issuer and audience |
| π€ OAuth | Client credentials (with expires_in), authorization code, refresh, token exchange, device and CIBA grants, introspection, revocation, a token guard |
| πͺΆ Lean | No runtime dependencies (fetch, Web Crypto and node:crypto only), ES module and CommonJS builds, one file per module for tree shaking |
flowchart LR
subgraph you["π₯οΈ Your server"]
SDK["@qeet-id/node"]
end
SDK -->|"ApiKey Β· retries Β· idempotency keys"| API["βοΈ api.id.qeet.in"]
API -->|"signed webhooks"| SDK
App["π± Your apps"] -->|"access tokens"| SDK
SDK -.->|"JWKS, cached"| API
Node.js 22.12 or later; tested on 22, 24 and 26.
npm install @qeet-id/node
# or: bun add @qeet-id/node Β· pnpm add @qeet-id/node Β· yarn add @qeet-id/nodeCreate an API key in the Qeet ID console and keep it in a secret store.
import { QeetID } from "@qeet-id/node";
const qeetId = new QeetID({ apiKey: process.env.QEET_ID_API_KEY });
const tenantId = process.env.QEET_ID_TENANT_ID!;
const user = await qeetId.users.create({
tenant_id: tenantId,
email: "meera@brightleaffoods.in",
display_name: "Meera Iyer",
});
await qeetId.roles.assign(tenantId, user.id, financeRoleId);
const allowed = await qeetId.permissions.check({ user_id: user.id, tenant_id: tenantId, permission: "invoice.approve" });Create one client per process and share it: it is safe for concurrent use.
const qeetId = new QeetID({
apiKey: process.env.QEET_ID_API_KEY,
timeoutMs: 30_000,
maxRetries: 2,
appInfo: { name: "brightleaf-onboarding", version: "1.4.0" },
logger: console,
});| Option | Default | |
|---|---|---|
apiKey |
none | Sent as Authorization: ApiKey <key>. Every management call needs it; the OAuth token endpoints, directory sign-in and credential verification work without it |
baseUrl |
https://api.id.qeet.in |
Must be https; plain http only for localhost and *.localhost |
timeoutMs |
30000 |
Each attempt's limit, response body included |
maxRetries |
2 |
Retries after a transient failure, for retry-safe requests only; 0 turns them off |
headers |
{} |
Sent on every request; can't replace Authorization, Accept, Content-Type, User-Agent or Idempotency-Key |
appInfo |
none | { name, version?, url? }, put in front of the User-Agent |
logger |
none | Any object with debug, info, warn, error (console fits) |
fetch |
global fetch |
For a proxy, custom TLS or tests |
Every method takes an options object last, overriding the client for that call:
await qeetId.users.get(userId, { timeoutMs: 5_000, maxRetries: 0, signal: AbortSignal.timeout(8_000) });Identifiers come first, then the request body, then options. Request and response objects keep the API's
field names (display_name, created_at).
| Area | Namespaces |
|---|---|
| π₯ Identity | tenants (the organisation) Β· domains Β· users Β· groups Β· invitations Β· branding |
| π‘οΈ Access | roles Β· permissions Β· relationships (ReBAC tuples) Β· decisions (OpenID AuthZEN) Β· authPolicies Β· securityPolicies Β· ipRules Β· threatDetection (bots, risk, anomalies) |
| π Federation | oauth Β· applications (OAuth/OpenID clients and RFC 8707 resources) Β· servicePrincipals Β· samlConnections Β· samlServiceProviders Β· ldapConnections Β· scim Β· socialProviders Β· adminPortalLinks |
| π οΈ Developer | apiKeys Β· agents Β· verifiableCredentials Β· authHooks Β· webhooks Β· secrets Β· tokenVault |
| π Operations | auditLogs Β· analytics Β· billing Β· gdpr Β· emailTemplates Β· retention Β· logSinks Β· rateLimits |
const page = await qeetId.auditLogs.list(tenantId, { action: "user.deleted", from: "2026-11-01T00:00:00Z" });
await qeetId.webhooks.update(tenantId, webhookId, { events: ["user.created", "user.deleted"] });
const { secret } = await qeetId.webhooks.rotateSecret(tenantId, webhookId);
const { data: secrets, truncated } = await qeetId.secrets.list(tenantId);The SDK covers the calls an API key may make. A person's own account (their sessions, factors, passkeys, email changes) is theirs to manage through the hosted pages. What isn't wrapped yet is listed in docs/FUTURE.md.
Every failure is thrown. Branch on the class or on code, never on message.
| Class | When | Worth reading |
|---|---|---|
BadRequestError |
400 | code, detail |
AuthenticationError |
401 | code |
β³ SessionRevokedError |
401 auth.session_revoked |
sign in again; never retried |
PermissionDeniedError |
403 | code |
NotFoundError |
404 | |
ConflictError |
409 | code, such as idempotency.outcome_unknown |
ValidationError |
422 | fields: each field's code and message |
RateLimitError |
429 | retryAfterSeconds |
InternalServerError |
5xx | requestId |
β³ ServiceUnavailableError |
503 | retryAfterSeconds |
OAuthError |
an OAuth token endpoint refused | code is the RFC 6749 error, such as invalid_grant |
NetworkError Β· TimeoutError |
no response, or none in time | cause |
WebhookVerificationError |
a delivery failed verification | code, such as signature_mismatch |
TokenVerificationError |
an access token failed verification | code, such as token_expired |
All of them extend QeetIDError; the API ones extend ApiError, which also has status, requestId
(quote it to support), metadata, retryable and headers.
import { ConflictError, RateLimitError, ValidationError } from "@qeet-id/node";
try {
await qeetId.users.create({ tenant_id: tenantId, email });
} catch (error) {
if (error instanceof ValidationError) {
for (const field of error.fields) console.log(field.field, field.code, field.message);
} else if (error instanceof ConflictError && error.code === "user.domain_managed_elsewhere") {
// another organisation manages that email domain
} else if (error instanceof RateLimitError) {
console.log(`retry in ${error.retryAfterSeconds} s, request ${error.requestId}`);
} else {
throw error;
}
}A mistake in your own arguments (an empty id, a malformed idempotency key) is a TypeError or RangeError.
A cancelled call rejects with the signal's reason. instanceof works even when both the CommonJS and the ES
module build of this package are loaded in one process.
| β Retried | GET and DELETE; POSTs that change nothing (permission and relationship checks, AuthZEN evaluations, previews, introspection); requests carrying an Idempotency-Key |
| β‘ After | no response, a timeout, 408, 429, 500, 502, 503, 504, 409 idempotency.in_progress |
| β Never | PUT, PATCH and other POSTs; OAuth grants (single-use); directory sign-in (counts toward the lockout); 401 auth.session_revoked, whatever the server's retryable hint says |
| β±οΈ Waits | the server's Retry-After up to 60 s (a longer one is thrown as is), otherwise 0.5 s doubling to 8 s with jitter |
Each attempt has its own timeoutMs. An AbortSignal bounds the whole call, retries included:
await qeetId.users.get(userId, { signal: AbortSignal.timeout(10_000) });users.create, groups.create, invitations.create, servicePrincipals.create, apiKeys.create,
agents.create, webhooks.create, billing.checkout and billing.payInvoice always send an
Idempotency-Key: one generated per call, so the SDK's own retries never create twice. Pass your own to make
retries across calls, deployments or processes safe too:
await qeetId.users.create(
{ tenant_id: tenantId, email: "meera@brightleaffoods.in" },
{ idempotencyKey: `onboarding-${employee.id}` }, // 1 to 255 printable ASCII characters
);The API keeps the answer for 24 hours. The same key with another body is refused (422 idempotency.key_reused); 409 idempotency.outcome_unknown means the first request was interrupted: check
whether it took effect before trying again with a new key.
Paged lists (users, audit log, consent grants, SCIM users and groups, anomalies, decisions, webhook
deliveries, relationship objects) return a PagePromise: iterate it for every item, or await it for one page.
for await (const user of qeetId.users.list({ status: "active", limit: 100 })) {
console.log(user.email);
}
const page = await qeetId.users.list({ limit: 50 });
page.data; // this page
if (page.hasNextPage()) await page.getNextPage();
for await (const p of page.iterPages()) console.log(p.data.length);Other lists come whole as { data, truncated }; truncated is true when the API capped the list.
Each delivery is a signed POST whose body is an event:
{ "id": "β¦", "type": "user.created", "tenant_id": "β¦", "occurred_at": "2026-11-05T09:30:00Z", "data": { β¦ } }constructWebhookEvent checks X-Qeet-Signature (sha256= and the HMAC-SHA256 of X-Qeet-Timestamp, .
and the raw body) in constant time, refuses a delivery signed more than five minutes from now, and returns
the typed event. Always pass the raw body: a parsed and re-serialised one won't match.
Express
import express from "express";
import { constructWebhookEvent, WebhookVerificationError } from "@qeet-id/node";
app.post("/webhooks/qeet-id", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = constructWebhookEvent(req.body, req.headers, process.env.QEET_ID_WEBHOOK_SECRET!);
} catch (error) {
if (error instanceof WebhookVerificationError) return res.status(400).send(error.code);
throw error;
}
if (await alreadyHandled(event.id)) return res.sendStatus(204); // deliveries can repeat
if (event.type === "user.deleted") await removeLocalData(event.data);
res.sendStatus(204);
});Next.js route handler, or any Fetch API server
export async function POST(request: Request) {
const event = constructWebhookEvent(await request.text(), request.headers, process.env.QEET_ID_WEBHOOK_SECRET!);
// β¦
return new Response(null, { status: 204 });
}node:http β see examples/webhook-receiver.ts.
| π Rotating the secret | webhooks.rotateSecret() returns a new secret; for 24 hours deliveries carry both signatures. Pass both meanwhile: constructWebhookEvent(body, headers, [newSecret, oldSecret]) |
| π§Ύ Duplicates | an event's id is the same in every delivery of it, retries included |
| π Unknown events | event.type may be newer than this SDK; ignore what you don't handle. WEBHOOK_EVENT_TYPES lists those this version knows |
| π§ͺ Testing | signWebhookPayload(body, secret) signs a body the way Qeet ID does |
X-Qeet-Event |
isn't signed: read event.type instead |
To accept Qeet ID access tokens in your own API, verify them locally against the issuer's signing keys:
import { createTokenVerifierFromDiscovery, TokenVerificationError } from "@qeet-id/node";
const verifier = await createTokenVerifierFromDiscovery({ audience: "https://api.brightleaffoods.in" });
const claims = await verifier.verify(bearerToken); // throws TokenVerificationError
claims.sub; claims.tenant_id; claims.scope;Or configure it directly with new TokenVerifier({ issuer, audience, jwksUrl }). Both issuer and audience
are required. The verifier accepts ES256 only, needs a known kid, checks iss, aud, exp, nbf and iat
with 30 seconds of clock tolerance (clockToleranceSeconds), caches the keys for five minutes, and refetches
them for an unknown kid at most every 30 seconds.
A token verified offline stays valid until it expires, even if its session was revoked. Where revocation must take effect at once, introspect instead:
const guard = qeetId.oauth.createTokenGuard({
audience: "https://mcp.brightleaffoods.in",
client_id: process.env.MCP_CLIENT_ID!,
client_secret: process.env.MCP_CLIENT_SECRET!,
});
const claims = await guard.verify(bearerToken, ["files:read"]); // AuthenticationError or PermissionDeniedError// Machine-to-machine: a service principal's token (POST /v1/oauth/token).
const token = await qeetId.oauth.clientCredentialsGrant({ client_id, client_secret });
token.access_token; token.expires_in; token.expires_at;
// The other grants (POST /v1/oauth/token-code).
await qeetId.oauth.authorizationCodeGrant({ client_id, client_secret, code, redirect_uri, code_verifier });
await qeetId.oauth.refreshTokenGrant({ client_id, client_secret, refresh_token });
await qeetId.oauth.tokenExchangeGrant({ client_id, client_secret, subject_token, actor_token, scope });
await qeetId.oauth.deviceCodeGrant({ client_id, device_code });
await qeetId.oauth.cibaGrant({ client_id, client_secret, auth_req_id });
await qeetId.oauth.introspect(accessToken, { client_id, client_secret });
await qeetId.oauth.revoke(refreshToken, { client_id, client_secret });The token endpoints' errors are thrown as OAuthError, with code set to the RFC 6749 error:
invalid_request, invalid_client (status 401), invalid_grant, unauthorized_client,
unsupported_grant_type, invalid_scope, invalid_target, and while polling authorization_pending,
slow_down, access_denied and expired_token. Grants are never retried. Client credentials travel in the
form body and are never logged. A token cache for machine-to-machine calls is in
examples/machine-to-machine.ts.
ldapConnections.authenticate signs a person in with their directory password. The result is tokens, or a
second-factor challenge:
const result = await qeetId.ldapConnections.authenticate(connectionId, { username, password });
if (result.type === "mfa_challenge") {
return askForSecondFactor(result.challenge.mfa_token, result.challenge.factors);
}
startSession(result.tokens);Pass a logger to see each request, response and retry. Messages carry the method, path, attempt, status,
duration and request id; never the API key, client secrets, query strings, headers or bodies.
ES module and CommonJS builds with matching declarations: import and require both work. Types are exported
for every request, response and error code:
import type { User, UserListParams, WebhookEvent, OAuthErrorCode } from "@qeet-id/node";bun install
bun run verify # typecheck, Biome, tests, build, publint and Are the Types WrongEvery command is in CONTRIBUTING.md.
| π§ͺ Examples | Webhook receiver, protecting an API, machine-to-machine tokens, managing users |
| ποΈ Architecture | How a request travels from a method to the API and back |
| π§ Design decisions | Namespaces, errors, wire names, retries, the dual package |
| π Engineering practices | The practices this SDK follows, with their sources |
| πΊοΈ What's not covered yet | API areas still to wrap, and those left out on purpose |
| π€ Contributing | Setting up, conventions, tests |
| π Changelog | What's in each version |
| π Security | Reporting a vulnerability to security@qeet.in |
Made with π§‘ by Qeet Group Β· β¬οΈ Back to top