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_recipients per row, with a JSON body and Content-Type: application/json. v2 has no file upload and no batch endpoint.
  • Send all four required fields for each person: first_name, last_name, email and metadata. metadata is a JSON object for your own labels; send {} when you have none. v1 skips a row with an empty field; v2 rejects the call with 400 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 POST with the address of a current email recipient returns 409 EMAIL_RECIPIENT_EXISTS, whatever names it sends.
  • Expect 409 EMAIL_RECIPIENT_EXISTS for every known person when you run the same list again; v1 answers a known person with updated or skipped inside a 201. Treat the 409 as "already there": fetch the recipient with GET /v2/email_recipients?email=<address> and, if a name changed, send PATCH /v2/email_recipients/{person_id}.
  • Store the person_id from each 201; v1 returns no ids. Audits name the same person with this value in people[].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 their person_id.
  • PATCH and DELETE /v2/email_recipients/{person_id} return 404 NOT_FOUND when your organization has no email recipient with this person_id; a deleted recipient returns 404 too. For a recipient who is also a dashboard user (a user who signs in to Brellium), a PATCH with a new address and a DELETE return 409 EMAIL_RECIPIENT_HAS_USER, with details.user_id naming the user. Change such a recipient's address with PATCH /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/token allows a burst of 20 requests per client_id, then one every 10 seconds; the 21st returns 429 with Retry-After.
  • 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 RATE_LIMITED, wait the number of seconds in Retry-After, then send the same call again.

Prerequisites

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 POST creates a new recipient with a new person_id.
  • A POST with the address of a recipient you deleted restores that recipient. The response is 201 with the same person_id and created_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:

Next