---
# === IDENTITY ===
id: business/erp-integration/idempotency-erp-integrations/2026
canonical_question: "How do you implement idempotency in ERP integrations - idempotency keys, deduplication, upsert patterns?"
aliases:
  - "Idempotent ERP API patterns and deduplication strategies"
  - "How to prevent duplicate records in ERP integrations"
  - "Upsert vs insert for ERP data sync idempotency"
  - "Idempotency key implementation for enterprise API integrations"
entity_type: erp_integration
domain: business > erp-integration > idempotency-erp-integrations
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === SYSTEM PROFILE ===
# Cross-system architecture pattern card — covers idempotency across all major ERPs.
systems:
  - name: "Salesforce"
    vendor: "Salesforce"
    version: "API v62.0"
    edition: "Enterprise, Unlimited"
    deployment: "cloud"
    api_surface: "REST, SOAP, Bulk API 2.0"
  - name: "SAP S/4HANA"
    vendor: "SAP"
    version: "2408"
    edition: "Cloud, On-Premise"
    deployment: "hybrid"
    api_surface: "OData v4, SOAP, IDoc, RFC"
  - name: "Oracle NetSuite"
    vendor: "Oracle"
    version: "2024.2"
    edition: "SuiteCloud"
    deployment: "cloud"
    api_surface: "SuiteTalk REST, SuiteTalk SOAP, RESTlet"
  - name: "Microsoft Dynamics 365"
    vendor: "Microsoft"
    version: "Dataverse API v9.2"
    edition: "Enterprise, Business Central"
    deployment: "cloud"
    api_surface: "OData v4, Web API"

# === VERIFICATION ===
last_verified: 2026-03-02
confidence: 0.92
version: 1.0
first_published: 2026-03-02

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "2024-10 — OData Repeatable Requests v1.0 specification finalized by OASIS"
  next_review: 2026-08-29
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Idempotency key TTL varies by system — Stripe standard is 24h, but ERP batch jobs may need 7+ days retention"
  - "Salesforce upsert requires an External ID field marked as unique — max 7 External ID fields per object"
  - "NetSuite external IDs are case-insensitive — 'ORD-001' and 'ord-001' collide and return 400 Bad Request"
  - "SAP OData services are stateless — duplicate detection requires middleware (SAP Integration Suite Idempotent Process Call or external dedup store)"
  - "Dynamics 365 alternate key upsert supports max 5 key fields per entity — composite keys beyond this require custom dedup logic"
  - "Bulk operations (Salesforce Bulk API, NetSuite CSV import) do not natively support idempotency keys — dedup must happen pre-submission or via external ID upsert"
  - "OData Repeatable Requests spec (OASIS) cannot be applied to batch requests — only to individual requests within change sets"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs single-system API reference (rate limits, endpoints, auth)"
    use_instead: "business/erp-integration/{system}-rest-api-capabilities/2026"
  - condition: "User needs data migration deduplication (one-time load, not ongoing sync)"
    use_instead: "Data migration deduplication — use pre-migration cleansing + external ID assignment, not runtime idempotency"
  - condition: "User needs message broker deduplication (Kafka, RabbitMQ exactly-once)"
    use_instead: "Kafka exactly-once semantics or broker-native dedup — this card focuses on ERP API-layer idempotency"

# === AGENT HINTS ===
inputs_needed:
  - key: erp_system
    question: "Which ERP system(s) are you integrating with?"
    type: choice
    options:
      - "Salesforce"
      - "SAP S/4HANA"
      - "Oracle NetSuite"
      - "Microsoft Dynamics 365"
      - "Multiple / cross-system"
  - key: integration_pattern
    question: "What integration pattern do you need?"
    type: choice
    options:
      - "real-time sync (individual records, <1s latency)"
      - "batch/bulk (scheduled, high volume)"
      - "event-driven (webhook, CDC, platform events)"
      - "file-based (CSV/XML import/export)"
  - key: failure_tolerance
    question: "What is your tolerance for duplicate records?"
    type: choice
    options:
      - "zero tolerance — financial transactions, orders, invoices"
      - "low tolerance — customer/contact records, deduplicate on match"
      - "best-effort — activity logs, telemetry, eventual consistency acceptable"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/business/erp-integration/idempotency-erp-integrations/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-02)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "business/erp-integration/salesforce-rest-api-capabilities/2026"
      label: "Salesforce REST API — External ID upsert endpoint reference"
    - id: "business/erp-integration/sap-integration-suite-capabilities/2026"
      label: "SAP Integration Suite — Idempotent Process Call feature"
  related_to:
    - id: "business/erp-integration/netsuite-suitetalk-api-capabilities/2026"
      label: "NetSuite SuiteTalk — upsert and external ID patterns"
    - id: "business/erp-integration/dynamics-365-web-api-capabilities/2026"
      label: "Dynamics 365 Web API — alternate key upsert"
  solves:
    - id: "business/erp-integration/salesforce-bulk-api-capabilities/2026"
      label: "Salesforce Bulk API — dedup strategy for high-volume loads"
  alternative_to: []
  often_confused_with:
    - id: "business/erp-integration/salesforce-streaming-platform-events/2026"
      label: "Platform Events — at-least-once delivery requires idempotent consumers, not idempotent producers"

