Invite and manage users

Create, find, update and delete the users who sign in to Brellium for your organization, for example to mirror your own roster or identity system. POST /v2/users emails each new user an invitation. To create or rotate keys, use the dashboard. For email recipients, the people who receive audit emails, see Manage email recipients.

Prerequisites

  • An access token in $BRELLIUM_ACCESS_TOKEN, cached and reused (Authenticate with OAuth2 client credentials).
  • An email address you control. With a live key the invitation goes to that address; with a sandbox key, no email goes out. This page uses jordan.rivera@clinic.example. In the sandbox that address belongs to the sample user, so step 1 returns 409 USER_EXISTS; use another address there.

Steps

Create the user and send the invitation

All three writable fields are required: name (1 to 255 characters), email and attributes (a JSON object; {} when empty). Your organization can have one user per email address, ignoring case. Send Content-Type: application/json with every request body; any other content type, or none, returns 400 INVALID_CONTENT_TYPE.

curl "https://api.brellium.com/v2/users" \
  --request POST \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Jordan Rivera",
    "email": "jordan.rivera@clinic.example",
    "attributes": { "department": "Behavioral Health" }
  }' \
  --write-out "HTTP %{http_code}\n"
const res = await fetch("https://api.brellium.com/v2/users", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Jordan Rivera",
    email: "jordan.rivera@clinic.example",
    attributes: { department: "Behavioral Health" },
  }),
});
if (!res.ok)
  throw new Error(
    `HTTP ${res.status}: ${JSON.stringify((await res.json()).error)}`,
  );
console.log(`HTTP ${res.status}`, await res.json());
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const user = await client.users.create({
  name: "Jordan Rivera",
  email: "jordan.rivera@clinic.example",
  attributes: { department: "Behavioral Health" },
});
console.log(user);
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

user = client.users.create(
    name="Jordan Rivera",
    email="jordan.rivera@clinic.example",
    attributes={"department": "Behavioral Health"},
)
print(user.to_json())

The full programs below send the same request and sort the result into the cases of step 2: invited, USER_EXISTS, INVITATION_PENDING, INVITATION_EXPIRED, or a 502 INVITE_FAILED to retry.

// Keep credentials on the server; never in a browser or mobile app.
const token = process.env.BRELLIUM_ACCESS_TOKEN;
if (!token) throw new Error("BRELLIUM_ACCESS_TOKEN is not set");

type User = {
  user_id: string;
  object: "user";
  name: string;
  email: string;
  attributes: Record<string, unknown>;
  person_id: string;
  created_at: string;
  updated_at: string;
};
type ApiError = {
  error: {
    type: string;
    code: string;
    message: string;
    param?: string;
    details?: Record<string, unknown>;
    request_id?: string;
  };
};
type UserFields = {
  name: string;
  email: string;
  attributes: Record<string, unknown>;
};

// POST /v2/users also returns the invitation it sent.
type CreatedUser = User & { invitation: { sent_at: string; expires_at: string } };

type InviteResult =
  | { kind: "invited"; user: CreatedUser }
  // 409 USER_EXISTS: they have signed in to Brellium; nothing was sent
  | { kind: "exists" }
  // 409 INVITATION_PENDING: invited, not accepted yet
  | { kind: "pending"; userId: string; expiresAt: string }
  // 409 INVITATION_EXPIRED: delete, then create again
  | { kind: "expired"; userId: string }
  // 502 INVITE_FAILED: the user was kept; the same call sends the invitation
  | { kind: "retry" };

async function inviteUser(input: UserFields): Promise<InviteResult> {
  const res = await fetch("https://api.brellium.com/v2/users", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(input),
  });
  if (res.status === 201) {
    return { kind: "invited", user: (await res.json()) as CreatedUser };
  }
  const { error } = (await res.json()) as ApiError;
  if (res.status === 409 && error.code === "USER_EXISTS") return { kind: "exists" };
  if (res.status === 409 && error.code === "INVITATION_PENDING") {
    return {
      kind: "pending",
      userId: String(error.details?.user_id),
      expiresAt: String(error.details?.invitation_expires_at),
    };
  }
  if (res.status === 409 && error.code === "INVITATION_EXPIRED") {
    return { kind: "expired", userId: String(error.details?.user_id) };
  }
  if (res.status === 502 && error.code === "INVITE_FAILED")
    return { kind: "retry" };
  throw new Error(
    `${res.status} ${error.code}${error.param ? ` on ${error.param}` : ""}: ` +
      `${error.message} (request_id ${error.request_id})`,
  );
}

