Patient API Documentation

PATIENT_API.md

Patient API Documentation

Base path: /api/patients Last updated: 2026-04-05

This document covers every patient-facing API endpoint — authentication, self-service portal data, public registration, and third-party app integration. Staff-only CRUD endpoints (e.g. GET /api/patients, DELETE /api/patients/[id]) are documented in the Staff API docs.


Table of Contents

  1. Authentication Overview
  2. Rate Limiting
  3. Authentication Endpoints
  4. Public Endpoints
  5. Patient Portal — Self-Service
  6. Patient Appointments (Portal)
  7. Response Format
  8. Error Reference
  9. Data Models

1. Authentication Overview

Patient portal routes accept credentials via two carriers — choose the one appropriate for the client type:

CarrierHeader / CookieIssued byTTLBest for
patient_session cookieCookie: patient_session=<jwt>/auth/login, /auth/otp/verify, /qr-login7 daysBrowser / web portal
Bearer tokenAuthorization: Bearer <jwt>/auth/token30 daysThird-party apps, mobile apps

Both carriers carry the same HS256 JWT payload signed with SESSION_SECRET. All protected routes (/me/*, /appointments, /session) accept either one — the cookie is checked first, then the Authorization header.

JWT payload structure

{
  "patientId": "664abc...",
  "patientCode": "CLINIC-0001",
  "type": "patient",
  "email": "jane@example.com",
  "iat": 1712000000,
  "exp": 1712604800
}

Login flow — web portal (cookie-based)

Choose one:
  ┌──────────────────┐    ┌──────────────────────┐    ┌───────────────────────────────┐
  │  QR code scan    │    │  Email + password     │    │  Phone OTP                    │
  │  POST /qr-login  │    │  POST /auth/login     │    │  POST /auth/otp/request       │
  └────────┬─────────┘    └──────────┬────────────┘    │  POST /auth/otp/verify        │
           │                         │                  └──────────────┬────────────────┘
           └─────────────────────────┴──────────────────────────────┘
                                     │
                           patient_session cookie set
                                     │
                    GET /api/patients/me/* (cookie auto-sent by browser)

Login flow — third-party app (Bearer token)

1. GET  /api/patients/lookup?tenantId=…&phone=…      ← confirm patient exists + get authMethods
2a. POST /api/patients/auth/token  { method:"password", email, password, tenantId }
2b. POST /api/patients/auth/otp/request { phone, tenantId }
    POST /api/patients/auth/token  { method:"otp", phone, otp, tenantId }
                                     │
                           { token, expiresIn } returned in JSON body
                                     │
    GET /api/patients/me             ← Authorization: Bearer <token>

First-time credential setup (no password yet)

Patient has QR card but no email/password:

1. POST /api/patients/auth/otp/request  { phone, tenantId }
2. POST /api/patients/auth/token        { method:"otp", phone, otp, tenantId }
   → receive Bearer token
3. POST /api/patients/auth/setup-credentials  (Bearer token in header)
   { email: "jane@example.com", password: "SecurePass123" }
   → credentials saved; patient can now use email+password on all future logins

2. Rate Limiting

Endpoint groupLimiterWindowMax requests
Auth endpoints (/auth/*, /qr-login)auth15 min5
/auth/tokenauth15 min5
/patients/lookuppublic15 min10
Public registration (/public)public1 min20
Portal data (/me/*, /appointments)None (session-gated)

429 response:

{
  "success": false,
  "error": "Too many authentication attempts, please try again later",
  "retryAfter": 843
}

Headers: Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset


3. Authentication Endpoints

3.1 POST /api/patients/auth/login

Email + password login. Issues a patient_session cookie on success.

Auth required: No
Rate limited: Yes (auth limiter)

Request body:

FieldTypeRequiredDescription
emailstringYesPatient's registered email address
passwordstringYesPlain-text password (bcrypt-compared)
tenantIdstringNoMongoDB ObjectId of the clinic. Scopes the lookup to a specific tenant when a patient is registered at multiple clinics.
{
  "email": "jane.doe@example.com",
  "password": "MySecurePass123",
  "tenantId": "664abc123def456789000001"
}

Success response — 200:

{
  "success": true,
  "message": "Login successful",
  "data": {
    "patientId": "664abc...",
    "patientCode": "CLINIC-0001",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com"
  }
}

Cookie set: patient_session (HttpOnly, 7 days)

Error responses:

StatusConditionError message
400Missing email or password"Email and password are required"
401Wrong credentials"Invalid email or password"
401Account has no password set"This account does not have a password set...", code: "NO_PASSWORD"
403Account is inactive"Patient account is inactive. Please contact the clinic."
429Rate limit exceeded"Too many authentication attempts..."

Note: When code: "NO_PASSWORD" is returned, redirect the patient to QR or OTP login. Use POST /api/patients/me/change-password to set an initial password after any successful login.


3.2 POST /api/patients/auth/otp/request

Generates a 6-digit OTP, hashes it, stores it with a 5-minute expiry, and sends it to the patient's registered phone via SMS (Twilio).

Auth required: No
Rate limited: Yes (auth limiter)
Security: Always returns the same success message regardless of whether the phone number exists (prevents enumeration).

Request body:

FieldTypeRequiredDescription
phonestringYesPhone number in any format. Auto-normalized to E.164.
tenantIdstringNoScope lookup to a specific clinic.
{
  "phone": "+63 917 123 4567",
  "tenantId": "664abc123def456789000001"
}

Success response — 200 (always returned, even if phone not found):

{
  "success": true,
  "message": "If a matching account is found, an OTP will be sent to your phone within 5 minutes."
}

OTP behavior:

  • OTP is 6 digits, zero-padded
  • Stored as bcrypt hash in patient.otp (select: false)
  • Expiry: 5 minutes (patient.otpExpiry)
  • Attempt counter reset to 0 on each new request
  • Searches both patient.phone and patient.contacts.phone

3.3 POST /api/patients/auth/otp/verify

Verifies the OTP and issues a patient_session cookie.

Auth required: No
Rate limited: Yes (auth limiter)

Request body:

FieldTypeRequiredDescription
phonestringYesSame number used in /otp/request
otpstringYes6-digit code received via SMS
tenantIdstringNoSame tenant used in /otp/request
{
  "phone": "+63 917 123 4567",
  "otp": "482916",
  "tenantId": "664abc123def456789000001"
}

Success response — 200:

{
  "success": true,
  "message": "Login successful",
  "data": {
    "patientId": "664abc...",
    "patientCode": "CLINIC-0001",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com"
  }
}

Cookie set: patient_session (HttpOnly, 7 days)

Error responses:

StatusConditionError message
400Missing phone or otp"Phone number and OTP are required"
401Wrong OTP or no OTP found"Invalid or expired OTP"
401OTP expired"OTP has expired. Please request a new one."
4295+ wrong attempts"Too many incorrect attempts. Please request a new OTP."

Attempt logic:

  • Each wrong OTP increments patient.otpAttempts
  • At 5 failed attempts, OTP is invalidated and patient must request a new one
  • On success, otp, otpExpiry, and otpAttempts are cleared from the document

3.4 POST /api/patients/auth/token

Issues a long-lived Bearer token in the response body for use by third-party and mobile applications. Supports two authentication methods selected via the method field.

Unlike /auth/login and /auth/otp/verify, this endpoint returns the JWT in the JSON response rather than setting a cookie. The third-party app stores the token securely and sends it as Authorization: Bearer <token> on subsequent requests.

Auth required: No
Rate limited: Yes (auth limiter)

Request body:

FieldTypeRequiredDescription
methodstringYes"password" or "otp"
emailstringConditionalRequired when method = "password"
passwordstringConditionalRequired when method = "password"
phonestringConditionalRequired when method = "otp"
otpstringConditionalRequired when method = "otp". Call /auth/otp/request first.
tenantIdstringNoScopes the patient lookup to a specific clinic.

Example — password method:

{
  "method": "password",
  "email": "jane.doe@example.com",
  "password": "MySecurePass123",
  "tenantId": "664abc123def456789000001"
}

Example — OTP method:

{
  "method": "otp",
  "phone": "+63 917 123 4567",
  "otp": "482916",
  "tenantId": "664abc123def456789000001"
}

Success response — 200:

{
  "success": true,
  "message": "Authentication successful",
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "tokenType": "Bearer",
  "expiresIn": 2592000,
  "patient": {
    "id": "664abc...",
    "patientCode": "CLINIC-0001",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com"
  }
}

expiresIn is in seconds. 2592000 = 30 days.

Usage in subsequent requests:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

Error responses:

StatusConditionError
400Missing method or required fields"Invalid method. Use 'password' or 'otp'."
401Wrong email/password"Invalid email or password"
401No password set on account"This account has no password set...", code: "NO_PASSWORD"
401Wrong or expired OTP"Invalid or expired OTP"
403Account inactive"Account is inactive. Please contact the clinic."
429Rate limit exceeded

When code: "NO_PASSWORD" is returned, use method: "otp" to log in, then call /auth/setup-credentials to set a password.


3.5 POST /api/patients/auth/setup-credentials

Registers or updates the email and password a patient uses to log into third-party applications. Requires an active session (cookie or Bearer token).

Auth required: Yes (cookie or Bearer token)
Rate limited: No

Typical use:

  • Patient first logs in via OTP to get a Bearer token, then calls this endpoint to register permanent credentials.
  • Can also be used from the patient portal (web) to set/update third-party login credentials.

Request body:

FieldTypeRequiredDescription
passwordstringYesNew password (min 8 characters)
emailstringNoLogin email. If omitted, existing email is kept.
currentPasswordstringConditionalRequired if the patient already has a password set.
{
  "email": "jane.doe@example.com",
  "password": "NewSecurePass456",
  "currentPassword": "OldPass123"
}

Success response — 200:

{
  "success": true,
  "message": "Credentials saved. You can now log in with email and password on any supported application.",
  "email": "jane.doe@example.com"
}

Error responses:

StatusConditionError
400password missing or < 8 chars"Password must be at least 8 characters long"
400currentPassword missing when one already exists"currentPassword is required when updating an existing password"
400Invalid email format"Invalid email format"
401currentPassword is wrong"Current password is incorrect"
403Account inactive"Account is inactive."
409Email already taken by another patient"This email is already registered to another patient..."

3.6 POST /api/patients/qr-login

Authenticates a patient by scanning their clinic-issued QR code.

Auth required: No
Rate limited: Yes (auth limiter)

Request body:

FieldTypeRequiredDescription
qrCodestring or objectYesJSON string or parsed object from the QR code
tenantIdstringNoOverride tenant from body (falls back to QR data or subdomain)

Expected QR code payload (JSON-encoded string):

{
  "type": "patient_login",
  "patientId": "664abc...",
  "patientCode": "CLINIC-0001",
  "tenantId": "664abc123def456789000001"
}

Success response — 200:

{
  "success": true,
  "message": "Login successful",
  "data": {
    "patientId": "664abc...",
    "patientCode": "CLINIC-0001",
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com"
  }
}

Error responses:

StatusConditionError message
400No QR code provided"QR code is required"
400Malformed JSON"Invalid QR code format"
400Missing patient identifier"Patient identification not found in QR code"
400Wrong QR type"Invalid QR code type. This QR code is not for patient login."
403Inactive patient"Patient account is inactive. Please contact the clinic."
404Patient not found"Patient not found"

3.7 GET /api/patients/session

Returns the authenticated patient's profile and optionally their related clinical data in a single request.

Auth required: Yes (patient_session cookie or Authorization: Bearer header)

Query parameters:

ParamTypeDescription
includestringComma-separated list of data to include. Accepted values: appointments, visits, prescriptions, labResults, invoices, documents, referrals, all

Example:

GET /api/patients/session?include=appointments,prescriptions
GET /api/patients/session?include=all

Success response — 200:

{
  "success": true,
  "data": {
    "patient": {
      "_id": "664abc...",
      "patientCode": "CLINIC-0001",
      "firstName": "Jane",
      "lastName": "Doe",
      "dateOfBirth": "1990-05-15T00:00:00.000Z",
      "sex": "female",
      "email": "jane.doe@example.com",
      "phone": "+63 917 123 4567",
      "address": { "street": "123 Main St", "city": "Manila", "state": "NCR", "zipCode": "1000" },
      "emergencyContact": { "name": "John Doe", "phone": "+63 917 987 6543", "relationship": "Spouse" },
      "allergies": [],
      "medicalHistory": "",
      "preExistingConditions": [],
      "discountEligibility": {}
    },
    "appointments": [ /* up to 10, sorted by appointmentDate desc */ ],
    "visits": [ /* up to 10, sorted by date desc */ ],
    "prescriptions": [ /* up to 10, sorted by issuedAt desc */ ],
    "labResults": [ /* up to 10, sorted by orderDate desc */ ],
    "invoices": [ /* up to 10, sorted by createdAt desc */ ],
    "documents": [ /* up to 20, sorted by uploadDate desc — non-confidential only */ ],
    "referrals": [ /* up to 10, sorted by referredDate desc */ ]
  }
}

