Attachments Built
A polymorphic file/photo attachment API for the Flutter Sales-Executive (SE) field app: upload a cheque image against a payment voucher, a delivery-proof/POD photo against a package, or a damage photo against a return shipment. Files are stored privately and are only ever reachable through an authenticated, permission-gated download route. This page is for developers integrating against /api/v1, not for console users.
Who this is for
An SE captures evidence in the field — a photo of a cheque handed over, a signed proof-of-delivery, a photo of damaged goods on a return. Each of these documents attaches to an existing record the app already knows about (a payment voucher, a package, or a return shipment). The API is deliberately the same shape on all three parents: list, upload, download, delete.
The three parents
Every parent exposes the same four routes, nested under the parent's own resource. All routes sit inside the standard auth:sanctum + password.changed route group — a first-login token restricted to the password.change ability cannot reach any of them.
| Parent | Read perm (GET) | Write perm (POST / DELETE) | Routes |
|---|---|---|---|
| Payment voucher | payment.view | payment.create |
GET/POST /api/v1/payment-vouchers/{paymentVoucher}/attachmentsGET /api/v1/payment-vouchers/{paymentVoucher}/attachments/{attachment}/downloadDELETE /api/v1/payment-vouchers/{paymentVoucher}/attachments/{attachment}
|
| Package | package.view | package.track |
GET/POST /api/v1/packages/{package}/attachmentsGET /api/v1/packages/{package}/attachments/{attachment}/downloadDELETE /api/v1/packages/{package}/attachments/{attachment}
|
| Return shipment | return.view | return.manage |
GET/POST /api/v1/return-shipments/{returnShipment}/attachmentsGET /api/v1/return-shipments/{returnShipment}/attachments/{attachment}/downloadDELETE /api/v1/return-shipments/{returnShipment}/attachments/{attachment}
|
GET (list, download) is gated by the parent's existing read permission; POST (upload) and DELETE are gated by the parent's existing write permission — parity with however that parent is already gated elsewhere in the API. There is no separate "attachment" permission to seed or assign.
Uploading — multipart POST
Upload is a standard multipart/form-data request:
| Field | Required | Rules |
|---|---|---|
file | Yes | Max 10 MB (10240 KB). Allowed mimes: jpg, jpeg, png, webp, heic, pdf. Any other type, or a file over the limit, returns 422. |
category | No | One of the enum values below. Omitted → defaults to other. An unrecognised value returns 422. |
POST /api/v1/payment-vouchers/501/attachments
Content-Type: multipart/form-data
file: <binary>
category: cheque_image
A successful upload returns 201 with the created attachment (see response shape below).
Category enum
| Value | Typical use |
|---|---|
cheque_image | Photo of a cheque collected against a payment voucher. |
deposit_slip | Bank deposit slip evidencing a payment. |
delivery_proof | Signed or photographed proof of delivery for a package. |
pod | Proof-of-delivery document (carrier POD). |
damage_photo | Photo of damaged goods on a return shipment. |
other | Anything not covered above — also the default when category is omitted. |
Response shape — AttachmentResource
List (GET .../attachments) and upload (POST .../attachments) both return attachments in this shape:
{
"id": 42,
"category": "cheque_image",
"original_name": "cheque-front.jpg",
"mime_type": "image/jpeg",
"file_size": 184320,
"uploaded_by": 7,
"created_at": "2026-07-17T10:15:00Z",
"download_url": "/api/v1/payment-vouchers/501/attachments/42/download"
}
file_path is never returnedThe stored path on the private disk is an internal implementation detail — it never appears in any API response. To retrieve the file, follow download_url, which is the only supported way a client ever fetches the bytes.
Downloading
Follow the download_url from the resource (or construct it: GET /api/v1/{parent}/{parentId}/attachments/{attachment}/download). It streams the original file with its original filename and mime type. The route requires the parent's read permission — the same payment.view / package.view / return.view gates as listing.
Files are stored on the app's private local disk, never on a publicly-readable disk or path. There is no signed URL and no public link in v1 — every download goes through the authenticated, permission-gated route above. This matters most for cheque images, which are sensitive financial documents.
Deleting
DELETE /api/v1/{parent}/{parentId}/attachments/{attachment} removes both the database row and the underlying file, and requires the parent's write permission (payment.create / package.track / return.manage). A successful delete returns 204.
Scope binding — an attachment only exists under its own parent
The {attachment} segment is scope-bound to the named parent in the URL. Downloading or deleting an attachment ID that belongs to a different parent (e.g. an attachment created on payment voucher 501, addressed via voucher 502) returns 404 — there is no flat /attachments/{id} route and no way to enumerate or reach another parent's attachments by guessing IDs.
Error summary
| Status | Cause |
|---|---|
201 | Attachment uploaded successfully. |
204 | Attachment deleted successfully. |
404 | Parent record doesn't exist, or {attachment} doesn't belong to the named parent. |
403 | Actor lacks the read (GET) or write (POST/DELETE) permission for that parent. |
422 | Missing file, file over 10 MB, disallowed mime type, or invalid category. |
Related pages
See Payment Vouchers, Packages, and the despatch pages for the console-side workflows these attachments support, and Offline Sync for the other Flutter-app integration surface.