Idempotency in ERP Integrations: Keys, Deduplication, and Upsert Patterns

Type: ERP Integration System: Cross-System (Salesforce, SAP, NetSuite, Dynamics 365) Confidence: 0.92 Sources: 8 Verified: 2026-03-02 Freshness: evolving

TL;DR

System Profile

This is a cross-system architecture pattern card covering idempotency implementation across the four major ERP platforms. Each ERP provides different native mechanisms for achieving idempotency, ranging from built-in upsert operations (Salesforce, NetSuite) to middleware-based duplicate detection (SAP) to alternate key upserts (Dynamics 365).

SystemNative Idempotency MechanismAPI SurfaceKey Limitation
SalesforceExternal ID upsert (PATCH)REST API v62.0Max 7 External ID fields per object
SAP S/4HANAIdempotent Process Call (middleware)OData v4 via Integration SuiteRequires SAP Integration Suite; OData is stateless
Oracle NetSuiteupsert/upsertList with External IDSuiteTalk REST / SOAPExternal IDs are case-insensitive
Microsoft Dynamics 365Alternate key upsert (PATCH)Dataverse Web API v9.2Max 5 key fields per alternate key

API Surfaces & Capabilities

PatternProtocolIdempotent?MechanismBulk SupportReal-time?
Upsert via External IDREST (PATCH/PUT)Yes — naturallyUnique external identifier matchSalesforce Bulk API 2.0, NetSuite upsertListYes
Idempotency Key HeaderREST (POST)Yes — with keyServer-side key store + dedupNo — per-request onlyYes
OData Repeatable RequestsOData (POST/PATCH/DELETE)Yes — with headersRepeatability-Request-ID + First-SentNo — not for batch requestsYes
Idempotent Process CallSAP Integration SuiteYes — middlewareMessage ID in idempotent repositoryYes — per-messageAsync
Idempotent ConsumerMessage queue consumerYes — with dedup storeMessage ID tracked in DB before processingYes — per-messageEvent-driven
Database-level MERGE/UPSERTSQL (direct DB)Yes — naturallyON CONFLICT / MERGE statementYesBatch

Rate Limits & Quotas

Idempotency-Specific Limits

Limit TypeValueSystemNotes
External ID fields per object7SalesforceApplies across all External ID types [src4]
Alternate key fields per entity5Dynamics 365Composite alternate keys count each field separately
Idempotency key TTL24h typicalGeneral best practiceStripe standard; ERP batch jobs may need 7+ days [src1]
OData Repeatability-First-Sent windowServer-definedOData specServer returns 412 if timestamp precedes earliest tracked request [src3]
Idempotent repository retention30 days defaultSAP Integration SuiteConfigurable per iFlow; JMS-backed store [src8]
External ID uniqueness scopePer record typeNetSuiteOne external ID value per record; case-insensitive [src7]
Upsert batch size200 composite / 10K bulkSalesforceComposite API: 200 records; Bulk API 2.0: 150MB CSV [src4]

Key Generation Overhead

StrategyStorage CostLookup SpeedBest ForLimitation
UUIDv4 (random)High — store all keysO(1) hash lookupLow-volume, simple integrationsMust store entire key history within TTL window [src5]
UUIDv7 / ULID (timestamped)Medium — can prune by timeO(1) hash lookupMost ERP integrationsRequires clock synchronization [src5]
Monotonic sequenceLow — store only last valueO(1) comparisonSingle-producer, ordered streamsBreaks with multiple concurrent producers [src5]
CDC log position (LSN)Zero — derived from DBO(1) comparisonChange Data Capture pipelinesTied to source database transaction log [src5]
Natural business keyZero — uses existing fieldO(1) hash lookupUpsert patterns (order number, invoice ID)Assumes business key is truly unique and immutable

Authentication

Not applicable to this architecture pattern card. See individual system cards:

Constraints

Integration Pattern Decision Tree

