Migrate user calls from v1

v1 has five user calls: POST /v1/users (create and invite), GET /v1/users (list), GET /v1/users/:user_id (retrieve), POST /v1/users/:user_id (update) and DELETE /v1/users/:user_id (delete). v2 replaces them with POST /v2/users, GET /v2/users, GET /v2/users/{user_id}, PATCH /v2/users/{user_id} and DELETE /v2/users/{user_id}. v2 also adds POST /v2/users/batch_update, which updates up to 100 users in one call.

An update is no longer partial. v1 POST /v1/users/:user_id changes only the fields you send, while v2 PATCH /v2/users/{user_id} requires name, email and attributes every time and replaces all three. A port that sends one field gets 400 VALIDATION_ERROR, and one that sends a partial attributes object erases the fields it left out.

Migration checklist

Copy this list into a ticket. Each item is one change to the code that calls the v1 user endpoints.

  • Send requests to https://api.brellium.com/v2/... (v1 stays at /v1/...), with a v2 access token (Authenticate with OAuth2 client credentials). Send Content-Type: application/json with every request body; any other content type, or none, returns 400 INVALID_CONTENT_TYPE.
  • Change the paths from /v1/users to /v2/users. To update a user, change the method from POST to PATCH.
  • On create and update, send all three of name, email and attributes. name must be 1 to 255 characters, and attributes is a JSON object ({} when empty). v2 ignores metadata.
  • Responses are no longer wrapped. v1 returns {"user": {...}} and {"users": [...]}; v2 returns the user itself, and lists return { "object": "list", "data": [...], "has_more": ... }.
  • Remove org_id, the sign-in account id, metadata and is_deleted from the code that reads a user. v2 adds object (always "user").
  • POST /v2/users returns 201 instead of v1's 200, and its response adds invitation.sent_at and invitation.expires_at.
  • Handle the new create conflicts: 409 USER_EXISTS (the user has signed in to Brellium), 409 INVITATION_PENDING (invited, not accepted yet) and 409 INVITATION_EXPIRED (delete the user, then create them again). v1 returns 400 User already exists for all three.
  • Handle 502 INVITE_FAILED on create: the user is saved, so call create again with the same body.
  • DELETE /v2/users/{user_id} returns 204 with no body, where v1 returns 200 {"message": "success"}.
  • v1 returns every user. v2 returns at most limit users per page (default 20, maximum 100): loop with starting_after until has_more is false.
  • Handle the v2 error format. Every error response is one JSON envelope: { "error": { "type", "code", "message", "param"?, "details"?, "request_id" } }. Branch on type and code, which are stable; message and details can change. request_id equals the x-request-id response header. On a 429 (too many requests), wait Retry-After seconds, then retry.

Create a user and send the invitation

Request

- POST https://<your v1 host>/v1/users
+ POST https://api.brellium.com/v2/users
  Authorization: Bearer $BRELLIUM_ACCESS_TOKEN
  Content-Type: application/json

  {
    "name": "Jordan Rivera",
    "email": "jordan.rivera@clinic.example",
-   "metadata": {},
    "attributes": { "department": "Behavioral Health" }
  }
v1 request field v2 request field Notes
email (required) email (required) Both store the email address in lowercase. v1 removes spaces at the start and end; v2 does not, so an address with spaces returns 400 VALIDATION_ERROR. v2 rejects addresses at brellium.com or one of its subdomains with 400 VALIDATION_ERROR and "param": "email"; v1 accepts them.
name (optional, default "") name (required) 1 to 255 characters. A missing or empty name returns 400 VALIDATION_ERROR.
attributes (optional, default {}) attributes (required) A JSON object; send {} when you have none.
attributes.role (optional, default "staff") attributes.role v1 returns 400 when it is not a string. v2 stores it like any other field of attributes, and it has no effect on the user's linked email recipient.
metadata (optional, default {}) dropped v2 ignores it.

Response

v1 returns 200 with the user wrapped in user:

{
  "user": {
    "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
    "org_id": "org_sample",
    "name": "Jordan Rivera",
    "email": "jordan.rivera@clinic.example",
    "created_at": "2026-10-21T15:04:05.000Z",
    "updated_at": "2026-10-21T15:04:05.000Z",
    "metadata": {},
    "attributes": { "department": "Behavioral Health" },
    "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
    "is_deleted": false
  }
}

v2 returns 201 with the user itself and the invitation it sent:

{
  "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
  "object": "user",
  "name": "Jordan Rivera",
  "email": "jordan.rivera@clinic.example",
  "attributes": { "department": "Behavioral Health" },
  "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"
  }
}

Every user response has the same user fields (User fields, field by field). The create response drops the wrapper and adds one object:

