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
Install Client (Recommended)
pnpm add @loop/patient-graph-clientimport { 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 IDemail(string) — User email addresspublicMetadata.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:
| Role | Permissions |
|---|---|
admin | All resources, all actions (read/write/delete/export) |
staff | All resources except audit_log, all actions except export |
support | Read-only on profile, lab_results, protocols, events |
provider | Read on all patient data, write on protocols/events |
customer | Own 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
└──────────┬──────────┘
↓
ResponseRate 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: 1710958200Rate Limit Exceeded:
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests. Try again in 45 seconds.",
"retryAfter": 45
}
}Pagination
Parameters
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 20 | 100 | Results per page |
offset | integer | 0 | — | Results 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=40Response
{
"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 IDemail(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 statuslimit(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.jsonResponse:
{
"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-clientConfiguration
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— 45msGET /labs(paginated) — 120msGET /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
statusfor active/pending queries
Related Documentation
- Data Model — Complete schema reference
- RBAC — Permission matrix and examples
- Repositories — Direct database access (server-side)
- Deployment — Fly.io deployment guide