Integration guide
This is the API surface for NewInstance EHR (product code ehr): REST endpoints for third-party integrations, the webhook event system, and the FHIR R4 facade. It does not cover the GraphQL schema used by the dashboards; that is internal to the platform.
Base path: /api/v1/ehr, and /fhir/r4 for the FHIR facade (section 11). Authentication: API key, header x-api-key: <keyId>:<secretKey>. Every request needs a key scoped to the organization and to the ehr product, except POST /api/v1/ehr/sms/inbound, which takes no API key and is authenticated by the SMS provider's signature (section 8), and GET /fhir/r4/metadata, which needs no authentication (section 11). Every read that returns or names a client is written to the organization's PHI access log, one entry for each client, with actor type api_key; reference codes, webhook subscriptions and the FHIR capability statement name no client and are not written to it.
Reference codes (section 2), webhook management (section 3), event replay (section 7), appointments, client lookup by medical record number and message replies from the SMS provider (section 8), the FHIR capability statement (section 11), and reads of claims and of a charge (section 12) are what is served. Enrollments, coverages, consents, documents, encounters, remittances and client import are not part of the REST surface, a client is read only by looking up its medical record number, and the billing routes write nothing.
1. API keys and EHR permission groupslink
An EHR-scoped API key is granted one or more permission groups (defaultProductApiPermissionGroups.json, productCode: "ehr"). A key with no EHR product scope at all, and without FULL_ACCESS, is rejected on every EHR route.
| Group code | Actions | What it unlocks |
|---|---|---|
full-access | * | Every route in this document, and any other EHR route, since * carries every action |
clients-read | clients:read | POST /api/v1/ehr/clients/lookup, and the client identifiers carried by appointment and billing responses |
clients-write | clients:read, clients:write | No route: no client write route is built |
scheduling | appointments:read, appointments:write | GET and POST /api/v1/ehr/appointments |
documents-read | documents:read | No route: no document route is built |
billing-read | billing:read | GET /api/v1/ehr/billing/claims, GET /api/v1/ehr/billing/claims/{id}, GET /api/v1/ehr/billing/charges/{id} (section 12) |
fhir-read | fhir:read | No route: the only FHIR route served (GET /fhir/r4/metadata) does not require authentication at all |
fhir-write | fhir:read, fhir:write | No route: no FHIR write route is built |
webhooks | webhooks:manage | GET/POST /api/v1/ehr/webhooks, PATCH/DELETE /api/v1/ehr/webhooks/{id}, POST /api/v1/ehr/webhooks/{id}/test, GET /api/v1/ehr/events |
A permission group on a key names what the key may ask for. It does not grant it. Every authenticated EHR route resolves what a key reaches as the permissions its groups name, kept to the permissions held by the organization member who created the key, so a key can never reach further than the person who issued it. A key issued by someone who has since left the organization reaches nothing.
Seven routes act on a client, and each of them also needs a key issued by a member who reaches every client: GET /api/v1/ehr/appointments, POST /api/v1/ehr/appointments, POST /api/v1/ehr/clients/lookup, GET /api/v1/ehr/billing/claims, GET /api/v1/ehr/billing/claims/{id}, GET /api/v1/ehr/billing/charges/{id} and GET /api/v1/ehr/events. A member reaches every client when they own the organization or their role holds ehr.client.all. Of the seeded roles, EHR Admin, Clinical Supervisor, Front Desk, Biller, Quality Assurance and Auditor hold it; Clinician, Clinician (co-sign required), Prescriber and Nurse read a caseload and do not. A key issued by any other member is answered 403 on all seven before a record is read, whatever groups the key carries, full-access included. Where the key would otherwise reach the route, the refusal says why and names what the key cannot do, for example: "This key was issued by a member who cannot read every client record, so it cannot book an appointment. Issue the key from a member whose role reaches every client, such as the front desk or an administrator."
Three webhook routes need the same key, because a webhook is sent events about every client: POST /api/v1/ehr/webhooks, PATCH /api/v1/ehr/webhooks/{id} and DELETE /api/v1/ehr/webhooks/{id}. A key issued by any other member is answered 403 on all three before anything is written, whatever groups the key carries, full-access included, and where the key would otherwise reach the route the refusal says: "This key was issued by a member who cannot read every client record, so it cannot configure a webhook. Issue the key from a member whose role reaches every client, such as the front desk or an administrator." Of the seeded roles only EHR Admin both manages integrations and reaches every client; a key from the front desk is refused these routes earlier, because its member cannot manage integrations. Listing subscriptions and sending a test event need only the webhooks group. What happens to a webhook once the member it is delivered under loses that reach is in section 3.
A key these routes serve gets client identifiers on appointment, claim and charge responses only when its groups ask to read clients, as clients-read does, and its member holds ehr.client.view. Any other key they serve gets the same rows with no client identifier on them.
GET /api/v1/ehr/reference-codes is the exception: it only requires the key to have the ehr product enabled at all, with any permission group (or FULL_ACCESS), not a specific group.
Keys also support resourceScopes, which can bind a key to specific programs. No EHR route consults them: every route in this document is organization-wide, so a key's resource scopes change nothing on it.
2. Reference codeslink
GET /api/v1/ehr/reference-codeslink
Looks up billing and clinical reference codes by code prefix or display text.
| Query parameter | Required | Notes |
|---|---|---|
system | No | One of ICD10CM, HCPCS, POS, MODIFIER, LOINC, RXNORM, TAXONOMY |
q | No | Search text, 1 to 80 characters, matched against the code prefix or a display substring, case insensitive |
limit | No | 1 to 100, default 25 |
Seed data is loaded for POS (place of service), MODIFIER (HCPCS modifiers used in behavioral health billing), and a starter HCPCS subset of behavioral health service codes. ICD10CM, LOINC, RXNORM, and TAXONOMY are valid values for system but have no seeded codes, and no loader for ICD-10-CM codes is built.
Example request:
GET /api/v1/ehr/reference-codes?system=HCPCS&q=H000&limit=5
x-api-key: key_abc123:secret_xyz789Example response, 200 OK:
{
"items": [
{ "system": "HCPCS", "code": "H0001", "display": "Alcohol and/or drug assessment", "attributes": {} },
{ "system": "HCPCS", "code": "H0002", "display": "Behavioral health screening to determine eligibility for admission to treatment program", "attributes": {} },
{ "system": "HCPCS", "code": "H0004", "display": "Behavioral health counseling and therapy, per 15 minutes", "attributes": {} }
]
}3. Webhook managementlink
All webhook endpoints require the webhooks permission group (or full-access) and, on a self-hosted deployment, a configured platform SECRET_KEY; without it, these routes return 503. Creating, changing and deleting a subscription also need a key issued by a member who reaches every client (section 1).
The {id} in PATCH /api/v1/ehr/webhooks/{id}, DELETE /api/v1/ehr/webhooks/{id} and POST /api/v1/ehr/webhooks/{id}/test is the subscription's id, 24 hexadecimal characters. Anything else, a 12 character text included, is refused with 400 "Invalid webhook subscription id", and nothing is written.
The member a webhook is delivered under. Every create, change and delete records the member who issued the key as the member who configured the subscription; a change made in the dashboard records the member who made it. A webhook is delivered only while that member is still in the organization and still reaches every client. Once they leave, or their role no longer reaches every client, nothing more is sent to it: each delivery that falls due fails at once with this reason, which the delivery log shows: "Nothing was sent, because the member who last configured this webhook no longer reaches every client or has left the organization. A member whose role reaches every client can save the webhook again and then redeliver this." A member who reaches every client takes the webhook over by saving it again, with PATCH /api/v1/ehr/webhooks/{id} or from the dashboard, and the failed deliveries can then be redelivered. A subscription a key created before this rule shipped, on 26 September 2026, recorded no member as its configurer, only the organization owner as its creator, so it stops in the same way until someone who reaches every client saves it again. One created in the dashboard before then is delivered under the member who created it.
GET /api/v1/ehr/webhookslink
Lists the organization's webhook subscriptions. The stored secret is never returned, only a masked preview.
Example response, 200 OK:
{
"items": [
{
"id": "66f1a2b3c4d5e6f7a8b9c0d1",
"name": "Claims system integration",
"url": "https://integrations.example.com/newinstance/webhooks",
"events": ["ehr.breakglass.started", "ehr.breakglass.reviewed"],
"status": "active",
"includePhi": false,
"headers": {},
"failureCount": 0,
"secretMasked": "whsec_••••a1b2",
"createdAt": "2026-09-17T12:00:00.000Z",
"updatedAt": "2026-09-17T12:00:00.000Z"
}
]
}POST /api/v1/ehr/webhookslink
Creates a subscription and returns the signing secret in clear text once. Store it now; it cannot be retrieved again.
| Body field | Required | Notes |
|---|---|---|
name | Yes | 1 to 120 characters |
url | Yes | Public HTTPS URL, up to 2000 characters |
events | Yes | Array of event types from the catalog in section 10, or ["*"] for every event type |
includePhi | No | Default false |
phiAcknowledgmentText | Only if includePhi is true | At least 20 characters |
url must resolve to a public address. Loopback, private, link-local, multicast, and NAT64 ranges are rejected, as is every form of an IPv4-mapped IPv6 address. A hostname that cannot be resolved by DNS at creation time is accepted (DNS can be transient), but every delivery attempt against it fails until it resolves; the REST API does not accept a custom headers field, unlike the dashboard's GraphQL-backed form.
Example request:
POST /api/v1/ehr/webhooks
x-api-key: key_abc123:secret_xyz789
Content-Type: application/json
{
"name": "Claims system integration",
"url": "https://integrations.example.com/newinstance/webhooks",
"events": ["ehr.breakglass.started", "ehr.breakglass.reviewed"],
"includePhi": false
}Example response, 201 Created:
{
"id": "66f1a2b3c4d5e6f7a8b9c0d1",
"name": "Claims system integration",
"url": "https://integrations.example.com/newinstance/webhooks",
"events": ["ehr.breakglass.started", "ehr.breakglass.reviewed"],
"status": "active",
"includePhi": false,
"headers": {},
"failureCount": 0,
"secretMasked": "whsec_••••a1b2",
"createdAt": "2026-09-17T12:00:00.000Z",
"updatedAt": "2026-09-17T12:00:00.000Z",
"secret": "whsec_5f2e1a9c8b7d6e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d"
}PATCH /api/v1/ehr/webhooks/{id}link
Updates name, url, events, or status (active or paused) on an existing subscription. At least one field is required. Returns 200 OK with the updated subscription (no secret), or 404 if the subscription does not exist.
DELETE /api/v1/ehr/webhooks/{id}link
Soft deletes the subscription. Returns 204 No Content. It stops receiving deliveries and no longer appears in the list, but its delivery history is preserved.
POST /api/v1/ehr/webhooks/{id}/testlink
Queues an ehr.webhook.test event and a pending delivery for the subscription, so you can verify your receiver end to end. Returns 202 Accepted:
{ "eventId": "66f1a2b3c4d5e6f7a8b9c0e2", "deliveryId": "66f1a2b3c4d5e6f7a8b9c0e3" }Returns 409 Conflict if the subscription is not active (for example, paused).
The event's data names the subscription as subscriptionId, its id as stored, in lower case, whatever case the {id} in the path was written in.
The dashboard's equivalent (sendEhrWebhookTest, and redeliverEhrWebhook) throttles both actions together to once every 10 seconds per subscription; this REST endpoint does not carry that throttle.
4. The event envelopelink
Every webhook delivery POSTs this JSON body. This is what a subscription with includePhi: false (the default) receives for ehr.breakglass.started:
{
"id": "66f1a2b3c4d5e6f7a8b9c0d5",
"type": "ehr.breakglass.started",
"version": 1,
"orgId": "66f1a2b3c4d5e6f7a8b9c000",
"occurredAt": "2026-09-18T14:03:11.000Z",
"aggregate": { "type": "break_glass", "id": "66f1a2b3c4d5e6f7a8b9c0d6" },
"data": { "userId": "66f1a2b3c4d5e6f7a8b9c0d8", "expiresAt": "2026-09-18T18:03:11.000Z" }
}A subscription with includePhi: true receives the same envelope plus a top-level "clientId": "66f1a2b3c4d5e6f7a8b9c0d7".
| Field | Notes |
|---|---|
id | The event's unique ID |
type | One of the event types in section 10 |
version | Envelope version, currently always 1 |
orgId | The organization the event belongs to |
occurredAt | ISO 8601 timestamp of when the event happened |
aggregate | The domain object the event is about: { type, id } |
clientId | Present only when the subscription has includePhi: true and the event names a client: the client, enrollment, encounter, document, treatment plan and break-the-glass events, ehr.consent.created, ehr.consent.revoked and ehr.consent.expiring, and an appointment event when the appointment has exactly one client. A subscription with includePhi: false, the default, never receives clientId, for any event type |
data | Event-specific fields. Unless the subscription has includePhi: true, the fields the event catalog marks as PHI for the event type, its phiFields (section 10), are removed from data: clientId and mrn on the four client events, and survivorClientId on ehr.client.merged, clientId on the enrollment and encounter events, clientId and type on ehr.consent.created and ehr.consent.revoked, clientId on ehr.consent.expiring, and reviewDueAt on the three treatment plan events. No other event type has a PHI field inside data; the document and break-the-glass events carry their only PHI, the client, in the top-level clientId, gated the same way |
5. Signature verificationlink
Every delivery carries these headers:
| Header | Contents |
|---|---|
x-newinstance-signature | sha256=<hex HMAC-SHA256 of the raw request body, using the subscription secret as the key> |
x-newinstance-timestamp | Milliseconds since epoch, as a string, when the delivery was sent |
x-newinstance-event-id | The event's id, matching the envelope |
x-newinstance-delivery-attempt | The attempt number for this delivery, starting at 1 |
Verify the signature against the raw request body (before any JSON parsing, since re-serializing can change the bytes) using a constant-time comparison, and check x-newinstance-timestamp against a tolerance window to reject replayed deliveries. The platform does not enforce a tolerance window itself; five minutes is a reasonable choice for a receiver to enforce:
const crypto = require('node:crypto');
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000;
function verifyEhrWebhookSignature(rawBody, signatureHeader, timestampHeader, secret) {
const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody).digest('hex')}`;
const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(signatureHeader ?? '', 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
if (!crypto.timingSafeEqual(expectedBuffer, receivedBuffer)) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > TIMESTAMP_TOLERANCE_MS) {
return false;
}
return true;
}
// Example use with a raw body string captured before JSON.parse:
// const ok = verifyEhrWebhookSignature(
// rawBody,
// req.headers['x-newinstance-signature'],
// req.headers['x-newinstance-timestamp'],
// subscriptionSecret,
// );6. Retries and dead-letteringlink
| Attempt | Timing |
|---|---|
| 1 | Sent as soon as the event is dispatched |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 15 minutes after attempt 3 fails |
| 5 | 60 minutes after attempt 4 fails |
| 6 | 240 minutes (4 hours) after attempt 5 fails |
| 7 | 720 minutes (12 hours) after attempt 6 fails |
| 8 | 720 minutes (12 hours) after attempt 7 fails |
A delivery's status is served only by the GraphQL API, as EhrWebhookDelivery.status, in upper case; no REST route lists deliveries. After attempt 8 fails, the delivery is marked DEAD. Only a delivery reaching DEAD increments the subscription's failure count, not every failed attempt; a subscription that accumulates 100 dead deliveries is automatically paused, and any successful delivery resets the count to zero. While paused, due deliveries are left PENDING and rechecked every 15 minutes rather than attempted, so resuming the subscription delivers the backlog. A subscription that is deleted, or otherwise not active and not paused, fails its pending deliveries immediately instead of retrying them, and so does one whose configuring member has left or no longer reaches every client (section 3). Redeliver a FAILED or DEAD delivery through redeliverEhrWebhook (dashboard) to retry it immediately from attempt 1. A DEAD delivery also raises a work item on the Failed webhook deliveries queue and notifies whoever created the subscription. That queue opens to the permission that manages integrations, which is also what replaying a delivery takes; creating, changing, pausing or deleting the subscription takes that permission and the reach of every client.
Delivery records are deleted 30 days after creation regardless of status. Dispatched events are deleted 7 days after dispatch unless a delivery still PENDING, FAILED or DEAD references them, which is what keeps Redeliver able to find the event to resend.
This is separate from event dispatch itself: each event record also tracks its own status and attempts, capped at 10, for the outbox drain step that fans events out to deliveries. Its status is pending, dispatched, failed or withheld: a failed event never made it to dispatch at all (for example, a Redis outage), and is distinct from a webhook delivery being DEAD; a withheld event was held back from every webhook because a client it names is protected under 42 CFR Part 2 or has no record, or because whether one is protected could not be checked. GET /api/v1/ehr/events (section 7) serves the status as stored, pending, dispatched or failed, and never lists a withheld event. The GraphQL API's ehrEvents serves it upper cased as EhrEventStatus, PENDING, DISPATCHED, FAILED or WITHHELD, with attempts.
7. Replaying eventslink
GET /api/v1/ehr/eventslink
Returns the organization's event log in sequence order, for polling or backfilling missed deliveries. Requires the webhooks permission group, and a key issued by a member who reaches every client (section 1). Response items carry identifiers and status only, never payload or clientId; to recover an event's actual data, use Redeliver (dashboard) against one of its deliveries, not event replay. Events are removed 7 days after being dispatched unless an unresolved delivery still references them (section 6), so a gap longer than that window returns fewer events with no error or gap indicator: nextSeq simply does not advance past the missing range.
An event is left out of items when it names a client who is protected by 42 CFR Part 2 or who has no record in the organization, whether it names the client directly or through the appointment it is about, and when the appointment it is about is marked Part 2 or cannot be found. That includes an event written before its client became protected. nextSeq still moves past an event left out, so the next call does not walk it again, and a gap in seq is expected.
| Query parameter | Required | Notes |
|---|---|---|
sinceSeq | No | Return events with a sequence number greater than this value |
limit | No | 1 to 500, default 100 |
Example request:
GET /api/v1/ehr/events?sinceSeq=1040&limit=100
x-api-key: key_abc123:secret_xyz789Example response, 200 OK:
{
"items": [
{
"id": "66f1a2b3c4d5e6f7a8b9c0f1",
"seq": 1041,
"type": "ehr.staff.updated",
"occurredAt": "2026-09-18T09:12:00.000Z",
"aggregate": { "type": "staff", "id": "66f1a2b3c4d5e6f7a8b9c0f2" },
"status": "dispatched"
}
],
"nextSeq": 1041
}Use nextSeq from the response as sinceSeq on the following call.
8. Scheduling and client lookuplink
This section has three authenticated routes and one provider webhook. There are no REST routes for enrollments, coverages, consents, documents, encounters or client import: those exist on the GraphQL API and in the dashboard only.
GET /api/v1/ehr/appointmentslink
Requires the scheduling permission group. Returns appointments that overlap the requested range, oldest first. Instants are ISO 8601 in UTC and each appointment carries the IANA zone it was booked in.
| Query parameter | Required | Notes |
|---|---|---|
from | Yes | Range start, ISO 8601 |
to | Yes | Range end, ISO 8601 |
staffId | No | Only appointments this staff member attends, 24 characters |
status | No | One of scheduled, confirmed, checked_in, in_progress, completed, no_show, cancelled, late_cancel |
page | No | From 1, default 1 |
limit | No | 1 to 100, default 25 |
No client identifier is accepted as a filter. A query string reaches an access log, and an identifier there would say which client had care looked up, so filter the returned page yourself. Client identifiers are returned only to a key that also carries the clients-read permission group. Every client that appears in the response is written to the access log.
Example request:
GET /api/v1/ehr/appointments?from=2026-03-02T00:00:00Z&to=2026-03-03T00:00:00Z&limit=2
x-api-key: key_abc123:secret_xyz789Example response, 200 OK:
{
"items": [
{
"id": "65f1a2b3c4d5e6f7a8b9c0d1",
"typeId": "65f1a2b3c4d5e6f7a8b9c0a1",
"primaryStaffId": "65f1a2b3c4d5e6f7a8b9c0b1",
"clientIds": ["65f1a2b3c4d5e6f7a8b9c0c1"],
"locationId": "65f1a2b3c4d5e6f7a8b9c0e1",
"roomLocationId": "65f1a2b3c4d5e6f7a8b9c0e2",
"programId": "65f1a2b3c4d5e6f7a8b9c0f1",
"start": "2026-03-02T15:00:00.000Z",
"end": "2026-03-02T15:45:00.000Z",
"timezone": "America/New_York",
"status": "scheduled",
"part2": false
}
],
"total": 1,
"page": 1,
"limit": 2
}POST /api/v1/ehr/appointmentslink
Requires the scheduling permission group. Books one appointment.
| Body field | Required | Notes |
|---|---|---|
typeId | Yes | Appointment type id, 24 characters |
clientIds | Yes | 1 to 60 attending clients |
primaryStaffId | Yes | The staff member who owns the appointment |
staffIds | No | Up to 20 additional attending staff |
roomLocationId | No | Room to reserve |
locationId | No | Site or virtual location |
programId | No | Program the appointment belongs to |
start | Yes | Start instant, ISO 8601 UTC |
end | Yes | End instant, ISO 8601 UTC |
timezone | Yes | IANA zone the wall time was entered in |
allowConflicts | No | Default false. Book anyway where the organization policy only warns about clashes |
A clash returns 409 unless the organization's double-booking policy allows it or allowConflicts is true. The default policy is to warn.
Example request:
POST /api/v1/ehr/appointments
x-api-key: key_abc123:secret_xyz789
Content-Type: application/json
{
"typeId": "65f1a2b3c4d5e6f7a8b9c0a1",
"clientIds": ["65f1a2b3c4d5e6f7a8b9c0c1"],
"primaryStaffId": "65f1a2b3c4d5e6f7a8b9c0b1",
"start": "2026-03-04T15:00:00.000Z",
"end": "2026-03-04T15:45:00.000Z",
"timezone": "America/New_York"
}Example response, 201 Created:
{
"id": "65f1a2b3c4d5e6f7a8b9c0d2",
"status": "scheduled",
"start": "2026-03-04T15:00:00.000Z",
"end": "2026-03-04T15:45:00.000Z",
"timezone": "America/New_York"
}POST /api/v1/ehr/clients/lookuplink
Requires the clients-read permission group. Returns the demographic summary for one client, looked up by medical record number. The number is sent in the request body, never in the path or the query string, because both reach an access log and a medical record number there identifies the client to anyone who reads the log. The read is written to the access log with the API key as the actor.
POST /api/v1/ehr/clients/lookup
x-api-key: key_abc123:secret_xyz789
Content-Type: application/json
{
"mrn": "MRN-000123"
}POST /api/v1/ehr/sms/inboundlink
No API key. This route receives message replies from the SMS provider and is authenticated by the provider signature over the full URL and the sorted parameters, computed with the organization's auth token. The signature travels in x-twilio-signature.
The payload is application/x-www-form-urlencoded with To, From, Body and optionally MessageSid.
A reply of C, CONFIRM, YES or Y confirms the next appointment within seven days, except from a number more than one client shares, where it confirms nothing and the reply is recorded as ignored. STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT removes text message consent for that number immediately, so a reply of CANCEL opts the number out of text messages and cancels no appointment.
9. Part 2 and protected contentlink
The REST API is not capable of returning 42 CFR Part 2 content, and there is no header that makes it capable.
- Appointments belonging to a Part 2 client are excluded from
GET /api/v1/ehr/appointments. POST /api/v1/ehr/appointmentsrefuses a Part 2 client, and it refuses with404 Client not foundrather than with a message that would confirm the client exists.POST /api/v1/ehr/clients/lookuprefuses a Part 2 client the same way.- Claims and charges belonging to a Part 2 client are excluded from
GET /api/v1/ehr/billing/claimsand from its total, and the single reads answer404for them. - No webhook event is sent at all for an entry the ledger records on a Part 2 client's charge, or for a claim written for one. Nothing about them leaves the building on a webhook, and an event that was never sent cannot be inferred from one that was.
- Events that name a Part 2 client or a client with no record, directly or through the appointment they are about, and events about an appointment that is Part 2 or cannot be found, are left out of
GET /api/v1/ehr/events, andnextSeqstill moves past them.
The refusal is deliberately indistinguishable from an absent client. A refusal that said "this client is protected" would itself disclose that the client is in a substance use program, which is the fact the rule exists to protect.
Part 2 content is reachable from the dashboard and the GraphQL API, by two routes, each open only to a person who holds ehr.part2.access. The first is the program: a member of the program the record belongs to, or, for a record that names no program, of a Part 2 program the client is enrolled in, opens it with no consent asked. The second is a consent: anyone else opens it only while a signed 42 CFR Part 2 consent the client gave names them, as a person by their staff profile, covering the record's program or carrying a general designation. Such a consent opens nothing unless it names a disclosure purpose the product knows, nothing before its first day, after its last day or on or after its expiry, and nothing once it is revoked. Breaking the glass for the client also opens the record, and the session is kept for review. When one of those records is disclosed it carries the redisclosure notice.
10. Event cataloglink
These are the exact event types EVENT_CATALOG in gql-server/src/services/ehr/shared/outbox.ts catalogues.
phiFields on an event are omitted unless the subscription sets includePhi: true (see section 4). The column below says which events carry a client identifier at all.
| Event type | Aggregate type | Carries clientId |
|---|---|---|
ehr.config.updated | tenant_settings | No |
ehr.program.created | program | No |
ehr.program.updated | program | No |
ehr.location.created | location | No |
ehr.location.updated | location | No |
ehr.staff.updated | staff | No |
ehr.form_definition.published | form_definition | No |
ehr.form_definition.retired | form_definition | No |
ehr.workflow_definition.published | workflow_definition | No |
ehr.breakglass.started | break_glass | Only when includePhi: true (see section 4) |
ehr.breakglass.ended | break_glass | Only when includePhi: true (see section 4) |
ehr.breakglass.reviewed | break_glass | Only when includePhi: true (see section 4) |
ehr.webhook.test | webhook_subscription | No |
ehr.claim.submitted | claim | No. Never sent for a Part 2 client |
ehr.ledger.posted | charge | No. Never sent for a Part 2 client |
ehr.client.created | client | Yes, with mrn |
ehr.client.updated | client | Yes, with mrn |
ehr.client.status_changed | client | Yes, with mrn |
ehr.client.merged | client | Yes, with mrn and survivorClientId |
ehr.enrollment.created | enrollment | Yes |
ehr.enrollment.transitioned | enrollment | Yes |
ehr.consent.created | consent | Yes, with type |
ehr.consent.revoked | consent | Yes, with type |
ehr.consent.expiring | consent | Yes |
ehr.appointment.scheduled | appointment | Yes, when the appointment has exactly one client |
ehr.appointment.updated | appointment | Yes, when the appointment has exactly one client |
ehr.appointment.cancelled | appointment | Yes, when the appointment has exactly one client |
ehr.appointment.checked_in | appointment | Yes, when the appointment has exactly one client |
ehr.appointment.completed | appointment | Yes, when the appointment has exactly one client |
ehr.appointment.no_show | appointment | Yes, when the appointment has exactly one client |
ehr.encounter.completed | encounter | Yes |
ehr.encounter.voided | encounter | Yes |
ehr.document.signed | document | Yes |
ehr.document.cosigned | document | Yes |
ehr.document.amended | document | Yes |
ehr.document.voided | document | Yes |
ehr.treatment_plan.activated | treatment_plan | Yes, with reviewDueAt |
ehr.treatment_plan.review_due | treatment_plan | Yes, with reviewDueAt |
ehr.treatment_plan.closed | treatment_plan | Yes, with reviewDueAt |
ehr.group_session.created | group_session | No |
ehr.group_session.attendance_recorded | group_session | No |
ehr.group_session.notes_generated | group_session | No |
ehr.import.completed | import | No |
ehr.treatment_plan.review_due is sent once for each plan and review date. The background worker's nightly work queue sweep, ehr.workqueue.sweep, sends it for each active plan whose review date has passed since the sweep last ran; its first run tells of nothing that fell due before it. Signing a plan whose review date has already passed sends it at once, with ehr.treatment_plan.activated; signing one whose review is still to come sends nothing until that date passes. It is never sent for a plan about a 42 CFR Part 2 client, a Part 2 program or a Part 2 plan. Its data is the plan, its version and its review date, planId, version and reviewDueAt, the review date left out unless the subscription has includePhi: true.
ehr.consent.expiring is sent once for each consent and expiry date. The background worker's nightly work queue sweep, ehr.workqueue.sweep, sends it for each consent recorded as active whose expiry falls within the next thirty days, its occurredAt thirty days before the expiry, or the moment the consent was recorded if that is later; a consent already that close when it is recorded is told of at the next sweep, and one whose expiry date changes is told of again for the new date. It is never sent for a consent about a 42 CFR Part 2 client, or one whose client cannot be found. Its data is the consent, its client and its expiry, consentId, clientId and expiresAt, the client left out unless the subscription has includePhi: true.
A subscription can list specific event types or * for all of them. POST and PATCH /api/v1/ehr/webhooks accept every event type in this catalog by name in events, ehr.breakglass.ended among them. A type the catalog does not list is refused with 400 "Invalid request payload input", and nothing is written.
11. FHIR R4 facadelink
GET /fhir/r4/metadatalink
Returns the server's FHIR CapabilityStatement. No authentication required. Content type application/fhir+json.
{
"resourceType": "CapabilityStatement",
"status": "active",
"date": "2026-09-18T00:00:00.000Z",
"kind": "instance",
"fhirVersion": "4.0.1",
"format": ["json"],
"software": { "name": "NewInstance EHR" },
"rest": [{ "mode": "server" }]
}Only this capability endpoint is served. The rest[0] entry has no resource key at all: no FHIR resources (Patient, Practitioner, Encounter, and so on) are registered. The resource facade is not built; the program plan (README.md) places it in Phase 4, and SMART on FHIR authentication in Phase 5.
12. Billing and revenue cyclelink
Billing has three authenticated read routes and two webhook events, and no write route: charges, claims, payments, refunds, adjustments and statements are written from the dashboard and the GraphQL API only, where a named person holds the permission and the audit trail records who they were.
Each of the two events names an object and nothing else. There is no amount, no service code, no reason code, no client identifier and no Part 2 flag in either of them, because a webhook is a disclosure to a destination the organization configured and no consent names that destination. What a recipient may do with the event is come back through these routes, where the key is authenticated, the permission is checked, the read is written to the access log and a Part 2 client is absent.
| Event type | Sent when | Carries |
|---|---|---|
ehr.claim.submitted | A claim is written for a payer | The claim id, as aggregate |
ehr.ledger.posted | Money posts against a charge | The charge id as aggregate, and sourceType, one of charge, remittance_line, client_payment, refund, adjustment |
GET /api/v1/ehr/billing/claimslink
Requires billing-read, and a creator who holds the permission to view billing amounts. Returns claims newest first.
| Query parameter | Required | Notes |
|---|---|---|
status | No | One of queued, corrected, void |
from | No | Only claims written at or after this instant, ISO 8601 |
to | No | Only claims written before this instant, ISO 8601 |
page | No | From 1, default 1 |
limit | No | 1 to 100, default 25 |
Each row carries the claim id, the payer id, the control number, the status, the number of charges on it, what it was worth, and the state of its transmission (prepared, synthetic_not_sent, sent, acknowledged or rejected) with the instants it was sent and acknowledged. synthetic_not_sent is a claim handed to the synthetic destination inside the product, which development and testing use and production refuses: it was never sent, no payer has seen it, it carries no instant it was sent, and nothing was disclosed. clientId appears only for a key that also carries clients-read. Payer reason codes are not on this list, only on the single read below.
GET /api/v1/ehr/billing/claims/{id}link
Returns one claim with its service lines and, where the payer denied it, the reason and remark codes, the amount denied, the amount allowed and deniedAt, the date the payer gave for the denial on its remittance, served as that calendar date in the form YYYY-MM-DD, such as 2026-09-11, with no time of day, as the GraphQL API serves a denial's deniedAt. The claim file itself, which carries the member number, the name, the date of birth and the diagnoses, is never returned by REST. A claim for a Part 2 client answers 404 Claim not found.
GET /api/v1/ehr/billing/charges/{id}link
Returns one charge and every ledger entry posted against it, with what each party still owes on it: payerOwedMinor for the insurer, clientOwedMinor for the client, and transferredToClientMinor for what has moved from one to the other. There is no single party blind balance, because once responsibility has moved between the two a single figure names neither of them. entryCount is there because owing nothing is also what a charge nothing ever posted to looks like, and the two are different facts. The charge's own amountMinor is returned beside them, so a charge whose ledger does not agree with it can be seen rather than reconciled silently. Diagnoses are not returned. The three figures are worked out from the entries returned beside them when the charge is read, so they always agree with those entries and are never a figure stored earlier. A charge for a Part 2 client answers 404 Charge not found.