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 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 |
| 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 |
| 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 |
| 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 |
| 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) |
| 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]
| 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 |
| 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 |
https://your-environment.operations.dynamics.com (no trailing slash). A mismatch returns HTTP 401 with no useful error message. [src1]Data management > Data entities > Is public = Yes and Is OData accessible = Yes. [src1]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
| 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 |
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": "..."}.
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.
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.
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.
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.
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.
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.
# 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
# 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"}'
| 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) |
| 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 |
Configure dedicated number sequence range with sufficient capacity (10M+ range). [src1]Implement daily reconciliation batch to detect and fix drift. [src4]Validate dataAreaId mapping in middleware with lookup table. Reject records with unmapped business units. [src1]Check token expiry before each call; refresh proactively when <5 minutes remain. [src1]Dedicated integration user with explicit read/write access. Audit with /describe endpoint. [src4]// 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 -- 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 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 -- 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 -- 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 -- 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"})
Use direct API integration via middleware. [src3]Build mapping table: Salesforce Business Unit --> D365 dataAreaId. Validate before every write. [src1]Parse error response body JSON; extract InnerError.message; log with source record ID; route to manual review queue. [src1]Define External ID strategy BEFORE writing code. Minimum: Account.D365_Customer_Number__c and D365 CustTable Salesforce ID field. [src6]For >500 records/batch, use DMF. Keep OData for individual record operations only. [src1, src2]Load-test against full-copy sandbox with production volumes. Monitor for 429 responses. [src2]# === 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"
| 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 |
| 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 |
| 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) |
| 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 |