Skip to content

Attachments

Attach supporting files to an invoice or an estimate: a timesheet, a purchase order, a signed scope of work, a receipt. Each file is either shared, which puts it on the invoice page your customer opens, or internal, which only you and your team can see.

Attachments are switched on by Plirin for your account. If you do not see a Files section on an invoice or estimate, the feature is not available to you yet.

  • Types: PDF, PNG, JPEG, WebP, HEIC, CSV, plain text (TXT), Word (.docx) and Excel (.xlsx).
  • Size: up to 20 MB per file.
  • Count: up to 10 files per invoice or estimate.

Open an invoice or estimate and find the Files section. Drag files onto it, or choose Add files. You can add several at once, and each one shows its own progress. Anyone who can manage invoices (the invoice.manage permission) can add, share and remove files. Team members without it can see the list and download files, and each file shows Shared or Internal.

You can attach files to an invoice at any status, including after it has been sent or paid.

Every plan includes file storage, counted across all your invoices and estimates.

PlanStorage
Starter250 MB
Growth5 GB
Scale25 GB

The Files section shows how much you have used, for example “Using 12 MB of 250 MB”.

When storage is full, new uploads are refused with “Your plan’s file storage is full. Remove files or upgrade your plan.” Files you already have stay where they are, and your customers can still open the ones you shared. Removing a file gives its space back straight away.

Storage is shared between test mode and live mode. A file you attach while testing uses real storage, so it counts toward the same total as your live files. Remove test files you no longer need.

A file you add to an invoice is shared by default, and a file you add to an estimate starts internal (see Estimates). Each file has its own switch labelled Visible to customer. Turn it off and the file becomes internal: it stays on the invoice for your team but is not on the customer’s page. You can flip the switch at any time, and it takes effect immediately.

Shared files are listed on the hosted invoice page with their name and size. Selecting one downloads it. A file is never shown for a draft, because a draft has no customer link yet. Files you switch to internal are never listed.

Downloads use a short-lived link, so a copy of the link stops working after about a minute. The customer opens the file again from the page whenever they need it.

The invoice email, and every reminder and overdue email after it, list the names of shared files and link to the invoice page. The list is read when each email is sent, so a file you removed or switched to internal since the last email is not listed. No email ever attaches them. A file in a customer’s inbox cannot be taken back, while a file on the invoice page can be: if you remove a file or switch it to internal, it is gone for the customer too.

The invoice PDF is still attached to the email as before. Your own attachments are separate from it.

You can attach files to an estimate while you prepare it. Estimate files never appear on your customer’s estimate page.

When an estimate is accepted or you use Convert now, its files are copied onto the new draft invoice, with the same shared or internal setting. The copies count toward your storage, and they are independent: removing a file from the invoice does not remove it from the estimate, or the reverse.

Conversion never fails because of files. If your storage cannot hold every file, the conversion goes ahead and the files that did not fit are left on the estimate. The estimate’s audit entry records how many files were copied and how many were not, so you can attach the missing ones to the invoice by hand after freeing some space.

Choose the bin icon next to a file and confirm. The file is no longer visible to you or your customer, and its storage is released at once. The stored copy is deleted shortly afterwards. Removing a file cannot be undone: attach it again if you need it back.

Adding, sharing changes and removals are recorded in the audit trail as invoice.attachment_added, invoice.attachment_sharing_changed and invoice.attachment_deleted.

Files follow the document they are attached to. A file on a test invoice or estimate is a test file: it appears only while you are in test mode, and is never shown in live mode, whatever you share. A live file is invisible in test mode in the same way. A test API key sees only test files, and a live key only live ones. See Test and Live Mode.

Test files use the same storage allowance as live ones, as noted above.

Plirin checks what a file actually is, not only what its name or the browser says. A refusal reads “That file doesn’t look like the type it claims to be, so it wasn’t added.” Common reasons:

  • The contents do not match the type. A program renamed to invoice.pdf is not a PDF, so it is refused. The same applies to a Word or Excel file that is not really one.
  • Macros are not allowed. Word and Excel files that contain macros or other embedded code are refused, and so are the macro-enabled formats (.docm, .xlsm). Save the document as a plain .docx or .xlsx and upload that.
  • The type is not supported. Anything outside the list above, such as ZIP, SVG, HTML or video, is refused with “That file type isn’t supported. Use PDF, an image, CSV, TXT, Word or Excel.”
  • It is too large or the document is full. “Files can be up to 20 MB.” and “This document already has 10 files. Remove one to add another.”
  • The upload did not finish. A dropped connection shows “The upload didn’t finish. Please try again.” Nothing is kept, so uploading again is safe.