# === SOURCES ===
sources:
  - id: src1
    title: "Designing robust and predictable APIs with idempotency"
    author: Stripe
    url: https://stripe.com/blog/idempotency
    type: technical_blog
    published: 2023-07-12
    reliability: high
  - id: src2
    title: "Making retries safe with idempotent APIs"
    author: Amazon Web Services
    url: https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
    type: technical_blog
    published: 2023-01-15
    reliability: high
  - id: src3
    title: "OData Repeatable Requests Version 1.0 (OASIS Specification)"
    author: OASIS
    url: https://docs.oasis-open.org/odata/repeatable-requests/v1.0/cnprd01/repeatable-requests-v1.0-cnprd01.html
    type: official_docs
    published: 2024-10-01
    reliability: authoritative
  - id: src4
    title: "Insert or Update (Upsert) a Record Using an External ID"
    author: Salesforce
    url: https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_upsert.htm
    type: official_docs
    published: 2025-12-01
    reliability: authoritative
  - id: src5
    title: "On Idempotency Keys"
    author: Gunnar Morling
    url: https://www.morling.dev/blog/on-idempotency-keys/
    type: technical_blog
    published: 2024-06-15
    reliability: moderate_high
  - id: src6
    title: "Idempotent Consumer Pattern"
    author: Microservices.io (Chris Richardson)
    url: https://microservices.io/patterns/communication-style/idempotent-consumer.html
    type: community_resource
    published: 2024-03-01
    reliability: high
  - id: src7
    title: "NetSuite SuiteTalk — Using the Upsert Operation"
    author: Oracle
    url: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_156335203191.html
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src8
    title: "SAP Cloud Integration — Idempotent Process Call"
    author: SAP Community
    url: https://community.sap.com/t5/technology-blog-posts-by-members/sap-cloud-integration-idempotent-process-call/ba-p/13580854
    type: community_resource
    published: 2024-08-01
    reliability: moderate_high
---

# Idempotency in ERP Integrations: Keys, Deduplication, and 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 keys 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. It is not specific to a single vendor's API surface. 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). The patterns described here apply regardless of whether you use direct API calls or middleware (MuleSoft, Boomi, Workato, SAP Integration Suite).