START — User needs idempotent ERP integration
├── What type of operation?
│   ├── Create or Update records (most common)
│   │   ├── Does the target ERP support upsert with external ID?
│   │   │   ├── YES (Salesforce, NetSuite, Dynamics 365)
│   │   │   │   ├── Stable business key? → Native upsert with business key as External ID
│   │   │   │   └── No stable key? → Generate deterministic key (hash) or use source system ID
│   │   │   └── NO (SAP S/4HANA OData, custom APIs)
│   │   │       ├── Using SAP Integration Suite? → Enable Idempotent Process Call
│   │   │       ├── OData supports Repeatable Requests? → Use Repeatability-Request-ID
│   │   │       └── Neither? → Implement middleware dedup store (Redis/DB + key header)
│   ├── Event-driven / message consumer
│   │   └── At-least-once broker? → Idempotent Consumer pattern (track message IDs in DB)
│   └── Delete operations → Naturally idempotent (deleting already-deleted = no-op)
├── What key generation strategy?
│   ├── Natural business key → Use it (order_id, invoice_number)
│   ├── Source system ID → Use source_system:record_id format
│   ├── No stable key → Generate UUIDv7 at origin
│   └── CDC pipeline → Derive from transaction log position (LSN)
└── What TTL for idempotency keys?
    ├── Real-time sync → 24 hours
    ├── Daily batch → 7 days
    ├── Monthly close → 35 days
    └── Data migration → Duration + buffer

Quick Reference

ERP SystemUpsert EndpointHTTP MethodKey Field ParameterMax BatchBulk Upsert?
Salesforce/sobjects/{Object}/{ExtIdField}/{Value}PATCHURL path segment200 (Composite)Yes — Bulk API 2.0
NetSuite (REST)/record/v1/{type}/{externalId}PUT/PATCHexternalId in URL1 (REST)upsertList (SOAP, 100)
Dynamics 365/{EntitySet}({key}='{val}')PATCHAlternate key in URL1000 (batch)Yes — $batch
SAP S/4HANAOData entity path (varies)POST + headersRepeatability-Request-ID1 (individual)No — use Integration Suite
Generic RESTCustom endpointPOSTIdempotency-Key header1Per-record keys in payload
StrategyDuplicate RiskComplexityPerformance ImpactRecommended For
Upsert + External IDNear-zeroLowMinimalDefault for all CRUD integrations
Idempotency Key + Dedup StoreNear-zeroMedium+1 DB lookup per requestPOST-only APIs without upsert
OData Repeatable RequestsNear-zeroLowServer-managedOData v4 services that support it
Pre-check GET + POSTMedium (race condition)Low+1 API callLast resort — NOT recommended
Idempotent ConsumerNear-zeroMedium+1 DB write per messageEvent-driven architectures
Payload hash dedupLowMediumCPU cost for hashingWhen no natural key exists

Step-by-Step Integration Guide

1. Choose your idempotency strategy based on the target ERP

Evaluate whether the target ERP supports native upsert — if yes, use upsert with an External ID as your primary pattern. [src4, src7]

Decision matrix:
Target supports upsert?  → Use native upsert + External ID (90% of cases)
POST-only API?           → Add Idempotency-Key header
OData with Repeatable?   → Use Repeatability-Request-ID
Event consumer?          → Idempotent Consumer pattern

Verify: Check target ERP API docs for "upsert", "external ID", or "alternate key" support.

2. Design your external ID / idempotency key

The key must be deterministic, unique, and stable. Prefer natural business keys over synthetic ones. [src1, src5]

Good keys:
  order_id: "ORD-2026-00042"                    # Natural business key
  source_ref: "shopify:order:9876543210"         # Source system + type + ID
  composite: "INV-2026-003:LINE-007"             # Parent + child reference

Bad keys:
  timestamp: "2026-03-02T10:30:00Z"              # Not unique across concurrent ops
  random_per_retry: uuid()                        # Different on each retry
  mutable_field: customer.email                   # Can change between retries

Verify: Test uniqueness with SELECT external_id, COUNT(*) GROUP BY external_id HAVING COUNT(*) > 1

3. Implement the upsert call (Salesforce example)

Salesforce upsert via External ID is the gold standard. The endpoint creates if the External ID value does not exist, or updates if it does. [src4]

# Salesforce REST API upsert — PATCH to the External ID endpoint
curl -X PATCH \
  "$INSTANCE_URL/services/data/v62.0/sobjects/Invoice__c/Vendor_Invoice_Id__c/INV-2026-003" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"Amount__c": 1500.00, "Vendor__c": "ACME Corp", "Due_Date__c": "2026-04-01"}'

# CREATE: HTTP 201 + {"id": "a0B..."}
# UPDATE: HTTP 204 No Content
# CONFLICT: HTTP 300 Multiple Choices (duplicate External IDs — data quality issue)

Verify: Query the record by External ID and confirm field values match.

4. Implement idempotency key pattern (POST-only APIs)

When the target does not support upsert, add a client-generated idempotency key. The server stores key + result and returns cached result on replay. [src1, src2]

