Norvya Developers
Get a sandbox
API reference · Data in

Enrolment roster

Built — gated9 endpoints · 1 webhook event · OpenAPI tag 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

Scopes

ScopeAllows
members.roster.readRead 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.writeUpload 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

POST/v1/rostersmembers.roster.write

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

filenamequery · string | null
languagequery · string | null

Default for rows without one.

cohortquery · string | null

Default cohort label.

expires_in_daysquery · integer | null
sharing_requestquery · string | null

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 Unavailable

Codes 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_file
Response 201
{
  "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

GET/v1/rostersmembers.roster.read

Newest first; rosters YOUR client uploaded. Counts only.

Parameters

limitquery · integer

All responses

200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes every call can return are in Errors & limits.

curl -s "https://norvya.fly.dev/v1/rosters" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "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.

One roster: dry run, progress, result

GET/v1/rosters/{roster_ref}members.roster.read

Parameters

roster_refpath · stringrequired

All responses

200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes every call can return are in Errors & limits.

curl -s "https://norvya.fly.dev/v1/rosters/$ROSTER_REF" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "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.

Commit a staged roster: Norvya invites its people

POST/v1/rosters/{roster_ref}/commitmembers.roster.write

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

roster_refpath · stringrequired

Errors specific to this call

409roster_not_staged

All responses

202Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes 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"
Response 202
{
  "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.

Discard a staged roster

POST/v1/rosters/{roster_ref}/discardmembers.roster.write

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

roster_refpath · stringrequired

All responses

200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes 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"
Response 200
{
  "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.

Remind the people who have not joined yet

POST/v1/rosters/{roster_ref}/remindmembers.roster.write

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

roster_refpath · stringrequired

Errors specific to this call

409roster_remind_too_soon

All responses

202Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes 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"
Response 202
{
  "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.

The join report: who was invited, opened, joined, declined

GET/v1/rosters/{roster_ref}/enrolmentmembers.roster.read

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

roster_refpath · stringrequired
statusquery · string | null

Filter the member list by status.

cohortquery · string | null
offsetquery · integer
limitquery · integer

All responses

200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes every call can return are in Errors & limits.

curl -s "https://norvya.fly.dev/v1/rosters/$ROSTER_REF/enrolment" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "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.

Sandbox: a share of the roster's invitations join

POST/v1/rosters/{roster_ref}/simulate-joinsmembers.roster.write

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

roster_refpath · stringrequired

Request body

Content type: application/json

Errors specific to this call

403sandbox_only

All responses

200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service Unavailable

Codes 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
  }'
Response 200
{
  "joined": 0,
  "open_before": 0,
  "simulated": false
}

The response is the shape from the schema, not live data.

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

EventScopeWhen
roster.enrolment_summarymembers.roster.readA 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.

↑ ↓ to moveEnter to open