ZevAuth
Sign up

Building with the SDK

Backend

Manage users and organizations from your server, with a secret key.

npm install @zevauth/backend
import { createZevAuthBackend } from '@zevauth/backend';

const zevauth = createZevAuthBackend({
  secretKey: process.env.ZEVAUTH_SECRET_KEY!,
});

const user = await zevauth.users.create({
  email: 'ada@example.com',
  publicMetadata: { plan: 'pro' },
});

Users

// One page. `next` is opaque: pass it back as `after` to continue.
const page = await zevauth.users.list({ limit: 50, query: 'ada@' });

const user = await zevauth.users.get('usr_...');

Creating

Creates the account directly. No sign-up flow runs and no email is sent.

await zevauth.users.create({
  email: 'ada@example.com',
  password: 'optional',
  emailVerified: true,
  publicMetadata: { plan: 'pro' },
  privateMetadata: { stripeCustomerId: 'cus_...' },
});

Two fields here are only available to a backend, and that is the reason this client needs a secret key:

  • emailVerified: true trusts the address without proving it. That is the migration case: the system you are replacing already confirmed it. Reachable from a browser it would make verification decorative.
  • password is optional. An account without one still signs in by magic link or a social provider, which is usually what an import wants — create the accounts, let people set a password when they first arrive.

Updating

await zevauth.users.update('usr_...', {
  firstName: 'Augusta',
  publicMetadata: { plan: 'enterprise' },
});

Omitting a field leaves it alone. Sending null clears it, which is a different thing: most patches touch a name and should not clear an avatar on the way past.

Setting password here does not revoke sessions. A person changing their own password is usually reacting to a suspicion and wants every other device gone; a backend setting one is usually provisioning, and signing everybody out mid-migration would be its own outage. Use revokeSessions when that is the intent.

Blocking, revoking and deleting

const { sessionsRevoked } = await zevauth.users.setBlocked('usr_...', true);
await zevauth.users.revokeSessions('usr_...');
await zevauth.users.delete('usr_...');

Blocking revokes every session the person holds, in the same transaction as the status change. That is the point of it: an access token lives for minutes and a refresh token for weeks, so an account “blocked” without revocation stays signed in on every device it was already signed in on — which is every device that matters, if the reason for blocking is that somebody else has one.

delete is a real delete, not a flag. Somebody exercising a right to erasure is not served by a row that still holds their address. Sessions, identities and organization memberships go with it.

Listing one kind of user

metadata filters on top-level publicMetadata fields, which is how you fetch users of a given type:

const { data } = await zevauth.users.list({ metadata: { type: 'seller' } });

Several keys combine with AND. Values travel as text, so a stored true and a stored "true" both match 'true'. See Modelling your users for when to type a user this way and when to use a separate project instead.

Sandbox

Test-environment helpers. Every call is refused by the API with a live key, so a suite pointed at production by mistake fails rather than doing damage. The full surface is in Testing.

// A token for a user, without creating or storing a password.
const user = await zevauth.users.create({ email: 'ada@example.com' });
const { accessToken } = await zevauth.sandbox.mintToken(user.id);
// What we would have emailed. Read `code` or `actionUrl`, never the HTML.
const { data } = await zevauth.sandbox.emails({ to: 'ada@example.com' });
const code = data[0].code;
// Between CI runs.
const { deleted } = await zevauth.sandbox.resetUsers();

Organizations

const { organization } = await zevauth.organizations.create({
  name: 'Acme Corp',
  createdBy: 'usr_...',   // becomes the owner
});

const detail = await zevauth.organizations.get(organization.id);
await zevauth.organizations.update(organization.id, { name: 'Acme' });
await zevauth.organizations.addMember(organization.id, { userId: 'usr_...' });
await zevauth.organizations.removeMember(organization.id, 'usr_...');
await zevauth.organizations.delete(organization.id);

There is no permission check on these, and that is deliberate. The equivalent end-user calls re-read the caller’s live org:manage membership, because the caller is a person whose standing can change. This caller is your backend holding a secret key: it owns the environment, so there is no membership to consult.

Deleting an organization removes it and its memberships. The people survive — deleting users because the company they belonged to was deleted would be a data-loss bug wearing a cascade.

Retries

Requests that could not be answered are retried automatically: a dropped connection, a timeout, a 429, or a 502/503/504.

A bare 500 is not retried. Our code ran and may have written something before it failed, so repeating it is how one create becomes two records — in a nightly job, silently, with nobody watching. Nothing 4xx is retried either; it would fail identically forever.

createZevAuthBackend({
  secretKey: process.env.ZEVAUTH_SECRET_KEY!,
  timeoutMs: 15_000,   // one attempt gives up after this
  maxRetries: 2,
});

Errors

Everything throws ZevAuthError, carrying the message the API returned, a code, and the HTTP status.

import { ZevAuthError } from '@zevauth/backend';

try {
  await zevauth.users.get('usr_nope');
} catch (err) {
  if (err instanceof ZevAuthError && err.status === 404) {
    // ...
  }
}

A user belonging to another environment is a 404, not a 403. Telling the two apart would confirm that a guessed id belongs to somebody, which is a membership oracle across tenants.

Updated at, Wednesday, October 21, 2026