Patient API Endpoints
All patient endpoints are automatically scoped to the current organization via Row-Level Security (RLS). The organization context is determined by the user's current_organization_id.
Base URL
All endpoints are prefixed with /v1/patients
List Patients
GET /v1/patients
List all patients in the current organization. Automatically filtered by RLS:
- Patients see only their own record
- Specialists, Customer Support, Admins see all patients in their organization
- Superadmins see all patients across all organizations
Query Parameters
page(integer, default: 1) - Page number for paginationpage_size(integer, default: 25, max: 100) - Number of records per pagesort(string, default:-created_at) - Sort field (prefix with-for descending)search(string) - Search by name or email (searches patient_persons)include(string) - Related data to include (comma-separated:person,custom_field_values)
Example Request
GET /v1/patients?page=1&page_size=25&sort=created_at&include=person
Authorization: Bearer <token>Response: 200 OK
{
"data": [
{
"id": 100,
"organization_id": 1,
"patient_person_id": 81,
"profile_shared": true,
"consumer_id": "legacy-123",
"person": {
"id": 81,
"user_id": 42,
"name": "John Doe",
"date_of_birth": "1985-03-12",
"blood_type": "A+",
"allergies": [],
"chronic_conditions": [],
"insurance_entries": []
},
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z"
},
{
"id": 101,
"organization_id": 1,
"patient_person_id": 82,
"profile_shared": false,
"consumer_id": null,
"person": {
"id": 82,
"user_id": 43,
"name": "Jane Smith"
},
"created_at": "2025-01-16T11:00:00Z",
"updated_at": "2025-01-16T11:00:00Z"
}
],
"meta": {
"page": 1,
"page_size": 25,
"total": 87,
"total_pages": 4
}
}Create Patient
POST /v1/patients
Create a basic org-patient link. This endpoint creates ONLY the link record — it does not create a user account, Clerk identity, or patient_persons profile.
Use this when: The person already has a patient_persons record (e.g. they're registered at another org and you have their patient_person_id).
For full account creation + profile + org link in one transaction, use POST /v1/patients/onboard instead.
Request Body
{
"patient_person_id": 81,
"consumer_id": "legacy-123"
}Fields
patient_person_id(integer, required) - ID of the existingpatient_personsrecord to linkconsumer_id(string, optional) - External system identifier
Response: 201 Created
{
"data": {
"id": 100,
"organization_id": 1,
"patient_person_id": 81,
"profile_shared": false,
"consumer_id": "legacy-123",
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z"
}
}Errors
400 patient_person_id_required- patient_person_id is required404 patient_person_not_found- patient_persons record does not exist409 patient_already_exists- Patient record already exists for this person in this organization
Patient Onboarding
POST /v1/patients/onboard
Full patient onboarding flow: creates user account + patient_persons profile + org-patient link + organization membership in one atomic transaction.
This is the recommended way to add completely new patients to the system.
Request Body
{
"name": "John Doe",
"email": "[email protected]",
"phone": "+31612345678",
"organization_id": 1
}Fields
name(string, required) - Patient's full nameemail(string, required) - Email address (must be unique)phone(string, required) - Phone number (will be encrypted on patient_persons)organization_id(integer, required) - Organization to add patient to
Business Logic
- Validate required fields (name, email, phone, organization_id)
- Check if user with email already exists
- If user exists:
- Find their
patient_personsrecord - Check if already a patient in this org; if so, return 409
- Create
patientsorg-patient link (if not already linked)
- Find their
- If user doesn't exist:
- Create user via Clerk (they handle password setup/auth)
- Create internal
usersrecord linked to Clerk - Create
patient_personsrecord (name, phone_encrypted) - Create
patientsorg-patient link - Add to organization via
user_organizations
- Emit
patient.onboardedwebhook event
Response: 201 Created
{
"data": {
"user_id": 43,
"patient_person_id": 81,
"patient": {
"id": 100,
"patient_person_id": 81,
"profile_shared": false,
"organization_id": 1,
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z"
},
"person": {
"id": 81,
"user_id": 43,
"name": "John Doe",
"date_of_birth": null,
"blood_type": null,
"allergies": [],
"chronic_conditions": [],
"insurance_entries": []
},
"user_created": true,
"organization_added": true,
"required_forms": [
{
"form_id": 500,
"template_id": 42,
"title": "Privacy Policy & GDPR Consent",
"type": "disclaimer",
"status": "pending",
"must_sign_before": "first_appointment"
},
{
"form_id": 501,
"template_id": 43,
"title": "Profile Sharing Consent",
"type": "disclaimer",
"consent_types": ["profile_sharing"],
"status": "pending",
"must_sign_before": "first_appointment"
}
]
}
}Response Fields
user_id— ID of the user account (new or existing)patient_person_id— ID of the portable patient profile (patient_persons)patient— The thin org-patient link record (patients)person— The portable profile (name, DOB, blood type, allergies, etc.)user_created—trueif a new Clerk account was createdorganization_added—trueif user was added to the organizationrequired_forms— Forms that must be signed before the first appointment (from automation rules). Includes a "Profile Sharing Consent" form — signing it setsprofile_shared = trueon thepatientsrecord, unlocking the full portable profile for this org's staff
Errors
400 name_required- Name is required400 email_required- Email is required400 phone_required- Phone is required400 invalid_email_format- Email format is invalid409 patient_already_exists- Patient with this email already exists in this organization
See Also
- onboarding.md - Detailed onboarding flow documentation
Get Patient
GET /v1/patients/{id}
Get a single patient record by ID. Ownership validated by RLS.
Query Parameters
include(string) - Related data to include (comma-separated:person,custom_field_values,appointments)
Example Request
GET /v1/patients/100?include=person,custom_field_values
Authorization: Bearer <token>Response: 200 OK
{
"data": {
"id": 100,
"organization_id": 1,
"patient_person_id": 81,
"profile_shared": true,
"consumer_id": "legacy-123",
"person": {
"id": 81,
"user_id": 42,
"name": "John Doe",
"date_of_birth": "1985-03-12",
"sex": "male",
"occupation": "Engineer",
"residence": "Amsterdam",
"blood_type": "A+",
"allergies": ["Penicillin"],
"chronic_conditions": [],
"emergency_contact_name": "Jane Doe",
"insurance_entries": [
{ "provider": "AXA", "number": "AXA-123", "type": "private" }
]
},
"custom_field_values": {
"referral_source": "Google",
"training_surface": "grass"
},
"created_at": "2025-01-15T10:00:00Z",
"updated_at": "2025-01-15T10:00:00Z"
}
}Profile sharing gate: The person object above shows the full profile (when profile_shared = true on the patients record). When profile_shared = false, org staff only receive person.id, person.user_id, and person.name — all other fields are omitted. The patient themselves always sees their full own profile regardless. See Profile sharing consent.
custom_field_values contains org-specific data defined by this organization's custom fields.
Errors
404 not_found- Patient doesn't exist or not accessible (RLS filtered)
Update Patient
PUT /v1/patients/{id}
Update org-scoped patient information. This endpoint manages the org-patient link only.
To update portable profile data (name, phone, date of birth, blood type, etc.), update patient_persons directly — those fields are not on this record.
Request Body
{
"consumer_id": "updated-external-id"
}Fields
Protected fields (admin/customer support only):
consumer_id- External system identifier
Response: 200 OK
{
"data": {
"id": 100,
"organization_id": 1,
"patient_person_id": 81,
"profile_shared": true,
"consumer_id": "updated-external-id",
"updated_at": "2025-01-15T11:00:00Z"
}
}Errors
404 not_found- Patient doesn't exist or not accessible403 forbidden- Attempting to modify protected fields without proper role
Delete Patient
DELETE /v1/patients/{id}
Soft-delete a patient record. This sets the deleted_at timestamp but preserves the record for HIPAA compliance. Admin only.
Medical records are never hard-deleted. Soft-deleted patients are hidden from normal queries but remain in the database.
Note: This deletes only the org-patient link (patients record). The patient's patient_persons profile remains intact — they may still be registered at other organizations.
Response: 200 OK
{
"data": {
"id": 100,
"deleted_at": "2025-01-15T12:00:00Z",
"message": "Patient soft-deleted successfully"
}
}Errors
404 not_found- Patient doesn't exist or not accessible403 forbidden- Non-admin attempting to delete
Impersonate Patient
POST /v1/patients/{id}/impersonate
Generate a time-limited session token to impersonate a patient. Admin only. Used for customer support scenarios (e.g., helping a patient complete a form over the phone).
All actions during an impersonation session are logged to the audit trail with the impersonation_id for compliance.
Request Body
{
"reason": "Patient unable to complete intake form, assisting via phone"
}Fields
reason(string, required, min: 10 characters) - Reason for impersonation (for audit trail)
Business Logic
- Validate
reasonis non-empty and at least 10 characters - Generate short-lived token (max 1 hour, non-renewable)
- Log impersonation event to
audit_logwithaction_context = 'impersonation' - Store impersonation record for post-session review
- All actions during impersonation session are tagged in audit log with
impersonation_id
Response: 200 OK
{
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user_id": 42,
"expires_at": "2025-01-15T11:00:00Z",
"impersonation_id": 17
}
}Response Fields
token(string) - Session token to use for impersonationuser_id(integer) - User ID being impersonatedexpires_at(timestamp) - When the token expires (max 1 hour)impersonation_id(integer) - ID for audit trail (all actions tagged with this)
Errors
400 reason_required- Reason must be provided (min 10 characters)403 forbidden- Non-admin attempting impersonation404 not_found- Patient doesn't exist or not accessible429 rate_limited- Max 3 impersonations per 5 minutes
Security Considerations
- Tokens expire after 1 hour (not renewable)
- Every action is logged with the impersonation_id
- Rate limited to prevent abuse
- Requires explicit reason for audit compliance
See Also
- impersonation.md - Detailed impersonation feature documentation
Example Workflows
Full Patient Onboarding with Form Linking
Onboard patient
bashPOST /v1/patients/onboard { "name": "John Doe", "email": "[email protected]", "phone": "+31612345678", "organization_id": 1 }Create appointment from template (auto-generates forms)
bashPOST /v1/appointments/from-template { "template_id": 5, "patient_person_id": 81, "specialist_id": 2, "started_at": "2025-01-20T10:00:00Z" }Patient fills out survey form (auto-fills from portable profile)
bashPUT /v1/forms/201 { "values": { "residence": "Amsterdam", // Auto-filled from patient_persons.residence "pain_level": "Big pain" } }
Admin Support via Impersonation
Admin impersonates patient
bashPOST /v1/patients/100/impersonate { "reason": "Patient called support, unable to complete form on mobile device" }Use impersonation token to submit form
bashPUT /v1/forms/201 Authorization: Bearer <impersonation_token> { "values": { ... } }All actions logged with impersonation_id in audit_log
Related Endpoints
Patient Portable Profile (patient_persons)
GET /v1/patient-persons/{id}- Get portable profile (name, DOB, blood type, allergies, etc.)PUT /v1/patient-persons/{id}- Update portable profile (patient can update their own)
These endpoints manage cross-org portable profile data. Changes here are visible to all organizations the patient attends.
Patient Org Profile (Custom Field Values)
GET /v1/patients/{id}/profile- Get all org-specific custom field valuesPUT /v1/patients/{id}/profile- Bulk update org-specific profileGET /v1/patients/{id}/prefill?keys=referral_source,training_surface- Get specific fields for form auto-fill
See ../custom-fields/api.md for details.
Patient Segments
GET /v1/patients/{id}/segments- Get all segments this patient belongs to
See ../segments/api.md for details.
Patient Appointments
GET /v1/appointments?patient_person_id={id}- Get all appointments for a patient person
See ../appointments/api.md for details.