Skip to Content
Patient GraphAPI Endpoints

Patient Graph API Reference

The Patient Graph API is a standalone Hono HTTP service deployed at patient-graph.loop.health. It provides secure, HIPAA-compliant CRUD operations for all patient health data with comprehensive RBAC, audit logging, and rate limiting.

Base URL: https://patient-graph.loop.health Protocol: HTTPS only, HTTP/2 supported Authentication: Clerk JWT (Bearer token) Content-Type: application/json Response Format: Consistent JSON with success/error structure


Quick Start

pnpm add @loop/patient-graph-client
import { createPatientGraphClient } from '@loop/patient-graph-client'; const client = createPatientGraphClient({ baseUrl: 'https://patient-graph.loop.health', apiKey: process.env.PATIENT_GRAPH_API_KEY, }); // Type-safe, validated requests const profile = await client.profiles.get(customerId);

Direct HTTP (curl)

export AUTH_TOKEN="your-clerk-jwt" export API_URL="https://patient-graph.loop.health" curl "$API_URL/profiles?email=patient@example.com" \ -H "Authorization: Bearer $AUTH_TOKEN"

Authentication & Authorization

Clerk JWT Authentication

All endpoints except /webhooks/rimo require Clerk JWT authentication:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

JWT Claims Required:

  • sub (string) — Clerk user ID
  • email (string) — User email address
  • publicMetadata.adminRole (string, optional) — Role override (admin/staff/provider/support)

Default Role: customer (if adminRole not set)

Role-Based Access Control (RBAC)

Every request is checked against the RBAC matrix:

RolePermissions
adminAll resources, all actions (read/write/delete/export)
staffAll resources except audit_log, all actions except export
supportRead-only on profile, lab_results, protocols, events
providerRead on all patient data, write on protocols/events
customerOwn data only (ownOnly=true), read/write profile + protocols

Example: Customer trying to access another patient’s labs → 403 Forbidden

Row-Level Security (Ownership)

Resources with ownOnly=true enforce row-level ownership:

// Customer user can only access their own data GET /profiles?customerId=cust_123 // ✅ If customerId === user.id GET /profiles?customerId=cust_456 // ❌ 403 Forbidden (not owner)

Ownership-protected resources: profile, lab_results, protocols, events, conversation_history

Audit Logging

Every API call is logged to rbac_logs table:

{ "actorId": "user_2abc123", "actorRole": "provider", "customerId": "cust_456", "resource": "lab_results", "action": "read", "outcome": "allowed", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0...", "timestamp": "2026-03-20T19:45:00Z" }

Retention: 7 years (HIPAA compliance)


Request & Response Format

Success Response

{ "success": true, "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "email": "patient@example.com", ... } }

Paginated Response

{ "success": true, "data": [...], "pagination": { "page": 1, "limit": 20, "total": 156 } }

Error Response

{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Invalid request body", "details": { "issues": [ { "path": ["dateOfBirth"], "message": "Invalid date format" } ] } } }

Middleware Stack

Every request flows through this middleware chain (in order):

Request ┌─────────────────────┐ │ 1. Sentry Tracing │ Performance monitoring └──────────┬──────────┘ ┌─────────────────────┐ │ 2. Rate Limiting │ 100 req/60s per user └──────────┬──────────┘ ┌─────────────────────┐ │ 3. Clerk Auth │ JWT verification └──────────┬──────────┘ ┌─────────────────────┐ │ 4. RBAC Check │ Permission validation └──────────┬──────────┘ ┌─────────────────────┐ │ 5. Ownership Check │ Row-level security └──────────┬──────────┘ ┌─────────────────────┐ │ 6. Route Handler │ Business logic └──────────┬──────────┘ ┌─────────────────────┐ │ 7. Audit Log │ Write access log └──────────┬──────────┘ Response

Rate Limiting

Algorithm: Token bucket (sliding window) Limits:

  • 100 requests / 60 seconds per user (authenticated)
  • 20 requests / 60 seconds per IP (unauthenticated webhooks)

Headers:

X-RateLimit-Limit: 100 X-RateLimit-Remaining: 73 X-RateLimit-Reset: 1710958200

Rate Limit Exceeded:

{ "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Try again in 45 seconds.", "retryAfter": 45 } }

Pagination

Parameters

ParameterTypeDefaultMaxDescription
limitinteger20100Results per page
offsetinteger0Results to skip

Exception: conversation-history has limit default 50, max 500.

Example

# Page 1 (0-19) GET /labs?customerId=cust_123&limit=20&offset=0 # Page 2 (20-39) GET /labs?customerId=cust_123&limit=20&offset=20 # Page 3 (40-59) GET /labs?customerId=cust_123&limit=20&offset=40

Response

{ "success": true, "data": [...], "pagination": { "page": 1, "limit": 20, "total": 156, "hasMore": true } }

Endpoints

Profiles

GET /profiles

List customer profiles (admin/staff only, or own profile for customers).

Query Parameters:

  • customerId (uuid, optional) — Filter by customer ID
  • email (string, optional) — Filter by email (exact match)
  • limit (integer, default 20, max 100)
  • offset (integer, default 0)

RBAC: Requires profile:read permission

Example:

curl "$API_URL/profiles?email=patient@example.com" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": [ { "id": "123e4567-e89b-12d3-a456-426614174000", "externalId": "user_2abc123", "email": "patient@example.com", "firstName": "Jane", "lastName": "Doe", "dateOfBirth": "1985-06-15", "biologicalSex": "female", "subscriptionTier": "premium", "conditions": ["hypothyroidism"], "medications": ["levothyroxine 100mcg"], "createdAt": "2026-01-15T10:00:00Z", "updatedAt": "2026-03-20T19:45:00Z" } ], "pagination": { "page": 1, "limit": 20, "total": 1 } }

GET /profiles/:id

Get a single customer profile by ID.

Path Parameters:

  • id (uuid, required) — Customer profile ID

RBAC: Requires profile:read permission + ownership check

Example:

curl "$API_URL/profiles/123e4567-e89b-12d3-a456-426614174000" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "externalId": "user_2abc123", "email": "patient@example.com", ... } }