| 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 (upsert mode), 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 (text, auto-number) [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 file [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 across producers [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:
- [Salesforce OAuth Authentication](/business/erp-integration/salesforce-oauth-authentication/2026)
- [SAP Authentication Methods](/business/erp-integration/sap-authentication-methods/2026)
- [NetSuite TBA vs OAuth 2.0](/business/erp-integration/netsuite-tba-vs-oauth2/2026)
- [Dynamics 365 Authentication](/business/erp-integration/dynamics-365-authentication/2026)

## Constraints
<!-- Agents: read this section before recommending any integration approach from this unit.
     These are hard constraints that must not be violated. -->

- **Salesforce External ID limit**: Max 7 External ID fields per sObject. If you need more dedup keys, use a formula field concatenating multiple fields as a single External ID. [src4]
- **NetSuite case insensitivity**: External IDs are NOT case-sensitive. `ORD-001` and `ord-001` are treated as the same key and will collide with a 400 error. Normalize to uppercase or lowercase 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 in middleware. [src8]
- **Dynamics 365 alternate key limit**: Max 5 fields per alternate key definition. Complex composite keys exceeding this 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 `externalIdFieldName` parameter on Bulk API upsert jobs or deduplicate CSVs pre-submission. [src4]
- **OData batch limitation**: The OData Repeatable Requests specification explicitly states that repeatability cannot be applied to batch requests — only to individual requests within change sets. [src3]
- **Idempotency key storage**: Idempotency check and business logic MUST run in the same database transaction (ACID). If they are in separate transactions, a crash between the two creates a state where the key is recorded but the operation never executed. [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)
│   │   │   │   ├── Do you have a stable business key? (order number, invoice ID, SKU)
│   │   │   │   │   ├── YES → Use native upsert with business key as External ID
│   │   │   │   │   └── NO → Generate a deterministic key (hash of payload) or use source system ID
│   │   │   │   └── Is it a bulk operation?
│   │   │   │       ├── YES → Use Bulk API upsert mode with externalIdFieldName
│   │   │   │       └── NO → Use REST API PATCH upsert
│   │   │   └── NO (SAP S/4HANA OData, custom APIs)
│   │   │       ├── Using SAP Integration Suite?
│   │   │       │   ├── YES → Enable Idempotent Process Call on the iFlow
│   │   │       │   └── NO → Implement middleware dedup store (Redis/DB + idempotency key header)
│   │   │       └── OData API supports Repeatable Requests?
│   │   │           ├── YES → Use Repeatability-Request-ID + Repeatability-First-Sent headers
│   │   │           └── NO → Pre-check with GET before POST, or implement client-side dedup
│   ├── Event-driven / message consumer
│   │   ├── Is the message broker at-least-once? (Kafka, SQS, Platform Events)
│   │   │   ├── YES → Implement Idempotent Consumer pattern
│   │   │   │   ├── Track message ID in PROCESSED_MESSAGES table
│   │   │   │   ├── Check + insert + process in single DB transaction
│   │   │   │   └── On duplicate key violation → skip (already processed)
│   │   │   └── NO (exactly-once semantics enabled) → Still recommended as defense-in-depth
│   │   └── Need guaranteed ordering?
│   │       ├── YES → Use monotonic sequence keys + ordered processing
│   │       └── NO → UUIDv7 keys sufficient
│   └── Delete operations
│       └── DELETE is naturally idempotent (deleting an already-deleted record = no-op)
│           └── Exception: soft-delete systems — check if "undelete" is possible and handle accordingly
├── What key generation strategy?
│   ├── Natural business key exists → Use it (order_id, invoice_number, sku + warehouse)
│   ├── Source system has stable ID → Use source_system:record_id format
│   ├── No stable key → Generate UUIDv7 at point of origin, propagate through pipeline
│   └── CDC pipeline → Derive from transaction log position (LSN)
└── What TTL for idempotency keys?
    ├── Real-time sync → 24 hours (standard)
    ├── Daily batch → 7 days (covers rerun window)
    ├── Monthly close → 35 days (covers month-end retry cycles)
    └── Data migration → Duration of migration + buffer
```

## Quick Reference

| ERP System | Upsert Endpoint | HTTP Method | Key Field Parameter | Max Batch | Bulk Upsert? |
|---|---|---|---|---|---|
| **Salesforce** | `/services/data/v62.0/sobjects/{Object}/{ExtIdField}/{ExtIdValue}` | PATCH | URL path segment | 200 (Composite) | Yes — Bulk API 2.0 `externalIdFieldName` |
| **NetSuite (REST)** | `/record/v1/{recordType}/{externalId}` or upsert operation | PUT/PATCH | `externalId` in URL or body | 1 (REST) | upsertList (SOAP — up to 100) |
| **Dynamics 365** | `/api/data/v9.2/{EntitySet}({key1}='{val1}',{key2}='{val2}')` | PATCH | Alternate key in URL | 1000 (batch) | Yes — via $batch endpoint |
| **SAP S/4HANA** | OData entity path (varies) | POST + Repeatability headers | `Repeatability-Request-ID` header | 1 (OData individual) | No — use Integration Suite |
| **Generic REST** | Custom endpoint | POST with `Idempotency-Key` header | `Idempotency-Key` or `X-Idempotency-Key` | 1 | Implement per-record keys in payload |

| Strategy | Duplicate Risk | Complexity | Performance Impact | Recommended For |
|---|---|---|---|---|
| **Upsert + External ID** | Near-zero | Low | Minimal — single operation | 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 per operation | Last resort — NOT recommended for concurrent writes |
| **Idempotent Consumer (message queue)** | 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 (most do). If yes, use upsert with an External ID as your primary pattern — it is the simplest, most reliable, and best-performing approach. [src4, src7]

```
Decision matrix:
┌─────────────────────┬──────────────────────────────────┐
│ Target supports     │ Use native upsert + External ID  │ ← 90% of cases
│ upsert?             │                                  │
├─────────────────────┼──────────────────────────────────┤
│ POST-only API?      │ Add Idempotency-Key header       │
├─────────────────────┼──────────────────────────────────┤
│ OData with          │ Use Repeatability-Request-ID     │
│ Repeatable Requests?│                                  │
├─────────────────────┼──────────────────────────────────┤
│ Event consumer?     │ Idempotent Consumer pattern      │
└─────────────────────┴──────────────────────────────────┘
```

**Verify**: Confirm your target ERP's upsert capability by checking its API reference 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]

```
Key design principles:
1. DETERMINISTIC — same input always produces same key
2. UNIQUE — no two different operations share a key
3. STABLE — key does not change if the operation is retried
4. SCOPED — key is unique within its entity type / operation type

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
  deterministic: sha256(customer_id + amount + date + reference)  # When no natural key exists

Bad keys:
  timestamp: "2026-03-02T10:30:00Z"              # Not unique across concurrent operations
  random_per_retry: uuid()                        # Different on each retry — defeats the purpose
  mutable_field: customer.email                   # Can change between retries
```

**Verify**: Test uniqueness by running `SELECT external_id, COUNT(*) FROM records GROUP BY external_id HAVING COUNT(*) > 1` — result should be empty.

### 3. Implement the upsert call (Salesforce example)

Salesforce upsert via External ID is the gold standard. The endpoint creates a record if the External ID value does not exist, or updates the existing record if it does. [src4]

```bash
# Salesforce REST API upsert — PATCH to the External ID endpoint
# If Vendor_Invoice_Id__c = "INV-2026-003" exists → updates that record
# If it doesn't exist → creates a new record with that External ID value

curl -X PATCH \
  "https://yourinstance.salesforce.com/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"
  }'

# Response on CREATE: HTTP 201 Created + {"id": "a0B..."}
# Response on UPDATE: HTTP 204 No Content
# Response on DUPLICATE key already assigned to different record: HTTP 300 Multiple Choices
```

**Verify**: `curl -s ".../sobjects/Invoice__c/Vendor_Invoice_Id__c/INV-2026-003" -H "Authorization: Bearer $TOKEN"` → returns the record with correct field values.

### 4. Implement idempotency key pattern (for POST-only APIs)

When the target API does not support upsert, add a client-generated idempotency key to every mutating request. The server stores the key + result and returns the cached result on replays. [src1, src2]

```python
# Idempotency key pattern — Python implementation
# Input:  API endpoint, payload, idempotency key
# Output: API response (original or cached)

import hashlib
import uuid
import time
import requests

def call_with_idempotency(endpoint, payload, idempotency_key=None, max_retries=5):
    """Make an idempotent POST request with exponential backoff."""
    # Generate deterministic key from payload if not provided
    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,  # Server-side dedup key
    }

    for attempt in range(max_retries):
        try:
            response = requests.post(endpoint, json=payload, headers=headers)
            if response.status_code == 409:  # Key in-progress (concurrent request)
                time.sleep(2 ** attempt + random.uniform(0, 1))  # Backoff + jitter
                continue
            return response
        except requests.exceptions.ConnectionError:
            if attempt < max_retries - 1:
                time.sleep(2 ** attempt + random.uniform(0, 1))
                continue
            raise

    raise Exception(f"Failed after {max_retries} retries for key {idempotency_key}")
```

**Verify**: Send the same request twice with the same `Idempotency-Key` → second call returns identical response without creating a duplicate.

### 5. Implement server-side idempotency store

If you are building middleware or a custom API gateway, you need a server-side dedup store. The key requirement is atomicity — the idempotency check and business logic must execute in a single database transaction. [src2, src6]

```sql
-- Idempotency store schema (PostgreSQL)
CREATE TABLE idempotency_keys (
    idempotency_key  VARCHAR(255) PRIMARY KEY,
    request_path     VARCHAR(500) NOT NULL,
    request_hash     VARCHAR(64) NOT NULL,    -- SHA-256 of request body
    status           VARCHAR(20) NOT NULL DEFAULT 'in_progress',
    response_code    INTEGER,
    response_body    JSONB,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    completed_at     TIMESTAMPTZ,
    expires_at       TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);

-- Index for cleanup job
CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at)
    WHERE status = 'completed';