v1 v2 Notes
(wrapper user) (none) Read the fields from the top level.
(none) invitation.sent_at When the invitation email was sent.
(none) invitation.expires_at When the invitation link stops working. After that, delete the user and create them again.

Behavior changes that break a direct port

  • When the email address already belongs to a user who is not deleted, v1 returns 400 {"error": "User already exists"}, whether or not that user has signed in. In v2, each case has its own response, and no email goes out on a 409:

    User with this address v1 v2
    None 200, invitation sent 201, invitation sent
    Has signed in to Brellium 400 User already exists 409 USER_EXISTS
    Invited, not accepted yet 400 User already exists 409 INVITATION_PENDING, with details.user_id and details.invitation_expires_at; to send a new invitation now, delete the user, then create them again
    Never accepted, every invitation expired 400 User already exists 409 INVITATION_EXPIRED, with details.user_id and details.invitation_expired_at; delete the user, then create them again
    Saved, but never sent an invitation (after a 502) not possible in v1 201 with the user as saved, invitation sent; your name and attributes are not applied
    Deleted 200: restored with the same user_id and person_id 201: restored with the same user_id and person_id, your name and attributes, and a new invitation
  • When the invitation cannot be sent, v1 returns 502 {"error": "Failed to send invitation"} and saves no user. v2 returns 502 INVITE_FAILED and keeps the user; the same call again returns 201 and sends the invitation. A retry creates no second user.

  • On create, v2 links the user to the email recipient in your organization that has this email address and no user yet. When there is none, v2 creates a new email recipient.

  • When the email address belongs to another user's linked email recipient, v1 returns 409 {"error": "This email is already linked to another user"}. v2 creates the user with a new email recipient instead.

  • With a sandbox key, POST /v2/users creates the user and returns an invitation, but sends no email (Test end to end with sandbox credentials).

The invitation email is the same kind in both versions. 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, it is an invitation to join your organization that names audits@brellium.com as the inviter. For the full flow with code, see Invite and manage users.

List users

Request

v1 takes no parameters and returns every user. v2 returns one page and takes filters.

- GET https://<your v1 host>/v1/users
+ GET https://api.brellium.com/v2/users?limit=100
  Authorization: Bearer $BRELLIUM_ACCESS_TOKEN
v2 query parameter Default What it does
limit 20 Users per page, 1 to 100. Any other value returns 400 VALIDATION_ERROR.
starting_after none The user_id of the last user on the previous page; returns the next page.
ending_before none The user_id of the first user on the current page; returns the previous page.
include_total false true adds total_count, the number of users that match.
email none Return only the user with this email address, ignoring case. URL-encode it (@ becomes %40, + becomes %2B).
created_after, created_before none Inclusive bounds on created_at, as RFC 3339 timestamps in UTC ending in Z. An offset such as +02:00 or a date alone returns 400 VALIDATION_ERROR.
user_id none Return only these users. For more than one, repeat it (?user_id=<id>&user_id=<id>) or separate the ids with commas. Each id must be a UUID; an id that matches no user in your organization is left out, with no error.

Response

v1 wraps every user in users:

{
  "users": [
    {
      "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      "org_id": "org_sample",
      "name": "Jordan Rivera",
      "email": "jordan.rivera@clinic.example",
      "created_at": "2026-10-21T15:04:05.000Z",
      "updated_at": "2026-10-21T15:04:05.000Z",
      "metadata": {},
      "attributes": { "department": "Behavioral Health" },
      "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
      "is_deleted": false
    }
  ]
}

v2 returns one page in the list envelope:

{
  "object": "list",
  "data": [
    {
      "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      "object": "user",
      "name": "Jordan Rivera",
      "email": "jordan.rivera@clinic.example",
      "attributes": { "department": "Behavioral Health" },
      "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
      "created_at": "2026-10-21T15:04:05.000Z",
      "updated_at": "2026-10-21T15:04:05.000Z"
    }
  ],
  "has_more": false
}
v1 v2 Notes
users data Each item is a user (User fields, field by field).
(none) object Always "list".
(none) has_more true when more users follow; pass the last user_id as starting_after.
(none) total_count Only with include_total=true.

