I reach for Azure Service Bus as a bridge whenever an Acumatica integration needs to talk to a system that's either slow, unreliable, or simply not always available, a legacy on-prem ERP a client is migrating away from, a third-party logistics partner with a flaky API, a fan-out to multiple downstream consumers off one Acumatica event. Calling the downstream system directly from a PXGraph event handler works right up until that system has a bad day, and then it takes your Acumatica save down with it. A queue in the middle decouples that.
The core problem a bridge solves: synchronous coupling inside a transaction
Any external HTTP call made inside RowPersisting or RowPersisted ties Acumatica's save latency, and in the RowPersisting case its success, to a system you don't control. I've inherited instances where a client's WMS integration, called synchronously from RowPersisted, would occasionally hang for thirty seconds and users would report "the order screen is broken" without realizing the actual problem was an unrelated third-party system's response time. Publishing a message to Service Bus and returning immediately fixes the user-facing symptom entirely, and Service Bus's own retry and dead-letter mechanics handle the downstream flakiness instead of your Acumatica save transaction absorbing it.
Publishing: fire it from RowPersisted, keep the payload small and self-contained
protected virtual void _(Events.RowPersisted<SOOrder> e)
{
if (e.Row == null || e.TranStatus != PXTranStatus.Completed) return;
if (e.Operation != PXDBOperation.Insert && e.Operation != PXDBOperation.Update) return;
var evt = new OrderChangedEvent
{
OrderNbr = e.Row.OrderNbr,
OrderType = e.Row.OrderType,
Status = e.Row.Status,
LastModifiedUtc = DateTime.UtcNow,
EventId = Guid.NewGuid(), // consumer-side idempotency key
};
// Fire-and-forget from the graph's perspective, the actual send
// happens on a background task so the UI thread never waits on it
PXLongOperation.StartOperation(Base, () => ServiceBusPublisher.PublishAsync(evt).GetAwaiter().GetResult());
}
I keep the payload to IDs and a status, not the full order graph, the consumer calls back into Acumatica's REST API for full detail if it needs it. This avoids the message schema becoming a second, drifting copy of the SOOrder DAC shape that someone forgets to update when a field changes.
If today it's just the WMS listening for order events but there's any chance a second consumer (a BI pipeline, a partner notification service) joins later, publish to a Service Bus topic from day one rather than a point-to-point queue. Each subscriber gets its own subscription with independent message consumption and its own dead-letter queue, retrofitting a queue into a topic later means touching every existing publisher, while adding a new subscription to an existing topic touches nothing on the Acumatica side at all.
The other direction: an Azure Function bridges Service Bus back into Acumatica's REST API
For inbound flow (an external system placing orders that should land in Acumatica), I don't have that system call Acumatica's REST API directly either, I have it publish to a queue, and an Azure Function with a Service Bus trigger consumes messages and calls Acumatica's REST API with proper retry handling:
[Function("CreateOrderFromBus")]
public async Task Run(
[ServiceBusTrigger("inbound-orders", "acumatica-sync", Connection = "ServiceBusConnection")]
ServiceBusReceivedMessage message,
ServiceBusMessageActions messageActions)
{
var order = JsonSerializer.Deserialize<InboundOrder>(message.Body);
try
{
await _acumaticaClient.CreateOrderAsync(order); // idempotent on order.ExternalRef
await messageActions.CompleteMessageAsync(message);
}
catch (AcumaticaTransientException)
{
await messageActions.AbandonMessageAsync(message); // goes back on the queue, retried
}
catch (AcumaticaValidationException ex)
{
// Not going to succeed on retry, dead-letter it with context, don't loop forever
await messageActions.DeadLetterMessageAsync(message, "ValidationFailed", ex.Message);
}
}
The distinction between abandon (retry) and dead-letter (give up, park for inspection) matters enormously here. Treating every failure as retryable means a permanently malformed message loops through Service Bus's max-delivery-count and dead-letters anyway, just slower and noisier; treating every failure as terminal means a transient Acumatica maintenance window silently drops orders instead of retrying once the instance is back.
Idempotency is not optional, and don't assume ordering
Service Bus sessions can guarantee order within a session, but I avoid depending on that guarantee unless there's a specific reason, it adds real complexity to consumer scaling. Instead I make the Acumatica-side create/update idempotent on an external reference field, so a redelivered or out-of-order message is a safe no-op or a safe overwrite rather than a duplicate order. This is the same idempotency discipline that matters for any webhook consumer, just applied on the Service Bus side of the bridge instead.
Wrapping up
Azure Service Bus earns its place in an Acumatica architecture specifically as decoupling insurance: publish small, self-contained events from RowPersisted rather than calling out synchronously mid-transaction, default to topics over queues once more than one consumer is plausible, and build the consuming Azure Function around the abandon-versus-dead-letter distinction rather than treating every failure identically. The payoff shows up the first time a downstream system has an outage and Acumatica users never notice.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.