Tip: For paginated data, use the dedicated /me/* endpoints instead.


3.8 DELETE /api/patients/session

Logs the patient out by clearing the patient_session cookie.

Auth required: No (works even with invalid/expired session)

Success response — 200:

{
  "success": true,
  "message": "Logged out successfully"
}

4. Public Endpoints

4.1 POST /api/patients/public

Patient self-registration. No authentication required. Creates a new patient record.

Auth required: No
Rate limited: Yes (public limiter — 20 req/min)

Request body:

FieldTypeRequiredDescription
firstNamestringYes
lastNamestringYes
phonestringYes
dateOfBirthstringYesISO 8601 date
sexstringYes"male", "female", or "other"
emailstringNoIf omitted, a placeholder email is auto-generated
addressobjectYes{ street, city, state, zipCode }
tenantIdstringNoAssociates patient with a clinic
middleNamestringNo
suffixstringNo
emergencyContactobjectNo{ name, phone, relationship }

Success response — 201:

{
  "success": true,
  "message": "Patient registration successful. Your patient code is: CLINIC-0042",
  "data": { /* full patient document */ }
}

Error responses:

StatusCondition
400Missing required fields
400Validation error (e.g. invalid email format, invalid sex value)
409Duplicate email in same tenant
503Database connection error

4.2 GET /api/patients/lookup

