Files Overview
The Patient Portal Files API lets the authenticated patient list, read, upload, rename, and soft-delete files (the /me/files resource). Every endpoint is self-only: the JWT subject is the only patient whose files are visible, and renames/deletes additionally require the patient to be the original uploader.
Uploads support two modes: case attachment (supply caseId) and general document (omit caseId). General documents are stored as UserDocument rows (type GENERAL) and appear in the admin panel under Documents → User Documents, but are not returned by GET /me/files.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/v1/users/me/files | List the patient's attachments (optionally filtered) |
| 2 | GET | /api/v1/users/me/files/:id/metadata | Get a single attachment's metadata |
| 3 | GET | /api/v1/users/me/files/:id/download | Get a short-lived signed download URL |
| 4 | POST | /api/v1/users/me/files/upload | Upload a base64-encoded file; linked to a case if caseId is provided, otherwise saved as a general document |
| 5 | PATCH | /api/v1/users/me/files/:id/metadata | Rename an attachment the patient uploaded |
| 6 | DELETE | /api/v1/users/me/files/:id | Soft-delete an attachment the patient uploaded |
Authentication
Every endpoint requires a successful /verify-otp exchange first.
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401. |
Content-Type | Yes (POST/PATCH only) | Must be application/json. |
The patientPortalAuth() middleware enforces token type patient-portal, resolves the calling organization from the token's organizationId claim, and confirms that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".
Permission Matrix
| Action | Allowed when… |
|---|---|
| List own attachments | Always (filtered to the patient's cases in the calling org). |
| List a specific case's attachments | The case is owned by the patient (submitterId) and belongs to the calling org. |
| Read metadata for an attachment | The attachment's case is owned by the patient and belongs to the calling org, and the attachment is not soft-deleted. |
| Download an attachment | Same as metadata read. |
| Upload (case attachment) | caseId is provided, the case is owned by the patient (submitterId), and belongs to the calling org. |
| Upload (general document) | caseId is omitted. No case ownership check; file is stored as a UserDocument (type GENERAL). |
| Rename an attachment | All of the above plus the attachment's uploadedById equals the patient's userId. |
| Soft-delete an attachment | Same as rename — patient must be the original uploader. |
The 404 is intentionally uniform: "doesn't exist", "deleted", "not yours", "wrong tenant", and "you didn't upload it" all collapse to the same response so attachment ids cannot be probed.
Common Response Envelope
List responses wrap the array in data.files:
{ "status": 200, "success": true, "data": { "files": [ "..." ] } }
Single-attachment responses (metadata, download, upload, update) place the payload directly under data:
{ "status": 200, "success": true, "data": { "...": "see Attachment Object Shapes" } }
Delete returns:
{ "status": 200, "success": true, "message": "File deleted successfully" }
Error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Attachment Object Shapes
The shapes are not uniform across endpoints — pay attention to which fields appear where.
List item / Metadata result
Used by GET /me/files and GET /me/files/:id/metadata.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CaseAttachment.id. |
fileName | string | Original or last-renamed file name. |
isPHI | boolean | Defaults to false on creation. Server-controlled. |
isRestricted | boolean | Defaults to false. Server-controlled. |
caseId | string (UUID) | The case the attachment belongs to. |
uploadedBy | { id, firstName, lastName } | null | Includes the uploader's User row (staff or patient). |
createdAt | ISO-8601 datetime | Server-generated on create. |
viewUrl | string | null | Signed GCS URL for inline viewing (V4 read action, Content-Disposition: inline). Valid for 1 hour. null if the underlying GCS object is missing (orphaned record). |
viewUrlExpiresIn | number | null | 3600 (seconds) when viewUrl is present. null when viewUrl is null. |
Update result
Used by PATCH /me/files/:id/metadata. Same shape as above without viewUrl and viewUrlExpiresIn.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CaseAttachment.id. |
fileName | string | Original or last-renamed file name. |
isPHI | boolean | Server-controlled. |
isRestricted | boolean | Server-controlled. |
caseId | string (UUID) | The case the attachment belongs to. |
uploadedBy | { id, firstName, lastName } | null | Includes the uploader's User row (staff or patient). |
createdAt | ISO-8601 datetime | Server-generated on create. |
Download result
Used by GET /me/files/:id/download.
| Field | Type | Notes |
|---|---|---|
downloadUrl | string | Short-lived signed URL into the GCS bucket (V4 read action). |
fileName | string | The current file name to use as the download filename. |
expiresIn | number | 900 (seconds). Matches the 15-minute signed-URL TTL. |
Upload result
Used by POST /me/files/upload.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CaseAttachment.id (case upload) or UserDocument.id (general upload). |
fileName | string | The name you supplied. |
isPHI | boolean | Always false at creation. |
isRestricted | boolean | Always false at creation. |
caseId | string (UUID) | null | Echoed from the request, or null for general uploads. |
createdAt | ISO-8601 datetime | Server-generated. |
The upload response does not include uploadedBy. If your client needs uploader info immediately after upload, follow up with GET /me/files/:id/metadata.
Allowed MIME Types
Uploads must declare a mimeType from this allowlist. Anything else fails Zod validation.
application/pdfapplication/mswordtext/csvtext/plainimage/jpegimage/pngimage/svg+xmlimage/tiffimage/webp
Server-Side Behaviors and Defaults
- Soft-delete only.
DELETEflipsisDeleted = trueand writes aCaseActivityrow of typeDELETE_ATTACHMENT. The bucket object is retained. - Activity log. The patient's
userIdis recorded asactorIdon the activity row. isPHI/isRestrictedare server-controlled. Patient uploads always start withisPHI = falseandisRestricted = false. The patient portal does not expose a way to set or change these.- Sort order on list.
createdAtdescending (newest first). - Soft-deleted rows are excluded from every read endpoint.
- List page size.
GET /me/filesreturns up to 100 files per request (default 50). Pass?limit=Nto control the page size. No cursor is returned; usecaseIdortypeto narrow results beyond that cap. - Signed URLs. Two flavours: download URLs (
/downloadendpoint) have a 15-minute TTL andContent-Disposition: attachment; view URLs (viewUrlon list/metadata) have a 1-hour TTL andContent-Disposition: inlineso browsers render the file in-place rather than saving it. The content type of the signed URL is locked to the MIME type stored in GCS at upload time. viewUrl: nullon orphaned records. If the bucket object is missing for a DB row (e.g. a partial upload that was never cleaned up),viewUrl(andviewUrlExpiresIn) isnullfor that item in the list rather than failing the entire request. All other items are unaffected. Other storage errors (e.g. a temporary outage) return an error response for the whole request instead ofnull.- Object path layout. Case attachments:
cases/<organizationId>/<caseId>/<attachmentId>/<fileName>. General documents:userDocs/<userId>/<documentId>/<fileName>. - No size cap on uploads is enforced at the application layer. App Engine / proxy limits apply transitively.
- Atomic upload semantics. A failed bucket write is followed by a soft-delete of the just-created DB row; the patient sees
500 INTERNAL_ERRORand the row will not appear in subsequent listings.
Rate Limits
All three read endpoints are rate-limited per organization per minute. Exceeding the limit returns 429 with a retryAfter field (in seconds).
| Endpoint | Default limit |
|---|---|
GET /me/files | 30 req / min |
GET /me/files/:id/metadata | 100 req / min |
GET /me/files/:id/download | 100 req / min |
Security Properties
- Tenant isolation. Every file lookup checks
case.organizationId === req.patientOrganization.id. - Ownership isolation. Every file lookup checks
case.submitterId === req.patientUser.id. - Uploader-self gate on mutation. Rename and delete additionally require
uploadedById === userId. Staff-uploaded files are read-only to the patient. - Uniform 404. Not-found, soft-deleted, wrong-tenant, wrong-owner, and (for mutations) wrong-uploader all return the same
404 VALIDATION_ERROR"File not found". - Token type pinned. Only JWTs with
type: 'patient-portal'reach the handler. - Cross-tenant defense. Every lookup is scoped to the organization named by the JWT's
organizationIdclaim, so a token minted for one tenant can never read another tenant's data. - Signed-URL expiry. Download URLs expire in 15 minutes (
expiresIn: 900); view URLs (viewUrl) expire in 1 hour (viewUrlExpiresIn: 3600). Expiry is encoded as an absolute Unix timestamp in theExpiresquery parameter of the signed URL — GCS enforces it server-side and clients cannot extend it. - Allowlisted MIME types. Only the nine types above are accepted on upload.
Integrator Guidance
- Refresh proactively. Refresh the access token via
/refresh-tokenbefore the 15-minute expiry. - Listing strategy. Call
GET /me/filesonce per session and refresh after upload/rename/delete. Use?caseId=when surfacing files within a single-case view. The response is capped at 100 files; use?caseId=or?type=to narrow results if a patient may exceed that. - Filtering. Use
?type=phior?type=generalto split the patient view; omit to show everything. - Page size. Pass
?limit=N(1–100) to control how many files are returned. Defaults to 50. No cursor is returned; pagination is not supported — filter instead. - Upload payload. Send the file as base64 in
data. Chunked / multipart upload is not offered. - General uploads are not listed by
GET /me/files. OmittingcaseIdcreates aUserDocument, which is visible in the admin panel under Documents → User Documents but outside the scope of the/me/fileslist endpoint. - Renames are display-only. They change the visible label and the next download URL's filename, but do not relocate the underlying bucket object.
- Patient can only rename/delete their own uploads. Surface staff-uploaded attachments as read-only in the UI to avoid
404surprises. - Inline preview vs force-download. Use
viewUrl(returned by list and metadata) to render files inline in the browser — no extra round-trip needed. Use/downloadwhen you want to trigger a Save As dialog; re-fetch on demand rather than caching that URL. - Treat
404as "no permission, may or may not exist". Do not display id-specific debug text.