Migrate provider linking (link-providers) from v1
v1 POST /v1/link-providers takes one CSV file of providers and creates or updates a person for each row. v2 has no CSV upload and no batch endpoint for this: you send one JSON POST /v2/email_recipients per person. v2 also adds list, retrieve, update and delete endpoints.
Migration checklist
Copy this list into a ticket. Each item is one change to the code that calls POST /v1/link-providers.
- Stop uploading the CSV. Read the file in your own code and send one
POST https://api.brellium.com/v2/email_recipientsper row, with a JSON body andContent-Type: application/json. v2 has no file upload and no batch endpoint. - Send all four required fields for each person:
first_name,last_name,emailandmetadata.metadatais a JSON object for your own labels; send{}when you have none. v1 skips a row with an empty field; v2 rejects the call with400 VALIDATION_ERROR. - v2 matches people by email address alone, ignoring case; v1 looks for an existing person whose name contains the row's first and last name. A
POSTwith the address of a current email recipient returns409 EMAIL_RECIPIENT_EXISTS, whatever names it sends. - Expect
409 EMAIL_RECIPIENT_EXISTSfor every known person when you run the same list again; v1 answers a known person withupdatedorskippedinside a201. Treat the409as "already there": fetch the recipient withGET /v2/email_recipients?email=<address>and, if a name changed, sendPATCH /v2/email_recipients/{person_id}. - Store the
person_idfrom each201; v1 returns no ids. Audits name the same person with this value inpeople[].person_id. - v2 adds update and delete endpoints.
PATCH /v2/email_recipients/{person_id}replaces all four fields, so send the current value of any field you keep.DELETE /v2/email_recipients/{person_id}deletes the email recipient, and audits that name the person keep theirperson_id. PATCHandDELETE /v2/email_recipients/{person_id}return404 NOT_FOUNDwhen your organization has no email recipient with thisperson_id; a deleted recipient returns404too. For a recipient who is also a dashboard user (a user who signs in to Brellium), aPATCHwith a new address and aDELETEreturn409 EMAIL_RECIPIENT_HAS_USER, withdetails.user_idnaming the user. Change such a recipient's address withPATCH /v2/users/{user_id}, and delete the user before the recipient.- v2 rejects an address that is not a valid email address, and any address at brellium.com or its subdomains, with
400 VALIDATION_ERROR. It saves every address in lowercase. - Send requests to
https://api.brellium.com/v2/...(v1 stays at/v1/...), with a v2 access token (Authenticate with OAuth2 client credentials). Mint one token per key, cache it, and reuse it.POST /v2/oauth/tokenallows a burst of 20 requests perclient_id, then one every 10 seconds; the 21st returns429withRetry-After. - 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 RATE_LIMITED, wait the number of seconds inRetry-After, then send the same call again.
Prerequisites
- An access token in
$BRELLIUM_ACCESS_TOKEN, cached and reused (Authenticate with OAuth2 client credentials). - The CSV you uploaded to v1.
- With a live key, email recipients are protected health information (PHI), so test with fictional people or a sandbox key.
Convert a CSV upload
Start from the v1 call
This is the file, providers.csv, and the request that v1 takes. v1 requires the header row, reads the three columns in any order, and ignores any other column.
first_name,last_name,email
Jordan,Rivera,Jordan.Rivera@example.com
Sam,Okafor,sam.okafor@example.com
Avery,Chen,avery.chen@example.com
curl --request POST "https://<your v1 host>/v1/link-providers" \
--header "Authorization: Bearer $V1_ACCESS_TOKEN" \
--form "file=@providers.csv"
Send the same rows to v2
Each row becomes one call. For the first row, send this request:
curl --request POST "https://api.brellium.com/v2/email_recipients" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"first_name": "Jordan",
"last_name": "Rivera",
"email": "Jordan.Rivera@example.com",
"metadata": {}
}' \
--write-out "HTTP %{http_code}\n"
const res = await fetch("https://api.brellium.com/v2/email_recipients", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
first_name: "Jordan",
last_name: "Rivera",
email: "Jordan.Rivera@example.com",
metadata: {},
}),
});
if (!res.ok)
throw new Error(`HTTP ${res.status} ${JSON.stringify((await res.json()).error)}`);
console.log(await res.json());
console.log(`HTTP ${res.status}`);
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const emailRecipient = await client.emailRecipients.create({
first_name: "Jordan",
last_name: "Rivera",
email: "Jordan.Rivera@example.com",
metadata: {},
});
console.log(emailRecipient);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
email_recipient = client.email_recipients.create(
first_name="Jordan",
last_name="Rivera",
email="Jordan.Rivera@example.com",
metadata={},
)
print(email_recipient.to_json())
The response is 201 with the new recipient:
{
// store this; audits show it in people[].person_id
"person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
"object": "email_recipient",
"first_name": "Jordan",
"last_name": "Rivera",
// stored in lowercase
"email": "jordan.rivera@example.com",
"metadata": {},
"created_at": "2026-09-01T12:00:00.000Z",
"updated_at": "2026-09-01T12:00:00.000Z"
}
Send the same call again and v2 returns 409 EMAIL_RECIPIENT_EXISTS with param: "email".
Loop over the file
This script does what the v1 upload does: it sends one call per row and prints a summary of created, updated and skipped rows. On a 409 it fetches the recipient by address and, only when the name differs, sends a PATCH with the new name, the saved address and the saved metadata.
The script counts a row as skipped when the list does not include the recipient yet, because lists can take a few seconds to include a new or changed email recipient. On a 429 it waits Retry-After seconds and sends the call again, up to three times. Like v1, it splits lines on commas and does not handle quoted commas.
// Keep credentials on the server; never in a browser or mobile app.
import { readFileSync } from "node:fs";
const token = process.env.BRELLIUM_ACCESS_TOKEN;
if (!token) throw new Error("BRELLIUM_ACCESS_TOKEN is not set");
const BASE_URL = "https://api.brellium.com";
const HEADERS = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
type EmailRecipient = {
person_id: string;
first_name: string;
last_name: string;
email: string | null;
metadata: Record<string, unknown>;
};
type ApiError = { error: { code: string; message: string; request_id: string } };
type Row = { first_name: string; last_name: string; email: string };
// Same parsing as v1: header row required, columns by name, blank lines dropped.
function parseCsv(path: string): Row[] {
const lines = readFileSync(path, "utf8")
.split(/\r?\n/)
.filter((line) => line.trim() !== "");
const header = lines[0].split(",").map((cell) => cell.trim());
const columnOf = (name: string) => {
const index = header.indexOf(name);
if (index === -1) throw new Error(`CSV is missing the "${name}" column`);
return index;
};
const firstNameColumn = columnOf("first_name");
const lastNameColumn = columnOf("last_name");
const emailColumn = columnOf("email");
return lines.slice(1).map((line) => {
const cells = line.split(",").map((cell) => cell.trim());
return {
first_name: cells[firstNameColumn] ?? "",
last_name: cells[lastNameColumn] ?? "",
email: cells[emailColumn] ?? "",
};
});
}
// On 429, waits Retry-After seconds and sends the request again, up to three
// times; the third 429 is returned for the caller to report.
async function call(path: string, init: RequestInit): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${BASE_URL}${path}`, { ...init, headers: HEADERS });
if (res.status !== 429 || attempt === 3) return res;
const wait = Number(res.headers.get("Retry-After") ?? "1");
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
async function failure(res: Response): Promise<Error> {
const { error } = (await res.json()) as ApiError;
return new Error(
`${res.status} ${error.code}: ${error.message} (request_id ` +
`${error.request_id})`,
);
}
async function linkRow(row: Row): Promise<"created" | "updated" | "skipped"> {
const created = await call("/v2/email_recipients", {
method: "POST",
body: JSON.stringify({ ...row, metadata: {} }),
});
if (created.status === 201) return "created";
if (created.status !== 409) throw await failure(created);
// 409 EMAIL_RECIPIENT_EXISTS: someone already has this address.
const found = await call(
`/v2/email_recipients?email=${encodeURIComponent(row.email)}`,
{ method: "GET" },
);
if (!found.ok) throw await failure(found);
const existing = ((await found.json()) as { data: EmailRecipient[] }).data[0];
// not listed yet; lists can take a few seconds to include it
if (!existing) return "skipped";
if (
existing.first_name === row.first_name &&
existing.last_name === row.last_name
)
return "skipped";
const updated = await call(`/v2/email_recipients/${existing.person_id}`, {
method: "PATCH",
// PATCH replaces all four fields: send the saved email and metadata back.
body: JSON.stringify({
first_name: row.first_name,
last_name: row.last_name,
email: existing.email ?? row.email,
metadata: existing.metadata,
}),
});
if (!updated.ok) throw await failure(updated);
return "updated";
}
async function main() {
const summary = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const row of parseCsv(process.argv[2] ?? "providers.csv")) {
if (!row.first_name || !row.last_name || !row.email) {
// v1 skipped these with "Missing required fields."; v2 would answer 400
summary.skipped++;
continue;
}
try {
summary[await linkRow(row)]++;
} catch (err) {
summary.failed++;
console.error(row.email, (err as Error).message);
}
}
console.log(summary);
}
main();
// Keep credentials on the server; never in a browser or mobile app.
import { readFileSync } from "node:fs";
import BrelliumClient, { APIError } from "@brellium/api-client";
const token = process.env.BRELLIUM_ACCESS_TOKEN;
if (!token) throw new Error("BRELLIUM_ACCESS_TOKEN is not set");
// On a 429 the client waits Retry-After and sends the request again, twice by
// default, so the script adds no retry of its own.
const client = new BrelliumClient({ oAuth2: token });
type Row = { first_name: string; last_name: string; email: string };
// Same parsing as v1: header row required, columns by name, blank lines dropped.
function parseCsv(path: string): Row[] {
const lines = readFileSync(path, "utf8")
.split(/\r?\n/)
.filter((line) => line.trim() !== "");
const header = lines[0].split(",").map((cell) => cell.trim());
const columnOf = (name: string) => {
const index = header.indexOf(name);
if (index === -1) throw new Error(`CSV is missing the "${name}" column`);
return index;
};
const firstNameColumn = columnOf("first_name");
const lastNameColumn = columnOf("last_name");
const emailColumn = columnOf("email");
return lines.slice(1).map((line) => {
const cells = line.split(",").map((cell) => cell.trim());
return {
first_name: cells[firstNameColumn] ?? "",
last_name: cells[lastNameColumn] ?? "",
email: cells[emailColumn] ?? "",
};
});
}
async function linkRow(row: Row): Promise<"created" | "updated" | "skipped"> {
try {
await client.emailRecipients.create({ ...row, metadata: {} });
return "created";
} catch (err) {
if (!(err instanceof APIError) || err.status !== 409) throw err;
}
// 409 EMAIL_RECIPIENT_EXISTS: someone already has this address.
const existing = (await client.emailRecipients.list({ email: row.email }))
.data[0];
// not listed yet; lists can take a few seconds to include it
if (!existing) return "skipped";
if (
existing.first_name === row.first_name &&
existing.last_name === row.last_name
)
return "skipped";
// PATCH replaces all four fields: send the saved email and metadata back.
await client.emailRecipients.update(existing.person_id, {
first_name: row.first_name,
last_name: row.last_name,
email: existing.email ?? row.email,
metadata: existing.metadata,
});
return "updated";
}
async function main() {
const summary = { created: 0, updated: 0, skipped: 0, failed: 0 };
for (const row of parseCsv(process.argv[2] ?? "providers.csv")) {
if (!row.first_name || !row.last_name || !row.email) {
// v1 skipped these with "Missing required fields."; v2 would answer 400
summary.skipped++;
continue;
}
try {
summary[await linkRow(row)]++;
} catch (err) {
summary.failed++;
console.error(row.email, (err as Error).message);
}
}
console.log(summary);
}
main();
# Keep credentials on the server; never in a browser or mobile app.
import os
import sys
from typing import Dict, List
from brellium_api_client import APIStatusError, BrelliumClient
token = os.environ.get("BRELLIUM_ACCESS_TOKEN")
if not token:
raise SystemExit("BRELLIUM_ACCESS_TOKEN is not set")
# On a 429 the client waits Retry-After and sends the request again, twice by
# default, so the script adds no retry of its own.
client = BrelliumClient(o_auth2=token)
# Same parsing as v1: header row required, columns by name, blank lines dropped.
def parse_csv(path: str) -> List[Dict[str, str]]:
with open(path, encoding="utf-8") as file:
lines = [line for line in file.read().splitlines() if line.strip()]
header = [cell.strip() for cell in lines[0].split(",")]
def column_of(name: str) -> int:
if name not in header:
raise SystemExit(f'CSV is missing the "{name}" column')
return header.index(name)
columns = {
name: column_of(name) for name in ("first_name", "last_name", "email")
}
rows = []
for line in lines[1:]:
cells = [cell.strip() for cell in line.split(",")]
rows.append(
{
name: cells[i] if i < len(cells) else ""
for name, i in columns.items()
}
)
return rows
def link_row(row: Dict[str, str]) -> str:
try:
client.email_recipients.create(
first_name=row["first_name"],
last_name=row["last_name"],
email=row["email"],
metadata={},
)
return "created"
except APIStatusError as err:
if err.status_code != 409:
raise
# 409 EMAIL_RECIPIENT_EXISTS: someone already has this address.
found = client.email_recipients.list(email=row["email"]).data
if not found:
return "skipped" # not listed yet; lists can lag a few seconds
existing = found[0]
if (
existing.first_name == row["first_name"]
and existing.last_name == row["last_name"]
):
return "skipped"
# PATCH replaces all four fields: send the saved email and metadata back.
client.email_recipients.update(
existing.person_id,
first_name=row["first_name"],
last_name=row["last_name"],
email=existing.email or row["email"],
metadata=existing.metadata,
)
return "updated"
def main() -> None:
summary = {"created": 0, "updated": 0, "skipped": 0, "failed": 0}
path = sys.argv[1] if len(sys.argv) > 1 else "providers.csv"
for row in parse_csv(path):
if not row["first_name"] or not row["last_name"] or not row["email"]:
# v1 skipped these with "Missing required fields."; v2 would answer 400
summary["skipped"] += 1
continue
try:
summary[link_row(row)] += 1
except Exception as err:
summary["failed"] += 1
print(row["email"], err, file=sys.stderr)
print(summary)
main()
#!/usr/bin/env bash
# Creates one email recipient per CSV row; a 409 counts as "already there".
# Needs jq. Expects the columns first_name,last_name,email in that order.
set -euo pipefail
: "${BRELLIUM_ACCESS_TOKEN:?BRELLIUM_ACCESS_TOKEN is not set}"
tail -n +2 "${1:-providers.csv}" | tr -d '\r' |
while IFS=, read -r first last email _; do
[ -z "$first$last$email" ] && continue
body=$(jq -n --arg f "$first" --arg l "$last" --arg e "$email" \
'{first_name: $f, last_name: $l, email: $e, metadata: {}}')
status=$(curl --request POST "https://api.brellium.com/v2/email_recipients" \
--silent --output /tmp/recipient.json --write-out "%{http_code}" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data "$body")
case "$status" in
201) echo "created $email $(jq -r .person_id /tmp/recipient.json)" ;;
409) echo "exists $email" ;;
*)
code=$(jq -r .error.code /tmp/recipient.json)
echo "failed $email HTTP $status $code"
;;
esac
done
The Bash version does not retry a 429 or update names; use the TypeScript or Python version for that.
Request and response side by side
| v1 | v2 | |
|---|---|---|
| Method and path | POST /v1/link-providers |
POST /v2/email_recipients |
| Base URL | your v1 host | https://api.brellium.com |
| Body | multipart/form-data, one file in the part file |
application/json, one person |
| People per call | every row of the file | one |
| Existing person | updated or skipped, inside the 201 |
409 EMAIL_RECIPIENT_EXISTS |
| Success | 201 with a summary and three arrays |
201 with the recipient |
| Ids returned | none | person_id |
v1 returns this response for the sample file when Jordan Rivera is new, Sam Okafor exists without an email address, and Avery Chen exists with one:
{
"message": "Processed CSV: 1 created, 1 updated, 1 skipped.",
"created": [
{
"status": "created",
"name": "Jordan Rivera",
"email": "Jordan.Rivera@example.com"
}
],
"updated": [
{ "status": "updated", "name": "Sam Okafor", "email": "sam.okafor@example.com" }
],
"skipped": [
{
"status": "skipped",
"name": "Avery Chen",
"email": "avery.chen@example.com",
"reason": "No changes needed; already up to date."
}
]
}
From the same starting state, v2 answers each row on its own. Jordan Rivera gets 201 with the new recipient (shown above). The person v1 matches for Sam Okafor has no address, so v2 does not find it and creates a new recipient with 201. Avery Chen gets 409 if an email recipient already has the address, and 201 otherwise.
Field by field
| v1 (CSV column or response field) | v2 field | Notes |
|---|---|---|
| CSV header row | (none) | v1 requires it and finds the columns by name; v2 takes named JSON fields. |
first_name column |
first_name |
Required, 1 to 255 characters. v1 skips the row when the field is empty; v2 returns 400 VALIDATION_ERROR. |
last_name column |
last_name |
Same rules as first_name. |
email column |
email |
Required, must be a valid email address, not at brellium.com or a subdomain; stored in lowercase. v1 stores the address as sent and does no format check. |
| other CSV columns | (none) | v1 ignores them. Put your own labels in metadata. |
| (none) | metadata |
Required JSON object, {} when empty. PATCH replaces the whole object. |
file part name file |
(none) | v2 has no file upload. |
message |
(none) | v2 returns one result per call; count them yourself. |
created[] |
201 response |
One recipient per call, with person_id. |
updated[] |
PATCH /v2/email_recipients/{person_id} |
v1 fills in an address only when the person has none, and never changes a name. v2 PATCH replaces all four fields. |
skipped[] with reason: "No changes needed; already up to date." |
409 EMAIL_RECIPIENT_EXISTS |
v2 does not compare the other fields; a POST with the address of a current email recipient returns 409. |
skipped[] with reason: "Missing required fields." |
400 VALIDATION_ERROR |
details[].path names the missing field. |
[].status |
HTTP status | 201, 409 or an error. |
[].name |
first_name, last_name |
v1 echoes first_name last_name from the row. |
[].email |
email |
v1 echoes the address from the row; v2 returns the saved, lowercase address. |
[].reason |
error.code, error.message |
Only on errors. |
| (none) | person_id, object, created_at, updated_at |
New; person_id is the id audits show in people[].person_id. |
Changes to matching
v1 matches a row to an existing person in your organization whose full name contains the row's first_name and, separately, its last_name, ignoring case. It looks at every kind of person, patients included, and uses the first match it finds, in no defined order. On a match it sets the address only if the person has none.
v2 matches only on email, ignoring case. It looks only at email recipients, so a patient or anyone else who is not an email recipient never matches. This has two consequences for a migration:
- v2 does not find a person that v1 linked by name when that person has no address or an address other than the row's. The
POSTcreates a new recipient with a newperson_id. - A
POSTwith the address of a recipient you deleted restores that recipient. The response is201with the sameperson_idandcreated_at, and every other field takes the value from your request.
Error changes
| Case | v1 | v2 |
|---|---|---|
| Missing, invalid or expired token | 401, plain text Unauthorized |
401, authentication_error / UNAUTHORIZED |
No file, or the part is not named file |
400 {"error": "File is required"} or 400 {"error": "Unexpected field"} |
(no file upload) |
File is not .csv |
400 {"error": "File type not allowed"} or 400 {"error": "Only CSV files are allowed"} |
(no file upload) |
| File over 10 MB | 400 {"error": "File too large"} |
(no file upload) |
| Body is not JSON | not applicable; v1 takes a CSV file | 400, invalid_request_error / INVALID_CONTENT_TYPE without Content-Type: application/json; malformed JSON with that header returns HTTP_ERROR |
CSV without the first_name, last_name or email header |
500 {"error": "Unexpected error occurred"} |
(no CSV) |
| Row with an empty field | listed in skipped[], 201 |
400, invalid_request_error / VALIDATION_ERROR, with details[].path naming the field |
| Invalid or brellium.com address | accepted | 400, invalid_request_error / VALIDATION_ERROR; details[].path names email, and a brellium.com address adds param: "email" |
| Address already on an email recipient | updated[] or skipped[], 201 |
409, conflict_error / EMAIL_RECIPIENT_EXISTS, with param: "email" |
| Too many requests | (none) | 429, rate_limit_error / RATE_LIMITED, with a Retry-After header |
For every code and the retry rules, see Rate limits and errors.
API reference
The reference has the full schema of each v2 email recipient endpoint:
- Create an email recipient with
POST /v2/email_recipients, which also restores a deleted recipient. - List email recipients with
GET /v2/email_recipients, filtered byemail,person_id,created_afterorcreated_before. - Retrieve an email recipient with
GET /v2/email_recipients/{person_id}. - Update an email recipient with
PATCH /v2/email_recipients/{person_id}, which replaces all four fields. - Delete an email recipient with
DELETE /v2/email_recipients/{person_id}.
Next
Manage email recipients
Create, update, find and delete email recipients, and match them to an audit's people[].person_id.
Link an audit's people to recipients and users
Resolve an audit's people[] to email recipients and users by person_id.
Migrate from v1
Move an integration from the v1 API to v2, one resource at a time.