Public endpoint for third-party applications. Confirms whether a patient exists in a specific clinic and returns masked identity data so the patient can verify their account. Also returns authMethods so the app knows which login form to render.

Auth required: No
Rate limited: Yes (public limiter — 10 req / 15 min per IP)

Query parameters:

ParamTypeRequiredDescription
tenantIdstringYesMongoDB ObjectId of the clinic (from /api/tenants/directory)
phonestringAt least one requiredPatient's registered phone number
emailstringAt least one requiredPatient's registered email address
patientCodestringAt least one requiredClinic-issued patient code (e.g. CLINIC-0001)

Multiple identifiers can be sent together and are OR'd.

Example:

GET /api/patients/lookup?tenantId=664abc...&phone=%2B639171234567
GET /api/patients/lookup?tenantId=664abc...&email=jane%40example.com
GET /api/patients/lookup?tenantId=664abc...&patientCode=CLINIC-0001

Success response — patient found (200):

{
  "success": true,
  "found": true,
  "patient": {
    "patientCode": "CLINIC-0001",
    "firstName": "Jane",
    "maskedLastName": "D***",
    "maskedEmail": "j***@example.com",
    "maskedPhone": "+63*****4567",
    "active": true
  },
  "authMethods": {
    "password": true,
    "otp": true
  }
}

