DEV Community

Cover image for How to Make ERP Integration Services Retry-Safe in Node.js
Sanya Mittal
Sanya Mittal

Posted on

How to Make ERP Integration Services Retry-Safe in Node.js

A webhook times out after 30 seconds, so the sender retries. Your handler had already created the sales order in the ERP, so the retry creates a second one. The warehouse then picks the same items twice. This is the most common failure we see when ERP integration services meet unreliable networks: at-least-once delivery on one side, a non-idempotent create endpoint on the other. Here is how ERP integration services handle retries safely in production. We build a small Node.js and PostgreSQL write path that survives duplicate deliveries, process crashes, and ERP outages, then look at how the pattern applies on a live project.

Context and Setup

Idempotency means that applying the same operation twice leaves the system in the same state as applying it once.

Our scenario is a Node.js 20 service that turns storefront webhooks into ERP sales orders. You need PostgreSQL 14 or newer and an ERP endpoint with an external reference field. Postman’s State of the API Report (2025) found that 50.8% of developers cite unauthorized or excessive API calls as their top security concern. The finding centers on AI agents, but an unguarded retry loop creates the same traffic internally.

Fixing Duplicate Writes in ERP Integration Services

The fix has three parts: a deterministic key, an atomic claim, and bounded retries.

Step 1: Build a Deterministic Key for Each ERP Write

A deterministic key comes from the business event, so every retry produces the same value. A random ID per attempt makes each retry look like new work. Combine source system, event ID, and entity version.

ts
import { createHash } from "node:crypto";

export function idempotencyKey(source: string, eventId: string, version: number): string {
// Why: same event, same key, even after a restart
return createHash("sha256").update(${source}:${eventId}:${version}).digest("hex");
}

Step 2: Claim the Key in Postgres Before the ERP Integration Services Call

Claim the key atomically before any ERP write:

Insert the key with ON CONFLICT DO NOTHING.
Read the row status. Return early if it is done.
Call the ERP, passing the key as external_ref.
Mark the row done and store the ERP-assigned ID.
sql
CREATE TABLE erp_write_log (
idem_key text PRIMARY KEY, -- Why: rejects duplicate claims atomically
status text NOT NULL DEFAULT 'pending',
erp_id text
);
ts
export async function handleEvent(evt: OrderEvent) {
const key = idempotencyKey("storefront", evt.id, evt.version);
// Why: avoids a check-then-insert race under concurrent webhooks
await pool.query("INSERT INTO erp_write_log (idem_key) VALUES ($1) ON CONFLICT DO NOTHING", [key]);
const { rows } = await pool.query("SELECT status FROM erp_write_log WHERE idem_key = $1", [key]);
if (rows[0].status === "done") return; // Why: finished writes never rerun
// Why: a crash after the ERP write leaves 'pending'; a unique external_ref makes the retry safe
const created = await postOrder(evt.order, key);
await pool.query("UPDATE erp_write_log SET status = 'done', erp_id = $2 WHERE idem_key = $1", [key, created.id]);
}

Step 3: Retry With Jitter and Know the Trade-Offs

Bounded retries repeat only failures that can succeed later: 5xx and 429. Other 4xx responses mean bad input, so retrying only adds load.

ts
async function postOrder(order: Order, key: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(${process.env.ERP_URL}/sales-orders, {
method: "POST",
headers: { "content-type": "application/json", "Idempotency-Key": key },
body: JSON.stringify({ ...order, external_ref: key }),
});
if (res.ok) return res.json();
if (res.status < 500 && res.status !== 429) throw new Error(ERP rejected: ${res.status}); // Why: bad input never heals
// Why: full jitter stops workers retrying in lockstep
await new Promise((r) => setTimeout(r, Math.random() * Math.min(30_000, 500 * 2 ** attempt)));
}
throw new Error("ERP unavailable after 5 attempts");
}

Why a key table instead of trusting the ERP alone? Many ERP APIs do not enforce uniqueness on an external reference. If yours does not, look the order up by that field before creating it. Why not broker-level exactly-once delivery? It covers the queue, not the HTTP call to the ERP. The table costs one extra write per event, the price of a guard you control.

Real-World Application

In one of our ERP integration projects at Oodles, we implemented an open-source ERP for a Shanghai-based logistics provider, covering inventory management, order processing, and real-time tracking.

Derive keys from the business event and entity version, never from the request attempt.

Claim keys with ON CONFLICT, because check-then-insert races under concurrent webhooks.

Retry only 5xx and 429 responses, and fail fast on every other 4xx.
Apply full jitter so workers do not hit a recovering ERP in lockstep.

Hit another duplicate-write edge case? Share it in the comments, or talk to us about ERP integration services for a design review.

What are ERP integration services?

ERP integration services connect an ERP to other systems, such as storefronts, CRMs, and warehouse tools, through APIs, middleware, or files. They cover data mapping, sync logic, error handling, and monitoring so records stay consistent across systems.

Why do duplicate records appear in ERP integrations?

In ERP integration services Most webhook senders and queues guarantee at-least-once delivery. If a handler creates a record before acknowledging, any timeout triggers a retry that creates a second record. Without an idempotency key, the ERP cannot tell them apart.

What is an idempotency key?

An idempotency key is a unique value, derived from the business event, sent with each write. The receiver stores it and returns the original result for repeats, so retries cannot create duplicate records.

Should I retry 4xx errors from an ERP API?

No, except 429. A 4xx response usually signals invalid data or missing permissions, which retries cannot fix. Retry 5xx and 429 with exponential backoff and jitter, and send persistent failures to a dead-letter queue.

How long should idempotency keys be stored?

In ERP integration services Store keys longer than your maximum retry window and any sender replay window. Then purge older rows on a schedule, and keep the ERP-assigned ID elsewhere if audits need it.

Top comments (0)