Idempotency in ERP Integrations: Keys, Deduplication, and Upsert Patterns
How do you implement idempotency in ERP integrations - idempotency keys, deduplication, upsert patterns?
TL;DR
- Bottom line: Every ERP integration that creates or updates records must be idempotent — use upsert with external IDs as the default pattern, add idempotency keys for non-upsertable operations, and implement an idempotent consumer for event-driven flows.
- Key limit: Salesforce allows max 7 External ID fields per object; NetSuite external IDs are case-insensitive; Dynamics 365 alternate keys support max 5 fields per entity.
- Watch out for: Bulk APIs (Salesforce Bulk API 2.0, NetSuite CSV Import) do NOT support idempotency key headers natively — you must deduplicate before submission or use external ID upsert mode.
- Best for: Any integration that retries on failure, runs on a schedule, or processes messages from an at-least-once delivery queue (Kafka, SQS, Platform Events).
- Authentication: Not applicable — this is a cross-system architecture pattern. See individual system cards for auth details.
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).
| 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 |
API Surfaces & Capabilities
| 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 |
Rate Limits & Quotas
Idempotency-Specific Limits
| 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] |
Key Generation Overhead
| 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 |
Authentication
Not applicable to this architecture pattern card. See individual system cards:
Constraints
- Salesforce External ID limit: Max 7 External ID fields per sObject. Use a formula field concatenating multiple fields as a single External ID if you need more. [src4]
- NetSuite case insensitivity: External IDs are NOT case-sensitive.
ORD-001andord-001collide with a 400 error. Normalize before assignment. [src7] - SAP OData statelessness: Standard SAP S/4HANA OData services have no built-in idempotency. You MUST use SAP Integration Suite's Idempotent Process Call or implement your own dedup store. [src8]
- Dynamics 365 alternate key limit: Max 5 fields per alternate key definition. Complex composite keys must use custom duplicate detection rules.
- Bulk API gap: Salesforce Bulk API 2.0 and NetSuite CSV Import do not accept idempotency key headers. Use
externalIdFieldNameparameter on Bulk API upsert jobs. [src4] - OData batch limitation: OData Repeatable Requests cannot be applied to batch requests — only to individual requests within change sets. [src3]
- ACID requirement: Idempotency check and business logic MUST run in the same database transaction. Separate transactions risk recording the key without executing the operation. [src2]
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 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 |
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 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 |
Data Type Gotchas
- Salesforce External IDs are case-sensitive but NetSuite External IDs are case-INsensitive. Normalize to single case at middleware layer. [src4, src7]
- Dynamics 365 alternate key lookups with NULL values fail silently — all key fields must be non-null or the upsert falls back to create.
- SAP document numbers may have leading zeros that get stripped by JavaScript/Python — always preserve original format.
- Timestamps as key components are dangerous — clock skew between systems creates different keys for the same operation.
Error Handling & Failure Points
Common Error Codes
| 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] |
Failure Points in Production
- Orphaned in-progress keys: Process crashes between recording key and completing operation. Fix:
Implement TTL-based cleanup job that resets stuck in_progress keys after N minutes.[src1] - Key collision across environments: Same keys used in staging and production. Fix:
Prefix keys with environment: "prod:ORD-001" vs "staging:ORD-001".[src2] - Bulk API silent duplicates: Malformed External ID values in Bulk API create new records. Fix:
Validate all External IDs are non-null/non-empty before submission. Post-job duplicate check.[src4] - NetSuite case collision: Two integrations assign
ORD-001andord-001to different records. Fix:Normalize all external IDs to uppercase at assignment. Add lint check.[src7] - Transaction boundary violation: Key recorded in one transaction, business logic in another. Fix:
Always wrap check + logic in single ACID transaction. Use Outbox pattern for microservices.[src2] - Key reuse with different payload: Developer reuses a key for a different operation. Fix:
Store request payload hash alongside key. Reject with 422 if hash doesn't match.[src1]
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
- Confusing idempotency keys with deduplication IDs: An idempotency key identifies a unique intent (client-side). A deduplication ID identifies a unique message (server-side). Use both when integrating through a message broker to an ERP API. [src5]
- Forgetting HTTP 300 on Salesforce upsert: When an External ID matches multiple records, Salesforce returns 300 instead of updating. Fix:
Run duplicate detection reports periodically and merge records.[src4] - TTL too short for batch jobs: A 24h TTL works for real-time, but weekly batch retry on Monday finds Friday's keys expired. Fix:
Set TTL = max retry window + buffer. Weekly: 10 days. Monthly: 35 days.[src1] - Key collisions across integration flows: Two flows using the same key format creates cross-flow collisions. Fix:
Prefix keys with flow identifier: "order-sync:ORD-001" vs "invoice-sync:INV-001".[src2] - Ignoring partial success in bulk upserts: Bulk operations may partially succeed. Retrying the entire batch wastes API quota. Fix:
Parse per-record results and retry only failed records.[src4] - Using mutable fields as External IDs: Customer email as External ID fails when the email changes. Fix:
Use immutable identifiers (source system ID, UUID) as External IDs.[src7]
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 / 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 |
When to Use / When Not to Use
| 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 |
Cross-System Comparison
| 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 |
Important Caveats
- Idempotency does NOT equal ordering: Two idempotent operations applied out of order can produce wrong results. Combine idempotency with sequence numbers if order matters.
- Key storage grows continuously: Without TTL-based pruning, the idempotency key table becomes a bottleneck. Run daily cleanup. Monitor table size weekly.
- Sandbox vs production differences: Salesforce sandboxes may have stale External ID indexes. NetSuite sandboxes have independent namespaces. Verify behavior in full-copy sandbox.
- Payload consistency across retries: If the client changes the payload between retries (e.g., updated amount), the server should reject with 422. Verify your implementation enforces this.
- Multi-tenant ERP isolation: Ensure idempotency keys are scoped to the tenant. Shared dedup stores risk cross-tenant key collision.
- API version changes do not affect idempotency: Upgrading API versions does not change upsert behavior, but new validation rules or triggers may cause previously successful upserts to fail.