Response — patient not found (200):

{
  "success": false,
  "found": false
}

The endpoint always returns HTTP 200 for both found and not-found cases to prevent patient enumeration.

authMethods — what to show in the app UI:

passwordotpRecommended UI
truetrueEmail+password form with "Login with OTP" option
truefalseEmail+password form only
falsetrueOTP form + link to set up credentials
falsefalse"Contact your clinic to set up app access"

Error responses:

StatusCondition
400tenantId missing or invalid format
400None of phone, email, patientCode provided

5. Patient Portal — Self-Service

All routes in this section require authentication via patient_session cookie or Authorization: Bearer <token> header. All return 401 if unauthenticated, 403 if the account is inactive.


5.1 GET /api/patients/me

Returns the complete patient profile of the currently authenticated patient.

Auth required: Yes (cookie or Bearer token)

Success response — 200:

{
  "success": true,
  "data": {
    "_id": "664abc...",
    "patientCode": "CLINIC-0001",
    "tenantIds": ["664abc123def456789000001"],
    "firstName": "Jane",
    "middleName": "Marie",
    "lastName": "Doe",
    "suffix": null,
    "dateOfBirth": "1990-05-15T00:00:00.000Z",
    "sex": "female",
    "civilStatus": "married",
    "nationality": "Filipino",
    "occupation": "Engineer",
    "email": "jane.doe@example.com",
    "phone": "+63 917 123 4567",
    "address": {
      "street": "123 Main St",
      "city": "Manila",
      "state": "NCR",
      "zipCode": "1000"
    },
    "emergencyContact": {
      "name": "John Doe",
      "phone": "+63 917 987 6543",
      "relationship": "Spouse"
    },
    "identifiers": {
      "philHealth": "12-345678901-2",
      "govId": "A1234567"
    },
    "medicalHistory": "No significant history.",
    "preExistingConditions": [],
    "allergies": [],
    "discountEligibility": {},
    "active": true,
    "hasPassword": true,
    "createdAt": "2024-01-10T08:00:00.000Z",
    "updatedAt": "2024-04-01T10:30:00.000Z"
  }
}

