Acumatica AWS S3 Integration — A Complete Guide is the kind of integration that pays for itself the first time it runs without intervention. Most Acumatica integrations start as weekend projects and end as 6-month engagements — not because they are hard, but because the failure modes are not obvious until you have seen them. This guide is the field-tested pattern for AWS S3, with the failure modes called out and the production hygiene that keeps the integration quiet at 3 AM.
To build, to buy, or to wait
Before you write a line of code, decide whether the integration is worth building. The decision matrix:
| Question | If yes | If no |
|---|---|---|
| Does the provider have a documented, supported API? | Build | Wait for the provider to ship one |
| Is there an ISV solution on the Acumatica Marketplace? | Buy (and customise) | Build |
| Is the data volume > 100k records per day? | Build a queue-based integration | Synchronous REST will do |
| Is the integration a one-off for one tenant? | Build quickly, document | Build carefully, version, package |
If an ISV solution exists, buy it. The cost of an ISV solution is 2-4 weeks of subscription; the cost of building the same thing is 3-6 months of engineering. The ISV also handles the upgrade story, which is the most expensive part of a custom integration.
The data flow
Most integrations follow a four-step pattern: pull from source, transform, push to destination, reconcile. Each step has its own failure modes. The right way to build it:
// 1. PULL: fetch from source
var sourceRecords = await sourceClient.FetchSince(lastSyncTimestamp);
// 2. TRANSFORM: map to Acumatica shape
var acumaticaRecords = sourceRecords.Select(MapToAcumatica).ToList();
// 3. PUSH: write to Acumatica (with idempotency)
foreach (var batch in acumaticaRecords.Chunk(100))
await acumaticaClient.BulkUpsert(batch, idempotencyKey: r => r.ExternalRef);
// 4. RECONCILE: verify counts, alert on mismatch
if (Math.Abs(sourceCount - acumaticaCount) > threshold)
await alert.Send($"Sync mismatch: source={sourceCount}, acumatica={acumaticaCount}");
For the broader Acumatica REST patterns, see the REST API definitive guide and the bulk data loads guide.
Authentication
S3 uses IAM access keys or IAM roles for authentication. The flow:
- Register an OAuth client with the provider (developer portal).
- Configure the client ID, client secret, and redirect URI in a secrets manager (not in code).
- Request a token at the start of every integration run.
- Refresh the token before it expires (typically 5 minutes early).
- Handle 401 by re-authenticating and retrying once.
public class AcumaticaAwsSIntegrationClient
{
private readonly HttpClient _http;
private string _accessToken;
private DateTime _tokenExpiry = DateTime.MinValue;
public async Task<string> GetAccessToken()
{
if (DateTime.UtcNow < _tokenExpiry.AddMinutes(-5)) return _accessToken;
var form = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string,string>("grant_type", "client_credentials"),
new KeyValuePair<string,string>("client_id", _clientId),
new KeyValuePair<string,string>("client_secret", _clientSecret),
});
var resp = await _http.PostAsync("/oauth/token", form);
resp.EnsureSuccessStatusCode();
var token = await resp.Content.ReadFromJsonAsync<TokenResponse>();
_accessToken = token.access_token;
_tokenExpiry = DateTime.UtcNow.AddSeconds(token.expires_in);
return _accessToken;
}
}
For the broader OAuth pattern in Acumatica, see the OAuth 2.0 setup guide.
Idempotency is not optional
Every PUT to Acumatica must carry an idempotency key. The key is the natural identifier of the record — a customer reference, an order ID, an external system GUID. Without the key, a retry after a network timeout creates a duplicate. With the key, the retry is a no-op.
foreach (var record in acumaticaRecords)
{
var payload = new { ExternalRef = { value = record.ExternalRef } };
var maxRetries = 5;
for (int attempt = 0; attempt < maxRetries; attempt++)
{
try
{
var resp = await _http.PutAsJsonAsync(
$"/entity/Default/24.200.001/INItemSite", payload);
resp.EnsureSuccessStatusCode();
break;
}
catch (HttpRequestException) when (attempt < maxRetries - 1)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
}
}
}
When two processes try to update the same record, Acumatica returns a concurrency exception. Re-fetch the record (which gets the new ETag), apply your change, retry. The retry is part of the contract, not a bug.
For the full retry and concurrency pattern, see the rate limiting and retry guide and the error handling guide.
Rate limits and the licence cap
AWS S3 has rate limits. Acumatica has a licence cap on concurrent users. Both must be respected. The right way:
- Throttle outgoing requests to 10–20 concurrent per tenant.
- Use exponential backoff with jitter for retries.
- Track per-tenant licence headroom and alert before you run out.
var semaphore = new SemaphoreSlim(20);
var tasks = acumaticaRecords.Select(async record =>
{
await semaphore.WaitAsync();
try { await PushRecord(record); }
finally { semaphore.Release(); }
});
await Task.WhenAll(tasks);
For the full performance and licence guidance, see the licence concurrency guide and the performance tuning guide.
Webhooks for event-driven work
If the provider supports webhooks, use them. Polling is a tax; webhooks are free. The pattern:
- Register your HTTPS endpoint with the provider.
- Validate the signature on every incoming webhook.
- Respond 200 fast; process async.
- Make your handler idempotent — webhooks are at-least-once delivery.
[HttpPost("/webhooks/acumatica-aws-s3-integration")]
public async Task<IActionResult> Handle(AcumaticaAwsSIntegrationEvent evt)
{
if (!VerifySignature(Request)) return Unauthorized();
_ = Task.Run(async () =>
{
try { await Process(evt); }
catch (Exception ex) { _logger.LogError(ex, "Webhook processing failed"); }
});
return Ok();
}
For the full webhook pattern, see the webhooks vs polling guide.
Reconciliation and the runbook
The integration will fail. The question is how quickly you notice. The minimum hygiene:
- Log every push with the source ID, the Acumatica ID, the timestamp, and the response code.
- Run a daily reconciliation that compares source count to Acumatica count, alert on mismatch.
- Maintain a dead-letter table for records that failed to push; a worker replays them after fix.
- Track per-tenant error rate; alert on a spike.
var sourceCount = await sourceClient.CountSince(lastSync);
var acumaticaCount = await acumaticaClient.CountWhere($"ExternalRef like 'SRC-%' AND CreatedAt > '{lastSync:o}'");
if (Math.Abs(sourceCount - acumaticaCount) > 0.01 * sourceCount)
await alert.Send($"Sync mismatch: source={sourceCount}, acumatica={acumaticaCount}");
For the broader reconciliation patterns, see the error handling guide and the bulk loads guide.
Operational hygiene
The integration will need to be maintained. The habits that pay for themselves:
- All secrets in a vault, not in code or config files.
- Structured logging with a correlation ID that flows from source to Acumatica.
- Health check endpoint; monitor with a watchdog.
- Document the runbook: what to do when the integration fails, who to call, how to replay.
- Run the integration on a schedule that does not coincide with other batch jobs.
Always test the integration on a copy of production data before going live. Sample data misses the edge cases — a customer with a special character in the name, an invoice in a foreign currency, a record with a null in a required field. Production data has all of these.
For the broader operational patterns, see the CI/CD guide and the monitoring and alerting guide.
Wrapping up
The four steps (pull, transform, push, reconcile), the auth flow, the idempotency, the rate limit, the webhooks, the reconciliation, the runbook. Get all eight right and the integration is invisible. Skip any one and you are debugging at 2 AM. The discipline is not glamorous, but it is what separates an integration that works from an integration that survives.