Most Acumatica developers meet file attachments through the UI first: a paperclip icon, a drag-and-drop zone, a files tab on a screen. What's underneath is a well-defined API surface (PXNoteAttribute's file-related helpers, the UploadFileMaintenance graph, and the PX.SM.FileInfo object) that you'll eventually need directly, usually the first time a client asks for "attach a copy of the signed PO automatically when this document is approved." Here's what I've learned wiring that up from code rather than clicking it through the UI.
Attachments live in a shared file table, linked by NoteID
A DAC that supports attachments carries a NoteID field (a Guid?), declared through PXNoteAttribute on the primary key or a dedicated field. That GUID is the join key into UploadFile and NoteDoc - the actual file bytes live in a shared, DAC-agnostic table, not a column on your entity. This is why attachments survive customizations that add or remove fields on the parent DAC entirely unaffected: the relationship is indirect, by GUID, not a foreign key baked into your table's shape.
public class SOOrder : IBqlTable
{
[PXNote(DescriptionField = typeof(SOOrder.orderNbr))]
public virtual Guid? NoteID { get; set; }
public abstract class noteID : PX.Data.BQL.BqlGuid.Field<noteID> { }
}
If your custom DAC has no NoteID, it cannot have attachments or notes at all, full stop - this is a common surprise for developers extending a bare custom table that was never designed with the notes/files infrastructure in mind. Adding attachment support after the fact means adding this field via a DAC extension and running through the schema sync, not writing custom storage logic.
Attaching a file programmatically
The pattern I use most often: an event handler or action that needs to attach a generated or fetched file to the current row's NoteID. PXNoteAttribute.SetFileNotes is not what you want here - you want UploadFileMaintenance, instantiated as a PXGraph and used to actually persist the bytes and create the association:
protected virtual void AttachGeneratedPdf(SOOrder order, byte[] pdfBytes, string fileName)
{
if (order?.NoteID == null) return;
var uploadGraph = PXGraph.CreateInstance<UploadFileMaintenance>();
var fileInfo = new PX.SM.FileInfo(fileName, null, pdfBytes);
uploadGraph.SaveFile(fileInfo);
PXNoteAttribute.SetFileNotes(
Base.Caches[typeof(SOOrder)],
order,
fileInfo.UID.Value);
}
The order matters: save the file first through UploadFileMaintenance to get a real UID, then link it via PXNoteAttribute.SetFileNotes against the owning row's cache. Reversing that order, or calling SetFileNotes with a UID that never actually persisted, leaves you with an attachment row that shows up in the grid but 404s when a user tries to open it - a bug I've debugged on a client instance where a developer had written to the wrong graph's cache.
If the attach happens in a RowPersisted handler after the parent save already committed, and the file save fails for any reason (disk full on a self-hosted instance, a transient SQL timeout), you end up with a saved document and no attachment, silently. On anything client-facing - an approval flow that's supposed to carry a signed document - I attach inside RowPersisting, before the row's own commit, so a failure there rolls back the whole save rather than leaving an inconsistent, half-linked state.
Reading attachments back out
Fetching files attached to a row goes through the same shared tables, queried by NoteID rather than assuming a fixed relationship to your DAC:
var files = PXSelectJoin<UploadFile,
InnerJoin<NoteDoc, On<NoteDoc.fileID, Equal<UploadFile.fileID>>>,
Where<NoteDoc.noteID, Equal<Required<NoteDoc.noteID>>>>
.Select(Base, order.NoteID);
foreach (PXResult<UploadFile, NoteDoc> result in files)
{
UploadFile file = result;
// file.Name, file.FileID - pass FileID to UploadFileMaintenance.GetFile to read bytes
}
Getting the actual bytes back out is a second call, against UploadFileMaintenance.GetFile(fileID) - the join above only gets you metadata (name, size, the FileID key), which is usually all a list screen needs; don't pull every file's bytes into memory just to list attachments in a grid.
Detaching is not the same as deleting, and that's mostly a feature
Removing the NoteDoc link (detaching) does not delete the underlying UploadFile row if other NoteDoc rows still reference the same FileID - file de-duplication is real in Acumatica's storage model, and a file attached to two different documents shares one physical blob. This is good for storage efficiency and bad for a naive "delete this attachment and free the space" assumption; don't write cleanup code that deletes UploadFile rows directly based on one document's detach action without checking for other references, or you'll corrupt an unrelated document's attachment.
Wrapping up
Attachments hang off a DAC through NoteID into a shared UploadFile/NoteDoc pair of tables, not a column on your entity, which is exactly why they survive schema changes untouched. Save the file through UploadFileMaintenance before linking it, link inside the same transaction as the parent save when the attachment matters for correctness, and remember that detaching isn't deleting when files are shared across documents. Code-side attachment handling is a small API surface once you've seen it wired up once; the trap is always ordering and transaction boundaries, not the API itself.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.