password, otp, otpExpiry, otpAttempts are never returned (schema select: false).
hasPassword is a derived boolean indicating whether the patient has a password set — useful for third-party app UIs to decide whether to show a "Set Password" prompt.


5.2 PATCH /api/patients/me

Updates the patient's own profile. Only safe fields are accepted; system and auth fields are silently stripped.

Auth required: Yes (cookie or Bearer token)

Blocked fields (silently ignored if sent): patientCode, tenantIds, attachments, password, otp, otpExpiry, otpAttempts, active, _id, __v, createdAt, updatedAt

Updatable fields include: firstName, middleName, lastName, suffix, dateOfBirth, sex, civilStatus, nationality, occupation, email, phone, contacts, address, emergencyContact, identifiers, medicalHistory, allergies, socialHistory, familyHistory

Request body (partial update — send only fields to change):

{
  "phone": "+63 917 000 1111",
  "address": {
    "street": "456 New Ave",
    "city": "Quezon City",
    "state": "NCR",
    "zipCode": "1100"
  },
  "emergencyContact": {
    "name": "Mary Doe",
    "phone": "+63 917 555 6666",
    "relationship": "Sister"
  }
}

Success response — 200:

{
  "success": true,
  "message": "Profile updated successfully",
  "data": { /* full updated patient document */ }
}

Error responses:

StatusCondition
400All provided fields were blocked
400Mongoose validation error (e.g. invalid email format)
404Patient not found

5.3 POST /api/patients/me/change-password

Sets or changes the patient's password. This is a self-service endpoint (requires current password when one is set).

Third-party app alternative: Use POST /api/patients/auth/setup-credentials instead — it accepts a Bearer token and follows the same logic. Staff can also reset a patient's password via POST /api/patients/[id]/app-credentials.

Auth required: Yes (cookie or Bearer token)

Request body:

FieldTypeRequiredDescription
newPasswordstringYesMin 8 characters
currentPasswordstringConditionalRequired only if a password already exists on the account
{
  "currentPassword": "OldPass123",
  "newPassword": "NewSecurePass456"
}

Success response — 200:

{
  "success": true,
  "message": "Password updated successfully"
}

Error responses:

StatusConditionError
400newPassword missing or < 8 chars"New password must be at least 8 characters long"
400Account has password but currentPassword not provided"Current password is required"
401currentPassword is wrong"Current password is incorrect"

First-time password setup: If the patient logs in via QR or OTP and has no password, calling this endpoint with only newPassword (no currentPassword) will set their password for the first time.


5.4 GET /api/patients/me/visits

Returns a paginated list of the patient's visit history.

Auth required: Yes

Query parameters:

ParamTypeDefaultDescription
pagenumber1Page number
limitnumber10Results per page (max 50)
tenantIdstringFilter to a specific clinic (multi-clinic patients)

Success response — 200:

{
  "success": true,
  "data": [
    {
      "_id": "664abc...",
      "date": "2024-03-15T09:00:00.000Z",
      "type": "consultation",
      "chiefComplaint": "Headache",
      "diagnosis": "Tension headache",
      "provider": { "_id": "...", "firstName": "Dr. Juan", "lastName": "Cruz", "email": "dr.cruz@clinic.com" },
      "tenantId": "664abc123def456789000001"
    }
  ],
  "pagination": {
    "total": 24,
    "page": 1,
    "limit": 10,
    "totalPages": 3
  }
}

