Skip to main content
INS // Insights

Custom ERP Integration Development — What We Built

Updated July 2026 · 4 min read

A distribution company needed their custom order management system to sync bidirectionally with their ERP (enterprise resource planning) platform — inventory levels, order status, and customer records all needed to stay consistent across both systems without someone manually re-entering data. Here's the integration architecture we built, and why "just call the API" undersells how much engineering this actually takes.

Why ERP Integrations Are Harder Than They Look

ERP systems are built for internal completeness, not for being integrated with. Their APIs often expose deeply nested, vendor-specific data models that don't map cleanly onto a modern application's schema. Field names, unit conventions, and status enumerations frequently differ between systems in ways that silently produce wrong data if not handled carefully.

The Integration Architecture

1. Data Mapping Layer

Rather than embedding ERP-specific field names throughout the application, we build an explicit translation layer that maps the ERP's data model to the application's internal domain model:

def map_erp_order_to_domain(erp_order: dict) -> Order:
    return Order(
        id=erp_order["DocEntry"],
        customer_id=erp_order["CardCode"],
        status=ERP_STATUS_MAP.get(erp_order["DocStatus"], "unknown"),
        line_items=[
            LineItem(
                sku=item["ItemCode"],
                quantity=Decimal(item["Quantity"]),
                unit_price=Decimal(item["Price"])
            )
            for item in erp_order["DocumentLines"]
        ]
    )

This isolation means when the ERP vendor changes their API (which they do, without much warning), only the mapping layer needs updating — the rest of the application is insulated from vendor-specific data structures.

2. Idempotent Sync Jobs

ERP integrations run on a schedule or webhook trigger, and network failures mean some syncs will be retried. We design every sync operation to be idempotent — running the same sync twice produces the same end state, not duplicate records — using upsert patterns keyed on the ERP's unique identifiers rather than blind inserts.

3. Conflict Resolution for Bidirectional Sync

When both systems can modify the same record, conflicts are inevitable — an order updated in the ERP at the same time a customer modifies it in the application. We implement a last-write-wins strategy with a full audit log by default, escalating to a manual review queue for high-value records (large orders, credit limit changes) where silent conflict resolution carries real business risk.

4. Rate Limiting and Backoff

Most ERP APIs enforce strict rate limits, and hitting them can lock out the integration entirely for a cooldown period. We implement token-bucket rate limiting client-side, staying comfortably under vendor limits, with exponential backoff and jitter on any 429 or 503 responses.

@retry(
    wait=wait_exponential(multiplier=1, min=2, max=60),
    stop=stop_after_attempt(5),
    retry=retry_if_exception_type(RateLimitError)
)
def call_erp_api(endpoint: str, payload: dict) -> dict:
    response = erp_client.post(endpoint, json=payload)
    if response.status_code == 429:
        raise RateLimitError(response.headers.get("Retry-After", 30))
    return response.json()

5. Reconciliation Reporting

Beyond real-time sync, we build a nightly reconciliation job that compares record counts and key field values between both systems, flagging drift that real-time sync might have missed due to a missed webhook or an edge case in the mapping logic. This catches silent data drift before it compounds into a larger discrepancy.

Results

The distribution client eliminated a daily manual data reconciliation process that previously consumed roughly an hour of staff time, and — more importantly — eliminated the inventory discrepancies that had been causing occasional overselling of out-of-stock items before the integration went live.

Need a custom integration between systems that weren't built to talk to each other? Discuss your project — 907-841-8407 or contact@rutagon.com.

Frequently Asked Questions

Why are ERP integrations more complex than typical API integrations?

ERP systems expose data models built for internal completeness rather than external integration, often with deeply nested structures, vendor-specific terminology, and status codes that don't map cleanly onto other systems. A dedicated mapping and translation layer is usually necessary rather than consuming the ERP API directly throughout an application.

What happens when the ERP vendor changes their API?

With a properly isolated data mapping layer, vendor API changes only require updating that translation layer, not the rest of the application. Without this isolation, vendor changes can require touching code throughout the entire system.

How do you handle conflicts when both systems can edit the same record?

Common approaches include last-write-wins with a full audit trail for most records, and escalation to manual review for high-value or high-risk records where silent conflict resolution carries meaningful business risk.

What is an idempotent sync and why does it matter?

An idempotent sync produces the same result whether it runs once or multiple times, using upsert patterns rather than blind inserts. This matters because network failures and retries are inevitable in integration work, and non-idempotent syncs can create duplicate records.

How do you catch data drift between two integrated systems?

Beyond real-time sync, a nightly reconciliation job comparing record counts and key fields between systems catches drift that real-time sync might miss due to missed webhooks or edge cases, before it compounds into a larger discrepancy.