-- Cleanup: run daily
-- DELETE FROM idempotency_keys WHERE expires_at < NOW();
```

```python
# Server-side idempotency handler — atomic check + process
# Input:  idempotency_key, request_path, request_body
# Output: (is_duplicate, stored_response_or_none)

import psycopg2
import hashlib
import json

def process_idempotent_request(conn, idempotency_key, request_path, request_body):
    """Atomically check and process an idempotent request."""
    request_hash = hashlib.sha256(
        json.dumps(request_body, sort_keys=True).encode()
    ).hexdigest()

    with conn.cursor() as cur:
        # Atomic check-and-insert using ON CONFLICT
        cur.execute("""
            INSERT INTO idempotency_keys (idempotency_key, request_path, request_hash, status)
            VALUES (%s, %s, %s, 'in_progress')
            ON CONFLICT (idempotency_key) DO NOTHING
            RETURNING idempotency_key
        """, (idempotency_key, request_path, request_hash))

        inserted = cur.fetchone()

        if inserted is None:
            # Key already exists — check if completed or still in progress
            cur.execute("""
                SELECT status, response_code, response_body, request_hash
                FROM idempotency_keys
                WHERE idempotency_key = %s
            """, (idempotency_key,))
            existing = cur.fetchone()

            if existing[0] == 'completed':
                if existing[3] != request_hash:
                    # Same key, different payload — this is an error
                    return (True, {"error": "Idempotency key reused with different payload"}, 422)
                return (True, existing[2], existing[1])  # Return cached response
            else:
                # Still in progress — concurrent request
                return (True, {"error": "Request in progress"}, 409)

        # New request — process business logic here
        conn.commit()  # Commit the in_progress record
        return (False, None, None)  # Caller should proceed with business logic

