The usual reason to push Acumatica attachments into Dropbox is that someone outside the ERP needs them — a shop floor, an auditor, a partner who lives in shared folders and will never log into Acumatica. The integration itself is not hard, but the parts that decide whether it survives are unglamorous: OAuth token refresh, chunked uploads for large files, idempotency so a retried push does not create duplicates, and a reconciliation job for the times the two systems drift apart.
Setting up the Dropbox app and OAuth
Dropbox authorises API calls with OAuth 2. Create an app in the Dropbox App Console, scope it to the folders you need (files.content.write, files.content.read), and use the refresh-token flow rather than a short-lived access token. Short-lived tokens expire in a few hours; a server-to-server integration that only re-authenticates when a human clicks a button will break overnight. Store the refresh token securely and exchange it for a fresh access token on demand.
// Exchange the stored refresh token for a short-lived access token.
// Cache the access token and reuse it until it is near expiry.
public async Task<string> GetAccessTokenAsync()
{
if (_cachedToken != null && DateTime.UtcNow < _tokenExpiry.AddMinutes(-5))
return _cachedToken;
var form = new Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = _refreshToken,
["client_id"] = _appKey,
["client_secret"] = _appSecret,
};
var resp = await _http.PostAsync("https://api.dropbox.com/oauth2/token",
new FormUrlEncodedContent(form));
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<TokenResponse>();
_cachedToken = body.access_token;
_tokenExpiry = DateTime.UtcNow.AddSeconds(body.expires_in);
return _cachedToken;
}
Where to hook the upload in Acumatica
Attachments in Acumatica are UploadFile records linked to a document through NoteDoc. The natural place to push a file to Dropbox is a graph extension that reacts once the parent document is safely persisted — not on every keystroke. Do the actual upload after the database commit, not inside RowPersisting, because a slow network call to Dropbox has no business holding a SQL transaction open. Either queue the work and let a processing screen or automation schedule drain it, or fire it from RowPersisted outside the transaction scope.
public class SOOrderDropboxExt : PXGraphExtension<SOOrderEntry>
{
// Enqueue the push after the row is committed, not during persist.
protected virtual void _(Events.RowPersisted<SOOrder> e)
{
if (e.TranStatus != PXTranStatus.Completed) return; // only after real commit
if (e.Operation != PXDBOperation.Insert &&
e.Operation != PXDBOperation.Update) return;
foreach (var link in PXNoteAttribute.GetFileNotes(e.Cache, e.Row))
DropboxQueue.Enqueue(link, e.Row.OrderNbr); // outbox row, drained async
}
}
If you upload synchronously inside the save, one slow or failing Dropbox response makes users unable to save orders. Write an outbox row in the same transaction and let a separate worker upload it. The document saves regardless; the file syncs when Dropbox is reachable.
Uploading large files in chunks
Dropbox's simple /files/upload endpoint caps at 150 MB per request. Anything larger — and often you should use this well below the cap — goes through the upload-session API: upload_session/start, repeated upload_session/append_v2 calls, then upload_session/finish. Chunking also lets you resume rather than restart when a large upload dies partway on a poor connection, which matters a great deal on the links I work over in the region.
Idempotency and avoiding duplicates
The outbox pattern guarantees at-least-once delivery, which means a push can run twice — the worker uploaded the file, then crashed before marking the row done. Make the upload idempotent so a retry is harmless. Dropbox helps here: on /files/upload set mode: "overwrite" for a deterministic path, or use add with autorename: false and treat a "conflict" as success. Deriving the Dropbox path deterministically from the Acumatica document (for example /Acumatica/SO/{OrderNbr}/{FileName}) means the same source file always maps to the same destination, so a re-run overwrites rather than duplicates.
Keep a custom table (or Usr fields) recording, per attachment, the Dropbox path and the content hash you uploaded. That record is what makes reconciliation cheap and lets you skip re-uploading a file that has not changed.
Reconciling the two stores
Any two-system sync drifts: a push failed and its outbox row was purged, someone deleted a file in Dropbox, an attachment was added directly in the database during an outage. A scheduled reconciliation job closes the gap. List what Acumatica expects to be in Dropbox (from your mapping table), list what is actually there (/files/list_folder), and re-push anything missing. Compare content hashes rather than just filenames so a changed file is caught, not just a missing one. Run it nightly; treat a non-empty diff as an alert, not a routine.
| Concern | Wrong approach | What holds up |
|---|---|---|
| Auth | Long-lived access token pasted in config | Refresh-token flow, access token cached and renewed |
| Timing | Upload synchronously during save | Outbox row in-transaction, uploaded by async worker |
| Large files | Single /files/upload request | Chunked upload session with resume |
| Retries | Blind re-upload → duplicates | Deterministic path + overwrite mode = idempotent |
| Drift | Assume it stays in sync | Nightly hash-based reconciliation job |
The unglamorous parts are the integration
The single API call that uploads a file is the smallest part of this. What makes a Dropbox integration something you can leave running is the token refresh that survives the night, the outbox that decouples saving from syncing, the deterministic paths that make retries safe, and the reconciliation job that catches the drift you did not predict. Build those and the demo becomes a system.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.