Attachments
Files attached to a record: the signed grant letter on an invoice, the W-4 on an employee, the photo on an asset. Reached through the parent (GET|POST /invoices/{id}/attachments) and then by their own id. Hand-built, and deliberately without a flat list: the same table holds printed cheques, portal printouts and company logos, and a generic read would publish them. Every operation takes the PARENT's scope - uploading to an invoice needs invoices:write, removing an employee file needs payroll:cancel - so there is no attachments scope to grant. A file whose parent your token cannot read is 404, never 403: the flat routes do not name the parent, and a 403 naming payroll:read would say what the file is.
The object#
| Field | Type | Description |
|---|---|---|
| idread-only | integer | NolaPro id of the file (fileitem.id). |
| resourceread-onlyrequest-only | string(40) | The parent resource this file hangs off (invoices, customers, ...), resolved from filedefinitionid. Not a column: it is here so a flat GET /attachments/{id} tells you where the file belongs. |
| tablerecordidread-only | integer | The parent record's id - the invoice, customer or employee the file is attached to. Set by the nested POST, never from the body. |
| filedefinitionid | integer | Which of the parent's file lists this belongs to. Most parents have one; a customer has four (general files, B2B-visible files, a before picture, an after picture). Defaults to the parent's main list on create; the parent's reference page lists the accepted ids. Cannot be changed after upload. Id only - definitions are static configuration with no endpoint of their own. |
| filename | string(255) | The file name as it shows on the screen. Taken from the multipart part unless a filename field overrides it; trimmed, reduced to its base name (a path separator is refused), never empty. Cannot be changed after upload - the screens have no rename either. |
| datatyperead-only | string(100) | Media type as the uploader declared it (the part's Content-Type, sniffed when absent). Served back as Content-Type on /content. Not validated against the bytes, exactly as the screen stores it. |
| sizeread-only | integer | Bytes, measured from what was stored. |
| filedateread-only | string | When it was uploaded. There is no last-changed date on a file; the content never changes. |
| notes | text | Free text shown beside the file. Stored as text, no practical length limit. |
| filecategoryid | integer | Optional category the file is filed under. The target of filecategories; a category from another company is 422 unknown_value. |
| filecategory | string(255) | The category by name instead of id (filecategory.name, case-insensitive). Read back beside filecategoryid. |
Endpoints#
Retrieve an attachment's metadata.
Requires scope parent:read.
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | integer | The attachment id. |
| Status | Code | Meaning |
|---|---|---|
| 404 | not_found | No attachment with that id that your token can see. Also the answer for a file whose parent is in another company, whose parent your token lacks the scope to read, or whose file list is not one the API exposes (a printed cheque, a logo) - the three are deliberately indistinguishable. |
curl \ 'https://acme.nolapro.com/!/api/v2/attachments/104' \ -H 'Authorization: Bearer $NP_TOKEN'
$ch = curl_init('https://acme.nolapro.com/!/api/v2/attachments/104'); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NP_TOKEN')], CURLOPT_RETURNTRANSFER => true, ]); $res = json_decode(curl_exec($ch), true);
import os, requests r = requests.get( "https://acme.nolapro.com/!/api/v2/attachments/104", headers={"Authorization": f"Bearer {os.environ['NP_TOKEN']}"}, ) r.raise_for_status()
const res = await fetch( 'https://acme.nolapro.com/!/api/v2/attachments/104', { method: 'GET', headers: { Authorization: `Bearer ${process.env.NP_TOKEN}` }, } ); const data = await res.json();
using System.Net.Http.Json; var token = Environment.GetEnvironmentVariable("NP_TOKEN"); using var http = new HttpClient(); http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); var res = await http.GetAsync( "https://acme.nolapro.com/!/api/v2/attachments/104"); res.EnsureSuccessStatusCode();
{
"id": 4471,
"resource": "invoices",
"tablerecordid": 1043,
"filedefinitionid": 8,
"filename": "grant-letter-signed.pdf",
"datatype": "application/pdf",
"size": 184322,
"filedate": "2026-09-21T15:04:11Z",
"notes": "Signed copy received 21 Sep",
"filecategoryid": 3,
"filecategory": "Receipts"
}
Update the note or category.
Requires scope parent:write.
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | integer | The attachment id. |
| Idempotency-Key | header | string | Makes the write safely retryable. Any unique string per logical operation; a UUID is fine.
The failure this prevents is specific: the request succeeds, the response is lost to a timeout, you retry, and the customer is billed twice. With a key, the retry returns the original response - same status, same body, same id - and creates nothing.
The body is part of the key's identity. Reusing a key with a different body is a bug on your side, not a retry, and answers 409 idempotency_replay rather than silently handing back the old response. Keys are remembered for 24 hours. |
| Field | Type | Description |
|---|---|---|
| notes | text | Free text shown beside the file. Stored as text, no practical length limit. |
| filecategoryid | integer | Optional category the file is filed under. The target of filecategories; a category from another company is 422 unknown_value. |
| filecategory | string(255) | The category by name instead of id (filecategory.name, case-insensitive). Read back beside filecategoryid. |
| Status | Code | Meaning |
|---|---|---|
| 400 | unknown_parameter | A field that is not on the schema. |
| 404 | not_found | No attachment with that id that your token can see. Also the answer for a file whose parent is in another company, whose parent your token lacks the scope to read, or whose file list is not one the API exposes (a printed cheque, a logo) - the three are deliberately indistinguishable. |
| 409 | stale_record | If-Match did not match the current ETag. |
| 422 | read_only_field | filename, datatype, size or filedate in the body. None of them can change after upload. |
curl -X PATCH \ 'https://acme.nolapro.com/!/api/v2/attachments/104' \ -H 'Authorization: Bearer $NP_TOKEN' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: your-unique-key' \ -d '{ "notes": "Signed copy received 21 Sep", "filecategoryid": 3, "filecategory": "Receipts" }'
$ch = curl_init('https://acme.nolapro.com/!/api/v2/attachments/104'); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NP_TOKEN')], CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_POSTFIELDS => json_encode([ 'notes' => 'Signed copy received 21 Sep', 'filecategoryid' => 3, 'filecategory' => 'Receipts', ]), ]); $res = json_decode(curl_exec($ch), true);
import os, requests r = requests.patch( "https://acme.nolapro.com/!/api/v2/attachments/104", headers={"Authorization": f"Bearer {os.environ['NP_TOKEN']}"}, json={ "notes": "Signed copy received 21 Sep", "filecategoryid": 3, "filecategory": "Receipts", }, ) r.raise_for_status()
const res = await fetch( 'https://acme.nolapro.com/!/api/v2/attachments/104', { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.NP_TOKEN}` }, body: JSON.stringify({ notes: "Signed copy received 21 Sep", filecategoryid: 3, filecategory: "Receipts", }), } ); const data = await res.json();
using System.Net.Http.Json; var token = Environment.GetEnvironmentVariable("NP_TOKEN"); using var http = new HttpClient(); http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); var body = new { notes = "Signed copy received 21 Sep", filecategoryid = 3, filecategory = "Receipts", }; var req = new HttpRequestMessage(HttpMethod.Patch, "https://acme.nolapro.com/!/api/v2/attachments/104") { Content = JsonContent.Create(body), }; req.Headers.Add("Idempotency-Key", "your-unique-key"); var res = await http.SendAsync(req); res.EnsureSuccessStatusCode();
{
"id": 4471,
"resource": "invoices",
"tablerecordid": 1043,
"filedefinitionid": 8,
"filename": "grant-letter-signed.pdf",
"datatype": "application/pdf",
"size": 184322,
"filedate": "2026-09-21T15:04:11Z",
"notes": "Signed copy received 21 Sep",
"filecategoryid": 3,
"filecategory": "Receipts"
}
Remove the file.
Removes the file from storage. This cannot be undone. fileitem has no cancel flag, so unlike every document DELETE this one is not a cancel: the row, the stored bytes and any cloud object are gone. A second DELETE answers 404, which a client retrying after a lost response should read as done. Send If-Match - it is the only protection there is. If what you want is an audit trail, cancel the parent document instead. Needs the parent's :cancel scope, which its :write does not imply.
Requires scope parent:cancel.
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | integer | The attachment id. |
| Idempotency-Key | header | string | Makes the write safely retryable. Any unique string per logical operation; a UUID is fine.
The failure this prevents is specific: the request succeeds, the response is lost to a timeout, you retry, and the customer is billed twice. With a key, the retry returns the original response - same status, same body, same id - and creates nothing.
The body is part of the key's identity. Reusing a key with a different body is a bug on your side, not a retry, and answers 409 idempotency_replay rather than silently handing back the old response. Keys are remembered for 24 hours. |
| Status | Code | Meaning |
|---|---|---|
| 403 | insufficient_scope | Token has the parent's :write but not its :cancel. |
| 404 | not_found | No attachment with that id that your token can see. Also the answer for a file whose parent is in another company, whose parent your token lacks the scope to read, or whose file list is not one the API exposes (a printed cheque, a logo) - the three are deliberately indistinguishable. |
| 409 | stale_record | If-Match did not match the current ETag. |
curl -X DELETE \ 'https://acme.nolapro.com/!/api/v2/attachments/104' \ -H 'Authorization: Bearer $NP_TOKEN'
$ch = curl_init('https://acme.nolapro.com/!/api/v2/attachments/104'); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NP_TOKEN')], CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', ]); $res = json_decode(curl_exec($ch), true);
import os, requests r = requests.delete( "https://acme.nolapro.com/!/api/v2/attachments/104", headers={"Authorization": f"Bearer {os.environ['NP_TOKEN']}"}, ) r.raise_for_status()
const res = await fetch( 'https://acme.nolapro.com/!/api/v2/attachments/104', { method: 'DELETE', headers: { Authorization: `Bearer ${process.env.NP_TOKEN}` }, } ); const data = await res.json();
using System.Net.Http.Json; var token = Environment.GetEnvironmentVariable("NP_TOKEN"); using var http = new HttpClient(); http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); var req = new HttpRequestMessage(HttpMethod.Delete, "https://acme.nolapro.com/!/api/v2/attachments/104"); var res = await http.SendAsync(req); res.EnsureSuccessStatusCode();
{
"id": 4471,
"resource": "invoices",
"tablerecordid": 1043,
"filedefinitionid": 8,
"filename": "grant-letter-signed.pdf",
"datatype": "application/pdf",
"size": 184322,
"filedate": "2026-09-21T15:04:11Z",
"notes": "Signed copy received 21 Sep",
"filecategoryid": 3,
"filecategory": "Receipts"
}
Download the file.
The bytes, with Content-Type from datatype, Content-Length, a Content-Disposition carrying the file name, and an ETag. The content never changes, so If-None-Match with the ETag you were given answers 304 with no body. Binary, so it cannot be carried inside a /batch results array: asking for it there is 415 not_batchable.
Requires scope parent:read.
| Name | In | Type | Notes |
|---|---|---|---|
| idrequired | path | integer | The attachment id. |
| Status | Code | Meaning |
|---|---|---|
| 304 | Not modified - If-None-Match matched. | |
| 404 | not_found | No attachment with that id that your token can see. Also the answer for a file whose parent is in another company, whose parent your token lacks the scope to read, or whose file list is not one the API exposes (a printed cheque, a logo) - the three are deliberately indistinguishable. |
curl \ 'https://acme.nolapro.com/!/api/v2/attachments/104/content' \ -H 'Authorization: Bearer $NP_TOKEN'
$ch = curl_init('https://acme.nolapro.com/!/api/v2/attachments/104/content'); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NP_TOKEN')], CURLOPT_RETURNTRANSFER => true, ]); $res = json_decode(curl_exec($ch), true);
import os, requests r = requests.get( "https://acme.nolapro.com/!/api/v2/attachments/104/content", headers={"Authorization": f"Bearer {os.environ['NP_TOKEN']}"}, ) r.raise_for_status()
const res = await fetch( 'https://acme.nolapro.com/!/api/v2/attachments/104/content', { method: 'GET', headers: { Authorization: `Bearer ${process.env.NP_TOKEN}` }, } ); const data = await res.json();
using System.Net.Http.Json; var token = Environment.GetEnvironmentVariable("NP_TOKEN"); using var http = new HttpClient(); http.DefaultRequestHeaders.Add("Authorization", "Bearer " + token); var res = await http.GetAsync( "https://acme.nolapro.com/!/api/v2/attachments/104/content"); res.EnsureSuccessStatusCode();