Link an audit's people to recipients and users

Resolve an audit's people[] to email recipients and users when you need to reach the person an audit is about. You can then email them, find their dashboard user (a user who signs in to Brellium), or collect every audit that names them.

An audit names its people in people[], each with a role, a name and, when the name is linked to a person, a person_id. The email recipient and the user for that person carry the same person_id, which is how you match them to the audit. Group audits by person in your own code, because GET /v2/audits has no person or user filter.

Prerequisites

  • An access token in $BRELLIUM_ACCESS_TOKEN, cached and reused (Authenticate with OAuth2 client credentials).
  • An audit_id, for example from GET /v2/audits?status=complete&limit=1. This page uses the sample audit a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d, whose provider has the person_id 2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d.
  • With a live key the responses contain protected health information (PHI), including names and email addresses; a sandbox key returns the sample audit, email recipient and user.

Steps

Read people[] on the audit

people[] holds one person for each person field on the audit, such as the client and the provider, in display order, with names but no contact details. Each sample keeps only the two fields this step reads. The request returns 404 NOT_FOUND when your organization has no audit with this id, and 400 VALIDATION_ERROR when the id is not a UUID (Retrieve an audit). For the full audit, see Retrieve an audit by id.

curl "https://api.brellium.com/v2/audits/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  | jq '{people, display_info}'
const res = await fetch(
  "https://api.brellium.com/v2/audits/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  {
    headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
  },
);
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
const { people, display_info } = await res.json();
console.log(JSON.stringify({ people, display_info }, null, 2));
import BrelliumClient from "@brellium/api-client";

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

const { people, display_info } = await client.audits.retrieve(
  "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
);
console.log(JSON.stringify({ people, display_info }, null, 2));
import json
import os

from brellium_api_client import BrelliumClient

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

audit = client.audits.retrieve("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")
data = audit.model_dump(mode="json")
print(
    json.dumps(
        {"people": data["people"], "display_info": data["display_info"]}, indent=2
    )
)
{
  "people": [
    {
      "person_id": null,
      "role": "client",
      "role_title": "Client",
      "name": "Sample Client"
    },
    {
      "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
      "role": "provider",
      "role_title": "Provider",
      "name": "Jordan Rivera"
    } // the person_id to resolve
  ],
  "display_info": [
    {
      "key": "provider",
      "title": "Provider",
      "value": "Jordan Rivera",
      "format": "text"
    },
    {
      "key": "date_of_service",
      "title": "Date of service",
      "value": "2026-09-24T02:34:54.489Z",
      "format": "date"
    }
  ]
}

person_id is the id shared by the email recipient and user for that person. It is null when the name isn't linked to a person, as for the sample's client.

role is the identifier of the person's role (client, provider; other values can appear). role_title is its display title, and name is the name as it appears on the audit. display_info repeats the provider name for display.

Resolve a person_id to an email recipient

Fetch the email recipient whose person_id equals people[].person_id (Retrieve an email recipient).

curl \
  "https://api.brellium.com/v2/email_recipients/2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  --write-out "HTTP %{http_code}\n"
const res = await fetch(
  "https://api.brellium.com/v2/email_recipients/2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
  {
    headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
  },
);
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
console.log(await res.json(), `HTTP ${res.status}`);
import BrelliumClient from "@brellium/api-client";

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

const recipient = await client.emailRecipients.retrieve(
  "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
);
console.log(recipient);
import os

from brellium_api_client import BrelliumClient

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

recipient = client.email_recipients.retrieve("2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d")
print(recipient.to_json())

The response is 200 with the recipient. email is null when the recipient has no address on file, so check it before you send anything.

{
  // equals people[].person_id on the audit
  "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
  "object": "email_recipient",
  "first_name": "Jordan",
  "last_name": "Rivera",
  // illustrative; can be null
  "email": "jordan.rivera@clinic.example",
  "metadata": {},
  "created_at": "2026-09-01T12:00:00.000Z",
  "updated_at": "2026-09-01T12:00:00.000Z"
}

The full programs below look up the email recipient for each linked person_id on the sample audit. They print each person with the recipient's email address, or "no email recipient" when the person is not an email recipient or the recipient has no address on file.

// 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");

const BASE_URL = "https://api.brellium.com";

