# 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 groups

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 codes

### GET /api/v1/ehr/reference-codes

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:

```http
GET /api/v1/ehr/reference-codes?system=HCPCS&q=H000&limit=5
x-api-key: key_abc123:secret_xyz789
```

Example response, `200 OK`:

```json
{
  "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 management

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/webhooks

Lists the organization's webhook subscriptions. The stored secret is never returned, only a masked preview.

Example response, `200 OK`:

```json
{
  "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/webhooks

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:

```json
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`:

```json
{
  "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}

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}

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}/test

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`:

```json
{ "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 envelope

Every webhook delivery POSTs this JSON body. This is what a subscription with `includePhi: false` (the default) receives for `ehr.breakglass.started`:

```json
{
  "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 verification

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:

```javascript
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-lettering

| 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 events

### GET /api/v1/ehr/events

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:

```http
GET /api/v1/ehr/events?sinceSeq=1040&limit=100
x-api-key: key_abc123:secret_xyz789
```

Example response, `200 OK`:

```json
{
  "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 lookup

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/appointments

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:

```http
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_xyz789
```

Example response, `200 OK`:

```json
{
  "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/appointments

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:

```http
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`:

```json
{
  "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/lookup

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.

```http
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/inbound

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 content

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/appointments` refuses a Part 2 client, and it refuses with `404 Client not found` rather than with a message that would confirm the client exists.
- `POST /api/v1/ehr/clients/lookup` refuses a Part 2 client the same way.
- Claims and charges belonging to a Part 2 client are excluded from `GET /api/v1/ehr/billing/claims` and from its total, and the single reads answer `404` for 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`, and `nextSeq` still 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 catalog

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 facade

### GET /fhir/r4/metadata

Returns the server's FHIR `CapabilityStatement`. No authentication required. Content type `application/fhir+json`.

```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 cycle

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/claims

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}

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}

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`.
