---
title: Backend
description: Manage users and organizations from your server, with a secret key.
---

import Callout from '../../../components/Callout.astro';

```bash
npm install @zevauth/backend
```

```ts
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' },
});
```

<Callout type="warning">
This is not `@zevauth/js`. That package carries a **publishable** key and is
meant to ship inside your bundle. This one carries a **secret** key, which can
read every user's address, set anybody's password, and delete an entire
organization.

Never import it into client code, and never hardcode the key. It refuses a
`pk_…` key at construction, because the reverse mistake — reaching for this in
a browser and pasting a secret key to make it work — is the one that cannot be
undone.
</Callout>

## Users

```ts
// 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.

```ts
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

```ts
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.

<Callout>
`publicMetadata` travels inside the access token, so your own backend can read
it without a round trip — and your users cannot write it. `privateMetadata`
never leaves the server. Put anything an authorization decision depends on in
one of these two, never in `unsafeMetadata`, which the user's own client can
set.
</Callout>

### Blocking, revoking and deleting

```ts
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.

## Organizations

```ts
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.

```ts
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`.

```ts
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.