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
/v1/users /v1/users/{userId} /v1/users /v1/users/{userId} /v1/users/{userId}/blocked /v1/users/{userId}/sessions/revoke /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
/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
/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
/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
/v1/users/{userId}/sessions/revoke { "sessionsRevoked": 3 }
Ends every session without changing the account. They can sign in again immediately.
Deleting
/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
/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
/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
/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
/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