Acumatica stores every attachment — the signed delivery notes, the scanned supplier invoices, the fifteen-megapixel photos your field team insists on taking — in the UploadFile tables inside the application database. On a young instance nobody notices. Three years in, the database is 200 GB, 170 of that is files, backups take hours, and your SaaS tier or your DBA is complaining. That's the moment someone asks about S3. There are two genuinely different integrations hiding under "Acumatica S3 integration", and it's worth being clear which one you're building.
Two different problems
- Getting files out of the database — archiving or offloading attachments to object storage to keep the DB lean.
- Getting external files into Acumatica — documents that land in S3 from other systems (EDI, scanners, a mobile app) and need to appear attached to the right ERP record.
Both lean on the same two Acumatica capabilities: the contract-based REST API's file endpoints, and business events as change triggers. Let's take them in turn.
The REST file API in ninety seconds
Attachments hang off records via NoteID. Through the contract-based API, a GET on an entity returns a files array (when you ask for it) with metadata and an href per file; you download by following that href, and you attach by PUTting bytes to the record's files URL:
PUT /entity/Default/24.200.001/SalesOrder/SO/SO006721/files/pod-SO006721.pdf HTTP/1.1
Host: erp.example.com
Authorization: Bearer eyJ...
Content-Type: application/pdf
%PDF-1.7 ... (raw bytes)
Downloads are the mirror image: GET .../SalesOrder/SO/SO006721?$expand=files to list, then GET each file href. That's the whole vocabulary the sync service needs.
Problem 1: archiving attachments out to S3
My standard build is a small worker service (usually .NET, sometimes Laravel where the client's stack is PHP) that runs on a schedule and does this loop:
- Query a Generic Inquiry exposed via OData — the GI joins
UploadFile/UploadFileRevisionmetadata with the parent record type so the worker can build a meaningful S3 key. Something likecustomer-docs/AR/INV-004521/pod.pdfbeats a flat bucket of GUIDs forever. - Download each file over the REST API, stream it to
PutObjectwith SSE enabled, and verify the ETag/checksum. - Record the mapping — NoteID, file revision ID, S3 key — in a small state table owned by the worker. This ledger is the actual asset; treat it as production data.
- Only after a verified upload, delete or truncate the source file in Acumatica (there are maintenance screens and APIs for file cleanup — and take a DB backup before your first bulk run, obviously).
The deletion step is where you decide policy, not code: most of my clients keep the last 12 months of files hot in Acumatica and archive older revisions. Users overwhelmingly open recent attachments; the 2019 delivery notes can live in S3 Glacier Instant Retrieval at a fraction of the cost.
Once you delete a file from Acumatica, it's gone from the record's attachment list — users won't magically see S3. Plan the retrieval story before you archive: mine is a custom "Archived Documents" tab (a grid over the mapping table) with a button that generates a presigned S3 GET URL, valid for 15 minutes, and opens it. Presigned URLs mean the browser talks to S3 directly and no AWS credentials ever touch the client.
Problem 2: S3 as an inbound document channel
The reverse flow is event-driven and pleasantly clean: S3 raises an event notification on ObjectCreated, which triggers a Lambda, which pushes the file into Acumatica over REST. The only design work is the correlation rule — how does a dropped file know which record it belongs to? Options that have worked for me, in order of preference: a key convention (inbound/SO/SO006721/photo1.jpg — the path is the address), object metadata tags set by the producing system, or a manifest JSON dropped alongside a batch. Whatever you choose, make the Lambda idempotent: S3 events are at-least-once, and attaching the same POD twice looks unprofessional on a customer-facing record.
public async Task Handle(S3Event evt)
{
foreach (var rec in evt.Records)
{
var key = rec.S3.Object.Key; // inbound/SO/SO006721/pod.pdf
var parts = key.Split('/');
if (parts.Length != 4 || parts[0] != "inbound") continue;
var (docType, orderNbr, fileName) = (parts[1], parts[2], parts[3]);
using var obj = await _s3.GetObjectAsync(rec.S3.Bucket.Name, key);
var url = _erp + "/entity/Default/24.200.001/SalesOrder/" +
docType + "/" + orderNbr + "/files/" + fileName;
var put = new HttpRequestMessage(HttpMethod.Put, url)
{ Content = new StreamContent(obj.ResponseStream) };
put.Headers.Authorization = await _auth.BearerAsync();
var resp = await _http.SendAsync(put);
if (resp.StatusCode == HttpStatusCode.NotFound)
await _dlq.SendAsync(key, "order not found"); // park it, don't crash
else
resp.EnsureSuccessStatusCode();
// move processed object out of inbound/ so replays are visible
await _s3.CopyAsync(rec.S3.Bucket.Name, key, _processedPrefix + key);
await _s3.DeleteAsync(rec.S3.Bucket.Name, key);
}
}
Operational notes
- Login churn kills you. If your worker uses the cookie-based auth, reuse the session across the batch and log out once; if OAuth, cache the token. Acumatica licenses cap concurrent API users, and a worker that logs in per file will exhaust them and lock out the EDI integration nobody remembered was sharing the user.
- Throttle the archive worker. Bulk file downloads compete with interactive users for app-server resources. I run archive jobs off-hours and cap concurrency at 2–4 parallel transfers.
- Bucket hygiene: versioning on, public access blocked, SSE-S3 at minimum, lifecycle rules to Glacier tiers by age. This is boilerplate — the point is that it's your boilerplate now, not the DBA's backup problem.
Wrapping up
S3 integration with Acumatica is two projects wearing one name. The archive direction is a scheduled worker with a mapping ledger and a retrieval UI — measured in database gigabytes saved. The inbound direction is an event-driven Lambda with a key convention and idempotent attaches — measured in manual filing eliminated. Both stand on the REST file API and both live or die on the boring parts: the state ledger, idempotency, and not exhausting your API user licenses. Build those first and the AWS SDK calls are the easy afternoon.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.