DocumentController::store(), validated by StoreDocumentRequest.Document row with its status and expiry, retention of older documents of the same type, and an e-mail to the user's distributor. The same action (App\Actions\Documents\StoreUserDocument) serves both.App\Http\Controllers\Api\V1\Diver\DocumentController::store()api.v1.diver.documents.storeExamples produced by the feature tests, not captured live: the local environment writes to real S3 and SMTP. The bodies below are the ones tests/Api/Diver/DocumentUploadTest.phpasserts. The only value that differs in production isurl, which on S3 is an absolute URL like the ones inGET_list.md.
Authorization: Bearer {token}user_document-upload permission, which every diver and professional role holds. Without it the answer is 403.POST/api/v1/diver/documentsmultipart/form-data| Field | Type | Required | Validation | Description |
|---|---|---|---|---|
type | string | Yes | in: see below | diver_medical_certificate for every user. pro_medical_certificate and pro_insurance only for a user with the professional role. Any other value, including a professional type sent by a user without the role, is a 422 on type |
file | file | Yes | file|mimetypes:application/pdf,image/jpeg,image/png|mimes:pdf,jpeg,jpg,png|max:10240 | PDF, JPEG or PNG, at most 10 MB. The type is checked on the content, not only on the extension: a text file renamed .pdf is a 422 |
issuer | string | Yes | required|string|min:2|max:100 | Doctor or insurance company |
number | string | Insurance: Yes. Medical: No | required|... for pro_insurance; sometimes|nullable|string|min:2|max:100 for the medical types | Certificate or policy number |
issue_date | string | Yes | required|date|date_format:Y-m-d and a window, see below | Y-m-d |
expire_date | string | No | sometimes|nullable|date_format:Y-m-d|after:issue_date | Y-m-d. When omitted it is computed, see below |
is_professional | boolean | No | sometimes|nullable|boolean | Defaults to false. In a multipart body send 1 or 0: the string true is not accepted by the boolean rule |
issue_date window, the same as the website: for the two medical types, no more than 65 months in the past and not after one month from today; for pro_insurance, no more than 15 months in the past and not after one month from today.expire_date: issue_date plus a number of months taken from the user's distributor settings, minus one day. The months are certificate_expire_months (diver_medical_certificate), pro_certificate_expire_months (pro_medical_certificate) and pro_insurance_expire_months (pro_insurance), 12 when the distributor sets none.201 Created{
"success": true,
"status": "success",
"message": "Created",
"data": {
"id": 723,
"name": "my-certificate.pdf",
"file_name": "my-certificate.pdf",
"mime_type": "application/pdf",
"size": 26,
"url": "/storage/723/my-certificate.pdf",
"created_at": "2026-06-15 10:00:00",
"document": {
"id": 672,
"type": "diver_medical_certificate",
"status": "approved",
"is_professional": false,
"issuer": "Dr. Rossi",
"number": "N-1234",
"issue_date": "2026-06-01",
"expire_date": "2028-05-31"
}
}
}GET /api/v1/diver/documents (see GET_list.md). document is added.| Field | Type | Nullable | Notes |
|---|---|---|---|
id | integer | No | Media Library id of the stored file |
name, file_name | string | No | The uploaded name reduced to a slug. An image is re-encoded to JPEG, so scan-front.png becomes scan-front.jpg |
mime_type | string | No | application/pdf or image/jpeg (PNG and JPEG uploads are both stored as JPEG) |
size | integer | No | Bytes, after re-encoding |
url | string | No | |
created_at | string | No | Y-m-d H:i:s |
document.id | integer | No | Document row id |
document.type | string | No | The type sent |
document.status | string | No | approved for diver_medical_certificate, pending for pro_medical_certificate and pro_insurance until the distributor reviews them |
document.is_professional | boolean | No | |
document.issuer, document.number | string | No | number is "" when none was sent |
document.issue_date, document.expire_date | string | No | Y-m-d |
medical_certificate, medical_certificate_professional or insurance_certificate.401 Unauthorized{
"success": false,
"status": "error",
"message": "Unauthenticated"
}403 Forbiddenuser_document-upload permission.{
"success": false,
"status": "error",
"message": "Forbidden"
}422 Unprocessable Entity{
"success": false,
"status": "error",
"message": "Validation failed",
"errors": {
"type": ["The selected type is invalid."]
}
}fake.pdf) fails on file; the rule runs twice, so there are two messages:{
"success": false,
"status": "error",
"message": "Validation failed",
"errors": {
"file": [
"The file field must be a file of type: application/pdf, image/jpeg, image/png.",
"The file field must be a file of type: pdf, jpeg, jpg, png."
]
}
}422.GET /api/v1/diver/documents. That endpoint lists only the documents media collection; these uploads go to the collections named above, as the website's medical pages already do.docs/api/diver/documents/POST_upload.mdcurl --location 'https://test.diveraid.com/api/v1/diver/documents' \
--header 'Authorization: Bearer <token>' \
--form 'type=""' \
--form 'file=@""' \
--form 'issuer=""' \
--form 'number=""' \
--form 'issue_date=""' \
--form 'expire_date=""' \
--form 'is_professional=""'{
"success": true,
"status": "success",
"message": "string",
"data": {
"id": 0,
"name": "string",
"file_name": "string",
"mime_type": "string",
"size": 0,
"url": "string",
"created_at": "2019-08-24T14:15:22.123Z",
"document": {
"id": 0,
"type": "diver_medical_certificate",
"status": "approved",
"is_professional": true,
"issuer": "string",
"number": "string",
"issue_date": "2019-08-24",
"expire_date": "2019-08-24"
}
}
}