import hashlib, json, time, random, requests

def call_with_idempotency(endpoint, payload, idempotency_key=None, max_retries=5):
    if idempotency_key is None:
        payload_hash = hashlib.sha256(
            json.dumps(payload, sort_keys=True).encode()
        ).hexdigest()[:32]
        idempotency_key = f"idem-{payload_hash}"

    headers = {"Content-Type": "application/json", "Idempotency-Key": idempotency_key}
    for attempt in range(max_retries):
        try:
            response = requests.post(endpoint, json=payload, headers=headers)
            if response.status_code == 409:  # Concurrent request
                time.sleep(2 ** attempt + random.uniform(0, 1))
                continue
            return response
        except requests.exceptions.ConnectionError:
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt + random.uniform(0, 1))
                continue
            raise

Verify: Send same request twice with same key — second call returns identical response without creating a duplicate.

5. Implement server-side idempotency store

The key requirement is atomicity — check and business logic must execute in a single DB transaction. [src2, src6]

CREATE TABLE idempotency_keys (
    idempotency_key  VARCHAR(255) PRIMARY KEY,
    request_path     VARCHAR(500) NOT NULL,
    request_hash     VARCHAR(64) NOT NULL,
    status           VARCHAR(20) NOT NULL DEFAULT 'in_progress',
    response_code    INTEGER,
    response_body    JSONB,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at       TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at) WHERE status = 'completed';

Verify: SELECT COUNT(*) FROM idempotency_keys WHERE status = 'in_progress' AND created_at < NOW() - INTERVAL '1 hour' should be 0.

6. Implement Idempotent Consumer for event-driven flows

Track processed message IDs in a DB table within the same transaction as business logic. [src6]

def process_message_idempotently(conn, message):
    message_id = message["id"]
    subscriber_id = "erp-sync-worker"
    with conn.cursor() as cur:
        try:
            cur.execute("""
                INSERT INTO processed_messages (subscriber_id, message_id, received_at)
                VALUES (%s, %s, NOW())
            """, (subscriber_id, message_id))
            result = upsert_to_erp(message["payload"])  # Business logic
            cur.execute("""
                UPDATE processed_messages SET status = 'completed', processed_at = NOW()
                WHERE subscriber_id = %s AND message_id = %s
            """, (subscriber_id, message_id))
            conn.commit()
            return result
        except psycopg2.errors.UniqueViolation:
            conn.rollback()
            return {"status": "duplicate", "message_id": message_id}

Verify: Send same message ID twice — second returns {"status": "duplicate"}.

Code Examples

Python: Salesforce batch upsert with External ID and retry logic

# Input:  List of records with external IDs, Salesforce credentials
# Output: Upsert results (created/updated/error counts)

import requests, time, random

def salesforce_upsert_batch(instance_url, access_token, object_name,
                            ext_id_field, records, batch_size=200):
    headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
    results = {"created": 0, "updated": 0, "errors": []}
    for i in range(0, len(records), batch_size):
        batch = records[i:i + batch_size]
        composite_body = {
            "allOrNone": False,
            "compositeRequest": [
                {
                    "method": "PATCH",
                    "url": f"/services/data/v62.0/sobjects/{object_name}/{ext_id_field}/{rec[ext_id_field]}",
                    "referenceId": f"ref_{idx}",
                    "body": {k: v for k, v in rec.items() if k != ext_id_field},
                }
                for idx, rec in enumerate(batch)
            ],
        }
        for attempt in range(5):
            resp = requests.post(f"{instance_url}/services/data/v62.0/composite",
                                 json=composite_body, headers=headers, timeout=120)
            if resp.status_code == 429:
                time.sleep(2 ** attempt + random.uniform(0, 1))
                continue
            break
        for sub in resp.json().get("compositeResponse", []):
            if sub["httpStatusCode"] == 201: results["created"] += 1
            elif sub["httpStatusCode"] == 204: results["updated"] += 1
            else: results["errors"].append(sub)
    return results

JavaScript/Node.js: Generic idempotency middleware for Express

// Input:  Express request with Idempotency-Key header
// Output: Cached response on duplicate, or proceeds to handler

