Enrolment roster
For plans, state programmes and practices that hold covered lives: upload a roster (CSV / NDJSON, or a CMS eligibility / attribution file), review its dry run, and have Norvya invite everyone on it in bulk through the same invitation and in-app consent screen, under a daily cap (queued, not dropped), with reminders and phone-only join links. The enrolment report is small-cell-suppressed counts by cohort; MBIs are never stored raw, addresses are held sealed only until delivered, and a person who declines is never invited by that organisation again. Webhook: roster.enrolment_summary.
- Who calls it
- useful for Concierge doctors, Independent practices, Federal (via primes), State & local government, Insurers (incl. Medicaid managed care)
- Before production
- Treating practices or programmes with a BAA on file (or a sandbox).
- Sandbox
- Build against synthetic members first. What each sandbox holds
- Standards
- CMS eligibility / attribution files (MBIs never stored raw)
Markets that use this family
- Concierge doctors (useful)
- Independent practices (useful)
- Federal (via primes) (useful)
- State & local government (useful)
- Insurers (incl. Medicaid managed care) (useful)
Scopes
| Scope | Allows |
|---|---|
members.roster.read | Read your rosters and their enrolment report: invited, opened, joined, expired, revoked and declined as small-cell-suppressed counts by cohort, plus the status of each of your roster rows (medical practices / programmes only). |
members.roster.write | Upload a roster of your eligible members (CSV / NDJSON, or a CMS eligibility / attribution file), review its dry run, then have Norvya invite them in bulk (its own email in each person's language; daily cap, queued not dropped), send reminders and collect phone-only join links. MBIs never stored raw (medical practices / programmes with a BAA only). |
Endpoints
Upload a roster of eligible members and get its dry run
Parses the roster in the request, classifies every row (to invite, already a member, opted out, duplicate, invalid, not eligible today, no contact) and stages it. Nothing is sent until commit. MBIs and plan member ids are fingerprinted, birth dates and last names validated and dropped; addresses are kept as keyed hashes and, encrypted, only until delivered. Never says whether an address has a Norvya account.
Parameters
Default for rows without one.
Default cohort label.
Comma list of sections you will ask for (recorded only).
Request body
The roster as the raw body: text/csv (header row) or application/x-ndjson (one JSON object per line). Fields: external_id, first_name, last_name, email, phone (E.164), language (en es fr de it zh ja pt ar hi ko ur id vi th ru fa), date_of_birth (YYYY-MM-DD), mbi, plan_member_id, cohort. A CMS eligibility / attribution CSV (MBI, alignment dates, status) is accepted as is. At most 10 MB / 50,000 rows.
Content type: text/csv, application/x-ndjson (required)
All responses
201Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/rosters" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/csv" \
--data-binary @your_body_fileimport os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
with open("your_body_file", "rb") as fh:
resp = requests.post(
BASE + "/v1/rosters",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "text/csv"},
data=fh,
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
import { readFile } from "node:fs/promises";
const BASE = "https://norvya.fly.dev";
const url = new URL(`${BASE}/v1/rosters`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "text/csv",
},
body: await readFile("your_body_file"),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "ros_3f9c1a0b7d2e4c6a8b1d0e2f",
"status": "staged",
"source_format": "string",
"file_sha256": "string",
"file_bytes": 0,
"language": "string",
"expires_in_days": 0,
"sharing_request": [
"string"
],
"dry_run": {
"rows_read": 0,
"source_format": "string",
"columns_used": {},
"columns_ignored": [
"string"
],
"mbi_column": false,
"to_invite": 0,
"already_members": 0,
"opted_out": 0,
"duplicates": 0,
"invalid": 0,
"not_eligible": 0,
"no_contact": 0,
"open_invites_reused": 0,
"email_rows": 0,
"phone_only_rows": 0,
"invalid_contacts": 0,
"language_defaulted": 0,
"errors_by_code": {},
"rejections": [
{
"row": 17,
"code": "email_invalid",
"duplicate_of": 0
}
],
"daily_invite_cap": 0,
"invites_left_today": 0,
"estimated_days": 0,
"can_commit": false,
"blocking": [
"string"
],
"raw_identifiers_stored": false,
"contacts_stored": "string"
},
"result": {
"deduplicated": 4,
"invited": 180,
"opted_out": 1,
"queued": 20,
"refused": 0,
"reminded": 0,
"waiting_for_daily_cap": true
},
"error_code": "string",
"simulated": false,
"reminders_sent": 0,
"last_reminded_at": "string",
"created_at": "string",
"commit_started_at": "string",
"committed_at": "string",
"discarded_at": "string"
}The response is the shape from the schema, not live data.
Your rosters
Newest first; rosters YOUR client uploaded. Counts only.
Parameters
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/rosters" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/rosters",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const url = new URL(`${BASE}/v1/rosters`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"items": [
{
"roster_ref": "ros_3f9c1a0b7d2e4c6a8b1d0e2f",
"status": "staged",
"source_format": "string",
"file_sha256": "string",
"file_bytes": 0,
"language": "string",
"expires_in_days": 0,
"sharing_request": [
"string"
],
"dry_run": {
"rows_read": 0,
"source_format": "string",
"columns_used": {},
"columns_ignored": [],
"mbi_column": false,
"to_invite": 0,
"already_members": 0,
"opted_out": 0,
"duplicates": 0,
"invalid": 0,
"not_eligible": 0,
"no_contact": 0,
"open_invites_reused": 0,
"email_rows": 0,
"phone_only_rows": 0,
"invalid_contacts": 0,
"language_defaulted": 0,
"errors_by_code": {},
"rejections": [],
"daily_invite_cap": 0,
"invites_left_today": 0,
"estimated_days": 0,
"can_commit": false,
"blocking": [],
"raw_identifiers_stored": false,
"contacts_stored": "string"
},
"result": {
"deduplicated": 4,
"invited": 180,
"opted_out": 1,
"queued": 20,
"refused": 0,
"reminded": 0,
"waiting_for_daily_cap": true
},
"error_code": "string",
"simulated": false,
"reminders_sent": 0,
"last_reminded_at": "string",
"created_at": "string",
"commit_started_at": "string",
"committed_at": "string",
"discarded_at": "string"
}
]
}The response is the shape from the schema, not live data.
Try it in your sandbox
One roster: dry run, progress, result
Parameters
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/rosters/$ROSTER_REF" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.get(
f"{BASE}/v1/rosters/{roster_ref}",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "ros_3f9c1a0b7d2e4c6a8b1d0e2f",
"status": "staged",
"source_format": "string",
"file_sha256": "string",
"file_bytes": 0,
"language": "string",
"expires_in_days": 0,
"sharing_request": [
"string"
],
"dry_run": {
"rows_read": 0,
"source_format": "string",
"columns_used": {},
"columns_ignored": [
"string"
],
"mbi_column": false,
"to_invite": 0,
"already_members": 0,
"opted_out": 0,
"duplicates": 0,
"invalid": 0,
"not_eligible": 0,
"no_contact": 0,
"open_invites_reused": 0,
"email_rows": 0,
"phone_only_rows": 0,
"invalid_contacts": 0,
"language_defaulted": 0,
"errors_by_code": {},
"rejections": [
{
"row": 17,
"code": "email_invalid",
"duplicate_of": 0
}
],
"daily_invite_cap": 0,
"invites_left_today": 0,
"estimated_days": 0,
"can_commit": false,
"blocking": [
"string"
],
"raw_identifiers_stored": false,
"contacts_stored": "string"
},
"result": {
"deduplicated": 4,
"invited": 180,
"opted_out": 1,
"queued": 20,
"refused": 0,
"reminded": 0,
"waiting_for_daily_cap": true
},
"error_code": "string",
"simulated": false,
"reminders_sent": 0,
"last_reminded_at": "string",
"created_at": "string",
"commit_started_at": "string",
"committed_at": "string",
"discarded_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Commit a staged roster: Norvya invites its people
One invitation per row, in the person's language (Norvya's email with a decline link, your organisation's branding), in the background. Open invitations to the same address are reused (no second email); people who declined you are skipped. When the daily cap (PARTNER_INVITES_PER_DAY, shared with single invitations) is reached the remaining rows stay queued and go out on the following UTC days: none is dropped (status inviting, result.queued). Phone-only rows: collect their links from join-links. 409 roster_not_staged / roster_nothing_to_invite / organisation_not_joinable.
Parameters
Errors specific to this call
409roster_not_stagedAll responses
202Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/commit" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.post(
f"{BASE}/v1/rosters/{roster_ref}/commit",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}/commit`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "ros_3f9c1a0b7d2e4c6a8b1d0e2f",
"status": "staged",
"source_format": "string",
"file_sha256": "string",
"file_bytes": 0,
"language": "string",
"expires_in_days": 0,
"sharing_request": [
"string"
],
"dry_run": {
"rows_read": 0,
"source_format": "string",
"columns_used": {},
"columns_ignored": [
"string"
],
"mbi_column": false,
"to_invite": 0,
"already_members": 0,
"opted_out": 0,
"duplicates": 0,
"invalid": 0,
"not_eligible": 0,
"no_contact": 0,
"open_invites_reused": 0,
"email_rows": 0,
"phone_only_rows": 0,
"invalid_contacts": 0,
"language_defaulted": 0,
"errors_by_code": {},
"rejections": [
{
"row": 17,
"code": "email_invalid",
"duplicate_of": 0
}
],
"daily_invite_cap": 0,
"invites_left_today": 0,
"estimated_days": 0,
"can_commit": false,
"blocking": [
"string"
],
"raw_identifiers_stored": false,
"contacts_stored": "string"
},
"result": {
"deduplicated": 4,
"invited": 180,
"opted_out": 1,
"queued": 20,
"refused": 0,
"reminded": 0,
"waiting_for_daily_cap": true
},
"error_code": "string",
"simulated": false,
"reminders_sent": 0,
"last_reminded_at": "string",
"created_at": "string",
"commit_started_at": "string",
"committed_at": "string",
"discarded_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Discard a staged roster
Its sealed addresses are purged at once; the manifest (counts, codes, hash) stays. A committed roster cannot be discarded (revoke invitations with POST /v1/invites/{ref}/revoke).
Parameters
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/discard" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.post(
f"{BASE}/v1/rosters/{roster_ref}/discard",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}/discard`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "ros_3f9c1a0b7d2e4c6a8b1d0e2f",
"status": "staged",
"source_format": "string",
"file_sha256": "string",
"file_bytes": 0,
"language": "string",
"expires_in_days": 0,
"sharing_request": [
"string"
],
"dry_run": {
"rows_read": 0,
"source_format": "string",
"columns_used": {},
"columns_ignored": [
"string"
],
"mbi_column": false,
"to_invite": 0,
"already_members": 0,
"opted_out": 0,
"duplicates": 0,
"invalid": 0,
"not_eligible": 0,
"no_contact": 0,
"open_invites_reused": 0,
"email_rows": 0,
"phone_only_rows": 0,
"invalid_contacts": 0,
"language_defaulted": 0,
"errors_by_code": {},
"rejections": [
{
"row": 17,
"code": "email_invalid",
"duplicate_of": 0
}
],
"daily_invite_cap": 0,
"invites_left_today": 0,
"estimated_days": 0,
"can_commit": false,
"blocking": [
"string"
],
"raw_identifiers_stored": false,
"contacts_stored": "string"
},
"result": {
"deduplicated": 4,
"invited": 180,
"opted_out": 1,
"queued": 20,
"refused": 0,
"reminded": 0,
"waiting_for_daily_cap": true
},
"error_code": "string",
"simulated": false,
"reminders_sent": 0,
"last_reminded_at": "string",
"created_at": "string",
"commit_started_at": "string",
"committed_at": "string",
"discarded_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Remind the people who have not joined yet
At most once every PARTNER_ROSTER_REMIND_DAYS (default 7) per roster and PARTNER_ROSTER_MAX_REMINDERS (default 2) per person; never to someone who joined, declined or whose invitation you revoked. Each reminder is a new invitation (the old code stops working) through the same queue and cap. 409 roster_remind_too_soon / roster_not_committed.
Parameters
Errors specific to this call
409roster_remind_too_soonAll responses
202Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/remind" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.post(
f"{BASE}/v1/rosters/{roster_ref}/remind",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}/remind`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "string",
"queued_for_reminder": 0,
"skipped": {},
"next_reminder_after": "string",
"max_reminders_per_row": 0
}The response is the shape from the schema, not live data.
Try it in your sandbox
The join report: who was invited, opened, joined, declined
Counts per status overall and per cohort with small-cell suppression (AGG_MIN_CELL, primary + complementary; safe to share beyond your team), then your own roster rows: external_id, status, member_ref once joined and while linked, joined_at. Audited as a disclosure.
Parameters
Filter the member list by status.
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/enrolment" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.get(
f"{BASE}/v1/rosters/{roster_ref}/enrolment",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}/enrolment`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "string",
"generated_at": "string",
"min_cell": 0,
"suppression": "string",
"overall": {
"counts": {},
"total": 0,
"total_suppressed": false
},
"by_cohort": [
{
"counts": {},
"total": 0,
"total_suppressed": false,
"cohort": "string"
}
],
"members": {
"total": 0,
"offset": 0,
"limit": 0,
"items": [
{
"row_number": 0,
"external_id": "string",
"cohort": "string",
"status": "string",
"code": "string",
"invite_ref": "string",
"member_ref": "string",
"joined_at": "string",
"reminders": 0,
"last_sent_at": "string"
}
]
}
}The response is the shape from the schema, not live data.
Try it in your sandbox
Phone-only rows' join links (shown once)
Norvya never texts someone without an account (no opt-in on record): deliver each join_url, with its decline_url, through your own consented channel. Each link is returned ONCE and then forgotten (only its hash remains); call again after a reminder for the new links.
Parameters
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/join-links" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.get(
f"{BASE}/v1/rosters/{roster_ref}/join-links",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}/join-links`);
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"roster_ref": "string",
"items": [
{
"row_number": 0,
"external_id": "string",
"invite_ref": "string",
"language": "string",
"join_url": "string",
"decline_url": "string",
"expires_at": "string"
}
],
"notice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Sandbox: a share of the roster's invitations join
Sandbox credentials only (403 sandbox_only otherwise): synthetic members join through the roster's open invitations (member.joined fires), so the enrolment report shows data.
Parameters
Request body
Content type: application/json
Errors specific to this call
403sandbox_onlyAll responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X POST "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/simulate-joins" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fraction": 0.0
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
roster_ref = os.environ["ROSTER_REF"]
resp = requests.post(
f"{BASE}/v1/rosters/{roster_ref}/simulate-joins",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"fraction": 0.0,
},
timeout=30,
)
resp.raise_for_status()
print(resp.json())// Node.js 18+ (ES module). Keep production credentials on your server.
const BASE = "https://norvya.fly.dev";
const roster_ref = process.env.ROSTER_REF;
const url = new URL(`${BASE}/v1/rosters/${encodeURIComponent(roster_ref)}/simulate-joins`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"fraction": 0.0
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"joined": 0,
"open_before": 0,
"simulated": false
}The response is the shape from the schema, not live data.
Try it in your sandbox
Webhook events
Payloads carry no health data, only a reference to fetch with your token. Each event needs its scope as well as webhooks.manage. Signing and retries
| Event | Scope | When |
|---|---|---|
roster.enrolment_summary | members.roster.read | A roster you uploaded and committed has new enrolment numbers (at most once a UTC day per roster, only when its counts changed). |
Generated from the Partner API router and developers_catalog.py. The router docstring is the source of each description.