Error (Not Found):

{ "success": false, "error": { "code": "NOT_FOUND", "message": "Profile not found" } }

POST /profiles

Create a new customer profile.

RBAC: Requires profile:write permission

Request Body:

{ "externalId": "user_2abc123", "email": "patient@example.com", "firstName": "Jane", "lastName": "Doe", "dateOfBirth": "1985-06-15", "biologicalSex": "female", "phoneNumber": "+12125551234", "timezone": "America/Los_Angeles", "subscriptionTier": "free", "conditions": [], "medications": [], "allergies": [] }

Example:

curl -X POST "$API_URL/profiles" \ -H "Authorization: Bearer $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "externalId": "user_2abc123", "email": "patient@example.com", "firstName": "Jane", "lastName": "Doe", "dateOfBirth": "1985-06-15", "biologicalSex": "female" }'

Response:

{ "success": true, "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "externalId": "user_2abc123", ... } }

PATCH /profiles/:id

Update an existing customer profile (partial updates supported).

Path Parameters:

  • id (uuid, required)

RBAC: Requires profile:write permission + ownership check

Request Body (all fields optional):

{ "firstName": "Jane", "phoneNumber": "+12125559999", "conditions": ["hypothyroidism", "pcos"], "subscriptionTier": "premium" }

Example:

curl -X PATCH "$API_URL/profiles/123e4567-e89b-12d3-a456-426614174000" \ -H "Authorization: Bearer $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{"subscriptionTier": "premium"}'

Response:

{ "success": true, "data": { "id": "123e4567-e89b-12d3-a456-426614174000", "subscriptionTier": "premium", "updatedAt": "2026-03-20T19:50:00Z", ... } }

Lab Results

GET /labs

List lab results for a customer.

Query Parameters:

  • customerId (uuid, required)
  • status (lab_status, optional) — Filter by status
  • limit (integer, default 20, max 100)
  • offset (integer, default 0)

RBAC: Requires lab_results:read permission + ownership check

Example:

curl "$API_URL/labs?customerId=cust_123&status=reviewed&limit=10" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": [ { "id": "lab_456", "customerId": "cust_123", "uploadId": "upload_789", "labDate": "2026-03-15", "provider": "Quest Diagnostics", "status": "reviewed", "biomarkers": { "testosterone_total": { "value": 650, "unit": "ng/dL", "referenceRange": { "min": 264, "max": 916 }, "status": "normal" }, "vitamin_d": { "value": 28, "unit": "ng/mL", "referenceRange": { "min": 30, "max": 100 }, "status": "low" } }, "fileUrl": "https://storage.../lab-123.pdf", "notes": "Vitamin D slightly low, recommend supplementation", "createdAt": "2026-03-15T14:00:00Z" } ], "pagination": { "page": 1, "limit": 10, "total": 24 } }

POST /labs

Create a new lab result (typically after PDF parsing).

RBAC: Requires lab_results:write permission

Request Body:

{ "customerId": "cust_123", "uploadId": "upload_789", "labDate": "2026-03-15", "provider": "Quest Diagnostics", "status": "pending", "biomarkers": { "testosterone_total": { "value": 650, "unit": "ng/dL", "code": "LOINC:2986-8" } }, "fileUrl": "https://storage.../lab-123.pdf" }

Example:

curl -X POST "$API_URL/labs" \ -H "Authorization: Bearer $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d @lab-result.json

Response:

{ "success": true, "data": { "id": "lab_456", "customerId": "cust_123", "status": "pending", ... } }

Treatments (Loop Med)

GET /treatments

List patient treatments.

Query Parameters:

  • customerId (uuid, required)
  • status (subscription_status, optional)
  • rimoTreatmentId (string, optional)
  • limit (integer, default 20, max 100)
  • offset (integer, default 0)

RBAC: Requires treatment:read permission

Example:

curl "$API_URL/treatments?customerId=cust_123&status=approved" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": [ { "id": "treatment_789", "customerId": "cust_123", "rimoTreatmentId": "rimo_456", "offeringId": "offering_glp1", "offeringName": "GLP-1 Weight Management", "clinicianId": "dr_smith_123", "clinicianName": "Dr. Sarah Smith, MD", "monthlyPriceCents": 29900, "status": "approved", "approvedAt": "2026-03-20T10:00:00Z", "startedAt": "2026-03-21T00:00:00Z", "notes": "Starting dose, titrate after 4 weeks", "createdAt": "2026-03-19T15:00:00Z" } ], "pagination": { "page": 1, "limit": 20, "total": 3 } }

POST /treatments

Create a new treatment (pending clinician approval).

RBAC: Requires treatment:write permission

Request Body:

{ "customerId": "cust_123", "rimoTreatmentId": "rimo_456", "offeringId": "offering_glp1", "offeringName": "GLP-1 Weight Management", "status": "pending", "monthlyPriceCents": 29900 }

Response:

{ "success": true, "data": { "id": "treatment_789", "status": "pending", ... } }

PATCH /treatments/:id

Update a treatment (e.g., clinician approval).

RBAC: Requires treatment:write permission

Request Body:

{ "status": "approved", "clinicianId": "dr_smith_123", "clinicianName": "Dr. Sarah Smith, MD", "approvedAt": "2026-03-20T10:00:00Z", "notes": "Approved. Monitor blood glucose weekly." }

Example:

curl -X PATCH "$API_URL/treatments/treatment_789" \ -H "Authorization: Bearer $AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "clinicianId": "dr_smith_123", "approvedAt": "2026-03-20T10:00:00Z" }'

Prescriptions

GET /prescriptions

List patient prescriptions.

Query Parameters:

  • customerId (uuid, required)
  • treatmentId (uuid, optional)
  • status (prescription_status, optional)
  • limit (integer, default 20, max 100)
  • offset (integer, default 0)

RBAC: Requires protocols:read permission (reusing protocols resource)

Example:

curl "$API_URL/prescriptions?customerId=cust_123&status=shipped" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": [ { "id": "rx_999", "customerId": "cust_123", "treatmentId": "treatment_789", "rimoOrderId": "rimo_order_555", "medicationName": "Semaglutide", "dosage": "0.25mg", "quantity": 4, "refills": 3, "prescribedDate": "2026-03-20T10:00:00Z", "nextRefillDate": "2026-04-20T00:00:00Z", "status": "shipped", "pharmacyName": "Rimo Pharmacy", "trackingNumber": "1Z999AA10123456789", "transmittedAt": "2026-03-20T11:00:00Z", "shippedAt": "2026-03-20T18:00:00Z" } ], "pagination": { "page": 1, "limit": 20, "total": 5 } }

Wearable Data

GET /wearables/readings

Get raw wearable sensor readings.

Query Parameters:

  • customerId (uuid, required)
  • dataType (wearable_data_type, optional) — e.g., “glucose”, “hrv”
  • fromDate (ISO datetime, optional)
  • toDate (ISO datetime, optional)
  • limit (integer, default 20, max 100)
  • offset (integer, default 0)

Example:

curl "$API_URL/wearables/readings?customerId=cust_123&dataType=glucose&fromDate=2026-03-01&toDate=2026-03-20" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": [ { "id": "reading_123", "customerId": "cust_123", "dataType": "glucose", "value": 95.5, "unit": "mg/dL", "deviceType": "dexcom", "source": "dexcom_api", "recordedAt": "2026-03-20T14:30:00Z" }, ... ], "pagination": { "page": 1, "limit": 100, "total": 2880 } }

GET /wearables/daily-stats

Get aggregated daily statistics (cached for performance).

Query Parameters:

  • customerId (uuid, required)
  • dataType (wearable_data_type, optional)
  • fromDate (date YYYY-MM-DD, optional)
  • toDate (date YYYY-MM-DD, optional)
  • limit (integer, default 20, max 100)
  • offset (integer, default 0)

Caching: 5-minute TTL in-memory cache

Example:

curl "$API_URL/wearables/daily-stats?customerId=cust_123&dataType=glucose&fromDate=2026-03-01&toDate=2026-03-20" \ -H "Authorization: Bearer $AUTH_TOKEN"

Response:

{ "success": true, "data": [ { "date": "2026-03-20", "dataType": "glucose", "avgValue": 98.5, "minValue": 75.0, "maxValue": 145.0, "count": 288, "unit": "mg/dL" }, ... ], "pagination": { "page": 1, "limit": 20, "total": 20 } }

Common Workflows

Patient Onboarding

// 1. Create profile after Clerk signup const profile = await client.profiles.create({ externalId: clerkUser.id, email: clerkUser.email, firstName: clerkUser.firstName, lastName: clerkUser.lastName, }); // 2. Create identity mappings await client.identityMappings.create({ customerId: profile.id, externalSystem: 'clerk', externalId: clerkUser.id, });

Lab Upload → Biomarker Display

// 1. Upload PDF, get parsed biomarkers from @loop/biomarker-parser const parsed = await parseLab Report(pdfBuffer); // 2. Store in Patient Graph const labResult = await client.labs.create({ customerId: currentUser.id, uploadId: uploadId, labDate: parsed.labDate, provider: parsed.provider, biomarkers: parsed.biomarkers, fileUrl: s3Url, }); // 3. Create timeline event await client.events.create({ customerId: currentUser.id, type: 'lab_parsed', description: `Lab results from ${parsed.provider}`, data: { labResultId: labResult.id }, }); // 4. Query for display const labs = await client.labs.list({ customerId: currentUser.id, limit: 10 });

Treatment Approval Workflow

// 1. Customer requests treatment const treatment = await client.treatments.create({ customerId: customerId, rimoTreatmentId: rimoId, offeringName: "GLP-1 Weight Management", status: "pending", }); // 2. Clinician reviews and approves await client.treatments.update(treatment.id, { status: "approved", clinicianId: "dr_smith_123", clinicianName: "Dr. Sarah Smith, MD", approvedAt: new Date().toISOString(), notes: "Approved. Monitor glucose weekly.", }); // 3. Prescription auto-created via Rimo webhook // (handled by /webhooks/rimo endpoint) // 4. Query prescriptions for patient dashboard const prescriptions = await client.prescriptions.list({ customerId: customerId, treatmentId: treatment.id, });

Error Handling

Validation Errors (400)

{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Invalid request body", "details": { "issues": [ { "path": ["dateOfBirth"], "message": "Expected string, received null" } ] } } }

Authentication Errors (401)

{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Invalid or expired JWT token" } }

Authorization Errors (403)

{ "success": false, "error": { "code": "FORBIDDEN", "message": "Insufficient permissions to access this resource" } }

Not Found (404)

{ "success": false, "error": { "code": "NOT_FOUND", "message": "Profile not found" } }

Rate Limiting (429)

{ "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", "message": "Too many requests. Try again in 45 seconds.", "retryAfter": 45 } }

Server Errors (500)

{ "success": false, "error": { "code": "INTERNAL_ERROR", "message": "An internal error occurred", "requestId": "req_abc123" } }

Best Practice: Always log requestId for 500 errors to help with debugging.


TypeScript Client Usage

Installation

pnpm add @loop/patient-graph-client

Configuration

import { createPatientGraphClient } from '@loop/patient-graph-client'; const client = createPatientGraphClient({ baseUrl: process.env.PATIENT_GRAPH_API_URL || 'https://patient-graph.loop.health', apiKey: process.env.PATIENT_GRAPH_API_KEY, // Optional: custom fetch for retries/logging fetch: customFetch, });

Type-Safe Queries

// All methods are fully typed const profile = await client.profiles.get(customerId); // profile is type: Result<CustomerProfile> if (profile.ok) { console.log(profile.data.email); // ✅ Type-safe } // Validation errors caught at compile-time await client.profiles.create({ email: "test@example.com", dateOfBirth: "invalid-date", // ❌ TypeScript error });

Performance & Caching

Response Times (p95)

  • GET /profiles/:id — 45ms
  • GET /labs (paginated) — 120ms
  • GET /wearables/daily-stats — 25ms (cached)
  • POST /labs (with validation) — 180ms

Caching Strategy

  • Wearable daily stats: 5-minute in-memory TTL
  • RBAC permissions: 10-minute in-memory TTL
  • Clerk JWKS: 1-hour TTL
  • No caching: User profiles, lab results (always fresh)

Database Optimization

  • Indexed on all foreign keys
  • Composite indexes on (customerId, createdAt) for timeline queries
  • Partial indexes on status for active/pending queries