Integrations

How to design an ecommerce–ERP integration without duplicate orders

A duplicate order in the ERP throws stock and invoicing off. Six design decisions so your integration survives retries and errors without duplicating anything.

A duplicate order in the ERP is not a harmless glitch: it creates an extra delivery note, throws stock off and ends in an awkward call with the customer. It almost always comes down to the same thing: the integration assumes every message arrives exactly once. This is how to design an online store–ERP integration that survives retries, outages and errors without duplicating anything.

Why orders get duplicated

  • Automatic retries: the store or webhook resends the order because the ERP was slow to reply, even though it had saved it.
  • Multiple triggers: the order is sent on creation and again on payment, or from a cron and an event at the same time.
  • Parallel processes: two workers pick up the same order simultaneously.
  • Manual resends: someone clicks "send again" without knowing whether the first attempt arrived.

1. Decide which system owns each piece of data

Before writing code, decide the source of truth: usually the store owns orders and web customers, and the ERP owns stock, prices and shipping status. Each piece of data travels in one direction only. "Sync everything both ways" integrations are the ones that create most duplicates and conflicts.

2. Use an idempotency key

Every order needs a unique, stable identifier, such as the store’s order number. The receiver stores it and, if it arrives again, returns the previous result instead of creating another order. With a UNIQUE constraint in the database, not even two simultaneous processes can get around it:

// Control table: integration_orders (external_id UNIQUE, erp_id, status)
$record = IntegrationOrder::firstOrCreate(
    ['external_id' => $order->number],
    ['status' => 'pending']
);

if ($record->erp_id) {
    return $record->erp_id; // already sent: no duplicate
}

$erpId = $erp->createOrder($order);
$record->update(['erp_id' => $erpId, 'status' => 'sent']);

3. Queue the sends and retry sensibly

Sending to the ERP should not happen inside the customer’s request. Put it on a queue, with limited retries and increasing back-off. And separate errors: a 400 (bad data) is not fixed by retrying; a 503 or a timeout is.

4. Validate the format before sending

Many rejections come from format mismatches: an address sent as an object when the ERP expects flat fields, decimals with commas, different country codes. Document the data contract and validate the order before sending it. That is exactly what we solved in the Magento 2 order integration with an external API case.

5. Log everything with context

Every send should leave a trail: order, payload, response, status code and attempt number. When something fails, the alert has to say which order and why, not just "integration error".

6. Have a reconciliation process

Even with a good design, a daily check pays off: store orders without an ERP ID, ERP orders without a store origin and statuses that do not match. Catching it the same day is far cheaper than finding it at month-end.

Quick checklist

  • Each piece of data has a single owning system.
  • Each order carries an idempotency key and there is a unique constraint at the destination.
  • Sends go through a queue, with retries only for temporary errors.
  • The format is validated before sending.
  • There are contextual logs and useful alerts.
  • There is periodic reconciliation.

If your store and your ERP do not quite get along, I can review the integration and propose a phased plan. See my development and integration services or tell me about your case.

Want to apply these ideas to your project?

We can review the context and define the next step.