const { Pool } = require("pg");
const crypto = require("crypto");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function idempotencyMiddleware(req, res, next) {
  const key = req.headers["idempotency-key"];
  if (!key || req.method === "GET") return next();
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const hash = crypto.createHash("sha256").update(JSON.stringify(req.body)).digest("hex");
    const { rowCount } = await client.query(
      `INSERT INTO idempotency_keys (idempotency_key, request_path, request_hash, status)
       VALUES ($1, $2, $3, 'in_progress') ON CONFLICT DO NOTHING RETURNING idempotency_key`,
      [key, req.path, hash]);
    if (rowCount === 0) {
      const { rows } = await client.query(
        `SELECT status, response_code, response_body FROM idempotency_keys WHERE idempotency_key = $1`, [key]);
      await client.query("COMMIT");
      if (rows[0].status === "completed") return res.status(rows[0].response_code).json(rows[0].response_body);
      return res.status(409).json({ error: "Request in progress" });
    }
    await client.query("COMMIT");
    const origJson = res.json.bind(res);
    res.json = async (body) => {
      await pool.query(`UPDATE idempotency_keys SET status='completed', response_code=$1, response_body=$2
         WHERE idempotency_key=$3`, [res.statusCode, JSON.stringify(body), key]);
      return origJson(body);
    };
    next();
  } catch (err) { await client.query("ROLLBACK"); next(err); }
  finally { client.release(); }
}

cURL: OData Repeatable Request with idempotency headers

# Input:  OData v4 endpoint, bearer token, UUIDv4 for request ID
# Output: Idempotent POST — server skips on replay

REQUEST_ID=$(uuidgen)

# First request — creates the sales order
curl -X POST \
  "https://your-s4hana.sap/sap/opu/odata4/sap/API_SALES_ORDER_SRV/A_SalesOrder" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Repeatability-Request-ID: $REQUEST_ID" \
  -H "Repeatability-First-Sent: $(date -u '+%a, %d %b %Y %H:%M:%S GMT')" \
  -d '{"SalesOrderType": "OR", "SoldToParty": "10100001"}'

# Retry with SAME headers — server returns: Repeatability-Result: accepted

Data Mapping

Idempotency Key Mapping Across Systems

Source System KeySalesforce TargetNetSuite TargetDynamics 365 TargetSAP Target
order_id (string)External ID field on Order__cexternalId on SalesOrderAlternate key on salesorderRepeatability-Request-ID or IDoc control
invoice_numberInvoice_Ref__c External IDexternalId on VendorBillAlternate key on invoiceDocument number (BELNR)
customer_idExt_Customer_Id__c External IDexternalId on CustomerAlternate key on accountBusiness Partner (PARTNER)
composite_keyFormula field → single External IDSingle externalId (flatten)Multi-field alternate key (up to 5)Concatenate in middleware

Data Type Gotchas

Error Handling & Failure Points

Common Error Codes

CodeMeaningSystemResolution
201Created (new record)Salesforce upsertSuccess — record did not exist, was created
204No Content (updated)Salesforce upsertSuccess — record existed, was updated
300Multiple ChoicesSalesforce upsertExternal ID matched multiple records — deduplicate first [src4]
400Bad RequestNetSuiteExternal ID already assigned to a different record (case collision) [src7]
409ConflictIdempotency keyConcurrent request with same key still in progress — retry with backoff [src1]
412Precondition FailedOData RepeatableTimestamp precedes server's retention window [src3]
422Unprocessable EntityIdempotency keyKey reused with different payload — generate new key [src1]
429Too Many RequestsAll systemsExponential backoff: wait 2^n seconds, max 5 retries
501Not ImplementedOData RepeatableServer does not support repeatable requests [src3]

Failure Points in Production

Anti-Patterns

Wrong: POST without idempotency for record creation

# BAD — every retry creates a duplicate record
def create_order(api_client, order_data):
    response = api_client.post("/api/orders", json=order_data)
    return response  # Network timeout → retry → duplicate order!

Correct: Use upsert with External ID or idempotency key

# GOOD — retries are safe, no duplicates
def create_order(api_client, order_data):
    ext_id = order_data["order_reference"]  # Stable business key
    response = api_client.patch(f"/api/orders/external/{ext_id}", json=order_data)
    return response  # Retry 100 times — same result every time

Wrong: Pre-check GET then POST (check-then-act race condition)

# BAD — race condition between check and create
def create_if_not_exists(api_client, record):
    existing = api_client.get(f"/api/records?ext_id={record['ext_id']}")
    if not existing.json()["records"]:
        # Another thread creates the record HERE → duplicate!
        api_client.post("/api/records", json=record)

Correct: Atomic upsert or database-level constraint

# GOOD — atomic operation, no race condition
def create_if_not_exists(api_client, record):
    api_client.patch(f"/api/records/ext_id/{record['ext_id']}", json=record)

