Skip to content

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 pagination
  • page_size (integer, default: 25, max: 100) - Number of records per page
  • sort (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

bash
GET /v1/patients?page=1&page_size=25&sort=created_at&include=person
Authorization: Bearer <token>

Response: 200 OK

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

json
{
  "patient_person_id": 81,
  "consumer_id": "legacy-123"
}

Fields

  • patient_person_id (integer, required) - ID of the existing patient_persons record to link
  • consumer_id (string, optional) - External system identifier

Response: 201 Created

json
{
  "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 required
  • 404 patient_person_not_found - patient_persons record does not exist
  • 409 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

json
{
  "name": "John Doe",
  "email": "[email protected]",
  "phone": "+31612345678",
  "organization_id": 1
}

Fields

  • name (string, required) - Patient's full name
  • email (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

  1. Validate required fields (name, email, phone, organization_id)
  2. Check if user with email already exists
  3. If user exists:
    • Find their patient_persons record
    • Check if already a patient in this org; if so, return 409
    • Create patients org-patient link (if not already linked)
  4. If user doesn't exist:
    • Create user via Clerk (they handle password setup/auth)
    • Create internal users record linked to Clerk
    • Create patient_persons record (name, phone_encrypted)
    • Create patients org-patient link
    • Add to organization via user_organizations
  5. Emit patient.onboarded webhook event

Response: 201 Created

json
{
  "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_createdtrue if a new Clerk account was created
  • organization_addedtrue if user was added to the organization
  • required_forms — Forms that must be signed before the first appointment (from automation rules). Includes a "Profile Sharing Consent" form — signing it sets profile_shared = true on the patients record, unlocking the full portable profile for this org's staff

Errors

  • 400 name_required - Name is required
  • 400 email_required - Email is required
  • 400 phone_required - Phone is required
  • 400 invalid_email_format - Email format is invalid
  • 409 patient_already_exists - Patient with this email already exists in this organization

See Also


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

bash
GET /v1/patients/100?include=person,custom_field_values
Authorization: Bearer <token>

Response: 200 OK

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

json
{
  "consumer_id": "updated-external-id"
}

Fields

Protected fields (admin/customer support only):

  • consumer_id - External system identifier

Response: 200 OK

json
{
  "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 accessible
  • 403 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

json
{
  "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 accessible
  • 403 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

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

  1. Validate reason is non-empty and at least 10 characters
  2. Generate short-lived token (max 1 hour, non-renewable)
  3. Log impersonation event to audit_log with action_context = 'impersonation'
  4. Store impersonation record for post-session review
  5. All actions during impersonation session are tagged in audit log with impersonation_id

Response: 200 OK

json
{
  "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 impersonation
  • user_id (integer) - User ID being impersonated
  • expires_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 impersonation
  • 404 not_found - Patient doesn't exist or not accessible
  • 429 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


Example Workflows

Full Patient Onboarding with Form Linking

  1. Onboard patient

    bash
    POST /v1/patients/onboard
    {
      "name": "John Doe",
      "email": "[email protected]",
      "phone": "+31612345678",
      "organization_id": 1
    }
  2. Create appointment from template (auto-generates forms)

    bash
    POST /v1/appointments/from-template
    {
      "template_id": 5,
      "patient_person_id": 81,
      "specialist_id": 2,
      "started_at": "2025-01-20T10:00:00Z"
    }
  3. Patient fills out survey form (auto-fills from portable profile)

    bash
    PUT /v1/forms/201
    {
      "values": {
        "residence": "Amsterdam",      // Auto-filled from patient_persons.residence
        "pain_level": "Big pain"
      }
    }

Admin Support via Impersonation

  1. Admin impersonates patient

    bash
    POST /v1/patients/100/impersonate
    {
      "reason": "Patient called support, unable to complete form on mobile device"
    }
  2. Use impersonation token to submit form

    bash
    PUT /v1/forms/201
    Authorization: Bearer <impersonation_token>
    {
      "values": { ... }
    }
  3. All actions logged with impersonation_id in audit_log


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 values
  • PUT /v1/patients/{id}/profile - Bulk update org-specific profile
  • GET /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.