async function main() {
  const result = await inviteUser({
    name: "Jordan Rivera",
    email: "jordan.rivera@clinic.example",
    attributes: { department: "Behavioral Health" },
  });
  if (result.kind === "exists") {
    console.log("Already signed in to Brellium; nothing sent");
  } else if (result.kind === "pending") {
    console.log(`Already invited; the invitation expires at ${result.expiresAt}`);
  } else if (result.kind === "expired") {
    console.log(
      `Invitation expired: DELETE /v2/users/${result.userId}, then create the ` +
        `user again`,
    );
  } else if (result.kind === "retry") {
    console.log("Invitation not sent; call again later with the same body");
  } else {
    console.log(
      `user ${result.user.user_id} invited; the invitation expires at ` +
        `${result.user.invitation.expires_at}`,
    );
  }
}

main();
# Keep credentials on the server; never in a browser or mobile app.
import os

import requests

TOKEN = os.environ["BRELLIUM_ACCESS_TOKEN"]
BASE_URL = "https://api.brellium.com"
HEADERS = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}


def invite_user(name: str, email: str, attributes: dict):
    """Returns ("invited", user) on 201, ("exists", None) on 409 USER_EXISTS,
    ("pending", details) on 409 INVITATION_PENDING, ("expired", user_id) on
    409 INVITATION_EXPIRED and ("retry", None) on 502 INVITE_FAILED."""
    body = {"name": name, "email": email, "attributes": attributes}
    res = requests.post(
        f"{BASE_URL}/v2/users", headers=HEADERS, json=body, timeout=30
    )
    if res.status_code == 201:
        return "invited", res.json()
    err = res.json()["error"]
    if res.status_code == 409 and err["code"] == "USER_EXISTS":
        return "exists", None
    if res.status_code == 409 and err["code"] == "INVITATION_PENDING":
        return "pending", err["details"]
    if res.status_code == 409 and err["code"] == "INVITATION_EXPIRED":
        return "expired", err["details"]["user_id"]
    if res.status_code == 502 and err["code"] == "INVITE_FAILED":
        return "retry", None
    where = f" on {err['param']}" if err.get("param") else ""
    raise RuntimeError(
        f"{res.status_code} {err['code']}{where}: {err['message']} "
        f"(request_id {err.get('request_id')})"
    )


kind, value = invite_user(
    "Jordan Rivera",
    "jordan.rivera@clinic.example",
    {"department": "Behavioral Health"},
)
if kind == "exists":
    print("Already signed in to Brellium; nothing sent")
elif kind == "pending":
    print(
        f"Already invited; the invitation expires at "
        f"{value['invitation_expires_at']}"
    )
elif kind == "expired":
    print(
        f"Invitation expired: DELETE /v2/users/{value}, then create the user again"
    )
elif kind == "retry":
    print("Invitation not sent; call again later with the same body")
else:
    print(
        f"user {value['user_id']} invited; the invitation expires at "
        f"{value['invitation']['expires_at']}"
    )

The API returns 201 with the user and an invitation object that says when the email was sent and when the invitation expires. With Brellium password sign-in and an address that has no Brellium account yet, the email carries a link to set a password. With single sign-on, or an address that already has a Brellium account, the email is an invitation to join your organization that names audits@brellium.com as the inviter. The user belongs to your organization from the moment the email is sent, and must follow the link before invitation.expires_at.

{
  // store this
  "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
  "object": "user",
  "name": "Jordan Rivera",
  // stored in lowercase
  "email": "jordan.rivera@clinic.example",
  // replaced whole on every write
  "attributes": { "department": "Behavioral Health" },
  // read-only; audits show it in people[].person_id
  "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
  "created_at": "2026-10-21T15:04:05.000Z",
  "updated_at": "2026-10-21T15:04:05.000Z",
  "invitation": {
    "sent_at": "2026-10-21T15:04:05.000Z",
    "expires_at": "2026-10-28T15:04:05.000Z"
  }
}

The API rejects a brellium.com address, or one at any of its subdomains, with 400 VALIDATION_ERROR and "param": "email" ("Email addresses at brellium.com can't be added as users.").

Handle 409 and 502

The result depends on who already has the email address. On a 409, Brellium sends no email.

User with this address Result
None 201, invitation sent.
Has signed in to Brellium 409 USER_EXISTS ("A user with this email already exists in this organization").
Invited, not accepted yet 409 INVITATION_PENDING with details.user_id and details.invitation_expires_at. To send a new invitation now, delete the user and create it again (below).
Never accepted, every invitation expired 409 INVITATION_EXPIRED with details.user_id and details.invitation_expired_at. Delete the user and create it again (below).
Never invited, for example after a 502 201 with the user as saved and the invitation sent; your name and attributes are not applied.
Deleted 201. The user is restored with the same user_id and person_id, your name and attributes, and a new invitation.

To send a new invitation after INVITATION_PENDING or INVITATION_EXPIRED, delete the user (step 6), then repeat the step 1 call with the same address. Both message values name the DELETE to run. The DELETE returns 204, and the POST returns 201 with the same user_id and person_id and a new invitation.

