DEV Community

Cover image for ERP Integration Services: Fixing Event Order
Sanya Mittal
Sanya Mittal

Posted on

ERP Integration Services: Fixing Event Order

We once saw an ERP Integration Services return successful API responses while the business workflow was still wrong.

The order reached the ERP. The warehouse received the inventory request. The CRM received the customer update. Every HTTP call returned 2xx.

The failure appeared later.

The warehouse processed an order before its customer record was fully synchronized. Reconciliation then required manual checks because each system had accepted a different part of the transaction.

This is the part of ERP Integration Services that API documentation rarely solves. The difficult problem is not moving JSON between systems. It is preserving business-event order when systems fail independently.

In this article, we will build that reasoning into the ERP Integration Services design. The examples use Python 3.11+ and asynchronous HTTP patterns. The code is intentionally simplified so the sequencing problem stays visible.

Step 1: Start With the Business Event, Not the API

The first mistake is usually visible in the controller.

A developer receives an order and immediately calls three systems:

# Naive ERP Integration Services flow: every downstream call happens inline.
async def process_order(order):
    await erp.create_order(order)
    await warehouse.reserve_stock(order)
    await crm.update_customer(order.customer)
Enter fullscreen mode Exit fullscreen mode

This looks reasonable until the warehouse call fails.

The ERP order now exists. The warehouse reservation does not. A retry of the whole function can create another ERP order unless the operation is idempotent.

The bigger issue is ordering.

The actual dependency may be:

Customer confirmed
       |
       v
Order accepted
       |
       v
Inventory reserved
       |
       v
Shipment confirmed
       |
       v
Invoice posted
       |
       v
Payment reconciled
Enter fullscreen mode Exit fullscreen mode

That sequence should become an explicit state model.

Microsoft's Business Central documentation describes business events around changes in process state. It specifically gives a posted sales order as an example of a business event.

This is a useful distinction.

POST /orders is an API operation.

OrderPosted is a business event.

The second carries more meaning to downstream consumers.

Step 2: Separate the Transaction From Its Side Effects

Once the event sequence is explicit, the next problem is atomicity.

Your ERP database and your CRM database do not share a transaction. Neither should pretend they do.

A common workaround is to make the HTTP request part of the original transaction:

# The problem: the database transaction depends on an external network call.
async def create_order(db, order):
    saved = db.insert_order(order)

    await crm.create_customer(order.customer)

    db.mark_synced(saved.id)
    db.commit()
Enter fullscreen mode Exit fullscreen mode

If the CRM takes 10 seconds, your application waits 10 seconds.

If the CRM returns 503, your local transaction now depends on whether the caller retries correctly.

We instead persist the business event locally and process the external call separately.

A minimal outbox table can look like this:

-- The important part is storing the event before attempting external delivery.
CREATE TABLE integration_outbox (
    id INTEGER PRIMARY KEY,
    event_type TEXT NOT NULL,
    aggregate_id TEXT NOT NULL,
    payload TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    attempts INTEGER NOT NULL DEFAULT 0
);
Enter fullscreen mode Exit fullscreen mode

The application then commits the order and event together:

# The order and its integration event commit as one local transaction.
def create_order(conn, order):
    conn.execute(
        "INSERT INTO orders(id, status) VALUES (?, ?)",
        (order["id"], "confirmed"),
    )

    conn.execute(
        """
        INSERT INTO integration_outbox
        (event_type, aggregate_id, payload)
        VALUES (?, ?, ?)
        """,
        ("OrderConfirmed", order["id"], json.dumps(order)),
    )

    conn.commit()
Enter fullscreen mode Exit fullscreen mode

Python's SQLite documentation confirms that explicit commit() and rollback() control pending transactions when using the standard transaction mode.

The important change is architectural.

The request no longer needs the CRM to succeed before the order can become confirmed.

Step 3: Make the Worker Idempotent

Persisting an event fixes one failure mode. It does not prevent duplicate delivery.

A worker may successfully call the ERP and crash before marking the event as processed.

The next worker execution can therefore receive the same event.

That is expected in many asynchronous systems.

The consumer must handle it.

# ERP Integration Services should treat duplicate delivery as a normal condition.
async def handle_event(event):
    if await already_processed(event.id):
        return

    await post_to_erp(event)

    await mark_processed(event.id)
Enter fullscreen mode Exit fullscreen mode

There is a race in this simplified version.

Two workers can read already_processed(event.id) before either writes the processed record.

The production version should enforce uniqueness in the database:

-- The database becomes the final guard against duplicate event processing.
CREATE UNIQUE INDEX ux_processed_events
ON processed_events(event_id);
Enter fullscreen mode Exit fullscreen mode

Then the worker can attempt to record the event inside a transaction.

This matters more than adding another retry library. Retries without idempotency can turn a temporary outage into duplicate invoices, duplicate customers, or duplicate inventory movements.

Step 4: Use Webhooks for Notification, Not Workflow Ownership

Once the worker handles events independently, teams often move everything to webhooks.

That introduces another trap.

