Skip to main content

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

#MethodPathPurpose
1GET/api/v1/users/me/filesList the patient's attachments (optionally filtered)
2GET/api/v1/users/me/files/:id/metadataGet a single attachment's metadata
3GET/api/v1/users/me/files/:id/downloadGet a short-lived signed download URL
4POST/api/v1/users/me/files/uploadUpload a base64-encoded file; linked to a case if caseId is provided, otherwise saved as a general document
5PATCH/api/v1/users/me/files/:id/metadataRename an attachment the patient uploaded
6DELETE/api/v1/users/me/files/:idSoft-delete an attachment the patient uploaded

Authentication

Every endpoint requires a successful /verify-otp exchange first.

HeaderRequiredDescription
AuthorizationYesBearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401.
Content-TypeYes (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

ActionAllowed when…
List own attachmentsAlways (filtered to the patient's cases in the calling org).
List a specific case's attachmentsThe case is owned by the patient (submitterId) and belongs to the calling org.
Read metadata for an attachmentThe attachment's case is owned by the patient and belongs to the calling org, and the attachment is not soft-deleted.
Download an attachmentSame 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 attachmentAll of the above plus the attachment's uploadedById equals the patient's userId.
Soft-delete an attachmentSame 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.

FieldTypeNotes
idstring (UUID)CaseAttachment.id.
fileNamestringOriginal or last-renamed file name.
isPHIbooleanDefaults to false on creation. Server-controlled.
isRestrictedbooleanDefaults to false. Server-controlled.
caseIdstring (UUID)The case the attachment belongs to.
uploadedBy{ id, firstName, lastName } | nullIncludes the uploader's User row (staff or patient).
createdAtISO-8601 datetimeServer-generated on create.
viewUrlstring | nullSigned 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).
viewUrlExpiresInnumber | null3600 (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.

FieldTypeNotes
idstring (UUID)CaseAttachment.id.
fileNamestringOriginal or last-renamed file name.
isPHIbooleanServer-controlled.
isRestrictedbooleanServer-controlled.
caseIdstring (UUID)The case the attachment belongs to.
uploadedBy{ id, firstName, lastName } | nullIncludes the uploader's User row (staff or patient).
createdAtISO-8601 datetimeServer-generated on create.

Download result

Used by GET /me/files/:id/download.

FieldTypeNotes
downloadUrlstringShort-lived signed URL into the GCS bucket (V4 read action).
fileNamestringThe current file name to use as the download filename.
expiresInnumber900 (seconds). Matches the 15-minute signed-URL TTL.

Upload result

Used by POST /me/files/upload.

FieldTypeNotes
idstring (UUID)CaseAttachment.id (case upload) or UserDocument.id (general upload).
fileNamestringThe name you supplied.
isPHIbooleanAlways false at creation.
isRestrictedbooleanAlways false at creation.
caseIdstring (UUID) | nullEchoed from the request, or null for general uploads.
createdAtISO-8601 datetimeServer-generated.
caution

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/pdf
  • application/msword
  • text/csv
  • text/plain
  • image/jpeg
  • image/png
  • image/svg+xml
  • image/tiff
  • image/webp

Server-Side Behaviors and Defaults

  • Soft-delete only. DELETE flips isDeleted = true and writes a CaseActivity row of type DELETE_ATTACHMENT. The bucket object is retained.
  • Activity log. The patient's userId is recorded as actorId on the activity row.
  • isPHI / isRestricted are server-controlled. Patient uploads always start with isPHI = false and isRestricted = false. The patient portal does not expose a way to set or change these.
  • Sort order on list. createdAt descending (newest first).
  • Soft-deleted rows are excluded from every read endpoint.
  • List page size. GET /me/files returns up to 100 files per request (default 50). Pass ?limit=N to control the page size. No cursor is returned; use caseId or type to narrow results beyond that cap.
  • Signed URLs. Two flavours: download URLs (/download endpoint) have a 15-minute TTL and Content-Disposition: attachment; view URLs (viewUrl on list/metadata) have a 1-hour TTL and Content-Disposition: inline so 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: null on orphaned records. If the bucket object is missing for a DB row (e.g. a partial upload that was never cleaned up), viewUrl (and viewUrlExpiresIn) is null for 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 of null.
  • 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_ERROR and 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).

EndpointDefault limit
GET /me/files30 req / min
GET /me/files/:id/metadata100 req / min
GET /me/files/:id/download100 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 organizationId claim, 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 the Expires query 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-token before the 15-minute expiry.
  • Listing strategy. Call GET /me/files once 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=phi or ?type=general to 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. Omitting caseId creates a UserDocument, which is visible in the admin panel under Documents → User Documents but outside the scope of the /me/files list 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 404 surprises.
  • 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 /download when you want to trigger a Save As dialog; re-fetch on demand rather than caching that URL.
  • Treat 404 as "no permission, may or may not exist". Do not display id-specific debug text.