502 INVITE_FAILED ("Could not send the invitation") means the user is saved but the invitation could not be sent. Repeat the same call, which returns 201 and sends the invitation. A retry creates no second user.

Find a user

The email filter matches the whole saved address, ignoring case. URL-encode it (+ becomes %2B).

curl "https://api.brellium.com/v2/users?email=jordan.rivera%40clinic.example" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
// URLSearchParams encodes the address (@ becomes %40, + becomes %2B).
const params = new URLSearchParams({ email: "jordan.rivera@clinic.example" });
const res = await fetch(`https://api.brellium.com/v2/users?${params}`, {
  headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
});
if (!res.ok)
  throw new Error(
    `HTTP ${res.status}: ${JSON.stringify((await res.json()).error)}`,
  );
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const users = await client.users.list({ email: "jordan.rivera@clinic.example" });
console.log(users);
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

users = client.users.list(email="jordan.rivera@clinic.example")
print(users.to_json())
{
  "object": "list",
  "data": [
    {
      "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      "email": "jordan.rivera@clinic.example" /* ... */
    } // the user from step 1
  ],
  "has_more": false
}

When no user matches, the API returns { "object": "list", "data": [], "has_more": false }. Lists never include deleted users. For the other filters (users have no updated_after) and cursor pagination, see Page through and filter lists.

Update a user

PATCH /v2/users/{user_id} requires all three fields and replaces them, so send the current value of any field you want to keep. It replaces the whole attributes object: to change one attribute, fetch the user, edit its attributes, then send the PATCH.

curl "https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f" \
  --request PATCH \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Jordan Rivera",
    "email": "j.rivera@clinic.example",
    "attributes": { "department": "Behavioral Health", "site": "north" }
  }'
const res = await fetch(
  "https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "Jordan Rivera",
      email: "j.rivera@clinic.example",
      attributes: { department: "Behavioral Health", site: "north" },
    }),
  },
);
if (!res.ok)
  throw new Error(
    `HTTP ${res.status}: ${JSON.stringify((await res.json()).error)}`,
  );
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const user = await client.users.update("7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f", {
  name: "Jordan Rivera",
  email: "j.rivera@clinic.example",
  attributes: { department: "Behavioral Health", site: "north" },
});
console.log(user);
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

user = client.users.update(
    "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
    name="Jordan Rivera",
    email="j.rivera@clinic.example",
    attributes={"department": "Behavioral Health", "site": "north"},
)
print(user.to_json())
{
  "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
  // stored in lowercase; the linked email recipient gets it too
  "email": "j.rivera@clinic.example",
  "attributes": { "department": "Behavioral Health", "site": "north" },
  // never changes
  "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
  "updated_at": "2026-10-21T16:30:00.000Z"
  // the other fields as in step 1
  /* ... */
}

The API copies a new email address to the user's linked email recipient (the one with the same person_id), so audit emails follow the user. A change of case alone leaves the recipient as it is. PATCH /v2/email_recipients/{person_id} can't change a user's address and returns 409 EMAIL_RECIPIENT_HAS_USER.

If the new address is taken, the API returns 409 USER_EXISTS when another user has it, ignoring case, or 409 EMAIL_RECIPIENT_EXISTS with "param": "email" when another email recipient has it. A deleted user keeps their address, so it still counts as taken. A missing field, a brellium.com address or a user_id that is not a UUID returns 400 VALIDATION_ERROR, and an unknown user_id returns 404 NOT_FOUND.

Update many users in one call

POST /v2/users/batch_update applies the PATCH rules to 1 to 100 items, each with a user_id and all three writable fields. The second item below has a user_id that matches no user, so its result shows a failed item.

curl "https://api.brellium.com/v2/users/batch_update" \
  --request POST \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  --header "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
        "name": "Jordan Rivera-Lee",
        "email": "j.rivera@clinic.example",
        "attributes": { "department": "Behavioral Health", "site": "north" }
      },
      {
        "user_id": "4e3d2c1b-0a9f-4e8d-9c7b-6a5f4e3d2c1b",
        "name": "Sam Okafor",
        "email": "sam.okafor@clinic.example",
        "attributes": { "site": "south" }
      }
    ]
  }'