A webhook tells you that something changed. It does not necessarily tell you that every dependent workflow has completed.

Odoo 19, for example, supports webhook-triggered automation and its external JSON-2 API exposes model methods through /json/2/<model>/<method>.

Odoo 19 External JSON-2 API documentation

For example, a webhook can produce:

{
  "event": "order.confirmed",
  "order_id": "SO-10482"
}
Enter fullscreen mode Exit fullscreen mode

The consumer should not immediately assume that inventory, payment, and fulfillment are ready.

Instead, it should query or consume the next authoritative state.

Business Central follows a similar model. Its webhook subscriptions notify consumers about entity changes, and Microsoft documents retries when the subscriber returns 408, 429, or 5xx responses.

That retry behavior is useful, but it does not remove the need for idempotency.

A retry means the same notification can reach your service again.

Step 5: Keep Slow ERP Calls Outside the Request Path

The previous steps separate event persistence from delivery. The final piece is execution.

Do not make an API consumer wait while several ERP operations complete unless the caller genuinely needs the result synchronously.

With Python 3.11+, asyncio.TaskGroup provides structured concurrency for related asynchronous tasks.

For independent work, that can look like:

async def notify_downstream(order):
    async with asyncio.TaskGroup() as tg:
        tg.create_task(update_crm(order))
        tg.create_task(update_analytics(order))
Enter fullscreen mode Exit fullscreen mode

But do not use concurrency when the operations have dependencies.

This is wrong:

# Do not parallelize events that depend on a specific business sequence.
async with asyncio.TaskGroup() as tg:
    tg.create_task(create_customer())
    tg.create_task(create_order())
    tg.create_task(reserve_inventory())
Enter fullscreen mode Exit fullscreen mode

If create_order() requires the customer ID, concurrency has changed the business semantics.

The correct boundary is:

Order confirmed
      |
      +--> CRM notification
      |
      +--> Analytics event

Inventory reserved
      |
      +--> Warehouse notification
      |
      +--> Shipment workflow
Enter fullscreen mode Exit fullscreen mode

Concurrency belongs inside an independent stage. It should not replace the dependency graph.

Real-World Application: Where This Changed the Outcome

That distinction between concurrency and dependency mattered in an Oodles logistics ERP implementation. The integration had to coordinate order processing, inventory, shipment workflows, and operational reporting.

We initially treated synchronization as data movement between systems. The harder problem appeared when different systems accepted updates at different points in the transaction.

We changed the workflow to use shared transaction states rather than treating each API response as completion. Downstream actions followed those states instead of blindly following the original request.

The result was a 45% reduction in order-processing time, 65% lower manual effort, and 99.5% inventory accuracy. Operational errors also fell by 35%.

The important lesson was not simply to add asynchronous processing. We had to preserve the business sequence first. Only then could individual stages run independently.

That is the trade-off behind good ERP Integration Services. More state and event handling adds implementation work. In return, failures become recoverable instead of becoming reconciliation exercises.

What Happens in Production

The happy path is rarely the difficult part.

Production introduces expired credentials, ERP maintenance, rate limits, duplicate webhooks, partial database failures, and downstream timeouts.

We therefore monitor the event lifecycle rather than only HTTP status codes.

A useful event record should answer:

event_id
event_type
aggregate_id
created_at
attempt_count
last_attempt_at
status
error_code
error_message
Enter fullscreen mode Exit fullscreen mode

When an operator sees OrderConfirmed stuck in retrying, they should not need to inspect five application logs to understand what happened.

The ERP integration services system should make the failure visible.

Conclusion: Key Takeaways

  • Model ERP workflows as business states before designing API calls.
  • Commit the business transaction and its integration event together.
  • Assume webhook and queue delivery can happen more than once.
  • Enforce idempotency with a database constraint, not only application logic.
  • Use concurrency only after identifying which operations are truly independent.
  • Monitor event state, retry count, and reconciliation status instead of HTTP success alone.

What are ERP Integration Services from a developer's perspective?

ERP Integration Services connect ERP APIs, databases, webhooks, queues, and external applications into a controlled workflow. Developers must handle authentication, data mapping, event ordering, retries, duplicate delivery, monitoring, and reconciliation.

Should ERP integrations be synchronous or asynchronous?

Use synchronous calls when the caller needs an immediate result. Use asynchronous processing for workflows that can tolerate delayed completion or involve unreliable downstream systems. Many ERP integrations need both patterns at different stages.

How do you prevent duplicate ERP records?

Use an idempotency key or unique event identifier. Store processed identifiers with a database uniqueness constraint. The consumer can then safely receive the same event again without creating another order, invoice, or customer.

Are webhooks enough for ERP integration?

No. Webhooks provide notifications, but they do not define the complete business workflow. Your integration still needs state handling, validation, retries, idempotency, and reconciliation when downstream processing fails.

When should developers use middleware for ERP integration?

In ERP Integration Services Middleware becomes useful when several systems share routing, transformation, authentication, retry, monitoring, or orchestration logic. A direct API connection can remain simpler when only two stable systems exchange limited data.

Top comments (0)