Behavior changes that break a direct port

  • v1 returns every user. v2 returns at most limit users per page (default 20, maximum 100): loop with starting_after until has_more is false. For the pagination loop, see Page through and filter lists. The samples fetch the first page of up to 100 users, then the page after its last user_id.

    # First page, then the page after the last user_id of the first page
    curl "https://api.brellium.com/v2/users?limit=100" \
      --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
    curl --get "https://api.brellium.com/v2/users" \
      --data-urlencode "limit=100" \
      --data-urlencode "starting_after=7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f" \
      --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
    
    const headers = { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` };
    
    // First page, then the page after the last user_id of the first page
    for (const params of [
      new URLSearchParams({ limit: "100" }),
      new URLSearchParams({
        limit: "100",
        starting_after: "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      }),
    ]) {
      const res = await fetch(`https://api.brellium.com/v2/users?${params}`, {
        headers,
      });
      if (!res.ok) throw new Error(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 });
    
    // First page, then the page after the last user_id of the first page
    console.log(await client.users.list({ limit: 100 }));
    console.log(
      await client.users.list({
        limit: 100,
        starting_after: "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      }),
    );
    
    import os
    
    from brellium_api_client import BrelliumClient
    
    client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
    
    # First page, then the page after the last user_id of the first page
    print(client.users.list(limit=100).to_json())
    print(
        client.users.list(
            limit=100, starting_after="7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f"
        ).to_json()
    )
    
  • v1 applies no sort. v2 returns the newest user first, by created_at, then by user_id.

  • In v1 you search the full list for an email address. In v2, use ?email=; when nothing matches, the response is 200 with an empty data array.

  • A query string over 2,048 bytes is rejected with a bare 403 and no JSON body, so about 44 user_id values fit in one request; split larger batches.

  • v2 lists can take a few seconds to include a new or changed user.

Retrieve a user

- GET https://<your v1 host>/v1/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f
+ GET https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f
  Authorization: Bearer $BRELLIUM_ACCESS_TOKEN

v1 returns 200 {"user": {...}} with the eleven fields shown under Create a user and send the invitation. In v2 the 200 response is the user itself, with the same eight fields as each item of a list's data.

v2 changes the retrieve call in these ways:

  • v2 has no user wrapper, so read the fields from the top level.
  • A user_id that is not a UUID returns 400 VALIDATION_ERROR.
  • Unknown and deleted users return 404 with the message User not found in both versions. v2 puts it in the error envelope with the code NOT_FOUND.
  • For a few seconds after a create, change or delete, GET /v2/users/{user_id} can return 404 or the previous version.

User fields, field by field

Every v2 user response (create, list item, retrieve, update, batch update item) has these fields. The v1 column is the user object v1 returns from every user call.

v1 field v2 field Notes
user_id user_id Unchanged.
(none) object Always "user".
name name Unchanged.
email email Unchanged. Stored in lowercase on every v2 write.
attributes attributes Unchanged. Replaced whole on every v2 write.
person_id person_id Unchanged and read-only. Audits show it in people[].person_id, and the user's linked email recipient has the same id.
created_at created_at Unchanged format, RFC 3339 in UTC.
updated_at updated_at Same format. v2 sets it on every write; a v1 update leaves it unchanged unless you send it.
org_id dropped Your access token is bound to one organization.
v1 sign-in account id dropped v1 shows null until the user first signs in. v2 has no equivalent field. The POST /v2/users conflicts tell the cases apart: 409 USER_EXISTS means the user has signed in, and 409 INVITATION_PENDING or 409 INVITATION_EXPIRED means they have not.
metadata dropped v2 does not return it and ignores it on writes.
is_deleted dropped v2 never returns deleted users; it returns 404 for them.

Update a user

Request

The method changes from POST to PATCH, and the body must carry all three fields.

- POST https://<your v1 host>/v1/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f
+ PATCH https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f
  Authorization: Bearer $BRELLIUM_ACCESS_TOKEN
  Content-Type: application/json

- { "email": "j.rivera@clinic.example" }
+ {
+   "name": "Jordan Rivera",
+   "email": "j.rivera@clinic.example",
+   "attributes": { "department": "Behavioral Health", "site": "north" }
+ }

To change one value, fetch the user with GET /v2/users/{user_id}, change that value, then send all three. To change one field of attributes, change it in the object you fetched and send the whole object.

v1 request field v2 request field Notes
name (optional) name (required) 1 to 255 characters.
email (optional) email (required) v1 stores it as sent, with no check. v2 checks the format, stores it in lowercase, and rejects brellium.com addresses with 400 VALIDATION_ERROR.
attributes (optional) attributes (required) A JSON object that replaces the stored one.
metadata (optional) dropped v2 ignores it.
v1 sign-in account id dropped v1 refuses to change it with 400; v2 ignores it.
person_id dropped v1 returns 400 {"error": "Cannot update person_id"}; v2 ignores it.
any other field dropped An unknown field makes v1 refuse the update with 404 User not found. v2 ignores unknown fields.

Response

v1 returns 200 {"user": {...}}. v2 returns 200 with the updated user itself (User fields, field by field).

{
  "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
  "object": "user",
  "name": "Jordan Rivera",
  "email": "j.rivera@clinic.example",
  "attributes": { "department": "Behavioral Health", "site": "north" },
  "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
  "created_at": "2026-10-21T15:04:05.000Z",
  "updated_at": "2026-10-21T16:30:00.000Z"
}

Behavior changes that break a direct port

  • v1 changes only the fields you send, but every v2 update replaces name, email and attributes whole. A missing field returns 400 VALIDATION_ERROR and changes nothing.
  • For a user that does not exist or is deleted, v1 returns 400 {"error": "Cannot find user"}. v2 returns 404 NOT_FOUND.
  • v1 refuses the update with 404 {"error": "User not found"}, for example when another user has the email address. v2 returns 409 USER_EXISTS when another user has the address, ignoring case; deleted users keep their address, so they count. It returns 409 EMAIL_RECIPIENT_EXISTS with "param": "email" when another email recipient has it.
  • v1 changes only the user, so the linked email recipient keeps the old address. v2 also writes a new address to the user's linked email recipient (same person_id), so audit emails follow the user. A change of case alone leaves the recipient as it is.

Update many users in one call

v1 has no batch update. POST /v2/users/batch_update applies the PATCH rules to 1 to 100 items, each a user_id plus all three fields. A validation failure in any item returns 400 VALIDATION_ERROR and updates no user.

When every item passes validation, the status is 200, even if every item then fails. The response has one result per item, in request order: the updated user, or { "user_id", "error" } with the error a single PATCH would return (NOT_FOUND, USER_EXISTS, EMAIL_RECIPIENT_EXISTS) or INTERNAL_ERROR. A failed item does not stop the others. For an example, see Invite and manage users.

Delete a user

- DELETE https://<your v1 host>/v1/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f
+ DELETE https://api.brellium.com/v2/users/7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f
  Authorization: Bearer $BRELLIUM_ACCESS_TOKEN
v1 v2 Notes
200 {"message": "success"} 204, no body Check the status code, not a body.
404 {"error": "User not found"} 404 NOT_FOUND An unknown or already deleted user.
502 (v1's error about removing the user's sign-in) 502 REVOKE_FAILED The API could not revoke the invitation or sign-in. Both versions keep the user, so call again.

v2 changes the delete call in these ways:

  • v1 also deletes the user's linked email recipient (same person_id), so audit emails to that person stop. v2 deletes only the user, and the linked email recipient keeps receiving audit emails. To stop those too, call DELETE /v2/email_recipients/{person_id}.
  • For a few seconds after a delete, GET /v2/users/{user_id} and lists can still return the user.

Deleting a user cancels a pending invitation and removes the user's access to your organization. Both versions keep the email address on the deleted user, so a later create with that address restores the same user_id and person_id and sends a new invitation.

Error changes

Case v1 v2
Missing, invalid or expired token 401, plain text Unauthorized 401, authentication_error / UNAUTHORIZED
Body is not JSON v1 also accepts form-encoded bodies; malformed JSON is 400 {"message": "Invalid JSON format"} 400, invalid_request_error / INVALID_CONTENT_TYPE without the JSON content type; malformed JSON with it is HTTP_ERROR
Bad field, missing field or bad id 400 with one {"error": "..."} message, for example A valid email is required 400, invalid_request_error / VALIDATION_ERROR, with details per failing field; a brellium.com address adds "param": "email"
Create: address already has a user 400 {"error": "User already exists"}, or 409 with the same body when two creates for the same address run at once 409, conflict_error / USER_EXISTS, INVITATION_PENDING or INVITATION_EXPIRED
Create: address belongs to another user's linked email recipient 409 {"error": "This email is already linked to another user"} no error; a new email recipient is created
Create: invitation not sent 502 {"error": "Failed to send invitation"}, nothing saved 502, api_error / INVITE_FAILED, user saved; call again
Update: user not found 400 {"error": "Cannot find user"} 404, not_found_error / NOT_FOUND
Update: address taken 404 {"error": "User not found"} 409, conflict_error / USER_EXISTS or EMAIL_RECIPIENT_EXISTS
Retrieve or delete: user not found 404 {"error": "User not found"} 404, not_found_error / NOT_FOUND
Delete: sign-in not revoked 502 (v1's error about removing the user's sign-in) 502, api_error / REVOKE_FAILED
Too many requests (none) 429, rate_limit_error / RATE_LIMITED, with a Retry-After header
Server error 500 {"error": "Unexpected error occurred"}; create also returns 500 {"error": "Failed to create user"} after the invitation is sent 500, api_error / INTERNAL_ERROR

Every v2 error body carries request_id, which equals the x-request-id response header; send it to Brellium support when you report a problem. Branch on error.type and error.code, which are stable; message can change. For every code and the retry rules, see Rate limits and errors.

API reference

The reference has the full schema of each v2 user endpoint:

Next