def complete_idempotent_request(conn, idempotency_key, response_code, response_body):
    """Mark an idempotent request as completed with its response."""
    with conn.cursor() as cur:
        cur.execute("""
            UPDATE idempotency_keys
            SET status = 'completed', response_code = %s, response_body = %s, completed_at = NOW()
            WHERE idempotency_key = %s
        """, (response_code, json.dumps(response_body), idempotency_key))
    conn.commit()
```

**Verify**: `SELECT COUNT(*) FROM idempotency_keys WHERE status = 'in_progress' AND created_at < NOW() - INTERVAL '1 hour'` → should be 0 (no stuck requests).

### 6. Implement the Idempotent Consumer pattern for event-driven flows

When consuming messages from Kafka, SQS, or ERP Platform Events (which all provide at-least-once delivery), track processed message IDs in a database table within the same transaction as your business logic. [src6]

```python
# Idempotent Consumer pattern — process each message exactly once
# Input:  message with unique ID from queue
# Output: processed result (or skip if duplicate)

def process_message_idempotently(conn, message):
    """Process a queue message exactly once using the Idempotent Consumer pattern."""
    message_id = message["id"]        # Unique message identifier
    subscriber_id = "erp-sync-worker"  # This consumer's identity

    with conn.cursor() as cur:
        try:
            # Step 1: Attempt to record this message as being processed
            # This INSERT will fail with a unique violation if already processed
            cur.execute("""
                INSERT INTO processed_messages (subscriber_id, message_id, received_at)
                VALUES (%s, %s, NOW())
            """, (subscriber_id, message_id))

            # Step 2: Execute business logic (e.g., upsert to ERP)
            result = upsert_to_erp(message["payload"])

            # Step 3: Record success — all in the same transaction
            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()  # Atomic: message tracking + business logic
            return result

        except psycopg2.errors.UniqueViolation:
            conn.rollback()
            # Message already processed — skip silently
            return {"status": "duplicate", "message_id": message_id}
```

**Verify**: Send the same message ID twice → second invocation returns `{"status": "duplicate"}` without executing business logic.

## Code Examples

### Python: Salesforce upsert with External ID and retry logic

```python
# Input:  List of records with external IDs, Salesforce credentials
# Output: Upsert results (created/updated/error counts)

import requests
import time
import random

def salesforce_upsert_batch(instance_url, access_token, object_name,
                            ext_id_field, records, batch_size=200):
    """Upsert records to Salesforce using External ID (idempotent).

    Uses Composite API for batches up to 200 records.
    Each record MUST include the external ID field value.
    """
    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]
        # Build composite request — each subrequest is an upsert
        composite_body = {
            "allOrNone": False,  # Allow partial success
            "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)
            ],
        }
        # Retry with exponential backoff
        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:  # Rate limited
                wait = 2 ** attempt + random.uniform(0, 1)
                time.sleep(wait)
                continue
            break

        for sub_resp in resp.json().get("compositeResponse", []):
            if sub_resp["httpStatusCode"] == 201:
                results["created"] += 1
            elif sub_resp["httpStatusCode"] == 204:
                results["updated"] += 1
            else:
                results["errors"].append(sub_resp)

    return results
```

### JavaScript/Node.js: Generic idempotency middleware for Express

```javascript
// Input:  Express request with Idempotency-Key header
// Output: Cached response on duplicate, or proceeds to handler on first request