Files are renamed to match what they are. The name you see always ends in an extension that fits the verified type. A file called run.bat that is uploaded as plain text is stored as run.bat.txt, and a PDF called statement becomes statement.pdf.

Plirin does not scan files for viruses, and it never opens or runs them: files are only ever offered as downloads. Treat attachments you receive from others with the care you would give any download.

The REST API manages attachments with the same rules as the dashboard: the same types, sizes, counts, storage limit and checks. Requests need an API key (Authorization: Bearer sk_...). A test key only sees test documents and test files, and a live key only live ones. A document or file from the other environment, or from another merchant, answers 404.

Uploading is three steps: ask for an upload, send the bytes to storage, then confirm. The SDKs do all three in one upload call.

Method and pathWhat it does
POST /api/v1/invoices/{invoice_id}/attachmentsStart an upload on an invoice
POST /api/v1/estimates/{estimate_id}/attachmentsStart an upload on an estimate
POST /api/v1/attachments/{attachment_id}/confirmFinish an upload. The file is checked and becomes visible
GET /api/v1/invoices/{invoice_id}/attachmentsList an invoice’s files
GET /api/v1/estimates/{estimate_id}/attachmentsList an estimate’s files
PATCH /api/v1/attachments/{attachment_id}Share a file with the customer or make it internal
DELETE /api/v1/attachments/{attachment_id}Remove a file
GET /api/v1/attachments/{attachment_id}/downloadRedirect (302) to a short-lived download link
POST /api/v1/invoices/{invoice_id}/attachments
{ "file_name": "scope-of-work.pdf", "content_type": "application/pdf", "size_bytes": 482113 }

content_type must be one of the supported types, and size_bytes the file’s size in bytes (the upload is refused if it turns out larger). The response is 201:

{
"attachment": {
"id": "6f0c7f0e-9d2b-4b0e-8a47-3b3c1f6f3a10",
"file_name": "scope-of-work.pdf",
"content_type": "application/pdf",
"size_bytes": 482113,
"shared_with_customer": true,
"created_at": "2026-10-03T09:30:00+00:00",
"livemode": true
},
"upload": {
"url": "https://...",
"fields": { "key": "...", "Content-Type": "application/pdf" }
},
"expires_at": "2026-10-03T09:35:00+00:00"
}

Send the file with a multipart/form-data POST to upload.url: every entry of upload.fields first, in order, then the file as the last part named file. Do not send your API key to this address. The form is valid for 5 minutes (expires_at), and its Content-Type and size limit must match what you declared.

A file that is never confirmed is never listed and does not use storage. An abandoned upload is removed after about an hour (up to two), and until then it holds one of the document’s 10 slots.

POST /api/v1/attachments/{attachment_id}/confirm

Plirin reads the stored file, checks its contents and size, and charges your storage. The response is the attachment object above. Confirming a file that is already confirmed returns it unchanged. If the check fails the file is discarded and nothing is charged.

GET .../attachments returns { "data": [ ...attachment objects... ] }, oldest first, confirmed files only.

PATCH /api/v1/attachments/{attachment_id} takes { "shared": true } or { "shared": false } and returns the updated attachment.

DELETE /api/v1/attachments/{attachment_id} returns { "deleted": true }.

GET /api/v1/attachments/{attachment_id}/download answers 302 with the link in Location. The link works for about a minute and forces a download.

Errors use the standard envelope, { "error": { "type", "code", "message", "param" } }. For a 400, param carries the specific attachment code.

StatuscodeparamWhen
400invalid_requestATTACHMENT_TYPE_NOT_ALLOWEDThe content type is not supported
400invalid_requestATTACHMENT_TOO_LARGEsize_bytes is zero or over 20 MB
400invalid_requestATTACHMENT_LIMIT_REACHEDThe document already has 10 files
400invalid_requestATTACHMENT_UPLOAD_MISSINGConfirm was called before the bytes reached storage. Upload again and retry
400invalid_requestATTACHMENT_SIZE_MISMATCH, ATTACHMENT_CONTENT_MISMATCH, ATTACHMENT_CHANGEDThe stored file is larger than declared, is not what its type claims, or changed during the check
402plan_limit_exceedednoneYour plan’s storage is full
403permission_deniednoneAttachments are not switched on for the platform, or file storage is not configured
404resource_not_foundnoneThe document or file does not exist, belongs to another merchant, or is in the other environment
503internal_errorSTORAGE_UNAVAILABLEFile storage is temporarily unavailable, or its credentials are not configured. Retry shortly

The same codes appear in GraphQL Mutations for the dashboard API.