This integration playbook covers the four most common O2C system combinations in enterprise environments. The Order-to-Cash cycle spans order management, credit management, fulfillment, invoicing, payment collection, and reconciliation — touching 4–6 systems minimum. The CRM is the entry point (order capture), the ERP is the system of record for financials, the WMS handles physical fulfillment, and the billing/AR system manages invoicing and collections.
| System | Role | API Surface | Direction |
|---|---|---|---|
| Salesforce Sales Cloud | CRM — order capture, customer 360 | REST API v62.0, Platform Events | Outbound (orders) / Inbound (status) |
| SAP S/4HANA Cloud | ERP — financial master, fulfillment orchestration | OData v4 (API_SALES_ORDER_SRV) | Inbound (orders) / Outbound (status) |
| Oracle NetSuite | ERP — mid-market alternative to SAP | SuiteTalk SOAP, RESTlets, REST API | Inbound (orders) / Outbound (status) |
| Dynamics 365 F&SCM | ERP — Microsoft ecosystem | OData v4, Data Entities, Business Events | Inbound (orders) / Outbound (status) |
| iPaaS (MuleSoft/Boomi/Workato/Celigo) | Middleware — orchestration, transformation, error handling | REST/SOAP connectors | Orchestrator |
| WMS / 3PL | Warehouse — pick, pack, ship | REST API (system-dependent) | Inbound (fulfillment) / Outbound (shipment) |
| System | API Surface | Protocol | Best For | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| Salesforce REST API | REST | HTTPS/JSON | Individual order CRUD, composite operations | 100K calls/24h (Enterprise) | Yes | No |
| Salesforce Bulk API 2.0 | Bulk | HTTPS/CSV | Historical order migration, >2K records | 15K batches/24h | No | Yes |
| Salesforce Platform Events | Pub/Sub | Bayeux/gRPC | Order status change notifications | 100K-10M events/24h | Yes | N/A |
| SAP S/4HANA OData v4 | OData | HTTPS/JSON | Sales order CRUD, deep insert | Throttled per tenant config | Yes | Via batch |
| SAP S/4HANA IDoc | EDI/XML | RFC/HTTP | Legacy batch order exchange | No hard limit (queued) | No | Yes |
| NetSuite SuiteTalk SOAP | SOAP | HTTPS/XML | Full record CRUD, bulk lists (25/call) | 5 concurrent (default) | Yes | Limited |
| NetSuite RESTlets | REST | HTTPS/JSON | Custom multi-step server-side logic | Shared with SOAP concurrency | Yes | Custom |
| Dynamics 365 OData v4 | OData | HTTPS/JSON | Sales order via Data Entities | 6,000 req/5min per user | Yes | Via $batch |
| Dynamics 365 Business Events | Pub/Sub | Azure Service Bus/Webhooks | Status change notifications | No hard limit | Yes | N/A |
| Limit Type | Value | System | Notes |
|---|---|---|---|
| Max records per SOQL query | 2,000 | Salesforce | Use queryMore/nextRecordsUrl for pagination |
| Max composite subrequests | 25 | Salesforce | All-or-nothing or independent mode |
| Max records per SOAP addList | 25 | NetSuite | SuiteTalk bulk operations |
| Max OData $batch requests | 1,000 | SAP S/4HANA | Changesets within a batch |
| Max request body size | 50 MB | Salesforce REST | Split larger payloads |
| Max SuiteQL result rows | 5,000 | NetSuite | Paginate with offset/hasMore |
| Max Dynamics 365 $batch | 1,000 operations | Dynamics 365 | Individual changesets within batch |
| Limit Type | Value | Window | System |
|---|---|---|---|
| API calls | 100,000 (Enterprise), 5M (Unlimited) | 24h rolling | Salesforce |
| Bulk API batches | 15,000 | 24h rolling | Salesforce |
| Platform Events published | 100K-10M (depends on license) | 24h | Salesforce |
| Concurrent requests | 5 (default), 10+ (SuiteCloud Plus) | Per account | NetSuite |
| API governance units | 5,000 units/script (scheduled) | Per execution | NetSuite |
| Service protection limit | 6,000 requests/5min | Per user | Dynamics 365 |
| Limit Type | Per-Transaction Value | Notes |
|---|---|---|
| SOQL queries | 100 (sync), 200 (async) | Includes queries from triggers — cascading triggers consume same pool |
| DML statements | 150 | Each insert/update/delete counts as 1, regardless of record count |
| Callouts (HTTP) | 100 | External API calls within a single transaction |
| CPU time | 10,000 ms (sync), 60,000 ms (async) | Exceeded = transaction rollback |
| Heap size | 6 MB (sync), 12 MB (async) | Watch large order payloads |
| Future calls | 50 per transaction | @future methods for async callouts |
| System | Flow | Use When | Token Lifetime | Refresh? |
|---|---|---|---|---|
| Salesforce | OAuth 2.0 JWT Bearer | Server-to-server iPaaS integration | Session timeout (default 2h) | New JWT per request |
| Salesforce | OAuth 2.0 Web Server | User-context operations | Access: 2h, Refresh: until revoked | Yes |
| NetSuite | Token-Based Auth (OAuth 1.0a) | All production integrations | Does not expire | N/A — permanent |
| NetSuite | OAuth 2.0 | Modern REST API integrations | Access: 60 min | Yes |
| SAP S/4HANA | OAuth 2.0 + x-csrf-token | All OData write operations | Configurable per tenant | Yes |
| Dynamics 365 | Azure AD OAuth 2.0 (client credentials) | Server-to-server | Default 60-90 min | Yes |
START — Implementing Order-to-Cash across CRM + ERP + WMS + Billing
├─ What CRM → ERP trigger?
│ ├ Opportunity Closed-Won
│ │ ├ Real-time needed? → Platform Events / Apex trigger + iPaaS webhook
│ │ └ Batch OK? → Scheduled job every 15-60 min
│ ├ Order record approved (CPQ flow)
│ │ └ Trigger on Order.Status = 'Activated'
│ └ Manual push (button click)
│ └ Apex callout via iPaaS → synchronous order creation
├ What's the daily order volume?
│ ├ < 500 orders/day → Real-time event-driven is fine
│ ├ 500-5,000 orders/day → Event-driven + batch fallback for peaks
│ └ > 5,000 orders/day → Batch/bulk processing required
├ Which ERP?
│ ├ SAP S/4HANA → OData v4 API_SALES_ORDER_SRV deep insert
│ ├ NetSuite → SuiteTalk SOAP / RESTlet
│ ├ Dynamics 365 → OData v4 SalesOrderHeaders + Lines
│ └ Other ERP → REST/SOAP or file-based (CSV/EDI) for legacy
├ Need fulfillment status back to CRM?
│ ├ Real-time → ERP webhooks/events → iPaaS → SF REST update
│ └ Batch → Scheduled sync every 15-60 min
└ Error tolerance?
├ Zero-loss → Idempotency keys + dead letter queue + manual review
└ Best-effort → Retry 3x with exponential backoff, alert on failure
| Step | Source System | Action | Target System | Data Objects | Failure Handling |
|---|---|---|---|---|---|
| 1. Order capture | Salesforce | Opportunity Closed-Won or Order Activated | iPaaS | Opportunity/Order + Line Items | N/A — CRM event |
| 2. Customer sync | iPaaS | Upsert customer (create if new) | ERP | Account → Customer | Retry 3x, then DLQ |
| 3. Order creation | iPaaS | Create sales order with line items | ERP | Order → Sales Order | Idempotency key, retry 3x, DLQ |
| 4. Order confirmation | ERP | Return order number + status | Salesforce | Sales Order ID → Order field | Update CRM with ERP order # |
| 5. Fulfillment trigger | ERP | Release order to warehouse | WMS / 3PL | Pick list / fulfillment order | Manual review if inventory short |
| 6. Ship confirmation | WMS | Ship complete → tracking number | ERP + Salesforce | Shipment + tracking | Alert if no confirmation in 24h |
| 7. Invoice creation | ERP | Auto-generate invoice on ship | Billing / AR | Invoice | Alert if auto-invoice fails |
| 8. Payment receipt | Payment gateway | Payment applied to invoice | ERP + Salesforce | Payment → Cash application | Reconciliation queue for mismatches |
| 9. Revenue recognition | ERP | Revenue schedule triggered | GL / Finance | Journal entries | Finance team review |
Configure OAuth credentials for each system. Use dedicated integration users with minimal permissions. Store all credentials in the iPaaS credential vault. [src1, src6]
# Test Salesforce auth (JWT bearer flow)
curl -X POST https://login.salesforce.com/services/oauth2/token \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=${JWT_TOKEN}"
# SAP: Fetch x-csrf-token before writes
curl -X GET "https://{host}/sap/opu/odata4/sap/api_sales_order_srv/srvd_a2x/sap/salesorder/0001/" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "x-csrf-token: Fetch" -c cookies.txt
Verify: Each system returns a valid access token. Salesforce returns {"access_token": "...", "instance_url": "..."}.
Before creating orders, ensure the customer exists in the ERP. Use upsert keyed on cross-system external ID to prevent duplicates. [src1]
def sync_customer_to_netsuite(sf_account):
existing = netsuite.search("customer",
filters=[("externalId", "is", sf_account["Id"])])
if existing:
netsuite.update("customer", existing[0]["internalId"], {
"companyName": sf_account["Name"][:83], # NS max 83 chars
"subsidiary": map_subsidiary(sf_account["Legal_Entity__c"]),
})
return existing[0]["internalId"]
else:
return netsuite.create("customer", {
"externalId": sf_account["Id"],
"companyName": sf_account["Name"][:83],
})["internalId"]
Verify: Customer exists in ERP with correct external ID.
Transform Salesforce order data into ERP sales order format. Include idempotency key (Salesforce Order ID) as external ID. [src1, src4]
# SAP S/4HANA: OData v4 deep insert (header + line items)
payload = {
"SalesOrderType": "OR",
"SalesOrganization": map_sales_org(sf_order["Legal_Entity__c"]),
"SoldToParty": sf_order["ERP_Customer_ID__c"],
"PurchaseOrderByCustomer": sf_order["PO_Number__c"],
"_Item": [
{"Material": item["ERP_Material_ID__c"],
"RequestedQuantity": str(item["Quantity"])}
for item in sf_line_items
]
}
response = requests.post(url, json=payload, headers=headers)
Verify: SAP returns 201 with SalesOrder number.
Configure CRM to fire an event when an order is ready. Salesforce Platform Events or CDC are preferred over polling. [src6]
// Apex trigger fires when Order status changes to 'Activated'
trigger OrderActivatedTrigger on Order (after update) {
List<Order_Integration_Event__e> events = new List<>();
for (Order o : Trigger.new) {
Order old = Trigger.oldMap.get(o.Id);
if (o.Status == 'Activated' && old.Status != 'Activated') {
events.add(new Order_Integration_Event__e(
Order_Id__c = o.Id,
Idempotency_Key__c = o.Id + '_' + o.LastModifiedDate
));
}
}
if (!events.isEmpty()) EventBus.publish(events);
}
Verify: Platform Event published. Check subscriptions in Salesforce Setup.
Sync fulfillment status from ERP back to Salesforce so sales reps see real-time order progress. [src1, src3]
# Poll ERP for fulfilled orders, update Salesforce (runs every 15 min)
def sync_fulfillment_to_salesforce(last_run):
fulfilled = netsuite.search("salesOrder",
filters=[("status", "is", "Billed"),
("lastModifiedDate", "onOrAfter", last_run)])
sf_updates = [{"Id": o["externalId"],
"ERP_Status__c": map_status(o["status"]),
"Tracking_Numbers__c": o["trackingNumbers"]}
for o in fulfilled]
for chunk in chunks(sf_updates, 25):
salesforce.composite_update("Order", chunk)
Verify: Salesforce Order shows updated ERP_Status__c and Tracking_Numbers__c.
All steps must handle transient errors (retry) vs. permanent errors (dead letter queue). [src1]
def execute_with_retry(operation, payload, idempotency_key):
for attempt in range(3):
try:
return operation(payload)
except RetryableError:
time.sleep(2 ** (attempt + 1)) # 2s, 4s, 8s
except PermanentError as e:
send_to_dlq(idempotency_key, payload, str(e))
return None
send_to_dlq(idempotency_key, payload, "Max retries exhausted")
Verify: Failed messages in DLQ with payload + error. DLQ count < 1% of total volume.
# Input: Salesforce Order ID (from Platform Event)
# Output: NetSuite Sales Order internal ID
def sync_order_sf_to_netsuite(sf_order_id, sf_conn, ns_config):
order = sf_conn.query(
f"SELECT Id, AccountId, TotalAmount, CurrencyIsoCode, "
f"PO_Number__c, ERP_Customer_ID__c "
f"FROM Order WHERE Id = '{sf_order_id}'"
)["records"][0]
items = sf_conn.query(
f"SELECT Product2.ERP_Item_ID__c, Quantity, UnitPrice "
f"FROM OrderItem WHERE OrderId = '{sf_order_id}'"
)["records"]
result = netsuite_upsert("salesOrder", {
"externalId": sf_order_id, # Idempotency key
"entity": {"internalId": order["ERP_Customer_ID__c"]},
"item": {"item": [
{"item": {"internalId": i["Product2"]["ERP_Item_ID__c"]},
"quantity": i["Quantity"], "rate": str(i["UnitPrice"])}
for i in items
]}
}, ns_config)
sf_conn.Order.update(sf_order_id, {
"ERP_Order_Number__c": result["tranId"],
"ERP_Status__c": "Pending Fulfillment"
})
return result["internalId"]
// Input: Order data from CRM (transformed)
// Output: Dynamics 365 Sales Order ID
const axios = require('axios');
const { ClientSecretCredential } = require('@azure/identity');
async function createD365SalesOrder(orderData, config) {
const credential = new ClientSecretCredential(
config.tenantId, config.clientId, config.clientSecret);
const token = await credential.getToken(`${config.d365Url}/.default`);
const header = await axios.post(
`${config.d365Url}/data/SalesOrderHeadersV2`,
{ SalesOrderNumber: orderData.idempotencyKey,
OrderingCustomerAccountNumber: orderData.customerAccount,
CurrencyCode: orderData.currency },
{ headers: { Authorization: `Bearer ${token.token}` } });
for (const line of orderData.lineItems) {
await axios.post(
`${config.d365Url}/data/SalesOrderLines`,
{ SalesOrderNumber: header.data.SalesOrderNumber,
ItemNumber: line.erpItemId,
OrderedSalesQuantity: line.quantity },
{ headers: { Authorization: `Bearer ${token.token}` } });
}
return header.data.SalesOrderNumber;
}
# Step 1: Fetch CSRF token
curl -s -X GET \
"https://{sap_host}/sap/opu/odata4/sap/api_sales_order_srv/srvd_a2x/sap/salesorder/0001/" \
-H "Authorization: Bearer ${SAP_TOKEN}" \
-H "x-csrf-token: Fetch" -c cookies.txt -D headers.txt
CSRF=$(grep -i 'x-csrf-token' headers.txt | awk '{print $2}' | tr -d '\r')
# Step 2: Create order with deep insert
curl -s -X POST \
"https://{sap_host}/.../SalesOrder" \
-H "Authorization: Bearer ${SAP_TOKEN}" \
-H "x-csrf-token: ${CSRF}" \
-H "Content-Type: application/json" -b cookies.txt \
-d '{"SalesOrderType":"OR","SoldToParty":"10100001",
"_Item":[{"Material":"TG11","RequestedQuantity":"10"}]}'
# Expected: 201 Created
| Source (Salesforce) | Target (NetSuite) | Target (SAP) | Target (D365) | Gotcha |
|---|---|---|---|---|
| Account.Name | customer.companyName | SoldToParty (lookup) | OrderingCustomerAccountNumber | NetSuite max 83 chars; SAP uses customer number not name |
| OrderItem.UnitPrice | salesOrderItem.rate | NetAmount | SalesPrice | Decimal precision: SF=2, NS=2, SAP=2-3, D365=2-6 |
| OrderItem.Quantity | salesOrderItem.quantity | RequestedQuantity | OrderedSalesQuantity | UoM must match: SF "Each" → SAP "EA" → NS "Each" |
| Product2.ProductCode | item.itemId (via externalId) | Material (number) | ItemNumber | Always use cross-reference table |
| Order.CurrencyIsoCode | currency (internal ID) | TransactionCurrency | CurrencyCode | NetSuite uses internal IDs, not ISO codes |
| Payment_Terms__c | terms (internal ID) | CustomerPaymentTerms | PaymentTerms | Map labels ("Net 30") to ERP codes |
| Order.ShipToAddress | shipAddress | ShipToParty (lookup) | DeliveryAddress | SAP uses Ship-To partner function |
| Order.TotalAmount | DO NOT MAP | DO NOT MAP | DO NOT MAP | Let ERP recalculate from line items |
| Code | Meaning | System | Resolution |
|---|---|---|---|
| 429 | Rate limit exceeded | Salesforce | Exponential backoff: wait 2^n seconds, max 5 retries |
| DUPLICATE_VALUE | External ID already exists | SF / NetSuite | Expected on retry — upsert handles this |
| INSUFFICIENT_ACCESS | Missing permissions | All | Audit integration user profile/role |
| SSS_REQUEST_LIMIT_EXCEEDED | Governance units breached | NetSuite | Reduce script complexity or split operations |
| CX_BAPI_ERROR | Business rule violation | SAP | Check customer credit status; may need manual override |
| 403 Forbidden | Missing x-csrf-token or perms | SAP / D365 | Re-fetch csrf (SAP); check app registration (D365) |
| UNABLE_TO_LOCK_ROW | Record locked by another txn | Salesforce | Retry with random jitter (0-2s) |
Use external ID / idempotency key on every creation. NetSuite externalId, SAP PurchaseOrderByCustomer, D365 SalesOrderNumber all support upsert dedup. [src1]Always sync customer before order — as a pre-step or dependency check. [src1]On 403, re-fetch token and retry once. [src4]Request queuing in middleware. Upgrade to SuiteCloud Plus. Use addList batch operations. [src1]Bulkify all triggers. Use @future or Queueable for callouts. [src6]Push quantities, let ERP price using its rate tables. Never push CRM-calculated amounts. [src1]# BAD — burns 1,440 API calls/day just for polling, adds latency
while True:
new_orders = salesforce.query("SELECT Id FROM Order WHERE Status = 'Activated'")
for order in new_orders: process_order(order)
time.sleep(60)
# GOOD — zero API calls for detection; fires instantly on status change
# iPaaS subscribes to Platform Events via CometD/gRPC Pub/Sub API
# API calls used only for actual data retrieval and order creation
# BAD — CRM total may not match ERP calculation (tax, discounts, rounding)
erp_order = {"total": sf_order["TotalAmount"], "tax": sf_order["Tax__c"]}
# GOOD — ERP applies its own pricing, tax, and discount rules
erp_order = {"items": [{"material": i["sku"], "quantity": i["qty"],
"unit_price": i["price"]} for i in sf_line_items]}
# BAD — 10 line items = 10 API calls, burns rate limit
for item in sf_line_items:
netsuite.create("salesOrderItem", transform(item))
# GOOD — 1 API call creates header + all line items atomically
netsuite.create("salesOrder", {
"externalId": sf_order_id,
"item": {"item": [transform(i) for i in sf_line_items]}})
Load-test in Full sandbox with production-equivalent data volumes. [src1]Pin API version in all endpoints. Set calendar reminders 6 months before EOL. [src6]Always check per-record results. Implement record-level error handling. [src1]Always use external IDs. Every creation must be safe to retry. [src1]Use mapping tables (database or iPaaS lookup) instead of hardcoded if/else. [src1]Include customer upsert as step 1 in every order flow. [src1]Use Platform Events for async handoff. Never make sync HTTP calls in triggers. [src6]# Salesforce: Check API usage / remaining limits
curl -s "https://{instance}.salesforce.com/services/data/v62.0/limits" \
-H "Authorization: Bearer ${SF_TOKEN}" | jq '.DailyApiRequests'
# SAP S/4HANA: Test connectivity and auth
curl -s -o /dev/null -w "%{http_code}" \
"https://{sap_host}/sap/opu/odata4/sap/api_sales_order_srv/..." \
-H "Authorization: Bearer ${SAP_TOKEN}"
# Expected: 200
# SAP: Verify sales order exists
curl -s "https://{sap_host}/.../SalesOrder('{ORDER_NUMBER}')" \
-H "Authorization: Bearer ${SAP_TOKEN}" | jq '.SalesOrder'
# Dynamics 365: Check service health
curl -s "https://{d365_host}/data/SalesOrderHeadersV2?\$top=1" \
-H "Authorization: Bearer ${D365_TOKEN}" -o /dev/null -w "%{http_code}"
# iPaaS DLQ depth (platform-specific):
# MuleSoft: Anypoint Monitoring > DLQ depth
# Boomi: Process Reporting > Errors tab
# Workato: Jobs > Failed > filter by recipe
| System / API | Version | Release | Status | Breaking Changes |
|---|---|---|---|---|
| Salesforce REST API v62.0 | Spring '26 | 2026-02 | Current | None |
| Salesforce REST API v61.0 | Winter '26 | 2025-10 | Supported | None |
| SAP API_SALES_ORDER_SRV (OData v4) | 2408 | 2024-08 | Current | Async processing added |
| SAP API_SALES_ORDER_SRV (OData v2) | A2X | 2020+ | Supported | Use v4 for new integrations |
| NetSuite 2024.2 | 2024.2 | 2024-07 | Current | REST API GA for more record types |
| D365 F&SCM 10.0.39 | 10.0.39 | 2024-03 | Current | New SalesOrderHeadersV2 entity |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Multiple systems handle different O2C steps | Single ERP handles entire O2C natively | Built-in ERP O2C workflows |
| Order volume > 50/day justifies automation | < 10 orders/day, simple products | Manual order entry or CSV import |
| Need real-time order status across sales + ops | Batch nightly sync is acceptable | Scheduled file-based integration |
| Multi-entity / multi-currency operations | Single entity, single currency | Simplified point-to-point integration |
| Complex pricing / fulfillment logic in ERP | Simple flat-rate pricing in CRM | CRM-native invoicing |
| Capability | SF + SAP | SF + NetSuite | SF + D365 | Notes |
|---|---|---|---|---|
| Pre-built connector | SAP CPI / MuleSoft | Celigo / Boomi / Workato | Microsoft Dual-write | Celigo most mature for SF+NS |
| Order creation API | OData v4 deep insert | SuiteTalk SOAP / RESTlet | OData v4 Data Entities | SAP and D365 share OData pattern |
| Real-time events | SAP Business Events | User Event Scripts | Business Events via Service Bus | SAP newest; NS most custom |
| Bulk order support | $batch (1,000 ops) | addList (25 records/call) | $batch (1,000 ops) | NetSuite most limited |
| Idempotency | PurchaseOrderByCustomer | externalId (native upsert) | SalesOrderNumber | NetSuite most elegant |
| Concurrency limit | Tenant-configurable | 5 default / 10+ Plus | 6,000 req/5min per user | NetSuite most restrictive |
| Auth complexity | OAuth + x-csrf (2-step) | TBA (OAuth 1.0a) | Azure AD OAuth 2.0 | SAP most complex |
| iPaaS ecosystem | MuleSoft, SAP CPI | Celigo, Boomi, Workato | Power Automate, Logic Apps | Most choice for SF+NS |
| Avg implementation | 12-20 weeks | 8-14 weeks | 10-16 weeks | NetSuite fastest (mid-market) |
| Typical cost | $150K-$500K | $50K-$200K | $100K-$350K | Highly variable by complexity |