const res = await fetch("https://api.brellium.com/v2/users/batch_update", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    items: [
      {
        user_id: "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
        name: "Jordan Rivera-Lee",
        email: "j.rivera@clinic.example",
        attributes: { department: "Behavioral Health", site: "north" },
      },
      {
        user_id: "4e3d2c1b-0a9f-4e8d-9c7b-6a5f4e3d2c1b",
        name: "Sam Okafor",
        email: "sam.okafor@clinic.example",
        attributes: { site: "south" },
      },
    ],
  }),
});
if (!res.ok)
  throw new Error(
    `HTTP ${res.status}: ${JSON.stringify((await res.json()).error)}`,
  );
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const result = await client.users.batchUpdate({
  items: [
    {
      user_id: "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      name: "Jordan Rivera-Lee",
      email: "j.rivera@clinic.example",
      attributes: { department: "Behavioral Health", site: "north" },
    },
    {
      user_id: "4e3d2c1b-0a9f-4e8d-9c7b-6a5f4e3d2c1b",
      name: "Sam Okafor",
      email: "sam.okafor@clinic.example",
      attributes: { site: "south" },
    },
  ],
});
console.log(result);
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

result = client.users.batch_update(
    items=[
        {
            "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
            "name": "Jordan Rivera-Lee",
            "email": "j.rivera@clinic.example",
            "attributes": {"department": "Behavioral Health", "site": "north"},
        },
        {
            "user_id": "4e3d2c1b-0a9f-4e8d-9c7b-6a5f4e3d2c1b",
            "name": "Sam Okafor",
            "email": "sam.okafor@clinic.example",
            "attributes": {"site": "south"},
        },
    ],
)
print(result.to_json())
{
  "object": "list",
  "data": [
    {
      "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      "object": "user",
      "name": "Jordan Rivera-Lee" /* ... */
    }, // the updated user, as in step 1
    {
      "user_id": "4e3d2c1b-0a9f-4e8d-9c7b-6a5f4e3d2c1b",
      "error": {
        "type": "not_found_error",
        "code": "NOT_FOUND",
        "message": "User not found"
      }
    }
  ],
  "has_more": false
}

A validation failure in any item, such as a missing field or a brellium.com address, returns 400 VALIDATION_ERROR and updates no one. Otherwise the status is 200, even when every item fails.

The response has one result per item, in request order: the updated user ("object": "user") or { user_id, error }. The error is the one a single PATCH would return (NOT_FOUND, USER_EXISTS or EMAIL_RECIPIENT_EXISTS), or INTERNAL_ERROR when the item could not be saved. A failed item does not stop the others or undo the ones already applied.

Delete the user

DELETE /v2/users/{user_id} deletes the user. They can no longer sign in to Brellium for your organization, and a pending invitation stops working.

curl "https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f" \
  --request DELETE \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  --write-out "HTTP %{http_code}\n"
const res = await fetch(
  "https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
  {
    method: "DELETE",
    headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
  },
);
if (!res.ok)
  throw new Error(
    `HTTP ${res.status}: ${JSON.stringify((await res.json()).error)}`,
  );
console.log(`HTTP ${res.status}`);
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

await client.users.delete("7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f");
console.log("Deleted");
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

client.users.delete("7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f")
print("Deleted")

The API returns 204 with no body. Afterward, PATCH and a second DELETE return 404 NOT_FOUND ("User not found"), the same as for an unknown id, and a batch_update item for the user gets the same NOT_FOUND error in its result. GET and lists can still return the user for a few seconds; after that, GET returns 404 and lists leave the user out. 502 REVOKE_FAILED ("Could not revoke the user's invitation") means the user was not deleted; call DELETE again.

The deleted user keeps their email address. POST /v2/users with that address restores the same user_id and person_id and sends a new invitation (step 2). A PATCH that moves another user onto the address returns 409 USER_EXISTS.

The linked email recipient stays, and audits that name the user keep their person_id. To stop the person's audit emails too, call Delete an email recipient (DELETE /v2/email_recipients/{person_id}) with the user's person_id. That call returns 409 EMAIL_RECIPIENT_HAS_USER while the user exists, and 404 when the person is not an email recipient.

Expected response

A retrieved or updated user, like every successful batch item, has the eight fields of the user in step 1. A created user has the same fields plus invitation. For the full schemas, see Create a user, Retrieve a user and Update a user. A name is protected health information (PHI); keep other PHI out of attributes.

On create, Brellium links the user to the person in your organization who has that email address and no user yet. When there is no such person, Brellium creates a new email recipient for the user. GET /v2/audits has no user filter, so match the user's person_id against people[].person_id yourself (Link an audit's people to recipients and users).

A create or update response is the current record, so use it instead of fetching the record again. For a few seconds after a create, change or delete, GET /v2/users/{user_id} can return 404 or the previous version. Lists can take a few seconds to include a new or changed user.

Common mistakes

Mistake What you see Fix
Retrying a 502 INVITE_FAILED with another address Two users for one person Repeat the same call.
Sending person_id in a body 201 or 200 with Brellium's person_id, not yours The API ignores unknown fields; person_id is read-only.
Leaving out Content-Type: application/json 400 INVALID_CONTENT_TYPE, and nothing changes Set the header. Malformed JSON with the header set returns 400 HTTP_ERROR.

Next