ZevAuth
Sign up

API reference

Users

Reading your userbase, and what a person may change themselves.

Two different things live here, and the difference is who is asking.

Your userbase: secret key

GET /v1/users
GET /v1/users/{userId}
POST /v1/users
PATCH /v1/users/{userId}
POST /v1/users/{userId}/blocked
POST /v1/users/{userId}/sessions/revoke
DELETE /v1/users/{userId}

Your administrative view. These read and change anybody, so they take a secret key and must be called from your server.

@zevauth/backend wraps all of them if you would rather not write the HTTP yourself. See the backend SDK.

Listing

GET /v1/users is cursor paginated. Pass next back as after.

{
  "data": [{ "id": "usr_...", "email": "ada@example.com" }],
  "hasMore": true,
  "next": "eyJ0IjoiMjAyNi0w...",
  "totalCount": 1284
}

?query= filters by email or name. ?limit= defaults to 50.

Filtering by type

?metadata[key]=value returns only users whose publicMetadata has that field, which is how you fetch one kind of user:

curl "https://api.zevauth.net/v1/users?metadata[type]=seller" \
  -H "Authorization: Bearer sk_live_..."

Several keys combine with AND:

?metadata[type]=seller&metadata[plan]=pro

totalCount counts the filtered set, and paging works normally within it.

A query string carries no types, so ?metadata[verified]=true matches a stored boolean true and a stored string "true" alike. The same goes for numbers. Only top-level fields are matched, up to five at a time, and repeating a key is an error rather than a silent empty page.

Creating

POST /v1/users

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

{
  "email": "ada@example.com",
  "password": "optional",
  "emailVerified": true,
  "publicMetadata": { "plan": "pro" },
  "privateMetadata": { "stripeCustomerId": "cus_..." }
}

Two fields here exist only because this is a backend surface. emailVerified trusts the address without proving it, which is what an import from another system needs. password is optional: an account without one still signs in by magic link or a social provider, so you can create accounts now and let people set a password when they first arrive.

Returns 409 if the address already exists.

Updating

PATCH /v1/users/{userId}
{
  "firstName": "Augusta",
  "username": "ada",
  "imageUrl": "https://...",
  "publicMetadata": { "plan": "enterprise" },
  "password": "new-password",
  "emailVerified": true
}

Omitting a field leaves it alone. Sending null clears it, which is a different thing.

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 the revoke endpoint when that is what you mean.

email is not editable here. Changing an address has to be proved, or anybody who reaches your admin tools can move an account somewhere they control.

Blocking

POST /v1/users/{userId}/blocked
{ "blocked": true }
{ "user": { "id": "usr_...", "status": "blocked" }, "sessionsRevoked": 3 }

Blocking revokes every session the person holds, in the same transaction. 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.

Signing somebody out everywhere

POST /v1/users/{userId}/sessions/revoke
{ "sessionsRevoked": 3 }

Ends every session without changing the account. They can sign in again immediately.

Deleting

DELETE /v1/users/{userId}
{ "deleted": true }

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

The signed-in person: session token

GET /v1/me

Returns the caller, the organizations they belong to, and which one the session is currently acting as.

{
  "user": { "id": "user_…", "email": "ada@example.com", "firstName": "Ada", "…": "…" },
  "organizations": [
    { "id": "org_…", "name": "Acme", "slug": "acme", "role": "admin", "imageUrl": null }
  ],
  "organization": null
}

The organizations list is what a switcher renders. switch-organization can change the active one, but nothing else can tell you what the choices are.

Update your own profile

PATCH /v1/me
{ "firstName": "Ada", "lastName": "Lovelace", "username": "ada" }

imageUrl must be an https URL. Send null to remove the picture; leaving the field out leaves it alone.

Ask for an upload URL

POST /v1/me/assets/upload-url
{
  "purpose": "user_avatar",
  "contentType": "image/png",
  "contentLength": 84213,
  "sha256": "e3b0c44298fc1c14…",
  "width": 512,
  "height": 512
}

Returns a short-lived signed URL to PUT the bytes to, plus the publicUrl to save afterwards with PATCH /v1/me. The file never passes through this API.

sha256 is the hex digest of the file and becomes the object key, so uploading the same picture twice costs one upload: the second response comes back with alreadyExists: true and no uploadUrl, and you save the publicUrl directly.

PNG, JPEG and WebP, up to 1 MB. SVG is refused: an SVG is a document that can carry a script, and these images render on sign-in screens.

purpose may be user_avatar or organization_image. The second needs the org:manage permission on the organization, checked against the live membership. Branding assets belong to you rather than to your users and are uploaded from the console, not from here.

Change your password

POST /v1/me/password
{ "currentPassword": "…", "newPassword": "…" }

The current password is required even though the session already proves who signed in. A session is proof that somebody signed in, not that the person at the keyboard right now is the same one.

Every other session is revoked. The one making the change survives.

{ "changed": true, "otherSessionsRevoked": 3 }

Updated at, Wednesday, October 21, 2026