Acumatica · Rabbitmq

RabbitMQ + ERP Integration

How to use RabbitMQ exchange types, durable acknowledgments, and dead-letter queues to move data reliably in and out of an ERP system without losing messages when a consumer crashes.

John Kihiu12 min read

Most ERP integrations start as a point-to-point HTTP call — system A posts to system B's API when something happens — and that works fine until you have three or four downstream systems that all need to know about the same event, and one of them is slow or down half the time. RabbitMQ earns its keep at exactly that point: it decouples the ERP from the systems that consume its events, so a warehouse system being down for maintenance doesn't block an order from also reaching the reporting pipeline. The parts that actually matter are the exchange topology, delivery guarantees, and what happens when a consumer rejects a message — not the "hello world" publish/subscribe demo.

Exchange types and how they map to ERP events

RabbitMQ routes messages through an exchange before they reach a queue, and the exchange type determines the routing logic. A direct exchange routes on an exact routing key match — useful for a single well-known consumer, like a dedicated invoice-processing queue. A fanout exchange ignores the routing key entirely and copies the message to every bound queue — the right shape for "broadcast this event to everyone who cares," such as a stock-level change that both a pricing engine and a low-stock alert service need to see. A topic exchange matches routing keys against wildcard patterns (* for one segment, # for many), which is what most real ERP integrations end up using: publish with a key like erp.order.created or erp.order.cancelled, and let the warehouse system bind to erp.order.* while the reporting system binds to erp.# and picks up everything. One exchange, one publish call in the ERP, and each consumer decides what it cares about via its binding — the ERP never needs to know who's listening.

Durability and acknowledgments so writes are not lost

The default RabbitMQ behaviour — auto-ack, non-durable queue, in-memory only — will lose messages the moment either the broker restarts or a consumer crashes mid-processing, and for ERP data that means a sale that never reaches the warehouse or an invoice that never gets fiscalised. Three settings fix this together: declare the queue and exchange as durable so they survive a broker restart, publish messages with delivery_mode=2 (persistent) so the message itself is written to disk, and use manual acknowledgment (ack only after the consumer has actually committed the write) instead of auto-ack. Manual ack is the one people skip because it's an extra line of code, and it's also the one that actually matters: with auto-ack, RabbitMQ considers the message delivered the instant it hits the consumer's TCP socket, whether or not the consumer's ERP write ever succeeds.

Python · pika
import pika, json

connection = pika.BlockingConnection(pika.ConnectionParameters("rabbitmq.internal"))
channel = connection.channel()

channel.exchange_declare(exchange="erp.events", exchange_type="topic", durable=True)
channel.queue_declare(queue="warehouse.orders", durable=True)
channel.queue_bind(queue="warehouse.orders", exchange="erp.events", routing_key="erp.order.*")

def publish_order_created(order):
    channel.basic_publish(
        exchange="erp.events",
        routing_key="erp.order.created",
        body=json.dumps(order),
        properties=pika.BasicProperties(delivery_mode=2, content_type="application/json"),
    )

def on_order_message(ch, method, properties, body):
    order = json.loads(body)
    try:
        write_order_to_warehouse_system(order)
        ch.basic_ack(delivery_tag=method.delivery_tag)
    except ValidationError:
        # reject without requeue -> routed to the dead-letter queue instead of looping forever
        ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)

channel.basic_consume(queue="warehouse.orders", on_message_callback=on_order_message)
channel.start_consuming()
Prefetch limits protect the consumer

Set channel.basic_qos(prefetch_count=10) on the consumer so RabbitMQ only pushes 10 unacknowledged messages at a time instead of flooding the process with the entire queue backlog. Without it, a slow ERP write can leave thousands of messages sitting unacknowledged in the consumer's memory.

Dead-letter queues for ERP-side validation failures

Not every message failure is transient. A malformed order, a reference to a customer that doesn't exist yet, a currency code the ERP doesn't recognise — these will fail the same way every time you retry, and a naive consumer that requeues on error will loop the same bad message forever, burning CPU and flooding logs. The fix is a dead-letter exchange (DLX): configure the main queue with x-dead-letter-exchange pointing at a separate exchange, and any message that's rejected with requeue=False (or that expires via a TTL) gets routed there instead of vanishing or looping. The dead-letter queue becomes a holding area a human or a reconciliation job can inspect, rather than a silent black hole.

Distinguish retryable from non-retryable failures

A database connection timeout should be retried; a message that fails schema validation should not be retried, it should go straight to the dead-letter queue. Conflating the two either causes infinite retry loops on bad data, or silently drops messages that would have succeeded on a second attempt. Catch validation errors separately from infrastructure errors and nack them differently.

Queue declaration with dead-lettering configured

The dead-letter binding is set at queue-declaration time as an argument, not as a separate runtime decision — which means it has to be right before the first message ever lands in the queue, since RabbitMQ won't let you redeclare a queue with different arguments once it exists.

Python · pika
channel.exchange_declare(exchange="erp.events.dlx", exchange_type="fanout", durable=True)
channel.queue_declare(queue="warehouse.orders.failed", durable=True)
channel.queue_bind(queue="warehouse.orders.failed", exchange="erp.events.dlx")

channel.queue_declare(
    queue="warehouse.orders",
    durable=True,
    arguments={
        "x-dead-letter-exchange": "erp.events.dlx",
        "x-message-ttl": 86400000,  # optional: also dead-letter anything unprocessed after 24h
    },
)

What this buys you over direct HTTP calls

The honest case for RabbitMQ over direct service-to-service HTTP calls isn't throughput, it's failure isolation and fan-out. If the warehouse system's API is down for an hour, the messages queue up and get processed when it comes back, instead of the ERP retrying an HTTP call and giving up after a few attempts. Adding a fourth consumer of order-created events — say, a Slack notification service — is a queue binding, not a code change in the ERP or in any existing consumer. The cost is operational: you now have a broker to monitor, queue depth to alert on, and a dead-letter queue that needs someone to actually look at it periodically, or it becomes a graveyard of failures nobody ever fixes.

Wrapping up

RabbitMQ's value in an ERP integration comes from three specific mechanisms working together: topic exchanges so one publish reaches every interested consumer without the ERP knowing who they are, durable queues plus manual acknowledgment so a crashed consumer doesn't silently lose a write, and dead-letter queues so validation failures land somewhere visible instead of looping or disappearing. Skip any one of the three and the integration looks fine in testing and loses data quietly in production the first time something goes wrong.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.