5.5 GET /api/patients/me/visits/[id]

Returns the full detail of a single visit. Ownership is enforced — only visits belonging to the authenticated patient are returned.

Auth required: Yes

URL params: id — MongoDB ObjectId of the visit

Success response — 200:

{
  "success": true,
  "data": { /* full visit document with provider populated */ }
}

Error responses:

StatusCondition
404Visit not found or does not belong to this patient

5.6 GET /api/patients/me/prescriptions

Returns a paginated list of the patient's prescriptions.

Auth required: Yes

Query parameters: page, limit (max 50), tenantId

Success response — 200:

{
  "success": true,
  "data": [
    {
      "_id": "664abc...",
      "issuedAt": "2024-03-15T09:00:00.000Z",
      "status": "active",
      "medications": [
        { "name": "Amoxicillin", "dosage": "500mg", "frequency": "3x daily", "duration": "7 days" }
      ],
      "prescribedBy": { "firstName": "Dr. Juan", "lastName": "Cruz", "email": "..." }
    }
  ],
  "pagination": { "total": 5, "page": 1, "limit": 10, "totalPages": 1 }
}

5.7 GET /api/patients/me/lab-results

Returns a paginated list of the patient's lab results.

Auth required: Yes

Query parameters: page, limit (max 50), tenantId

Success response — 200:

{
  "success": true,
  "data": [
    {
      "_id": "664abc...",
      "testName": "Complete Blood Count",
      "status": "available",
      "orderDate": "2024-03-14T00:00:00.000Z",
      "results": { /* structured result data */ }
    }
  ],
  "pagination": { "total": 8, "page": 1, "limit": 10, "totalPages": 1 }
}

5.8 GET /api/patients/me/invoices

Returns a paginated list of the patient's invoices plus an outstanding balance summary.

Auth required: Yes

Query parameters:

ParamTypeDefaultDescription
pagenumber1
limitnumber10Max 50
statusstringFilter by status: paid, unpaid, partial, cancelled
tenantIdstringFilter to a specific clinic

Success response — 200:

{
  "success": true,
  "data": [
    {
      "_id": "664abc...",
      "invoiceNumber": "INV-000042",
      "total": 2500.00,
      "status": "partial",
      "payments": [{ "amount": 1000, "method": "cash", "date": "..." }],
      "createdAt": "2024-03-15T09:00:00.000Z"
    }
  ],
  "summary": {
    "outstandingBalance": 1500.00,
    "unpaidCount": 2
  },
  "pagination": { "total": 12, "page": 1, "limit": 10, "totalPages": 2 }
}

5.9 GET /api/patients/me/documents

Returns a paginated list of the patient's documents. Confidential documents are always excluded.

Auth required: Yes

Query parameters:

ParamTypeDefaultDescription
pagenumber1
limitnumber20Max 50
categorystringFilter by document category
tenantIdstringFilter to a specific clinic

Success response — 200:

{
  "success": true,
  "data": [
    {
      "_id": "664abc...",
      "documentCode": "DOC-0001",
      "title": "Blood Test Results March 2024",
      "description": "CBC panel results",
      "category": "lab",
      "documentType": "pdf",
      "filename": "cbc-march-2024.pdf",
      "size": 204800,
      "uploadDate": "2024-03-16T08:00:00.000Z"
    }
  ],
  "pagination": { "total": 6, "page": 1, "limit": 20, "totalPages": 1 }
}

To download a document, use the staff-accessible /api/documents/[id]/download endpoint (requires staff authentication).


5.10 GET /api/patients/me/notifications

Returns a patient-centric notification feed derived from recent clinical activity (appointments, lab results, invoices, prescriptions) from the last 90 days.

Auth required: Yes

Query parameters:

ParamTypeDefaultDescription
pagenumber1
limitnumber20Max 50
unreadOnlybooleanfalseReturn only unread notifications

Success response — 200:

