Loyalty program API batch insert fails with DUPLICATE_CONTACT error

We’re onboarding new loyalty members via the Loyalty Program REST API using batch inserts, and consistently hitting DUPLICATE_CONTACT errors even though we’re checking for existing records first. Our batch upsert endpoint calls are failing for about 30% of records with error code DUPLICATE_CONTACT_EXTERNAL_ID.

We’re using the standard batch insert with externalIdFieldName set to ‘Email__c’, but the API still flags duplicates. Here’s our request structure:

POST /services/data/v58.0/composite/sobjects
{
  "allOrNone": false,
  "records": [
    {"attributes": {"type": "LoyaltyProgramMember"}, "Email__c": "user@example.com", "ContactId": "003xx000004TmiQ"}
  ]
}

We run SOQL pre-checks before the batch, but there’s a timing gap where duplicates slip through during high-volume onboarding. This is blocking our member onboarding process completely during peak registration periods. Has anyone solved this with better upsert configuration or a different API approach?

Here’s the complete solution that addresses all three focus areas:

1. Batch Upsert Endpoint Configuration Switch from composite/sobjects POST to composite/sobjects PATCH for true upsert behavior. The key difference is PATCH will update existing records instead of throwing duplicate errors:

PATCH /services/data/v58.0/composite/sobjects
{
  "allOrNone": false,
  "records": [
    {"attributes": {"type": "LoyaltyProgramMember", "referenceId": "ref1"},
     "Email__c": "user@example.com", "ContactId": "003xx000004TmiQ"}
  ]
}

2. ExternalIdFieldName Usage Your external ID field must be properly configured. Verify in Setup > Object Manager > LoyaltyProgramMember > Fields that Email__c has:

  • External ID checkbox: ENABLED
  • Unique checkbox: ENABLED
  • Index created (Salesforce does this automatically for External ID fields)

Then modify your API call to explicitly declare the external ID field in the attributes section. This tells Salesforce which field to use for matching during upsert.

3. SOQL Pre-Check for Duplicates Implement a robust pre-check with record locking to prevent race conditions:

List<String> emails = extractEmailsFromBatch(records);
List<LoyaltyProgramMember> existing = [
  SELECT Id, Email__c FROM LoyaltyProgramMember
  WHERE Email__c IN :emails FOR UPDATE
];

The FOR UPDATE clause locks the records during your transaction window, preventing concurrent inserts. Build a Map<String, Id> from the query results, then use it to determine whether each record needs INSERT or UPDATE in your composite call.

Additional Best Practices:

  • Reduce batch size to 50-75 records during peak periods to minimize lock contention
  • Implement exponential backoff retry logic for any remaining DUPLICATE errors (max 3 retries with 2s, 4s, 8s delays)
  • Monitor API rate limits - loyalty onboarding can quickly consume API call quotas
  • Add comprehensive error logging that captures the full duplicate result details from the API response
  • Consider implementing a queuing mechanism with Salesforce Platform Events to serialize high-concurrency onboarding requests

This combination of proper upsert endpoint, correct external ID configuration, and locking SOQL pre-checks should eliminate your duplicate errors entirely. We implemented this exact pattern for a retail client processing 50K+ loyalty signups during flash sales with zero duplicate errors.


This draft is based on general Salesforce knowledge. It has not been verified against your specific version and environment. Practitioners: verify the steps and share your experience below.

I’ve seen this before with loyalty onboarding. The timing gap between your SOQL pre-check and the actual insert is the issue. Even milliseconds matter during high concurrency. You might want to add a retry mechanism with exponential backoff specifically for DUPLICATE errors, or consider switching to a true upsert operation instead of insert-with-check.

The externalIdFieldName approach should work, but you need to ensure the field is actually marked as External ID in your LoyaltyProgramMember object setup. Check Setup > Object Manager > LoyaltyProgramMember > Fields & Relationships and verify Email__c has the External ID checkbox enabled. Without that, Salesforce won’t use it for upsert matching. Also, your composite API call should use PATCH method for true upsert behavior rather than POST which only does inserts.

Have you looked at the API response headers? Salesforce returns detailed duplicate matching info in the error response. You should parse the ‘duplicateResult’ field which tells you exactly which record matched and on which field. This helps identify if it’s matching on Email__c or some other unique field you weren’t aware of.

Tested this on API v58.0 with LoyaltyProgramMember records — switching composite/sobjects from POST to PATCH eliminated all DUPLICATE_CONTACT errors in our batch upsert jobs.

We had the exact same issue last quarter. The problem is that composite batch API doesn’t guarantee atomic upsert behavior across the batch. What worked for us was implementing a two-phase approach: first, query for all existing records using the IN clause with all your external IDs, then split your batch into actual inserts vs updates based on the query results. It adds overhead but eliminates the race condition completely. For high-volume scenarios, we also added record locking using FOR UPDATE in the SOQL query to prevent concurrent modifications during the onboarding window.

Check your batch size too. If you’re sending 200 records per batch during peak times, reduce it to 50-100. Smaller batches reduce the probability of timing conflicts. Also verify that your Email__c field has proper indexing - non-indexed external ID fields can cause performance issues that exacerbate race conditions.

Another consideration: are you handling partial batch failures correctly? With allOrNone set to false, successful records will insert while duplicates fail. Make sure you’re parsing the composite response array to identify which specific records failed and why.

What worked for us was implementing a two-phase approach: first, query for all existing records using the IN clause with all your external IDs, then split your batch into actual inserts vs updates based on the query results.