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).
| System | Native Idempotency Mechanism | API Surface | Key Limitation |
|---|---|---|---|
| Salesforce | External ID upsert (PATCH) | REST API v62.0 | Max 7 External ID fields per object |
| SAP S/4HANA | Idempotent Process Call (middleware) | OData v4 via Integration Suite | Requires SAP Integration Suite; OData is stateless |
| Oracle NetSuite | upsert/upsertList with External ID | SuiteTalk REST / SOAP | External IDs are case-insensitive |
| Microsoft Dynamics 365 | Alternate key upsert (PATCH) | Dataverse Web API v9.2 | Max 5 key fields per alternate key |
| Pattern | Protocol | Idempotent? | Mechanism | Bulk Support | Real-time? |
|---|---|---|---|---|---|
| Upsert via External ID | REST (PATCH/PUT) | Yes — naturally | Unique external identifier match | Salesforce Bulk API 2.0, NetSuite upsertList | Yes |
| Idempotency Key Header | REST (POST) | Yes — with key | Server-side key store + dedup | No — per-request only | Yes |
| OData Repeatable Requests | OData (POST/PATCH/DELETE) | Yes — with headers | Repeatability-Request-ID + First-Sent | No — not for batch requests | Yes |
| Idempotent Process Call | SAP Integration Suite | Yes — middleware | Message ID in idempotent repository | Yes — per-message | Async |
| Idempotent Consumer | Message queue consumer | Yes — with dedup store | Message ID tracked in DB before processing | Yes — per-message | Event-driven |
| Database-level MERGE/UPSERT | SQL (direct DB) | Yes — naturally | ON CONFLICT / MERGE statement | Yes | Batch |
| Limit Type | Value | System | Notes |
|---|---|---|---|
| External ID fields per object | 7 | Salesforce | Applies across all External ID types [src4] |
| Alternate key fields per entity | 5 | Dynamics 365 | Composite alternate keys count each field separately |
| Idempotency key TTL | 24h typical | General best practice | Stripe standard; ERP batch jobs may need 7+ days [src1] |
| OData Repeatability-First-Sent window | Server-defined | OData spec | Server returns 412 if timestamp precedes earliest tracked request [src3] |
| Idempotent repository retention | 30 days default | SAP Integration Suite | Configurable per iFlow; JMS-backed store [src8] |
| External ID uniqueness scope | Per record type | NetSuite | One external ID value per record; case-insensitive [src7] |
| Upsert batch size | 200 composite / 10K bulk | Salesforce | Composite API: 200 records; Bulk API 2.0: 150MB CSV [src4] |
| Strategy | Storage Cost | Lookup Speed | Best For | Limitation |
|---|---|---|---|---|
| UUIDv4 (random) | High — store all keys | O(1) hash lookup | Low-volume, simple integrations | Must store entire key history within TTL window [src5] |
| UUIDv7 / ULID (timestamped) | Medium — can prune by time | O(1) hash lookup | Most ERP integrations | Requires clock synchronization [src5] |
| Monotonic sequence | Low — store only last value | O(1) comparison | Single-producer, ordered streams | Breaks with multiple concurrent producers [src5] |
| CDC log position (LSN) | Zero — derived from DB | O(1) comparison | Change Data Capture pipelines | Tied to source database transaction log [src5] |
| Natural business key | Zero — uses existing field | O(1) hash lookup | Upsert patterns (order number, invoice ID) | Assumes business key is truly unique and immutable |
Not applicable to this architecture pattern card. See individual system cards:
ORD-001 and ord-001 collide with a 400 error. Normalize before assignment. [src7]externalIdFieldName parameter on Bulk API upsert jobs. [src4]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
| ERP System | Upsert Endpoint | HTTP Method | Key Field Parameter | Max Batch | Bulk Upsert? |
|---|---|---|---|---|---|
| Salesforce | /sobjects/{Object}/{ExtIdField}/{Value} | PATCH | URL path segment | 200 (Composite) | Yes — Bulk API 2.0 |
| NetSuite (REST) | /record/v1/{type}/{externalId} | PUT/PATCH | externalId in URL | 1 (REST) | upsertList (SOAP, 100) |
| Dynamics 365 | /{EntitySet}({key}='{val}') | PATCH | Alternate key in URL | 1000 (batch) | Yes — $batch |
| SAP S/4HANA | OData entity path (varies) | POST + headers | Repeatability-Request-ID | 1 (individual) | No — use Integration Suite |
| Generic REST | Custom endpoint | POST | Idempotency-Key header | 1 | Per-record keys in payload |
| Strategy | Duplicate Risk | Complexity | Performance Impact | Recommended For |
|---|---|---|---|---|
| Upsert + External ID | Near-zero | Low | Minimal | Default for all CRUD integrations |
| Idempotency Key + Dedup Store | Near-zero | Medium | +1 DB lookup per request | POST-only APIs without upsert |
| OData Repeatable Requests | Near-zero | Low | Server-managed | OData v4 services that support it |
| Pre-check GET + POST | Medium (race condition) | Low | +1 API call | Last resort — NOT recommended |
| Idempotent Consumer | Near-zero | Medium | +1 DB write per message | Event-driven architectures |
| Payload hash dedup | Low | Medium | CPU cost for hashing | When no natural key exists |
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.
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
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.
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.
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.
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"}.
# 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
// 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(); }
}
# 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
| Source System Key | Salesforce Target | NetSuite Target | Dynamics 365 Target | SAP Target |
|---|---|---|---|---|
order_id (string) | External ID field on Order__c | externalId on SalesOrder | Alternate key on salesorder | Repeatability-Request-ID or IDoc control |
invoice_number | Invoice_Ref__c External ID | externalId on VendorBill | Alternate key on invoice | Document number (BELNR) |
customer_id | Ext_Customer_Id__c External ID | externalId on Customer | Alternate key on account | Business Partner (PARTNER) |
composite_key | Formula field → single External ID | Single externalId (flatten) | Multi-field alternate key (up to 5) | Concatenate in middleware |
| Code | Meaning | System | Resolution |
|---|---|---|---|
| 201 | Created (new record) | Salesforce upsert | Success — record did not exist, was created |
| 204 | No Content (updated) | Salesforce upsert | Success — record existed, was updated |
| 300 | Multiple Choices | Salesforce upsert | External ID matched multiple records — deduplicate first [src4] |
| 400 | Bad Request | NetSuite | External ID already assigned to a different record (case collision) [src7] |
| 409 | Conflict | Idempotency key | Concurrent request with same key still in progress — retry with backoff [src1] |
| 412 | Precondition Failed | OData Repeatable | Timestamp precedes server's retention window [src3] |
| 422 | Unprocessable Entity | Idempotency key | Key reused with different payload — generate new key [src1] |
| 429 | Too Many Requests | All systems | Exponential backoff: wait 2^n seconds, max 5 retries |
| 501 | Not Implemented | OData Repeatable | Server does not support repeatable requests [src3] |
Implement TTL-based cleanup job that resets stuck in_progress keys after N minutes. [src1]Prefix keys with environment: "prod:ORD-001" vs "staging:ORD-001". [src2]Validate all External IDs are non-null/non-empty before submission. Post-job duplicate check. [src4]ORD-001 and ord-001 to different records. Fix: Normalize all external IDs to uppercase at assignment. Add lint check. [src7]Always wrap check + logic in single ACID transaction. Use Outbox pattern for microservices. [src2]Store request payload hash alongside key. Reject with 422 if hash doesn't match. [src1]# 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!
# 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
# 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)
# 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)
# 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)
# 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)
Run duplicate detection reports periodically and merge records. [src4]Set TTL = max retry window + buffer. Weekly: 10 days. Monthly: 35 days. [src1]Prefix keys with flow identifier: "order-sync:ORD-001" vs "invoice-sync:INV-001". [src2]Parse per-record results and retry only failed records. [src4]Use immutable identifiers (source system ID, UUID) as External IDs. [src7]# 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;"
| Standard / Feature | Release Date | Status | Key Change | Impact |
|---|---|---|---|---|
| OData Repeatable Requests v1.0 | 2024-10 | OASIS Standard | Formalized Repeatability-Request-ID headers | SAP and Microsoft OData services adopting |
| Salesforce External ID upsert | 2008+ (API v12+) | GA — stable | No breaking changes since introduction | Industry gold standard |
| NetSuite SuiteTalk upsert | 2015+ | GA — stable | REST API upsert added 2021 | Case-insensitivity unchanged |
| Dynamics 365 Alternate Keys | 2016+ (v8.0+) | GA — stable | Max 5 fields, Web API support | Consistent across Dataverse |
| SAP Idempotent Process Call | 2020+ | GA in Integration Suite | JMS-backed repository | Requires Cloud Integration license |
| Stripe Idempotency-Key | 2017+ | Industry de facto | 24h TTL, automatic cleanup | Most widely copied pattern |
| AWS ClientToken | 2013+ | GA | Per-API opt-in | EC2, Lambda, other services |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Any integration that retries on failure | Read-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-triggered | One-time migration with manual verification | Pre-migration dedup + manual review |
| Financial transactions (orders, invoices, payments) | Logging/telemetry where occasional duplicates are OK | Best-effort retry without dedup |
| Multi-system sync through multiple hops | Direct database replication (binlog, logical replication) | DB-level replication handles dedup natively |
| Capability | Salesforce | SAP S/4HANA | Oracle NetSuite | Dynamics 365 |
|---|---|---|---|---|
| Native upsert | Yes — External ID PATCH | No — OData POST only (middleware) | Yes — upsert/upsertList | Yes — Alternate Key PATCH |
| Idempotency key header | No native support | OData Repeatable Requests (limited) | No native support | OData Repeatable Requests (Dataverse) |
| External ID limit | 7 per object | N/A (no external ID in OData) | 1 per record (case-insensitive) | 5 fields per alternate key |
| Bulk upsert | Bulk API 2.0 (externalIdFieldName) | File-based (FBDI) with dedup rules | upsertList (SOAP, 100/batch) | $batch endpoint (1000/req) |
| Middleware dedup | Not needed (native upsert) | Required — Idempotent Process Call | Not needed (native upsert) | Optional — native keys usually sufficient |
| CDC for consumers | CDC + Platform Events | Event Mesh + Business Events | User Event Scripts | Dataverse change tracking |
| Duplicate detection | Built-in duplicate management | Custom ABAP checks | SuiteScript-based | Built-in duplicate detection rules |