// npm install express pg uuid -- save (Node.js 18+)
const { Pool } = require("pg");

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(); // Skip non-mutating

  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    // Atomic insert — fails on duplicate key
    const { rowCount } = await client.query(
      `INSERT INTO idempotency_keys (idempotency_key, request_path, request_hash, status)
       VALUES ($1, $2, $3, 'in_progress')
       ON CONFLICT (idempotency_key) DO NOTHING
       RETURNING idempotency_key`,
      [key, req.path, hashPayload(req.body)]
    );

    if (rowCount === 0) {
      // Key exists — return cached or 409
      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");

    // Wrap res.json to capture response and store it
    const originalJson = res.json.bind(res);
    res.json = async (body) => {
      await pool.query(
        `UPDATE idempotency_keys SET status='completed', response_code=$1, response_body=$2, completed_at=NOW()
         WHERE idempotency_key=$3`,
        [res.statusCode, JSON.stringify(body), key]
      );
      return originalJson(body);
    };
    next();
  } catch (err) {
    await client.query("ROLLBACK");
    next(err);
  } finally {
    client.release();
  }
}

function hashPayload(body) {
  const crypto = require("crypto");
  return crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex");
}

module.exports = { idempotencyMiddleware };
```

### cURL: OData Repeatable Request with idempotency headers

```bash
# Input:  OData v4 endpoint, bearer token, UUIDv4 for request ID
# Output: Idempotent POST — server skips on replay

# Generate a UUID for the request
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", "RequestedDeliveryDate": "2026-04-01"}'

# Retry with SAME headers — server returns cached response
# Response header: Repeatability-Result: accepted
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", "RequestedDeliveryDate": "2026-04-01"}'

# Check response header for:
# Repeatability-Result: accepted  → first execution (or replay of first)
# Repeatability-Result: rejected  → timestamp outside server's retention window
```

## 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 segment |
| `invoice_number` (string) | `Invoice_Ref__c` External ID | `externalId` on VendorBill | Alternate key on invoice | Document number (BELNR) with check for existing |
| `customer_id` (string) | `Ext_Customer_Id__c` External ID | `externalId` on Customer | Alternate key on account | Business Partner number (PARTNER) |
| `composite_key` (multi-field) | Formula field concatenating fields → single External ID | Single externalId (must flatten composite) | Multi-field alternate key (up to 5) | Concatenated in middleware before API call |

### Data Type Gotchas

- **Salesforce External IDs are case-sensitive** but NetSuite External IDs are case-INsensitive. If syncing between the two, normalize keys to a single case (uppercase recommended) at the middleware layer. [src4, src7]
- **Dynamics 365 alternate key lookups with NULL values fail silently** — all key fields must have non-null values or the upsert falls back to create, causing duplicates.
- **SAP document numbers may have leading zeros** that get stripped by JavaScript/Python string handling — always preserve the original format or pad before comparison.
- **Timestamps as key components are dangerous** — clock skew between systems can cause the same logical operation to generate different keys. Use business timestamps (order date, invoice date) not system timestamps.

## 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 — data quality issue. 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 | Repeatability-First-Sent timestamp precedes server's retention window. [src3] |
| **422** | Unprocessable Entity | Idempotency key | Key reused with different payload (integrity violation). Generate new key. [src1] |
| **429** | Too Many Requests | All systems | Rate limit exceeded. Exponential backoff: wait 2^n seconds, max 5 retries. |
| **501** | Not Implemented | OData Repeatable | Server recognizes headers but does not support repeatable requests. [src3] |

### Failure Points in Production

- **Orphaned in-progress keys**: If the process crashes between recording the idempotency key and completing the operation, the key is stuck as "in_progress" and all retries return 409. Fix: `Implement a TTL-based cleanup job that resets keys stuck in "in_progress" for >N minutes back to available, or set a reaper cron job.` [src1]
- **Key collision across environments**: Using the same idempotency keys in staging and production (e.g., both pulling from the same source queue). Fix: `Prefix keys with environment: "prod:ORD-001" vs "staging:ORD-001".` [src2]
- **Bulk API silent duplicates**: Salesforce Bulk API 2.0 upsert jobs with malformed External ID values create new records instead of updating. Fix: `Validate all External ID values are non-null and non-empty before submitting the CSV. Add a post-job check: query records created in the job window and verify External ID uniqueness.` [src4]
- **NetSuite case collision**: Two integrations assign `ORD-001` and `ord-001` as external IDs to different records, causing 400 errors. Fix: `Normalize all external IDs to uppercase at the point of assignment. Add a lint check to your pipeline.` [src7]
- **Transaction boundary violation**: Idempotency key recorded in one database transaction, business logic in another. A crash between them means the key is "completed" but the operation never executed. Fix: `Always wrap idempotency check + business logic in a single ACID transaction. If using microservices, use the Outbox pattern.` [src2]
- **Idempotency key reuse with different payload**: Developer accidentally reuses a key for a genuinely different operation, getting back the wrong cached response. Fix: `Store a hash of the request payload alongside the key. On replay, verify the hash matches — reject with 422 if it doesn't.` [src1]