{
  "success": true,
  "unreadCount": 3,
  "data": [
    {
      "id": "apt-664abc...",
      "type": "appointment",
      "title": "Appointment Confirmed",
      "message": "Your appointment on Mon, Apr 7 at 09:00 has been confirmed.",
      "date": "2024-04-05T14:30:00.000Z",
      "read": false,
      "actionUrl": "/patient/portal?tab=appointments",
      "metadata": { "appointmentCode": "APT-000042", "status": "confirmed" }
    },
    {
      "id": "lab-664abc...",
      "type": "lab_result",
      "title": "Lab Result Requires Attention",
      "message": "Your CBC result is abnormal. Please contact your doctor.",
      "date": "2024-04-04T11:00:00.000Z",
      "read": false,
      "actionUrl": "/patient/portal?tab=lab-results",
      "metadata": { "testName": "Complete Blood Count", "status": "abnormal" }
    },
    {
      "id": "inv-664abc...",
      "type": "invoice",
      "title": "Outstanding Balance",
      "message": "Invoice #INV-000042 has an outstanding balance of 1500.",
      "date": "2024-04-01T09:00:00.000Z",
      "read": true,
      "actionUrl": "/patient/portal?tab=invoices",
      "metadata": { "invoiceNumber": "INV-000042", "total": 2500, "status": "partial" }
    }
  ],
  "pagination": { "total": 7, "page": 1, "limit": 20, "totalPages": 1 }
}

Notification ID format:

PrefixSource
apt-{id}Appointment
lab-{id}Lab result
inv-{id}Invoice
rx-{id}Prescription

Notification types and triggers:

TypeTrigger conditions
appointmentStatus is confirmed, cancelled, pending, or scheduled; updated in last 90 days
lab_resultStatus is available, abnormal, or critical; updated in last 90 days
invoiceStatus is unpaid or partial (no time restriction — always shown until resolved)
prescriptionCreated in last 90 days

5.11 PATCH /api/patients/me/notifications

Marks one or more notifications as read.

Auth required: Yes

Option A — Mark specific notifications:

{
  "ids": ["apt-664abc...", "lab-664abc..."]
}

Option B — Mark all as read:

{
  "markAllRead": true
}

Success response — 200:

{
  "success": true,
  "message": "2 notification(s) marked as read"
}

or

{
  "success": true,
  "message": "All notifications marked as read"
}

Error responses:

StatusCondition
400Neither ids array nor markAllRead: true provided
400ids is empty

How read-state works: IDs are stored in patient.readNotificationIds (a string array on the Patient document). The GET endpoint cross-references this list. markAllRead clears the list, so the next GET will correctly recalculate unread state from freshly derived notifications.


6. Patient Appointments (Portal)

6.1 GET /api/patients/appointments

Returns available doctors and optionally available time slots for a given date and doctor.

Auth required: Yes (patient_session cookie)

Query parameters:

ParamTypeDescription
datestringISO 8601 date. If provided with doctorId, returns available slots.
doctorIdstringMongoDB ObjectId of the doctor

Example:

GET /api/patients/appointments
GET /api/patients/appointments?date=2024-04-10&doctorId=664abc...

Success response (no date/doctorId) — 200:

{
  "success": true,
  "data": {
    "doctors": [
      { "_id": "664abc...", "firstName": "Juan", "lastName": "Cruz", "specialization": "General Medicine", "schedule": {} }
    ]
  }
}

Success response (with date + doctorId) — 200:

{
  "success": true,
  "data": {
    "doctors": [ /* same as above */ ],
    "availableSlots": ["09:00", "09:30", "10:00", "11:30", "14:00"]
  }
}

Time slots are 30-minute intervals between 09:00 and 17:00. Already-booked slots are excluded.


6.2 POST /api/patients/appointments

Books an appointment for the authenticated patient.

Auth required: Yes

Request body:

FieldTypeRequiredDescription
doctorIdstringYesMongoDB ObjectId
appointmentDatestringYesISO 8601 date
appointmentTimestringYesFormat: HH:mm (24-hour)
reasonstringNoMax 500 characters
{
  "doctorId": "664abc...",
  "appointmentDate": "2024-04-10",
  "appointmentTime": "10:30",
  "reason": "Annual check-up"
}

Success response — 201:

{
  "success": true,
  "message": "Appointment request submitted successfully. You will receive a confirmation shortly.",
  "data": { /* full appointment document with patient and doctor populated */ }
}

Error responses:

StatusCondition
400Missing required fields
400Invalid date or time format
404Doctor not found in patient's tenant
409Time slot already taken by another patient
409Patient already has an appointment at this time

