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). SendContent-Type: application/jsonwith every request body; any other content type, or none, returns400 INVALID_CONTENT_TYPE. - Change the paths from
/v1/usersto/v2/users. To update a user, change the method fromPOSTtoPATCH. - On create and update, send all three of
name,emailandattributes.namemust be 1 to 255 characters, andattributesis a JSON object ({}when empty). v2 ignoresmetadata. - 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,metadataandis_deletedfrom the code that reads a user. v2 addsobject(always"user"). POST /v2/usersreturns201instead of v1's200, and its response addsinvitation.sent_atandinvitation.expires_at.- Handle the new create conflicts:
409 USER_EXISTS(the user has signed in to Brellium),409 INVITATION_PENDING(invited, not accepted yet) and409 INVITATION_EXPIRED(delete the user, then create them again). v1 returns400 User already existsfor all three. - Handle
502 INVITE_FAILEDon create: the user is saved, so call create again with the same body. DELETE /v2/users/{user_id}returns204with no body, where v1 returns200 {"message": "success"}.- v1 returns every user. v2 returns at most
limitusers per page (default 20, maximum 100): loop withstarting_afteruntilhas_moreisfalse. - Handle the v2 error format. Every error response is one JSON envelope:
{ "error": { "type", "code", "message", "param"?, "details"?, "request_id" } }. Branch ontypeandcode, which are stable;messageanddetailscan change.request_idequals thex-request-idresponse header. On a429(too many requests), waitRetry-Afterseconds, 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 a409:User with this address v1 v2 None 200, invitation sent201, invitation sentHas signed in to Brellium 400 User already exists409 USER_EXISTSInvited, not accepted yet 400 User already exists409 INVITATION_PENDING, withdetails.user_idanddetails.invitation_expires_at; to send a new invitation now, delete the user, then create them againNever accepted, every invitation expired 400 User already exists409 INVITATION_EXPIRED, withdetails.user_idanddetails.invitation_expired_at; delete the user, then create them againSaved, but never sent an invitation (after a 502)not possible in v1 201with the user as saved, invitation sent; yournameandattributesare not appliedDeleted 200: restored with the sameuser_idandperson_id201: restored with the sameuser_idandperson_id, yournameandattributes, and a new invitation -
When the invitation cannot be sent, v1 returns
502 {"error": "Failed to send invitation"}and saves no user. v2 returns502 INVITE_FAILEDand keeps the user; the same call again returns201and 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/userscreates the user and returns aninvitation, 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
limitusers per page (default 20, maximum 100): loop withstarting_afteruntilhas_moreisfalse. 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 lastuser_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 byuser_id. -
In v1 you search the full list for an email address. In v2, use
?email=; when nothing matches, the response is200with an emptydataarray. -
A query string over 2,048 bytes is rejected with a bare
403and no JSON body, so about 44user_idvalues 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
userwrapper, so read the fields from the top level. - A
user_idthat is not a UUID returns400 VALIDATION_ERROR. - Unknown and deleted users return
404with the messageUser not foundin both versions. v2 puts it in the error envelope with the codeNOT_FOUND. - For a few seconds after a create, change or delete,
GET /v2/users/{user_id}can return404or 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,emailandattributeswhole. A missing field returns400 VALIDATION_ERRORand changes nothing. - For a user that does not exist or is deleted, v1 returns
400 {"error": "Cannot find user"}. v2 returns404 NOT_FOUND. - v1 refuses the update with
404 {"error": "User not found"}, for example when another user has the email address. v2 returns409 USER_EXISTSwhen another user has the address, ignoring case; deleted users keep their address, so they count. It returns409 EMAIL_RECIPIENT_EXISTSwith"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, callDELETE /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:
- Create a user with
POST /v2/users, which also sends the invitation. - List users with
GET /v2/users, filtered byemail,user_id,created_afterorcreated_before. - Retrieve a user with
GET /v2/users/{user_id}. - Update a user with
PATCH /v2/users/{user_id}, which replaces all three fields. - Update many users with
POST /v2/users/batch_update. - Delete a user with
DELETE /v2/users/{user_id}.
Next
Invite and manage users
Create a user and send its invitation in one call, then find, update, batch update and delete users.
Page through and filter lists
Cursor pagination, batch get by id, and each resource's filters.
Migrate from v1
Move an integration from the v1 API to v2, one resource at a time.