## Anti-Patterns

### Wrong: Using POST without idempotency for record creation

```python
# 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

```python
# 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)

```python
# 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

```python
# GOOD — atomic operation, no race condition
def create_if_not_exists(api_client, record):
    # Upsert is atomic — the ERP checks + creates/updates in one operation
    api_client.patch(
        f"/api/records/ext_id/{record['ext_id']}",
        json=record,
    )
```

### Wrong: Using timestamps or random values as idempotency keys

```python
# BAD — different key on every retry, so server treats each as new
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: Generate deterministic key at point of origin, reuse on retry

```python
# GOOD — same key on every retry, server returns cached response
def create_payment(api_client, payment_data, idempotency_key):
    # Key generated ONCE by the caller and passed in
    # e.g., f"pay-{order_id}-{attempt_date}" or a stored UUIDv7
    headers = {"Idempotency-Key": idempotency_key}
    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, attached to a request). A deduplication ID identifies a unique _message_ (server-side, for consumer-side dedup). They serve different purposes and belong in different layers. Use both when integrating through a message broker to an ERP API. [src5]
- **Forgetting to handle HTTP 300 (Multiple Choices) on Salesforce upsert**: When an External ID matches multiple records, Salesforce returns 300 instead of updating. This indicates a data quality problem upstream. Fix: `Run a duplicate detection report periodically and merge records with duplicate External IDs.` [src4]
- **Setting idempotency key TTL too short for batch jobs**: A 24h TTL works for real-time APIs, but a weekly batch job that fails on Friday and retries on Monday will find its keys expired. Fix: `Set TTL = max expected retry window + buffer. For weekly batches: 10 days. For monthly close: 35 days.` [src1]
- **Not validating key uniqueness across integration flows**: Two different integration flows (e.g., order sync and invoice sync) using the same key format ("ORD-001") 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**: Salesforce Composite API and Bulk API return per-record success/failure. A bulk job may upsert 9,990 of 10,000 records successfully. If you retry the entire batch, the 9,990 records are safely re-upserted (idempotent), but you waste API quota. Fix: `Parse per-record results and retry only the failed records.` [src4]
- **Using mutable fields as External IDs**: Setting customer email as an External ID fails when the customer changes their email — the next sync creates a duplicate instead of updating. Fix: `Use immutable identifiers (source system ID, UUID) as External IDs. Map mutable fields as regular data fields only.` [src7]

## Diagnostic Commands

```bash
# 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 and TTL compliance
psql -c "SELECT status, COUNT(*), MIN(created_at), MAX(created_at)
         FROM idempotency_keys
         GROUP BY status;"

# Verify OData Repeatable Requests support on SAP endpoint
curl -I -X OPTIONS "https://your-s4hana.sap/sap/opu/odata4/sap/API_SALES_ORDER_SRV/" \
  -H "Authorization: Bearer $TOKEN"