type AuditPerson = {
  person_id: string | null;
  role: string;
  role_title: string;
  name: string;
};
type EmailRecipient = {
  person_id: string;
  object: "email_recipient";
  first_name: string;
  last_name: string;
  email: string | null;
  metadata: Record<string, unknown>;
  created_at: string;
  updated_at: string;
};

async function get<T>(path: string): Promise<T | null> {
  const res = await fetch(`${BASE_URL}${path}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (res.status === 404) return null;
  if (!res.ok) {
    const body = (await res.json()) as {
      error: { code: string; message: string; request_id?: string };
    };
    throw new Error(
      `${res.status} ${body.error.code}: ${body.error.message} (request_id ` +
        `${body.error.request_id})`,
    );
  }
  return (await res.json()) as T;
}

// One recipient per linked person. A person_id of null has nothing to resolve;
// a 404 means the person is not an email recipient (a client, say) or was deleted.
async function resolveRecipients(
  people: AuditPerson[],
): Promise<Map<string, EmailRecipient>> {
  const out = new Map<string, EmailRecipient>();
  for (const person of people) {
    if (person.person_id === null || out.has(person.person_id)) continue;
    const recipient = await get<EmailRecipient>(
      `/v2/email_recipients/${person.person_id}`,
    );
    if (recipient) out.set(person.person_id, recipient);
  }
  return out;
}

async function main() {
  const audit = await get<{ people: AuditPerson[] }>(
    "/v2/audits/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  );
  if (!audit) throw new Error("Audit not found in this organization");
  const recipients = await resolveRecipients(audit.people);
  for (const person of audit.people) {
    const recipient = person.person_id
      ? recipients.get(person.person_id)
      : undefined;
    console.log(
      `${person.role_title}: ${person.name} -> ` +
        `${recipient?.email ?? "no email recipient"}`,
    );
  }
}

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}"}


def get(path: str) -> dict | None:
    """Returns None for a 404; raises for any other error."""
    res = requests.get(f"{BASE_URL}{path}", headers=HEADERS, timeout=30)
    if res.status_code == 404:
        return None
    if not res.ok:
        err = res.json()["error"]
        raise RuntimeError(
            f"{res.status_code} {err['code']}: {err['message']} "
            f"(request_id {err.get('request_id')})"
        )
    return res.json()


def resolve_recipients(people: list[dict]) -> dict[str, dict]:
    """One recipient per linked person.

    person_id None has nothing to resolve; a 404 means the person is not an
    email recipient (a client, for example) or was deleted.
    """
    out: dict[str, dict] = {}
    for person in people:
        person_id = person["person_id"]
        if person_id is None or person_id in out:
            continue
        recipient = get(f"/v2/email_recipients/{person_id}")
        if recipient is not None:
            out[person_id] = recipient
    return out


audit = get("/v2/audits/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")
if audit is None:
    raise SystemExit("Audit not found in this organization")
recipients = resolve_recipients(audit["people"])
for person in audit["people"]:
    recipient = recipients.get(person["person_id"]) if person["person_id"] else None
    email = (recipient or {}).get("email") or "no email recipient"
    print(f"{person['role_title']}: {person['name']} -> {email}")

A 404 NOT_FOUND ("Email recipient not found") means the person_id belongs to a person who is not an email recipient, such as a client, or to a recipient deleted after the audit ran. Audits keep the person_id after a deletion, so the audit itself is still valid.

Find the dashboard user for the same person

A user links to the same person through its read-only person_id, so a user's person_id, the email recipient's person_id and people[].person_id are one value. List users (GET /v2/users) has no person_id filter, so look the user up by email address, then check person_id.

curl "https://api.brellium.com/v2/users?email=jordan.rivera@clinic.example" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
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) {
  const { error } = await res.json();
  throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
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": [
    {
      // illustrative
      "user_id": "7c6d5e4f-3a2b-4c1d-9e8f-7a6b5c4d3e2f",
      "object": "user",
      "email": "jordan.rivera@clinic.example",
      // equals people[].person_id on the audit
      "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d"
      // name, attributes, created_at and updated_at omitted
    }
  ],
  "has_more": false
}

The email filter matches the stored address ignoring case, so an empty data means no user has that address. For an email recipient whose email is null, page through GET /v2/users once and check that no user has the recipient's person_id.

To invite the person as a user, you need the recipient's email address, because the API links a new user to a person by address. If the recipient's email is null, set it with PATCH /v2/email_recipients/{person_id}, sending the new email and the first_name, last_name and metadata you fetched in step 2. That call returns 409 EMAIL_RECIPIENT_EXISTS when another email recipient already has the address.

Then invite the person with Create a user (POST /v2/users) and that address, as in Invite and manage users. If your organization has a person with the address who isn't linked to another user, the API links the new user to them, so the user carries the audit's person_id. Otherwise it creates a new email recipient with a different person_id. A later change to the user's address through PATCH /v2/users/{user_id} also applies to the linked email recipient.

Group a synced list by person

The API can't list one person's audits. GET /v2/audits filters only on audit_id, status, set_id, flags, documents, external_id and the created and updated time ranges. It ignores a parameter it doesn't know, such as person_id, and returns the page unfiltered. Sync a local copy as described in Keep audits in sync with updated_after, then group it by people[].person_id.

type AuditPerson = {
  person_id: string | null;
  role: string;
  role_title: string;
  name: string;
};
type SyncedAudit = {
  audit_id: string;
  status: string;
  updated_at: string;
  people: AuditPerson[];
};

// Audit ids per linked person_id. A person_id of null is skipped: there is no
// record to group it under. Pass a role to keep only, say, providers.
function groupByPerson(
  audits: SyncedAudit[],
  role?: string,
): Map<string, string[]> {
  const byPerson = new Map<string, string[]>();
  for (const audit of audits) {
    const seen = new Set<string>();
    for (const person of audit.people) {
      if (person.person_id === null || seen.has(person.person_id)) continue;
      if (role !== undefined && person.role !== role) continue;
      seen.add(person.person_id);
      const ids = byPerson.get(person.person_id) ?? [];
      ids.push(audit.audit_id);
      byPerson.set(person.person_id, ids);
    }
  }
  return byPerson;
}

// `synced` is whatever your sync loop stores; one audit from the sample here.
const synced: SyncedAudit[] = [
  {
    audit_id: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    status: "complete",
    updated_at: "2026-09-24T02:37:12.105Z",
    people: [
      {
        person_id: null,
        role: "client",
        role_title: "Client",
        name: "Sample Client",
      },
      {
        person_id: "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
        role: "provider",
        role_title: "Provider",
        name: "Jordan Rivera",
      },
    ],
  },
];

const providers = groupByPerson(synced, "provider");
console.log(providers.get("2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d"));
// ["a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"]
from collections import defaultdict


def group_by_person(
    audits: list[dict], role: str | None = None
) -> dict[str, list[str]]:
    """Audit ids per linked person_id.

    A person_id of None is skipped: there is no record to group it under.
    Pass a role to keep only, say, providers.
    """
    by_person: dict[str, list[str]] = defaultdict(list)
    for audit in audits:
        seen: set[str] = set()
        for person in audit["people"]:
            person_id = person["person_id"]
            if person_id is None or person_id in seen:
                continue
            if role is not None and person["role"] != role:
                continue
            seen.add(person_id)
            by_person[person_id].append(audit["audit_id"])
    return dict(by_person)


# `synced` is whatever your sync loop stores; one audit from the sample here.
synced = [
    {
        "audit_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "status": "complete",
        "updated_at": "2026-09-24T02:37:12.105Z",
        "people": [
            {
                "person_id": None,
                "role": "client",
                "role_title": "Client",
                "name": "Sample Client",
            },
            {
                "person_id": "2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
                "role": "provider",
                "role_title": "Provider",
                "name": "Jordan Rivera",
            },
        ],
    },
]

providers = group_by_person(synced, role="provider")
print(providers["2a1b0c9d-8e7f-4a6b-9c5d-4e3f2a1b0c9d"])
# ['a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d']

Store each person_id with its audit and index it, so grouping is one query against your own copy. When your sync loop updates an audit, replace the audit's stored links with the ones in its latest people[], because a re-completed audit can name different people.

Common mistakes

Mistake What you see Fix
Matching on name or display_info instead of people[].person_id People with the same name merge, and a renamed recipient stops matching older audits Join on people[].person_id; name and display_info[].value show the name as it was on the audit.
Assuming every person has a person_id TypeError on null, or 400 VALIDATION_ERROR from GET /v2/email_recipients/null Skip a person whose person_id is null, like the sample's client.
Filtering users by person_id An unfiltered page of users Query ?email= with the recipient's address, then compare person_id (step 3).

Next