Billing & scheduling
For practice-management and revenue-cycle systems. Appointments both ways: send yours by your own appointment id (idempotent; cancelling never deletes) so the pre-visit brief is ready before each visit, and read every appointment of the practice, including those patients booked in Norvya, on the practice's clock with cursor paging. Norvya does not send its own reminders for appointments your system sent unless the practice turns that on. Care-management minutes and remote-monitoring days per patient per program per month (CCM, PCM, chronic pain, RPM, RTM, APCM, CoCM, BHI, TCM), with the program's published reference marks reached and its reference codes, each labelled a candidate for your biller's review: never a charge, an eligibility determination or a code selected for the patient. Billing candidates for staff review. No Surprises Act Good Faith Estimates from your codes and charges, with Norvya's required-contents check and a PDF in the patient's language. Not exposed: claim submission, ERA posting and eligibility-file intake; your practice-management system remains the claims engine. Treating practices only.
- Who calls it
- Core for Practice management & billing; useful for Concierge doctors, Independent practices, Medical travel desks, Behavioral health practices, Rehab & nutrition practices
- Before production
- A BAA with each practice. The care-management view also needs the practice to switch on care-management documentation in Norvya (after its own compliance review).
- Sandbox
- Build against synthetic members first. What each sandbox holds
- Standards
- No Surprises Act Good Faith Estimates
Markets that use this family
- Concierge doctors (useful)
- Independent practices (useful)
- Practice management & billing (core)
- Medical travel desks (useful)
- Behavioral health practices (useful)
- Rehab & nutrition practices (useful)
Scopes
| Scope | Allows |
|---|---|
billing.estimates.write | Issue a No Surprises Act Good Faith Estimate for a patient from your system: your codes and your charges, the same required-contents check as Norvya's own screen (treating practices only). |
billing.read | Read care-management time and remote-monitoring days per patient per month, with the program's published reference marks reached, as candidates for your biller's review (never a charge or an eligibility determination); billing candidates for staff review; and Good Faith Estimates (treating practices only). |
scheduling.read | Read your practice's appointments (booked in Norvya or sent by your system), one practice zone, cursor-paginated, plus the scheduling settings (treating practices only). |
scheduling.write | Send appointments from your practice-management system, idempotently by your own appointment id; cancel never deletes. Change whether Norvya sends its own reminders for them (treating practices only). |
Endpoints
Your practice's zone, providers and reminder setting
The practice zone Norvya stores appointments on (from your practice's Norvya hours; UTC when none are set), its active clinicians and staff as staff_ref values to map your own provider list to (names and roles only), and whether Norvya sends its own reminders for appointments your system sends (off by default: your system usually reminds).
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/scheduling/settings" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/scheduling/settings",
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/scheduling/settings`);
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());{
"practice_zone": "string",
"zone_basis": "string",
"norvya_reminders_for_partner_appointments": false,
"providers": [
{
"staff_ref": "string",
"name": "string",
"role": "string",
"title": "string"
}
],
"statuses": [
"string"
],
"past_booked_window_hours": 0
}The response is the shape from the schema, not live data.
Try it in your sandbox
Turn Norvya's reminders for your appointments on or off
Set norvya_reminders_for_partner_appointments. While false, Norvya sends no appointment reminder (push, in-app or email) for an appointment your system sent, so a patient is not reminded twice. Appointments booked in Norvya itself are reminded either way. Audited as a settings change.
Request body
Content type: application/json (required)
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X PUT "https://norvya.fly.dev/v1/scheduling/settings" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"norvya_reminders_for_partner_appointments": false
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.put(
BASE + "/v1/scheduling/settings",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"norvya_reminders_for_partner_appointments": False,
},
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/scheduling/settings`);
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"norvya_reminders_for_partner_appointments": false
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"practice_zone": "string",
"zone_basis": "string",
"norvya_reminders_for_partner_appointments": false,
"providers": [
{
"staff_ref": "string",
"name": "string",
"role": "string",
"title": "string"
}
],
"statuses": [
"string"
],
"past_booked_window_hours": 0
}The response is the shape from the schema, not live data.
Try it in your sandbox
Create or update an appointment by YOUR appointment id
Idempotent: the same external_id from your client always lands on the same Norvya appointment (another client's ids are a separate namespace). The appointment becomes an ordinary Norvya appointment, so the pre-visit brief and your staff's screens see it. `start` must carry a UTC offset. A booked appointment that started more than 24 hours ago is refused (booked_in_past: send history as completed, no_show or cancelled). An id stays bound to its first patient (409 external_id_member_mismatch). Your own change is never sent back to you as a webhook.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
Request body
Content type: application/json (required)
ISO 8601 with a UTC offset; a time without one is refused (start_timezone_required).
IANA zone the visit is on; default the practice's zone.
booked | cancelled | completed | no_show
Errors specific to this call
409external_id_member_mismatchAll responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X PUT "https://norvya.fly.dev/v1/appointments/ext/$EXTERNAL_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"member_ref": "mr_3f9c1a0b7d2e4c6a8b1d0e2f",
"start": "2026-10-20T09:30:00-04:00",
"duration_minutes": 30,
"timezone": "America/New_York",
"status": "booked",
"visit_type": "follow_up",
"provider": {
"staff_ref": "stf_1a2b3c4d5e6f",
"label": "Dr. Ana Ruiz"
},
"location": "Main Street office",
"telehealth": false,
"cancellation_reason": "string"
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
external_id = os.environ["EXTERNAL_ID"]
resp = requests.put(
f"{BASE}/v1/appointments/ext/{external_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"member_ref": "mr_3f9c1a0b7d2e4c6a8b1d0e2f",
"start": "2026-10-20T09:30:00-04:00",
"duration_minutes": 30,
"timezone": "America/New_York",
"status": "booked",
"visit_type": "follow_up",
"provider": {
"staff_ref": "stf_1a2b3c4d5e6f",
"label": "Dr. Ana Ruiz",
},
"location": "Main Street office",
"telehealth": False,
"cancellation_reason": "string",
},
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 external_id = process.env.EXTERNAL_ID;
const url = new URL(`${BASE}/v1/appointments/ext/${encodeURIComponent(external_id)}`);
const res = await fetch(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"member_ref": "mr_3f9c1a0b7d2e4c6a8b1d0e2f",
"start": "2026-10-20T09:30:00-04:00",
"duration_minutes": 30,
"timezone": "America/New_York",
"status": "booked",
"visit_type": "follow_up",
"provider": {
"staff_ref": "stf_1a2b3c4d5e6f",
"label": "Dr. Ana Ruiz"
},
"location": "Main Street office",
"telehealth": false,
"cancellation_reason": "string"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"appointment_ref": "apt_5e7a9c1b3d5f7a9c1e3a5b7d",
"external_id": "PM-000123",
"source": "partner",
"member_ref": "string",
"status": "booked",
"start_utc": "2026-10-20T13:30:00Z",
"end_utc": "string",
"start_local": "2026-10-20T09:30:00-04:00",
"duration_minutes": 0,
"timezone": "America/New_York",
"practice_zone": "string",
"visit_type": "string",
"provider": {
"staff_ref": "string",
"label": "string"
},
"location": "string",
"telehealth": false,
"norvya_reminders": false,
"created_at": "string",
"updated_at": "string",
"cancelled_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
One of YOUR appointments, by your id
An appointment your client sent. Anything else answers 404 appointment_not_found.
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/appointments/ext/$EXTERNAL_ID" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
external_id = os.environ["EXTERNAL_ID"]
resp = requests.get(
f"{BASE}/v1/appointments/ext/{external_id}",
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 external_id = process.env.EXTERNAL_ID;
const url = new URL(`${BASE}/v1/appointments/ext/${encodeURIComponent(external_id)}`);
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());{
"appointment_ref": "apt_5e7a9c1b3d5f7a9c1e3a5b7d",
"external_id": "PM-000123",
"source": "partner",
"member_ref": "string",
"status": "booked",
"start_utc": "2026-10-20T13:30:00Z",
"end_utc": "string",
"start_local": "2026-10-20T09:30:00-04:00",
"duration_minutes": 0,
"timezone": "America/New_York",
"practice_zone": "string",
"visit_type": "string",
"provider": {
"staff_ref": "string",
"label": "string"
},
"location": "string",
"telehealth": false,
"norvya_reminders": false,
"created_at": "string",
"updated_at": "string",
"cancelled_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Cancel one of YOUR appointments (never deleted)
DELETE means cancel: the status becomes cancelled and the row stays, so its history is legible and the brief no longer counts it. Repeating it is harmless. Returns the appointment.
Parameters
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s -X DELETE "https://norvya.fly.dev/v1/appointments/ext/$EXTERNAL_ID" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
external_id = os.environ["EXTERNAL_ID"]
resp = requests.delete(
f"{BASE}/v1/appointments/ext/{external_id}",
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 external_id = process.env.EXTERNAL_ID;
const url = new URL(`${BASE}/v1/appointments/ext/${encodeURIComponent(external_id)}`);
const res = await fetch(url, {
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"appointment_ref": "apt_5e7a9c1b3d5f7a9c1e3a5b7d",
"external_id": "PM-000123",
"source": "partner",
"member_ref": "string",
"status": "booked",
"start_utc": "2026-10-20T13:30:00Z",
"end_utc": "string",
"start_local": "2026-10-20T09:30:00-04:00",
"duration_minutes": 0,
"timezone": "America/New_York",
"practice_zone": "string",
"visit_type": "string",
"provider": {
"staff_ref": "string",
"label": "string"
},
"location": "string",
"telehealth": false,
"norvya_reminders": false,
"created_at": "string",
"updated_at": "string",
"cancelled_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Your practice's appointments, booked in Norvya or sent by a practice system
Ordered by start time. Includes appointments patients booked in Norvya (so your system can mirror them) and every practice system's appointments; external_id is filled only for your own. Patients no longer linked to your practice are left out. Pass next_cursor back as ?cursor= for the next page.
Parameters
YYYY-MM-DD on the practice's clock; default today.
YYYY-MM-DD inclusive; default from + 30 days; at most 366 days.
booked | checked_in | completed | cancelled | no_show
norvya | partner
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/appointments" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/appointments",
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/appointments`);
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": [
{
"appointment_ref": "apt_5e7a9c1b3d5f7a9c1e3a5b7d",
"external_id": "PM-000123",
"source": "partner",
"member_ref": "string",
"status": "booked",
"start_utc": "2026-10-20T13:30:00Z",
"end_utc": "string",
"start_local": "2026-10-20T09:30:00-04:00",
"duration_minutes": 0,
"timezone": "America/New_York",
"practice_zone": "string",
"visit_type": "string",
"provider": {
"staff_ref": "string",
"label": "string"
},
"location": "string",
"telehealth": false,
"norvya_reminders": false,
"created_at": "string",
"updated_at": "string",
"cancelled_at": "string"
}
],
"next_cursor": "string",
"practice_zone": "string",
"zone_basis": "clinic_schedule",
"window": {}
}The response is the shape from the schema, not live data.
Try it in your sandbox
One appointment of your practice
The resource an appointment.booked / appointment.cancelled webhook points to. Another practice's appointment, an unknown ref and one whose patient is no longer linked are the same 404 appointment_not_found.
Parameters
Errors specific to this call
404appointment_not_foundAll responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/appointments/$APPOINTMENT_REF" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
appointment_ref = os.environ["APPOINTMENT_REF"]
resp = requests.get(
f"{BASE}/v1/appointments/{appointment_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 appointment_ref = process.env.APPOINTMENT_REF;
const url = new URL(`${BASE}/v1/appointments/${encodeURIComponent(appointment_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());{
"appointment_ref": "apt_5e7a9c1b3d5f7a9c1e3a5b7d",
"external_id": "PM-000123",
"source": "partner",
"member_ref": "string",
"status": "booked",
"start_utc": "2026-10-20T13:30:00Z",
"end_utc": "string",
"start_local": "2026-10-20T09:30:00-04:00",
"duration_minutes": 0,
"timezone": "America/New_York",
"practice_zone": "string",
"visit_type": "string",
"provider": {
"staff_ref": "string",
"label": "string"
},
"location": "string",
"telehealth": false,
"norvya_reminders": false,
"created_at": "string",
"updated_at": "string",
"cancelled_at": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Care-management minutes and remote days per patient per program, for your biller
Every patient with an enrolment overlapping the month or minutes filed under a program, per program: enrolment and consent state, minutes (by activity) and, for remote monitoring, device days per 30-day period, whether the program's first published reference mark was reached, the marks reached, what is still missing, and the program's reference codes. Each row is status candidate_for_biller_review: never a charge, an eligibility determination or a code selected for the patient. The same evidence Norvya's staff month view shows (care_programs). Needs care management enabled for your practice.
Parameters
YYYY-MM on the practice's clock; default the current month.
ccm | pcm | chronic_pain | rpm | rtm | apcm | cocm | bhi | tcm
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/billing/care-management" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/billing/care-management",
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/billing/care-management`);
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());{
"service_month": "string",
"practice_zone": "string",
"zone_basis": "string",
"items": [
{
"member_ref": "string",
"program": "ccm",
"family": "time_based",
"service_month": "2026-10",
"status": "candidate_for_biller_review",
"enrolment": {},
"minutes": 0,
"time_entries": 0,
"minutes_by_activity": {},
"interactive_call_minutes": 0,
"remote_periods": [
{}
],
"tcm_periods": [
{}
],
"threshold": {
"met": false,
"basis": "minutes",
"marks_reached": [
"clinical_staff_first"
],
"next_mark": {}
},
"reference": {
"codes": [
"99490",
"99439",
"99491",
"99437",
"99487",
"99489"
],
"add_on_codes": [],
"add_on_marks": [],
"mark_minutes": {},
"codes_verified": false,
"source": "string",
"attached_to_member": false
},
"gaps": [
{}
],
"concurrency": [
{}
]
}
],
"reference_as_of": "string",
"notice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
One patient's care-management month, for your biller
The rows of GET /v1/billing/care-management for one patient (the resource billing.threshold_met points to). A patient with nothing filed this month answers an empty programs list.
Parameters
YYYY-MM; default the current month.
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/patients/$MEMBER_REF/care-management" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.get(
f"{BASE}/v1/patients/{member_ref}/care-management",
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 member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/patients/${encodeURIComponent(member_ref)}/care-management`);
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());{
"member_ref": "string",
"service_month": "string",
"practice_zone": "string",
"programs": [
{
"member_ref": "string",
"program": "ccm",
"family": "time_based",
"service_month": "2026-10",
"status": "candidate_for_biller_review",
"enrolment": {},
"minutes": 0,
"time_entries": 0,
"minutes_by_activity": {},
"interactive_call_minutes": 0,
"remote_periods": [
{}
],
"tcm_periods": [
{}
],
"threshold": {
"met": false,
"basis": "minutes",
"marks_reached": [
"clinical_staff_first"
],
"next_mark": {}
},
"reference": {
"codes": [
"99490",
"99439",
"99491",
"99437",
"99487",
"99489"
],
"add_on_codes": [],
"add_on_marks": [],
"mark_minutes": {},
"codes_verified": false,
"source": "string",
"attached_to_member": false
},
"gaps": [
{}
],
"concurrency": [
{}
]
}
],
"notice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Patients whose recorded data match published CMS criteria: for staff review
Patients whose RECORDED data match the published CMS criteria for CCM, PCM, APCM (by level) or a possibly-due annual wellness visit, each with the criteria met and the record each came from, what Norvya does not know (payer, QMB status, consent, enrolment elsewhere, Part B date, the clinician's judgement) and staff_must_confirm: true. For staff review, not an eligibility determination; no code is attached to a patient and no amount is shown. Patients already enrolled in the program are left out.
Parameters
ccm | pcm | apcm | awv
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/billing/candidates" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
resp = requests.get(
BASE + "/v1/billing/candidates",
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/billing/candidates`);
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());{
"as_of": "string",
"engine": "string",
"care_management_enabled": false,
"counts": {},
"items": [
{
"member_ref": "string",
"program": "ccm",
"label": "candidate_for_review",
"level": "string",
"criteria_met": [
{}
],
"unknowns": [
"string"
],
"coverage": {},
"open_enrollments": [
{}
],
"previously_declined_on": "string",
"staff_must_confirm": false
}
],
"criteria": {},
"limitations": [
"string"
],
"staff_must_confirm": false,
"notice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Issue a Good Faith Estimate for a patient
A No Surprises Act estimate (45 CFR 149.610) for an uninsured or self-pay patient, from YOUR codes and charges (Norvya never chooses a code or a price). Lines without a provider belong to your practice and take its name, organisation NPI, TIN and address unless you pass convening_*. The same required-contents check as Norvya's own screen: anything §149.610(c) asks for that is missing is refused as 422 {code: required_contents_missing, missing: [...]}, and nothing is recorded. The patient's name and date of birth come from Norvya. Issued documents are retained six years and never edited; delivered in the app when delivery_method is in_app.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
Request body
Content type: application/json (required)
scheduled | requested
YYYY-MM-DD: the scheduling or request date; default today on the practice's clock.
YYYY-MM-DD; required when trigger is scheduled.
in_app | email | paper | mail
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/patients/$MEMBER_REF/good-faith-estimates" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"trigger": "scheduled",
"triggered_on": "2026-10-16",
"primary_service": "Diabetes follow-up visit with A1c",
"service_date": "string",
"delivery_method": "string",
"lines": [
{
"description": "Office visit, established patient",
"service_code": "99213",
"code_type": "CPT",
"quantity": 0,
"expected_charge": "150.00",
"diagnosis_codes": [
"E11.9"
],
"provider_name": "string",
"npi": "string",
"tin": "string",
"location": "string",
"state": "string",
"role": "string"
}
],
"separately_scheduled": [
"string"
],
"diagnosis_codes": [
"string"
],
"convening_npi": "string",
"convening_tin": "string",
"convening_location": "string",
"convening_state": "string",
"note": "string"
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.post(
f"{BASE}/v1/patients/{member_ref}/good-faith-estimates",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"trigger": "scheduled",
"triggered_on": "2026-10-16",
"primary_service": "Diabetes follow-up visit with A1c",
"service_date": "string",
"delivery_method": "string",
"lines": [
{
"description": "Office visit, established patient",
"service_code": "99213",
"code_type": "CPT",
"quantity": 0,
"expected_charge": "150.00",
"diagnosis_codes": [
"E11.9",
],
"provider_name": "string",
"npi": "string",
"tin": "string",
"location": "string",
"state": "string",
"role": "string",
},
],
"separately_scheduled": [
"string",
],
"diagnosis_codes": [
"string",
],
"convening_npi": "string",
"convening_tin": "string",
"convening_location": "string",
"convening_state": "string",
"note": "string",
},
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 member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/patients/${encodeURIComponent(member_ref)}/good-faith-estimates`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"trigger": "scheduled",
"triggered_on": "2026-10-16",
"primary_service": "Diabetes follow-up visit with A1c",
"service_date": "string",
"delivery_method": "string",
"lines": [
{
"description": "Office visit, established patient",
"service_code": "99213",
"code_type": "CPT",
"quantity": 0,
"expected_charge": "150.00",
"diagnosis_codes": [
"E11.9"
],
"provider_name": "string",
"npi": "string",
"tin": "string",
"location": "string",
"state": "string",
"role": "string"
}
],
"separately_scheduled": [
"string"
],
"diagnosis_codes": [
"string"
],
"convening_npi": "string",
"convening_tin": "string",
"convening_location": "string",
"convening_state": "string",
"note": "string"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"estimate_ref": "gfe_8b0d2f4a6c8e0a2b4d6f8a0c",
"member_ref": "string",
"version": 0,
"reason": "initial",
"is_current": false,
"issued_on": "string",
"primary_service": "string",
"service_date": "string",
"total_cents": 0,
"expected_patient_total_cents": 0,
"retain_until": "string",
"delivered_via": "string",
"acknowledged": false,
"content_sha256": "string",
"verified": false,
"timing": {},
"request_status": "string",
"document": {},
"not_legal_advice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
A patient's Good Faith Estimates from your practice
Every estimate version your practice issued for the patient (through the API or Norvya's own screen), newest first, without the document body.
Parameters
EMR vendors also pass vendor (the practice) with a vendor-issued token. EMR vendor tokens
All responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/patients/$MEMBER_REF/good-faith-estimates" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
member_ref = os.environ["MEMBER_REF"]
resp = requests.get(
f"{BASE}/v1/patients/{member_ref}/good-faith-estimates",
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 member_ref = process.env.MEMBER_REF;
const url = new URL(`${BASE}/v1/patients/${encodeURIComponent(member_ref)}/good-faith-estimates`);
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());{
"member_ref": "string",
"items": [
{
"estimate_ref": "gfe_8b0d2f4a6c8e0a2b4d6f8a0c",
"member_ref": "string",
"version": 0,
"reason": "initial",
"is_current": false,
"issued_on": "string",
"primary_service": "string",
"service_date": "string",
"total_cents": 0,
"expected_patient_total_cents": 0,
"retain_until": "string",
"delivered_via": "string",
"acknowledged": false,
"content_sha256": "string",
"verified": false,
"timing": {},
"request_status": "string",
"document": {},
"not_legal_advice": "string"
}
]
}The response is the shape from the schema, not live data.
Try it in your sandbox
One Good Faith Estimate, with the issued document
The issued snapshot (what the PDF shows), its SHA-256 and whether it still verifies, and the timing status of its request. Another practice's estimate and an unknown ref are the same 404 estimate_not_found.
Parameters
Errors specific to this call
404estimate_not_foundAll responses
200Successful Response401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/good-faith-estimates/$ESTIMATE_REF" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
estimate_ref = os.environ["ESTIMATE_REF"]
resp = requests.get(
f"{BASE}/v1/good-faith-estimates/{estimate_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 estimate_ref = process.env.ESTIMATE_REF;
const url = new URL(`${BASE}/v1/good-faith-estimates/${encodeURIComponent(estimate_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());{
"estimate_ref": "gfe_8b0d2f4a6c8e0a2b4d6f8a0c",
"member_ref": "string",
"version": 0,
"reason": "initial",
"is_current": false,
"issued_on": "string",
"primary_service": "string",
"service_date": "string",
"total_cents": 0,
"expected_patient_total_cents": 0,
"retain_until": "string",
"delivered_via": "string",
"acknowledged": false,
"content_sha256": "string",
"verified": false,
"timing": {},
"request_status": "string",
"document": {},
"not_legal_advice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
The estimate as a PDF, in the patient's language
Rendered by the same builder as Norvya's own copy, in the patient's app language (or ?lang=), with the English disclaimers kept as the binding text. A disclosure: audited as an export before it is returned and refused when the audit log is down.
Parameters
An app language code; default the patient's.
All responses
200The estimate as a PDF in the patient's language.401Unauthorized403Forbidden404Not Found409Conflict422Unprocessable Entity429Too Many Requests503Service UnavailableCodes every call can return are in Errors & limits.
curl -s "https://norvya.fly.dev/v1/good-faith-estimates/$ESTIMATE_REF/pdf" \
-H "Authorization: Bearer $TOKEN"import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
estimate_ref = os.environ["ESTIMATE_REF"]
resp = requests.get(
f"{BASE}/v1/good-faith-estimates/{estimate_ref}/pdf",
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 estimate_ref = process.env.ESTIMATE_REF;
const url = new URL(`${BASE}/v1/good-faith-estimates/${encodeURIComponent(estimate_ref)}/pdf`);
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());Try it in your sandbox
Issue a new version of a Good Faith Estimate
A new version for a change in the items or charges, or after a reschedule (good_faith_estimate.issue: the same builder and §149.610(c) required-contents check as the first issue; a refusal records nothing). The previous version is kept, marked superseded, never edited; the PDF of the new version is rendered on request like any other. 409 request_closed for a cancelled request, 409 estimate_superseded for an older version.
Parameters
Request body
Content type: application/json (required)
change | reschedule; default: reschedule when the request was rescheduled after the current version, else change.
Errors specific to this call
409request_closed409estimate_supersededAll 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/good-faith-estimates/$ESTIMATE_REF/reissue" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "string",
"lines": [
{
"description": "Office visit, established patient",
"service_code": "99213",
"code_type": "CPT",
"quantity": 0,
"expected_charge": "150.00",
"diagnosis_codes": [
"E11.9"
],
"provider_name": "string",
"npi": "string",
"tin": "string",
"location": "string",
"state": "string",
"role": "string"
}
],
"separately_scheduled": [
"string"
],
"diagnosis_codes": [
"string"
],
"convening_npi": "string",
"convening_tin": "string",
"convening_location": "string",
"convening_state": "string",
"note": "string"
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
estimate_ref = os.environ["ESTIMATE_REF"]
resp = requests.post(
f"{BASE}/v1/good-faith-estimates/{estimate_ref}/reissue",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"reason": "string",
"lines": [
{
"description": "Office visit, established patient",
"service_code": "99213",
"code_type": "CPT",
"quantity": 0,
"expected_charge": "150.00",
"diagnosis_codes": [
"E11.9",
],
"provider_name": "string",
"npi": "string",
"tin": "string",
"location": "string",
"state": "string",
"role": "string",
},
],
"separately_scheduled": [
"string",
],
"diagnosis_codes": [
"string",
],
"convening_npi": "string",
"convening_tin": "string",
"convening_location": "string",
"convening_state": "string",
"note": "string",
},
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 estimate_ref = process.env.ESTIMATE_REF;
const url = new URL(`${BASE}/v1/good-faith-estimates/${encodeURIComponent(estimate_ref)}/reissue`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reason": "string",
"lines": [
{
"description": "Office visit, established patient",
"service_code": "99213",
"code_type": "CPT",
"quantity": 0,
"expected_charge": "150.00",
"diagnosis_codes": [
"E11.9"
],
"provider_name": "string",
"npi": "string",
"tin": "string",
"location": "string",
"state": "string",
"role": "string"
}
],
"separately_scheduled": [
"string"
],
"diagnosis_codes": [
"string"
],
"convening_npi": "string",
"convening_tin": "string",
"convening_location": "string",
"convening_state": "string",
"note": "string"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"estimate_ref": "gfe_8b0d2f4a6c8e0a2b4d6f8a0c",
"member_ref": "string",
"version": 0,
"reason": "initial",
"is_current": false,
"issued_on": "string",
"primary_service": "string",
"service_date": "string",
"total_cents": 0,
"expected_patient_total_cents": 0,
"retain_until": "string",
"delivered_via": "string",
"acknowledged": false,
"content_sha256": "string",
"verified": false,
"timing": {},
"request_status": "string",
"document": {},
"not_legal_advice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Record that the scheduled item was rescheduled
good_faith_estimate.reschedule: the §149.610(b)(1)(vi) clock restarts from scheduled_on and the current version then needs a new one (timing status reissue_needed): POST .../reissue next. 422 service_date_before_scheduling, scheduled_on_in_future; 409 request_closed.
Parameters
Request body
Content type: application/json (required)
YYYY-MM-DD: when the item was rescheduled; default today on the practice's clock.
YYYY-MM-DD: the new date of service.
Errors specific to this call
409request_closedAll 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/good-faith-estimates/$ESTIMATE_REF/reschedule" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scheduled_on": "2026-10-20",
"service_date": "2026-11-12"
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
estimate_ref = os.environ["ESTIMATE_REF"]
resp = requests.post(
f"{BASE}/v1/good-faith-estimates/{estimate_ref}/reschedule",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"scheduled_on": "2026-10-20",
"service_date": "2026-11-12",
},
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 estimate_ref = process.env.ESTIMATE_REF;
const url = new URL(`${BASE}/v1/good-faith-estimates/${encodeURIComponent(estimate_ref)}/reschedule`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"scheduled_on": "2026-10-20",
"service_date": "2026-11-12"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"estimate_ref": "gfe_8b0d2f4a6c8e0a2b4d6f8a0c",
"member_ref": "string",
"version": 0,
"reason": "initial",
"is_current": false,
"issued_on": "string",
"primary_service": "string",
"service_date": "string",
"total_cents": 0,
"expected_patient_total_cents": 0,
"retain_until": "string",
"delivered_via": "string",
"acknowledged": false,
"content_sha256": "string",
"verified": false,
"timing": {},
"request_status": "string",
"document": {},
"not_legal_advice": "string"
}The response is the shape from the schema, not live data.
Try it in your sandbox
Cancel the estimate request (the item will not take place)
good_faith_estimate.cancel: the request closes (request_status cancelled); issued versions stay on file for the six-year retention and stay readable. 409 request_closed when already closed.
Parameters
Request body
Content type: application/json
Errors specific to this call
409request_closedAll 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/good-faith-estimates/$ESTIMATE_REF/cancel" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "string"
}'import os
import requests
BASE = "https://norvya.fly.dev"
TOKEN = os.environ["TOKEN"]
estimate_ref = os.environ["ESTIMATE_REF"]
resp = requests.post(
f"{BASE}/v1/good-faith-estimates/{estimate_ref}/cancel",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"reason": "string",
},
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 estimate_ref = process.env.ESTIMATE_REF;
const url = new URL(`${BASE}/v1/good-faith-estimates/${encodeURIComponent(estimate_ref)}/cancel`);
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"reason": "string"
}),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(await res.json());{
"estimate_ref": "gfe_8b0d2f4a6c8e0a2b4d6f8a0c",
"member_ref": "string",
"version": 0,
"reason": "initial",
"is_current": false,
"issued_on": "string",
"primary_service": "string",
"service_date": "string",
"total_cents": 0,
"expected_patient_total_cents": 0,
"retain_until": "string",
"delivered_via": "string",
"acknowledged": false,
"content_sha256": "string",
"verified": false,
"timing": {},
"request_status": "string",
"document": {},
"not_legal_advice": "string"
}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 |
|---|---|---|
appointment.booked | scheduling.read | An appointment was booked in Norvya (your own writes are never echoed). |
appointment.cancelled | scheduling.read | An appointment was cancelled in Norvya (your own writes are never echoed). |
billing.threshold_met | billing.read | A patient's documented care-management time (or remote-monitoring days) for one program reached the program's first published reference mark this month. |
Generated from the Partner API router and developers_catalog.py. The router docstring is the source of each description.