Appointments are created with status: "pending" and must be confirmed by clinic staff. An SMS confirmation is sent to the patient's phone.


6.3 DELETE /api/patients/appointments/[id]

Cancels an appointment belonging to the authenticated patient.

Auth required: Yes

URL params: id — MongoDB ObjectId of the appointment

Constraints:

  • Only the patient who booked the appointment can cancel it
  • Cannot cancel appointments that are already completed or cancelled
  • Cannot cancel past appointments

Success response — 200:

{
  "success": true,
  "message": "Appointment cancelled successfully"
}

Error responses:

StatusCondition
403Appointment does not belong to this patient
404Appointment not found
409Appointment cannot be cancelled (wrong status or past date)

7. Response Format

All responses follow this consistent shape:

Success:

{
  "success": true,
  "data": { /* payload */ },
  "message": "Optional human-readable message"
}

Paginated success:

{
  "success": true,
  "data": [ /* array of items */ ],
  "pagination": {
    "total": 100,
    "page": 2,
    "limit": 10,
    "totalPages": 10
  }
}

Error:

{
  "success": false,
  "error": "Human-readable error message"
}

8. Error Reference

HTTP StatusMeaning
400Bad request — validation error or missing field
401Unauthenticated — missing or invalid patient_session cookie
403Forbidden — account inactive, or resource not owned by patient
404Resource not found
409Conflict — duplicate record or scheduling conflict
429Rate limit exceeded
500Server error
503Database unavailable

9. Data Models

Patient Session JWT payload

{
  patientId: string;       // MongoDB ObjectId
  patientCode: string;     // e.g. "CLINIC-0001"
  type: "patient";         // always "patient"
  email: string;
  iat: number;             // issued at (UNIX)
  exp: number;             // expires at (UNIX)
                           //   cookie: 7 days  (issued by /auth/login, /auth/otp/verify, /qr-login)
                           //   Bearer: 30 days (issued by /auth/token)
}

Both carriers use the same payload shape and signing key (SESSION_SECRET). All protected endpoints accept either one.

Patient document (relevant fields)

{
  _id: ObjectId;
  tenantIds: ObjectId[];         // clinics this patient belongs to
  patientCode: string;           // e.g. "CLINIC-0001"
  firstName: string;
  middleName?: string;
  lastName: string;
  suffix?: string;
  dateOfBirth: Date;
  sex: "male" | "female" | "other";
  civilStatus?: string;
  nationality?: string;
  occupation?: string;
  email?: string;
  phone: string;
  address: { street: string; city: string; state: string; zipCode: string };
  emergencyContact?: { name?: string; phone?: string; relationship?: string };
  identifiers?: { philHealth?: string; govId?: string };
  medicalHistory?: string;
  preExistingConditions?: Array<{ condition: string; status: "active"|"resolved"|"chronic" }>;
  allergies?: Array<string | { substance: string; reaction: string; severity: string }>;
  discountEligibility?: { pwd?: {...}; senior?: {...}; membership?: {...} };
  active?: boolean;
  // Auth fields — never returned in responses (select: false)
  password?: string;
  otp?: string;
  otpExpiry?: Date;
  otpAttempts?: number;
  // Notification read-state tracking
  readNotificationIds?: string[];
  createdAt: Date;
  updatedAt: Date;
}

Notification object

{
  id: string;                          // e.g. "apt-664abc...", "lab-664abc..."
  type: "appointment" | "lab_result" | "invoice" | "prescription";
  title: string;
  message: string;
  date: Date;
  read: boolean;
  actionUrl?: string;                  // e.g. "/patient/portal?tab=appointments"
  metadata?: Record<string, any>;     // type-specific fields (appointmentCode, status, etc.)
}

Staff credential management

Staff can set or reset a patient's app credentials without knowing the existing password via a dedicated staff endpoint:

MethodEndpointPermission required
GET/api/patients/[id]/app-credentialspatients → read
POST/api/patients/[id]/app-credentialspatients → write

GET returns { hasPassword: boolean, email: string | null }.

POST body: { password: string (min 8), email?: string } — admin override, no current password required.

These endpoints use staff session authentication (verifySession + requirePermission), not patient auth.


Generated from source code — app/api/patients/ — 2026-04-05