Wrong: Random values as idempotency keys

# BAD — different key on every retry
import uuid
def create_payment(api_client, payment_data):
    headers = {"Idempotency-Key": str(uuid.uuid4())}  # Random! New every call!
    return api_client.post("/api/payments", json=payment_data, headers=headers)

Correct: Deterministic key generated at point of origin

# GOOD — same key on every retry
def create_payment(api_client, payment_data, idempotency_key):
    headers = {"Idempotency-Key": idempotency_key}  # Generated ONCE by caller
    return api_client.post("/api/payments", json=payment_data, headers=headers)

Common Pitfalls

Diagnostic Commands

# Check for duplicate External IDs in Salesforce (SOQL via REST API)
curl -s "$INSTANCE_URL/services/data/v62.0/query?q=SELECT+Ext_Id__c,COUNT(Id)+FROM+MyObject__c+GROUP+BY+Ext_Id__c+HAVING+COUNT(Id)>1" \
  -H "Authorization: Bearer $TOKEN"

# Check idempotency key store for stuck in-progress keys
psql -c "SELECT idempotency_key, request_path, created_at
         FROM idempotency_keys
         WHERE status = 'in_progress'
         AND created_at < NOW() - INTERVAL '30 minutes';"

# Verify NetSuite external ID assignment
curl -s "https://{account_id}.suitetalk.api.netsuite.com/services/rest/record/v1/salesOrder?q=externalId IS 'ORD-2026-001'" \
  -H "Authorization: Bearer $TOKEN"

# Check Dynamics 365 alternate key definition
curl -s "$D365_URL/api/data/v9.2/EntityDefinitions(LogicalName='salesorder')/Keys" \
  -H "Authorization: Bearer $TOKEN"

# Monitor idempotency key store growth
psql -c "SELECT status, COUNT(*), MIN(created_at), MAX(created_at)
         FROM idempotency_keys GROUP BY status;"

Version History & Compatibility

Standard / FeatureRelease DateStatusKey ChangeImpact
OData Repeatable Requests v1.02024-10OASIS StandardFormalized Repeatability-Request-ID headersSAP and Microsoft OData services adopting
Salesforce External ID upsert2008+ (API v12+)GA — stableNo breaking changes since introductionIndustry gold standard
NetSuite SuiteTalk upsert2015+GA — stableREST API upsert added 2021Case-insensitivity unchanged
Dynamics 365 Alternate Keys2016+ (v8.0+)GA — stableMax 5 fields, Web API supportConsistent across Dataverse
SAP Idempotent Process Call2020+GA in Integration SuiteJMS-backed repositoryRequires Cloud Integration license
Stripe Idempotency-Key2017+Industry de facto24h TTL, automatic cleanupMost widely copied pattern
AWS ClientToken2013+GAPer-API opt-inEC2, Lambda, other services

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Any integration that retries on failureRead-only integrations (GET is naturally idempotent)No idempotency needed
At-least-once message queues (Kafka, SQS, Platform Events)ERP has built-in exactly-once (rare — verify)Still recommended as defense-in-depth
Scheduled batch jobs that may be re-triggeredOne-time migration with manual verificationPre-migration dedup + manual review
Financial transactions (orders, invoices, payments)Logging/telemetry where occasional duplicates are OKBest-effort retry without dedup
Multi-system sync through multiple hopsDirect database replication (binlog, logical replication)DB-level replication handles dedup natively

Cross-System Comparison

CapabilitySalesforceSAP S/4HANAOracle NetSuiteDynamics 365
Native upsertYes — External ID PATCHNo — OData POST only (middleware)Yes — upsert/upsertListYes — Alternate Key PATCH
Idempotency key headerNo native supportOData Repeatable Requests (limited)No native supportOData Repeatable Requests (Dataverse)
External ID limit7 per objectN/A (no external ID in OData)1 per record (case-insensitive)5 fields per alternate key
Bulk upsertBulk API 2.0 (externalIdFieldName)File-based (FBDI) with dedup rulesupsertList (SOAP, 100/batch)$batch endpoint (1000/req)
Middleware dedupNot needed (native upsert)Required — Idempotent Process CallNot needed (native upsert)Optional — native keys usually sufficient
CDC for consumersCDC + Platform EventsEvent Mesh + Business EventsUser Event ScriptsDataverse change tracking
Duplicate detectionBuilt-in duplicate managementCustom ABAP checksSuiteScript-basedBuilt-in duplicate detection rules

Important Caveats

Related Units