# Look for: Repeatability-Request-ID in Allow headers
```

## Version History & Compatibility

| Standard / Feature | Release Date | Status | Key Change | Impact |
|---|---|---|---|---|
| OData Repeatable Requests v1.0 | 2024-10 | OASIS Standard | Formalized Repeatability-Request-ID + First-Sent headers | SAP and Microsoft OData services adopting [src3] |
| Salesforce External ID upsert | 2008+ (API v12+) | GA — stable | No breaking changes since introduction | Industry gold standard for upsert idempotency [src4] |
| NetSuite SuiteTalk upsert | 2015+ | GA — stable | REST API upsert added 2021 (SuiteTalk REST) | External ID case-insensitivity unchanged [src7] |
| Dynamics 365 Alternate Keys | 2016+ (v8.0+) | GA — stable | Max 5 fields, Web API support | Consistent across Dataverse versions |
| SAP Idempotent Process Call | 2020+ | GA in Integration Suite | JMS-backed idempotent repository | Requires Cloud Integration license [src8] |
| Stripe Idempotency-Key | 2017+ | Industry de facto | 24h TTL, automatic cleanup | Most widely copied pattern [src1] |
| AWS ClientToken | 2013+ | GA | Per-API opt-in | EC2, Lambda, and other services [src2] |

### Deprecation Policy

Idempotency patterns are architectural — they do not have vendor-driven deprecation cycles. However, the specific API mechanisms evolve: Salesforce increments API versions every release (pin to v62.0+), OData Repeatable Requests is a formal OASIS standard unlikely to change, and SAP's Idempotent Process Call follows the Integration Suite roadmap (continuously updated).

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Any integration that retries on failure (network timeout, 500 error) | Read-only integrations (GET requests are naturally idempotent) | No idempotency needed — GET/HEAD are safe to retry |
| Processing messages from at-least-once queues (Kafka, SQS, Platform Events) | The ERP has built-in exactly-once semantics (rare — verify before trusting) | Verify the exactly-once claim; usually still worth adding idempotency |
| Scheduled batch jobs that may be re-triggered (cron overlap, manual rerun) | One-time data migration with manual verification after each step | Pre-migration deduplication + manual review is more appropriate |
| Financial transactions (orders, invoices, payments) where duplicates are costly | Logging/telemetry where occasional duplicates are acceptable | Best-effort retry without dedup — cheaper and simpler |
| Multi-system sync where the same record flows through multiple hops | Direct database replication (MySQL binlog, PostgreSQL logical replication) | Database-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 (use middleware) | Yes — upsert/upsertList | Yes — Alternate Key PATCH |
| **Idempotency key header** | No native support | OData Repeatable Requests (limited adoption) | No native support | OData Repeatable Requests (Dataverse) |
| **External ID limit** | 7 per object | N/A (no external ID concept 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/request) |
| **Middleware dedup** | Not needed (use native upsert) | Required — Idempotent Process Call | Not needed (use native upsert) | Optional — native alternate keys often sufficient |
| **CDC for idempotent consumers** | Change Data Capture + Platform Events | SAP Event Mesh + Business Events | SuiteScript User Event scripts | Dataverse change tracking |
| **Duplicate detection rules** | Built-in duplicate management | Custom ABAP checks | Duplicate detection via SuiteScript | Built-in duplicate detection rules |

## Important Caveats

- **Idempotency does NOT equal ordering**: Two idempotent operations applied out of order can still produce wrong results. If order matters (e.g., update then delete vs delete then update), combine idempotency with sequence numbers or event ordering.
- **Idempotency key storage grows continuously**: Without TTL-based pruning, the idempotency key table becomes a performance bottleneck. Run a daily cleanup job to remove expired keys. Monitor table size weekly.
- **Sandbox vs production behavior differs**: Salesforce sandboxes may have stale External ID indexes. NetSuite sandbox environments have independent external ID namespaces. Always verify idempotency behavior in a full copy sandbox with production-volume data.
- **Idempotency across retry boundaries**: If the original request succeeds but the response is lost, the client retries and gets the cached response. This is correct. But if the client changes the payload between retries (e.g., updated amount), the server should reject with 422 — verify your implementation enforces payload consistency.
- **Multi-tenant ERP isolation**: In multi-tenant ERP deployments, ensure idempotency keys are scoped to the tenant. A key that is unique within one tenant may collide with another tenant's key if the dedup store is shared.
- **API version changes do not affect idempotency**: Upgrading from Salesforce API v61 to v62 does not change upsert behavior. However, new validation rules or triggers in the target org may cause previously successful upserts to fail on retry — test after any org configuration change.

## Related Units

- [Salesforce REST API Capabilities](/business/erp-integration/salesforce-rest-api-capabilities/2026) — External ID upsert endpoint reference
- [SAP Integration Suite Capabilities](/business/erp-integration/sap-integration-suite-capabilities/2026) — Idempotent Process Call configuration
- [NetSuite SuiteTalk API Capabilities](/business/erp-integration/netsuite-suitetalk-api-capabilities/2026) — upsert and external ID patterns
- [Dynamics 365 Web API Capabilities](/business/erp-integration/dynamics-365-web-api-capabilities/2026) — Alternate key upsert
- [Salesforce Bulk API Capabilities](/business/erp-integration/salesforce-bulk-api-capabilities/2026) — Bulk upsert with externalIdFieldName
- [Salesforce Streaming & Platform Events](/business/erp-integration/salesforce-streaming-platform-events/2026) — At-least-once delivery requiring idempotent consumers
