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.

ParentRead perm (GET)Write perm (POST / DELETE)Routes
Payment voucher payment.view payment.create GET/POST /api/v1/payment-vouchers/{paymentVoucher}/attachments
GET /api/v1/payment-vouchers/{paymentVoucher}/attachments/{attachment}/download
DELETE /api/v1/payment-vouchers/{paymentVoucher}/attachments/{attachment}
Package package.view package.track GET/POST /api/v1/packages/{package}/attachments
GET /api/v1/packages/{package}/attachments/{attachment}/download
DELETE /api/v1/packages/{package}/attachments/{attachment}
Return shipment return.view return.manage GET/POST /api/v1/return-shipments/{returnShipment}/attachments
GET /api/v1/return-shipments/{returnShipment}/attachments/{attachment}/download
DELETE /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:

FieldRequiredRules
fileYesMax 10 MB (10240 KB). Allowed mimes: jpg, jpeg, png, webp, heic, pdf. Any other type, or a file over the limit, returns 422.
categoryNoOne 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

ValueTypical use
cheque_imagePhoto of a cheque collected against a payment voucher.
deposit_slipBank deposit slip evidencing a payment.
delivery_proofSigned or photographed proof of delivery for a package.
podProof-of-delivery document (carrier POD).
damage_photoPhoto of damaged goods on a return shipment.
otherAnything 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 returned

The 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.

Private storage — no public URLs, ever

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

StatusCause
201Attachment uploaded successfully.
204Attachment deleted successfully.
404Parent record doesn't exist, or {attachment} doesn't belong to the named parent.
403Actor lacks the read (GET) or write (POST/DELETE) permission for that parent.
422Missing 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.