Salesforce + D365 Finance & Operations Integration: Account/Order Sync, OData, Middleware
How do you integrate Salesforce CRM with Microsoft Dynamics 365 Finance and Operations?
TL;DR
- Bottom line: Build a custom middleware integration (Azure Logic Apps, MuleSoft, or Boomi) between Salesforce REST API and D365 F&O OData endpoints. Dual Write does NOT support Salesforce -- it only connects D365 CE (Sales) to D365 F&O. There is no Microsoft-built out-of-the-box Salesforce-to-D365-F&O connector.
- Key limit: D365 F&O enforces resource-based throttling (HTTP 429 with Retry-After header) and caps at 6,000 OData requests per 5-minute window per user. Salesforce adds 100,000 + per-license API calls per 24h.
- Watch out for: Dual Write confusion -- it is the #1 misunderstood component. It connects Dataverse/D365 Sales to D365 F&O only. Using it for Salesforce integration requires an extra Salesforce-to-Dataverse layer, doubling latency and failure points.
- Best for: Organizations running Salesforce as CRM and D365 F&O as ERP that need account/customer sync, opportunity-to-order handoff, price list reads, and invoice pushback.
- Authentication: Salesforce uses OAuth 2.0 (JWT bearer for server-to-server); D365 F&O uses Azure AD/Entra ID OAuth 2.0 client credentials flow via registered app in Azure portal.
System Profile
This playbook covers the end-to-end integration between Salesforce (CRM, source of truth for leads, opportunities, accounts, and customer-facing data) and Microsoft Dynamics 365 Finance & Operations (ERP, source of truth for financial master data, pricing, inventory, sales orders, and invoices). It applies to D365 F&O version 10.0.38+ (cloud only). For organizations using D365 Business Central instead of F&O, the integration surface differs significantly.
The architecture requires a middleware layer. Unlike the Microsoft-to-Microsoft stack (where Dual Write provides native near-real-time sync between D365 CE and D365 F&O), Salesforce-to-D365-F&O has no native connector. You must build the bridge using Azure Integration Services, MuleSoft, Boomi, or custom OData clients.
| System | Role | API Surface | Direction |
|---|---|---|---|
| Salesforce (API v62.0) | CRM -- source of truth for accounts, contacts, opportunities | REST API, Bulk API 2.0, Platform Events, CDC | Outbound (account/order push) + Inbound (pricing/invoice pull) |
| D365 F&O (10.0.40+) | ERP -- financial master, pricing, inventory, fulfillment | OData v4, Custom Services, Batch Data API (DMF), Business Events | Inbound (customer/order creation) + Outbound (pricing/invoice/inventory) |
| Middleware (Azure / MuleSoft / Boomi) | Integration orchestrator | Connectors for both systems | Bidirectional routing, transformation, error handling |
API Surfaces & Capabilities
Salesforce Side
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| REST API | HTTPS/JSON | Individual record CRUD, <2K records | 2,000 per SOQL query | 100K+ calls/24h | Yes | No |
| Bulk API 2.0 | HTTPS/CSV | ETL, data migration, >2K records | 150M per file | 15,000 batches/24h | No | Yes |
| SOAP API | HTTPS/XML | Metadata operations, legacy | 2,000 records | Shared with REST limit | Yes | No |
| Composite API | HTTPS/JSON | Multi-object ops in one call | 25 subrequests | Counts as 1 API call | Yes | No |
| Platform Events | CometD/gRPC | Real-time event notifications | N/A | 200K-1M events/24h | Yes | N/A |
| Change Data Capture | CometD | Track field-level changes | N/A | 72h replay window | Yes | N/A |
D365 Finance & Operations Side
| API Surface | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| OData v4 | HTTPS/JSON | Standard CRUD on data entities | Configurable via $top | 6,000 req/5min per user | Yes | Via $batch |
| Custom Services | HTTPS/JSON or SOAP | Custom business logic endpoints | Varies | Same throttling as OData | Yes | No |
| Batch Data API (DMF) | HTTPS/JSON | Large-volume async import/export | Millions (package-based) | Exempt from OData throttling | No (async) | Yes |
| Business Events | Azure Service Bus / Event Grid | Real-time event notifications | N/A | Per-event processing | Yes | N/A |
Rate Limits & Quotas
Salesforce Per-Request Limits
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max records per SOQL query | 2,000 | REST/SOAP API | Use queryMore/nextRecordsUrl for pagination |
| Max request body size | 50 MB | REST API | |
| Max composite subrequests | 25 | Composite API | All-or-nothing by default |
| Max batch file size | 150 MB | Bulk API 2.0 | Split larger files |
| Max records per batch | 10,000 | Bulk API 2.0 |
Salesforce Rolling / Daily Limits
| Limit Type | Value | Window | Edition Differences |
|---|---|---|---|
| Total API calls | 100,000 base | 24h rolling | Enterprise: +1,000/license; Unlimited: +5,000/license; Developer: 15,000 total |
| Concurrent API requests | 25 | Per org | Developer/Trial: 5 |
| Bulk API batches | 15,000 | 24h rolling | Shared across editions |
| Streaming API events | 200,000-1,000,000 | 24h | Enterprise: 200K; Unlimited: 1M |
Salesforce Governor Limits (Per Transaction)
| Limit Type | Per-Transaction Value | Notes |
|---|---|---|
| SOQL queries | 100 | Includes queries from triggers |
| DML statements | 150 | Each insert/update/delete counts as 1 |
| Callouts (HTTP) | 100 | External HTTP requests within transaction |
| CPU time | 10,000 ms (sync) / 60,000 ms (async) | Exceeded = transaction abort |
| Heap size | 6 MB (sync) / 12 MB (async) |
D365 F&O Service Protection Limits
| Limit Type | Value | Window | Notes |
|---|---|---|---|
| Number of requests | 6,000 | 5-minute sliding window | Per user, per application ID, per web server |
| Combined execution time | 1,200 seconds (20 min) | 5-minute sliding window | Per user, per web server |
| Concurrent requests | 52 | Per user | Per web server instance |
| Resource-based throttling | Dynamic | CPU/memory-based | 429 response when server resources exceed threshold |
Important: D365 F&O exempts DMF/DIXF, Recurring Integrations, Data Integrator, and Virtual Tables from OData throttling. For high-volume batch loads, use the Batch Data API instead of OData. [src2]
Authentication
Salesforce Authentication
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| OAuth 2.0 JWT Bearer | Server-to-server (recommended) | Session timeout (2h default) | New JWT per request | Requires connected app with digital certificate |
| OAuth 2.0 Client Credentials | Server-to-server (simpler) | 2h | No (new token per request) | Spring '23+; no user context |
| OAuth 2.0 Web Server Flow | User-context operations | Access: 2h; Refresh: until revoked | Yes | Requires callback URL |
D365 F&O Authentication
| Flow | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|
| Azure AD/Entra ID Client Credentials | Server-to-server (recommended) | Default 1h (configurable) | Yes | Register app in Azure portal; assign D365 F&O environment URL as audience |
| Azure AD Authorization Code | User-context interactive flows | Access: 1h; Refresh: 90 days | Yes | Requires redirect URI |
| S2S via Entra Managed Identity | Azure-hosted integrations | Auto-managed | Automatic | Only for Azure-hosted workloads |
Authentication Gotchas
- D365 F&O OAuth audience URI must match exactly: The resource/audience must be
https://your-environment.operations.dynamics.com(no trailing slash). A mismatch returns HTTP 401 with no useful error message. [src1] - Salesforce JWT bearer flow requires pre-authorized user: The integration user must be pre-authorized in the Connected App settings. [src4]
- D365 F&O token caching is critical: Cache tokens for their full lifetime (~1h). Requesting a new token per API call wastes quota and can trigger Entra ID throttling. [src1]
Constraints
- Dual Write does NOT support Salesforce: It exclusively connects D365 Customer Engagement (Sales, Marketing, Service) to D365 F&O. Any architecture that assumes Dual Write connects Salesforce to D365 F&O is fundamentally wrong. [src3]
- D365 F&O OData is synchronous: Each call triggers all validation, workflows, and posting logic immediately. Use Batch Data API (DMF) for volumes >2,000 records. [src1]
- D365 F&O data entities must be explicitly enabled for OData: Check
Data management > Data entities > Is public = YesandIs OData accessible = Yes. [src1] - Salesforce External IDs essential for upserts: Without External ID fields, you cannot do upserts. Create them before building the integration. [src4]
- D365 F&O number sequences can cause conflicts: Configure dedicated number sequence ranges for integration-created records. [src1]
- Resource-based throttling is non-negotiable: D365 F&O throttles if aggregate server CPU/memory is high, regardless of per-user limits. [src2]
- Salesforce field-level security blocks API access silently: Missing field-level access causes fields to be omitted from API responses with no error. [src4]
Integration Pattern Decision Tree
START -- Salesforce <-> D365 F&O Integration
|
+-- What's the primary process?
| +-- Account/Customer sync
| | +-- Volume < 1,000/day? --> OData real-time upserts
| | +-- Volume > 1,000/day? --> Batch Data API (DMF) on schedule
| | +-- ALWAYS assign External ID fields on both sides FIRST
| +-- Opportunity-to-Order
| | +-- Event-driven: SF Platform Event on Close --> Middleware --> D365 OData
| | +-- Real-time (sub-minute): recommended for order accuracy
| +-- Price List / Product sync (D365 --> Salesforce)
| | +-- Scheduled batch: D365 OData query --> SF Bulk API 2.0 upsert
| +-- Invoice pushback (D365 --> Salesforce)
| +-- D365 Business Event on invoice posting --> middleware --> SF REST upsert
|
+-- Which middleware?
| +-- Azure-native --> Logic Apps + Service Bus + Event Grid
| +-- Salesforce-native --> MuleSoft (pre-built D365 connector)
| +-- Multi-cloud --> Boomi, Workato, Celigo
|
+-- Error tolerance?
+-- Zero-loss (financial) --> Dead letter queue + idempotent retries
+-- Best-effort (reporting) --> Retry 3x with backoff, log and skip
Quick Reference
| Step | Source | Trigger | Target | Data Objects | Failure Handling |
|---|---|---|---|---|---|
| 1. Account/Customer sync | Salesforce | Account created/updated | D365 F&O | CustCustomerV3 | Retry 3x, then DLQ |
| 2. Contact sync | Salesforce | Contact created/updated | D365 F&O | smmContactPersonV2 | Retry 3x, skip on dup |
| 3. Opportunity-to-Order | Salesforce | Stage = Closed Won | D365 F&O | SalesOrderHeaderV2 + Lines | Retry 3x, then manual |
| 4. Product/Price sync | D365 F&O | Scheduled / Business Event | Salesforce | Product2 + PricebookEntry | Full refresh upsert |
| 5. Inventory check | D365 F&O | On-demand callout | Salesforce | Custom object | Real-time OData read |
| 6. Invoice pushback | D365 F&O | Invoice posted | Salesforce | Custom Invoice__c | Retry 3x, then recon batch |
| 7. Payment status sync | D365 F&O | Scheduled (hourly) | Salesforce | Custom field on Account | Upsert via External ID |
Step-by-Step Integration Guide
1. Register applications and configure authentication
Set up OAuth credentials on both sides before building any data flows. [src1, src4]
# Test Salesforce JWT auth
curl -X POST https://login.salesforce.com/services/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=${SALESFORCE_JWT_TOKEN}"
# Test D365 F&O auth
curl -X POST "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-d "client_id=${CLIENT_ID}" \
-d "client_secret=${CLIENT_SECRET}" \
-d "scope=https://${D365_ENVIRONMENT}.operations.dynamics.com/.default" \
-d "grant_type=client_credentials"
Verify: Both calls return {"access_token": "..."}.
2. Create External ID fields and data entity mappings
Create External ID fields on both systems to enable idempotent upserts. [src6]
# Salesforce: verify External ID field exists
curl -H "Authorization: Bearer ${SF_TOKEN}" \
"${INSTANCE_URL}/services/data/v62.0/sobjects/Account/describe" \
| jq '.fields[] | select(.name == "D365_Customer_Number__c")'
# D365 F&O: verify entity is OData-accessible
curl -H "Authorization: Bearer ${D365_TOKEN}" \
"https://${D365_ENV}.operations.dynamics.com/data/CustomersV3?\$top=1"
Verify: Salesforce returns field metadata with "externalId": true. D365 returns a JSON array with at least one customer record.
3. Build Account/Customer bidirectional sync
Salesforce Account is the sales-facing master; D365 F&O Customer is the financial master. [src5, src6]
Verify: Create a test account in Salesforce sandbox and verify it appears in D365 F&O within expected latency.
4. Implement Opportunity-to-Order handoff
Salesforce opportunity closure triggers D365 F&O sales order creation. [src5, src7]
Verify: Close an Opportunity in Salesforce sandbox. Confirm the Sales Order appears in D365 F&O with matching line items.
5. Configure Price List and Product sync (D365 --> Salesforce)
Products and pricing are mastered in D365 F&O and pushed to Salesforce for sales team visibility. [src6, src7]
# D365: query released products
curl -H "Authorization: Bearer ${D365_TOKEN}" \
"https://${D365_ENV}.operations.dynamics.com/data/ReleasedProductsV2?\$select=ItemNumber,ProductName&\$top=1000"
# Salesforce: upsert product via External ID
curl -X PATCH -H "Authorization: Bearer ${SF_TOKEN}" \
-H "Content-Type: application/json" \
"${INSTANCE_URL}/services/data/v62.0/sobjects/Product2/D365_Item_Number__c/ITEM001" \
-d '{"Name": "Widget A", "ProductCode": "ITEM001", "IsActive": true}'
Verify: Compare product count in D365 F&O vs Salesforce after sync.
6. Build Invoice pushback (D365 --> Salesforce)
Push invoice data from D365 back to Salesforce so sales reps see payment status. [src5, src7]
Verify: Post a test invoice in D365 F&O. Confirm the Invoice__c record appears on the corresponding Account in Salesforce.
7. Implement error handling and retry logic
Production integrations must handle D365 F&O 429 throttling and Salesforce API limits gracefully. [src2]
// Node.js: D365 F&O OData call with retry on 429
const axios = require('axios'); // v1.6+
async function d365ODataRequest(method, url, data, token, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await axios({ method, url, data,
headers: { 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'OData-MaxVersion': '4.0', 'OData-Version': '4.0' },
timeout: 30000 });
return response.data;
} catch (error) {
if (error.response?.status === 429) {
const retryAfter = parseInt(error.response.headers['retry-after'] || '30');
const backoff = retryAfter * 1000 * Math.pow(1.5, attempt);
await new Promise(resolve => setTimeout(resolve, backoff));
continue;
}
if (error.response?.status >= 500 && attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
continue;
}
throw error;
}
}
throw new Error(`Max retries exceeded for ${url}`);
}
Verify: Simulate rapid OData requests. Confirm retry logic pauses and resumes successfully.
Code Examples
Python: Bidirectional Account/Customer Sync
# Input: Salesforce Account ID or D365 Customer Number
# Output: Synced record on the target system
import requests # v2.31+
class SfD365Sync:
def __init__(self, sf_token, sf_instance, d365_token, d365_env):
self.sf_headers = {"Authorization": f"Bearer {sf_token}",
"Content-Type": "application/json"}
self.sf_base = f"{sf_instance}/services/data/v62.0"
self.d365_headers = {"Authorization": f"Bearer {d365_token}",
"Content-Type": "application/json",
"OData-MaxVersion": "4.0", "OData-Version": "4.0"}
self.d365_base = f"https://{d365_env}.operations.dynamics.com/data"
def sf_account_to_d365(self, account_id):
acct = requests.get(
f"{self.sf_base}/sobjects/Account/{account_id}",
headers=self.sf_headers).json()
d365_customer = {
"CustomerAccount": acct.get("D365_Customer_Number__c") or "",
"OrganizationName": acct["Name"][:100],
"CustomerGroupId": "10",
"dataAreaId": "usmf",
"SalesCurrencyCode": acct.get("CurrencyIsoCode", "USD")}
if d365_customer["CustomerAccount"]:
key = f"CustomerAccount='{d365_customer['CustomerAccount']}',dataAreaId='usmf'"
return requests.patch(f"{self.d365_base}/CustomersV3({key})",
json=d365_customer, headers=self.d365_headers).json()
else:
resp = requests.post(f"{self.d365_base}/CustomersV3",
json=d365_customer, headers=self.d365_headers).json()
requests.patch(f"{self.sf_base}/sobjects/Account/{account_id}",
json={"D365_Customer_Number__c": resp["CustomerAccount"]},
headers=self.sf_headers)
return resp
cURL: Quick API Tests
# Check Salesforce API limits remaining
curl -H "Authorization: Bearer ${SF_TOKEN}" \
"${INSTANCE_URL}/services/data/v62.0/limits" \
| jq '{DailyApiRequests, ConcurrentPerOrgDBConnections}'
# Check D365 F&O OData connectivity
curl -H "Authorization: Bearer ${D365_TOKEN}" \
"https://${D365_ENV}.operations.dynamics.com/data/CustomersV3?\$top=1&\$select=CustomerAccount,OrganizationName"
# Salesforce: upsert Account via External ID
curl -X PATCH -H "Authorization: Bearer ${SF_TOKEN}" \
-H "Content-Type: application/json" \
"${INSTANCE_URL}/services/data/v62.0/sobjects/Account/D365_Customer_Number__c/CUST-0001" \
-d '{"Name": "Acme Corp", "BillingCity": "New York"}'
Data Mapping
Field Mapping Reference
| Source (Salesforce) | Target (D365 F&O) | Type | Transform | Gotcha |
|---|---|---|---|---|
| Account.Name | CustomersV3.OrganizationName | String | Truncate to 100 chars | D365 max 100 vs SF max 255 |
| Account.BillingStreet | CustomersV3.AddressStreet | String | Direct | D365 splits Street + StreetNumber; SF is single field |
| Account.BillingCountry | CustomersV3.AddressCountryRegionId | String | Map name to ISO 3166-1 | D365 expects ISO code; SF may store full name |
| Opportunity.Amount | SalesOrderHeadersV2.SalesOrderAmount | Currency | Currency conversion if needed | Exchange rate timing: define which system's rate is authoritative |
| OpportunityLineItem.Quantity | SalesOrderLines.SalesQuantity | Decimal | Direct | SF allows 8 decimals; D365 default is 2 |
| OpportunityLineItem.UnitPrice | SalesOrderLines.SalesPrice | Currency | Direct | Tax-inclusive vs tax-exclusive depends on D365 tax group |
| Product2.ProductCode | SalesOrderLines.ItemNumber | String | Direct | Must match exactly; case-sensitive in D365 |
| Account.Id (18-char) | Custom field on CustTable | String | Direct | Always use 18-char Salesforce ID (case-insensitive) |
Data Type Gotchas
- DateTime timezone handling: Salesforce stores all DateTimes in UTC. D365 F&O OData API also returns UTC. But watch for date-only fields -- Salesforce Date has no time; D365 DateTimeOffset always has time. [src4]
- Currency decimal precision: Salesforce typically stores 2 decimal places. D365 F&O supports up to 6 on amounts and 10 on unit prices. Map precision explicitly or rounding errors accumulate. [src1]
- dataAreaId (legal entity): Every D365 F&O record requires a dataAreaId. Salesforce has no equivalent. Your middleware must inject the correct legal entity based on business rules. [src1]
- Address composite fields: Salesforce uses separate fields (Street, City, State, PostalCode, Country). D365 F&O uses a logistics postal address entity with country-region ISO codes. Requires lookup mapping. [src1]
Error Handling & Failure Points
Common Error Codes
| Source | Code | Meaning | Cause | Resolution |
|---|---|---|---|---|
| D365 F&O | 429 | Too Many Requests | OData throttling | Honor Retry-After header; exponential backoff. For persistent 429s, switch to DMF |
| D365 F&O | 400 | Bad Request | Invalid payload, missing required fields | Parse InnerError.message; common: missing dataAreaId |
| D365 F&O | 404 | Not Found | Entity not OData-enabled | Verify entity is public + OData-accessible in Data Management |
| D365 F&O | 401 | Unauthorized | Token expired or wrong audience URI | Verify token audience matches environment URL (no trailing slash) |
| Salesforce | INVALID_FIELD | Field doesn't exist | Wrong API version or missing FLS | Check field-level security; verify API version |
| Salesforce | DUPLICATE_VALUE | External ID exists | Duplicate record on upsert | Investigate: retry (safe) or legitimate duplicate? |
| Salesforce | REQUEST_LIMIT_EXCEEDED | 24h API limit reached | Too many API calls | Reduce polling; switch to events; request limit increase |
Failure Points in Production
- D365 number sequence exhaustion: Auto-generated numbers run out if range is too small. Fix:
Configure dedicated number sequence range with sufficient capacity (10M+ range).[src1] - Salesforce Platform Event replay failure: Events have 72h retention. If middleware is down longer, events are lost. Fix:
Implement daily reconciliation batch to detect and fix drift.[src4] - Multi-company routing errors: Wrong dataAreaId sends data to wrong legal entity -- accounting error not caught until month-end. Fix:
Validate dataAreaId mapping in middleware with lookup table. Reject records with unmapped business units.[src1] - OAuth token expiration during batches: Batches running >1h hit D365 token expiration. Fix:
Check token expiry before each call; refresh proactively when <5 minutes remain.[src1] - Salesforce field-level security silent data loss: Missing field access causes omitted fields (no error). Fix:
Dedicated integration user with explicit read/write access. Audit with /describe endpoint.[src4]
Anti-Patterns
Wrong: Using Dual Write as a Salesforce-to-D365-F&O bridge
// WRONG -- Dual Write only connects D365 CE (Sales/Marketing/Service) to D365 F&O
// Architecture: Salesforce --> Dual Write --> D365 F&O <-- DOES NOT WORK
// Salesforce --> Dataverse --> Dual Write --> D365 F&O <-- Adds unnecessary hop
Correct: Direct middleware integration
// CORRECT -- Salesforce REST API --> Middleware --> D365 F&O OData
// Single integration layer, direct API-to-API mapping
// Salesforce Platform Event --> Azure Logic App --> D365 OData POST
// D365 Business Event --> Azure Service Bus --> Logic App --> SF REST PATCH
Wrong: Polling Salesforce for changes on a timer
# WRONG -- Polling every 5 min = 288 calls/day PER object
# 10 objects = 2,880 API calls/day just for polling
def poll_salesforce_accounts():
accounts = sf_query("SELECT Id FROM Account WHERE LastModifiedDate > :last_run")
for acct in accounts:
sync_to_d365(acct)
Correct: Event-driven with Platform Events or CDC
# CORRECT -- Subscribe to Salesforce Change Data Capture
# Zero API calls wasted; instant notification on change
async def listen_for_changes():
async with SalesforceStreamingClient(...) as client:
await client.subscribe("/data/AccountChangeEvent")
async for message in client:
sync_to_d365(message["data"]["payload"])
Wrong: Sending records one-by-one to D365 for bulk loads
# WRONG -- 10,000 individual OData calls
for product in all_products: # 10K products
d365_odata_post("/data/ReleasedProductsV2", product)
# Burns through 6,000/5min limit in under 3 minutes
Correct: Use Batch Data API (DMF) for bulk loads
# CORRECT -- Package data into DMF import (exempt from OData throttling)
csv_content = convert_to_csv(records)
response = requests.post(dmf_import_url,
json={"packageUrl": upload_to_blob(csv_content),
"definitionGroupId": "SFProductSync",
"execute": True, "legalEntityId": "usmf"})
Common Pitfalls
- Assuming Dual Write works for Salesforce: The #1 architectural mistake. It only connects D365 CE to D365 F&O. Fix:
Use direct API integration via middleware.[src3] - Ignoring dataAreaId (legal entity): Every D365 F&O OData call requires a legal entity. Fix:
Build mapping table: Salesforce Business Unit --> D365 dataAreaId. Validate before every write.[src1] - Not handling D365 validation errors: D365 F&O runs full business validation on OData inserts. Fix:
Parse error response body JSON; extract InnerError.message; log with source record ID; route to manual review queue.[src1] - Building without External ID fields: Upserts require External IDs. Fix:
Define External ID strategy BEFORE writing code. Minimum: Account.D365_Customer_Number__c and D365 CustTable Salesforce ID field.[src6] - Overestimating D365 F&O OData throughput: OData is synchronous; each POST can take 500ms-2s. At 6,000/5min, effective throughput is lower than expected. Fix:
For >500 records/batch, use DMF. Keep OData for individual record operations only.[src1, src2] - Not testing with production data volumes: D365 sandbox may have fewer web servers; Salesforce sandbox has lower API limits. Fix:
Load-test against full-copy sandbox with production volumes. Monitor for 429 responses.[src2]
Diagnostic Commands
# === Salesforce Diagnostics ===
# Check remaining API limits
curl -H "Authorization: Bearer ${SF_TOKEN}" \
"${INSTANCE_URL}/services/data/v62.0/limits" \
| jq '{DailyApiRequests, ConcurrentPerOrgDBConnections}'
# Verify integration user field permissions
curl -H "Authorization: Bearer ${SF_TOKEN}" \
"${INSTANCE_URL}/services/data/v62.0/sobjects/Account/describe" \
| jq '[.fields[] | select(.name | test("D365|Billing")) | {name, updateable}]'
# === D365 F&O Diagnostics ===
# Test OData connectivity
curl -H "Authorization: Bearer ${D365_TOKEN}" \
"https://${D365_ENV}.operations.dynamics.com/data/CustomersV3?\$top=1&\$count=true"
# Check Azure Service Bus dead-letter queue depth
az servicebus queue show --resource-group ${RG} --namespace-name ${SB_NS} \
--name ${QUEUE_NAME} --query "countDetails.deadLetterMessageCount"
Version History & Compatibility
Salesforce API Versions
| API Version | Release | Status | Key Changes |
|---|---|---|---|
| v62.0 | Spring '26 (Feb 2026) | Current | Latest GA |
| v61.0 | Winter '26 (Oct 2025) | Supported | Minor CDC improvements |
| v60.0 | Spring '24 (Feb 2024) | Supported | Deprecated legacy SOAP partner endpoints |
| v58.0 | Spring '23 (Feb 2023) | Supported | Added Client Credentials OAuth flow |
D365 F&O Versions
| Version | Release | Status | Key Changes |
|---|---|---|---|
| 10.0.41 | 2026 Wave 1 (Apr 2026) | Upcoming | Enhanced Business Events framework |
| 10.0.40 | 2025 Wave 2 (Oct 2025) | Current | Virtual entity improvements |
| 10.0.36 | 2024 Wave 2 | Supported | User-based API limits disabled permanently |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Salesforce is your CRM and D365 F&O is your ERP | Both CRM and ERP are Microsoft (D365 CE + D365 F&O) | D365 Dual Write |
| You need bidirectional Account/Customer, Opportunity-to-Order, or Invoice pushback | You only need read-only D365 data in Salesforce | Salesforce Connect External Objects (OData adapter) |
| Daily volume is <100K records | Daily volume >1M records near-real-time | Custom ETL with D365 DMF + SF Bulk API 2.0 |
| You have middleware budget | Zero budget and zero dev resources | Pre-built connector (Rapidi, Celigo) |
Cross-System Comparison
| Capability | Salesforce (API v62.0) | D365 F&O (10.0.40+) | Notes |
|---|---|---|---|
| API Style | REST + SOAP + Bulk | OData v4 + Custom Services + DMF | Both REST-based; OData more structured |
| Rate Limits | 100K+ calls/24h rolling | 6,000/5min per user per web server | D365 per-window not per-day |
| Bulk Import | Bulk API 2.0 (150MB, async) | DMF (package-based, async, exempt from throttling) | Both async; D365 DMF more complex but no rate limit |
| Event-Driven | Platform Events + CDC (72h replay) | Business Events + Data Events (Azure transport) | SF more mature; D365 requires Azure |
| Auth Model | OAuth 2.0 (JWT, Client Credentials) | Azure AD/Entra ID OAuth 2.0 | SF self-contained; D365 depends on Azure AD |
| Data Model | Objects + Fields (flat, flexible) | Data Entities + Tables (normalized, rigid) | SF more flexible; D365 more structured |
| Metadata API | Describe calls (per object) | $metadata (full OData EDMX, 100MB+) | Parse D365 metadata selectively |
Important Caveats
- No native Microsoft connector for Salesforce-to-D365-F&O. All integrations require custom middleware or third-party connectors.
- D365 F&O resource-based throttling is dynamic -- activates when server CPU/memory is high, regardless of per-user limits. Plan around month-end peaks.
- Salesforce API limits vary by edition. Developer edition: only 15,000 total calls/24h. Always verify in Setup > Company Information > API Usage.
- D365 F&O data entities are not universally available. Custom tables require custom entity development (Visual Studio + LCS deployment).
- Exchange rate timing matters for multi-currency. Define which system's rate is authoritative.
- Rate limits and service protection thresholds are subject to change with each release. Always verify against current documentation.