Salesforce → ERP¶
These are the calls ERP receives. Salesforce is the client. The sample company is Sharma Distributors on every example. Auth, timeouts, and path aliases are on the overview.
Two calls arrive during onboarding.
| Order | Method and path | What it is |
|---|---|---|
| 1 | POST /webhook/onboarding |
The Temp Account. ERP answers with the Sold To and Bill To codes |
| 2 | POST /api/accounts/salesforce-callback |
The Salesforce Account Id, once for the Sold To and once for the Bill To |
Call 2 is sent only after ERP has already called Salesforce. Those outbound calls are on ERP → Salesforce.
POST /webhook/onboarding¶
Submit a distributor. ERP creates the Sold To and the Bill To, writes them back to Salesforce, then returns the codes in the same response.
POST /api/distributors/onboarding is the same operation. Header: Content-Type: application/json. Named Credential ERP_Named_Credential. Timeout 15 seconds. No retry (Max_Retries__c = 0).
Temp_AfterSave runs on Account when the record type is Temp. On insert it calls ErpOutboundSyncInvocable, which enqueues ErpOutboundSyncQueueable for integration Distributor_Sync. The queueable builds the JSON from the active request mappings and posts it. Account_AfterSave is obsolete.
A mapping with a blank Direction__c is sent on the request. A mapping with Direction__c = Response is not sent. Distributor_Sync_ERP_Code_Writeback is a response row, so soldToCode is not in the request. ErpPayloadBuilder turns a dot path into a nested object, so billingAddress.city is not a flat key. Two active rows both use the key salesforceRecordId and both read Id, so the key appears once.
What Salesforce sends¶
{
"salesforceRecordId": "001000000000001AAA",
"prospectName": "Sharma Distributors",
"territoryCode": "TER-KA-BLR-01",
"contactName": "Meena Rao",
"contactPhone": "9900112233",
"contactEmail": "meena.sharma@example.com",
"distributorOnboardingId": "a0X000000000001AAA",
"gstRegistrationNumber": "29ABCDE1234F1Z5",
"panNumber": "ABCDE1234F",
"billingAddress": {
"street": "21 Residency Road",
"city": "Bengaluru",
"state": "Karnataka",
"pincode": "560025"
}
}
| JSON path | Type | Required by ERP | Salesforce source | Stored in ERP | Also accepted |
|---|---|---|---|---|---|
salesforceRecordId |
string | no, but write-back is skipped without it | Account.Id |
onboarding_requests.salesforce_record_id |
|
prospectName |
string | yes, under the name of accountName |
Account.Name |
Sold To name, onboarding prospectName |
accountName is preferred when both are sent |
gstRegistrationNumber |
string | yes, under the name of gstNumber |
GST_Registration_Number__c |
Sold To GSTIN | gstNumber is preferred when both are sent |
panNumber |
string | yes | PAN_Number__c |
Sold To PAN, uppercased | |
billingAddress.street |
string | yes | BillingStreet |
Sold To and Bill To street | |
billingAddress.city |
string | yes | BillingCity |
Sold To and Bill To city | |
billingAddress.state |
string | yes | BillingState |
Sold To and Bill To state, rewritten to the full Indian state name | a code such as KA is rejected |
billingAddress.pincode |
string | yes, 6 digits | BillingPostalCode |
Sold To and Bill To pincode | |
billingAddress.country |
string | no | not sent | billing country, if a caller sends it | |
territoryCode |
string | no | Territory__r.Territory_Code__c |
Sold To territoryCode |
proposedTerritoryCode |
contactName |
string | no | Contact_Name__c |
onboarding contact name, sent back on the Sold To patch as Contact_Name__c |
|
contactPhone |
string | no | Contact_Phone__c |
onboarding contact mobile | contactMobile |
contactEmail |
string | no | Email__c |
onboarding contact email, sent back as Email__c |
|
distributorOnboardingId |
string | no | Distributor_Onboarding__c |
dropped. OnboardingPayload has no field for this key |
|
bankDetails.accountName |
string | no | not sent by the current mappings | Sold To bank account name | flat bankAccountName is not read |
bankDetails.accountNumber |
string | no | not sent | Sold To bank account number | flat bankAccountNumber is not read |
bankDetails.ifsc |
string | no | not sent | Sold To IFSC, uppercased | flat bankIfscCode is not read |
billingAddress.country is not mapped. Bank keys are not in the body Salesforce sends. Their mappings are inactive, and the Temp Account was not given bank fields.
Bank details do not arrive
The inactive mappings use flat keys (bankAccountName, bankAccountNumber, bankIfscCode). ERP only reads the nested bankDetails object. Turning the current rows back on, without changing the keys to bankDetails.accountName, bankDetails.accountNumber, and bankDetails.ifsc, still leaves the bank fields empty.
Payload mappings¶
Every ERP_Payload_Mapping row on Distributor_Sync for the request. Data_Type__c is String on every row. None of them set Literal_Value__c. A blank Direction__c is sent on the request and is not read from the response. Inactive rows are not loaded.
| Sort | Mapping | Active | Direction | JSON key | Salesforce field | Lands in ERP |
|---|---|---|---|---|---|---|
| 5 | Distributor_Sync_Salesforce_Record_Id |
yes | Request | salesforceRecordId |
Id |
onboarding salesforceRecordId |
| 10 | Distributor_Sync_salesforceId |
yes | blank, so request | salesforceRecordId |
Id |
same key as the row above. The JSON contains it once |
| 10 | Distributor_Sync_Name |
yes | blank, so request | prospectName |
Name |
Sold To name. accountName wins if both are present |
| 15 | Distributor_Sync_Territory_Code |
yes | Request | territoryCode |
Territory__r.Territory_Code__c |
Sold To territoryCode. proposedTerritoryCode is the alternate |
| 16 | Distributor_Sync_Contact_Name |
yes | Request | contactName |
Contact_Name__c |
onboarding contact name |
| 17 | Distributor_Sync_Mobile |
yes | Request | contactPhone |
Contact_Phone__c |
onboarding contact mobile. contactMobile is the alternate |
| 18 | Distributor_Sync_Email |
yes | Request | contactEmail |
Email__c |
onboarding contact email |
| 19 | Distributor_Sync_Onboarding_Id |
yes | Request | distributorOnboardingId |
Distributor_Onboarding__c |
dropped. OnboardingPayload has no matching field |
| 20 | Distributor_Sync_GST |
yes | blank, so request | gstRegistrationNumber |
GST_Registration_Number__c |
Sold To GSTIN. gstNumber wins if both are present |
| 30 | Distributor_Sync_PAN |
yes | blank, so request | panNumber |
PAN_Number__c |
Sold To PAN |
| 40 | Distributor_Sync_Billing_Street |
yes | blank, so request | billingAddress.street |
BillingStreet |
billing street |
| 50 | Distributor_Sync_Billing_City |
yes | blank, so request | billingAddress.city |
BillingCity |
billing city |
| 60 | Distributor_Sync_Billing_State |
yes | blank, so request | billingAddress.state |
BillingState |
billing state, normalized to the full name |
| 70 | Distributor_Sync_Billing_Pincode |
yes | blank, so request | billingAddress.pincode |
BillingPostalCode |
billing pincode |
| 80 | Distributor_Sync_Bank_Account_No |
no | blank | bankAccountNumber |
Bank_Account_Number__c |
not sent. The flat key would not fill bankDetails.accountNumber anyway |
| 90 | Distributor_Sync_Bank_IFSC |
no | blank | bankIfscCode |
Bank_IFSC_Code__c |
not sent. The nested key ERP reads is bankDetails.ifsc |
| 100 | Distributor_Sync_Bank_Account_Name |
no | blank | bankAccountName |
Bank_Account_Name__c |
not sent. The nested key is bankDetails.accountName |
What ERP does¶
- Bind the JSON. A body that is not JSON returns 400 before any of the steps below.
- Resolve the name from
accountName, thenprospectName. Resolve GST fromgstNumber, thengstRegistrationNumber. Uppercase the PAN. - Reject a blank name, GST, or PAN.
- Check the GSTIN against
^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[A-Z0-9]{1}Z[A-Z0-9]{1}$and the PAN against^[A-Z]{5}[0-9]{4}[A-Z]{1}$. - Require street, city, state, and a 6-digit pincode. The state must be a full Indian state or union territory name.
Karnatakais kept.KAis rejected. The stored state is the normalized name. - Reject the GSTIN if a Sold To with that GSTIN already exists.
- Allocate
ONB-2026-0001,DIST001, andBILL001. Each code is checked in Salesforce and skipped if that code is already on an Account. Up to 50 candidates are tried. - Insert the Sold To, the Bill To, and the onboarding row in one transaction. Status is
responded. Payment terms on the Sold To areNet 30, which was not in the request. One Bill To is created. Its name is{prospect name} - Billing. A manual respond that passes several codes names the later ones{prospect name} - Billing 2, and so on. - Call the Sold To upgrade and the Bill To create. A failure here is saved on the onboarding row and does not change the HTTP status. The codes are still returned. See Failures and retry.
- Write an inbound integration log:
flowB receive erpRef=ONB-2026-0001 name="Sharma Distributors" gst=29ABCDE1234F1Z5 soldTo=DIST001.
What ERP sends back¶
HTTP 200. Every field on OnboardingResponse is present. The callout is a success on any HTTP status from 200 to 299.
{
"soldToCode": "DIST001",
"billToCodes": ["BILL001"],
"recordTypeUpgrade": "Sold To",
"erpReference": "ONB-2026-0001",
"status": "responded"
}
| Field | Meaning | What Salesforce does with it |
|---|---|---|
soldToCode |
The code just assigned | Writes it to Account.ERP_Record_ID__c |
recordTypeUpgrade |
Always Sold To |
Resolves the label to the Sold To record type and writes RecordTypeId |
billToCodes |
One code on this path. The array can hold more than one when the manual respond API is used | Nothing. Not mapped |
erpReference |
The onboarding audit key, ONB-{year}-{sequence} |
Nothing. Not mapped |
status |
responded |
Nothing. Not mapped |
Response mappings. Response is read from the body and is not sent on the request.
| Sort | Mapping | Active | Direction | Data type | JSON key | Written to |
|---|---|---|---|---|---|---|
| 110 | Distrubtor_Sync_RecordType_Writeback |
yes | Response | RecordType | recordTypeUpgrade |
RecordTypeId. The value Sold To is resolved by label. The developer name of that row is misspelled (Distrubtor) and the integration still loads it because Integration__c is Distributor_Sync |
| 200 | Distributor_Sync_ERP_Code_Writeback |
yes | Response | String | soldToCode |
Account.ERP_Record_ID__c |
For Sharma Distributors those writes set ERP_Record_ID__c to DIST001 and RecordTypeId to the Sold To record type. Developer name is tried first, then the label, so Sold To matches the label rather than the developer name Sold_To. A missing path does not clear the field. A write-back exception, including an unknown record type name, is written on the integration log and the status stays Synced.
On success the queueable also sets ERP_Sync_Status__c to Synced and ERP_Last_Sync_Timestamp__c to the time the response was handled. Those two are the integration setting, not payload mappings. The queueable writes an Integration_Log__c for the callout. Log_Operation__c on the setting is Update.
Before this 200, ERP has already sent the Sold To upgrade and the Bill To create.
Records after a successful push¶
| System | Record | State |
|---|---|---|
| Salesforce | Account 001000000000001AAA |
Record type Sold To, Sold_To_Code__c = DIST001, ERP_Sync_Status__c = Synced, ERP_Record_ID__c = DIST001 |
| Salesforce | Account 001000000000002AAA |
Bill To created during the same request |
| ERP | Sold To DIST001 |
Active, payment terms Net 30, GSTIN and address from the request, salesforceRecordId still empty until the callback |
| ERP | Bill To BILL001 |
Name Sharma Distributors - Billing, parent DIST001 |
| ERP | Onboarding ONB-2026-0001 |
responded, both codes stored, errorMessage empty |
Error ERP sends back¶
These are returned by ReceiveOnboarding itself. Salesforce then sets ERP_Sync_Status__c to Failed and does not apply the response mapping. soldToCode is not written, because the response mapping runs only after a 2xx. Max_Retries__c is 0, so the queueable does not send the push again. The Account stays record type Temp with a blank Sold_To_Code__c. A new attempt means sending Distributor_Sync again for that Account.
No Sold To, Bill To, or onboarding row is inserted for a validation or conflict error. Code allocation also runs before that insert. If the Salesforce code lookup fails, the push returns 500 and nothing is saved.
The body is always { "error", "message" }. The name and GST messages say accountName and gstNumber even when Salesforce sent prospectName and gstRegistrationNumber. Those are the names of the blank resolved values.
| HTTP | error |
message |
|---|---|---|
| 400 | invalid_json |
invalid request body |
| 400 | validation_error |
accountName is required |
| 400 | validation_error |
gstNumber is required |
| 400 | validation_error |
panNumber is required |
| 400 | validation_error |
gst registration number must be a valid 15-character Indian GSTIN |
| 400 | validation_error |
pan number must be a valid PAN in format AAAAA9999A |
| 400 | validation_error |
billing address is required |
| 400 | validation_error |
billing street is required |
| 400 | validation_error |
billing city is required |
| 400 | validation_error |
billing state is required |
| 400 | validation_error |
billing state must be a valid Indian state/UT name (e.g. Maharashtra), not an abbreviation like MH |
| 400 | validation_error |
billing pincode is required |
| 400 | validation_error |
billing pincode must be exactly 6 digits |
| 409 | conflict |
a distributor with this GST number already exists |
| 500 | internal_error |
internal error: ... when code allocation or the database insert fails |
A duplicate GSTIN leaves the existing distributor as it is. Write-back failures are a different case. They still produce HTTP 200. They are described in Failures and retry.
POST /api/accounts/salesforce-callback¶
Return the Account Id ERP must store. Salesforce sends this twice: once for the Sold To, once for the Bill To. This callback is the only write of salesforceRecordId on the local Sold To and Bill To. ERP does not store the Id from the create or update response.
POST /webhook/salesforce-callback is the older path for the same operation. Header: Content-Type: application/json. Timeout 15 seconds. Salesforce retries three times after the first attempt. The endpoint is callout:ERP_Named_Credential/api/accounts/salesforce-callback.
When Salesforce sends it¶
SoldTo_AfterSave and BillTo_AfterSave call the subflow Account_AfterSave_Shared. Temp accounts do not. The subflow calls ErpCallbackInvocable when Bypass_Automation__c.Is_Bypassed__c is false and one of these is true:
- The Account is new and the record type is Sold To or Bill To. The Bill To insert hits this case.
- The record type changed from Temp to Sold To. The upgrade PATCH hits this case, because
SoldTo_AfterSavepasses the prior record type into the subflow. - The Account is not new, the record type is Sold To or Bill To,
SF_Record_ID_Sent_to_ERP__cis false, a Sold To or Bill To code is present, andERP_Sync_Status__cis not Failed.
The callback job sets a flag while it runs so the Pending stamp does not enqueue a second job. ErpCallbackService also refuses to enqueue when ERP_Sync_Status__c is already Failed. A blank code is not sent. Enqueue stamps ERP_Sync_Status__c to Pending, then queues ErpCallbackService.CallbackJob. The job does not run until the Salesforce transaction commits. During onboarding, the original HTTP response is still in flight when the job is queued.
| Record type | accountType sent |
erpCode sent |
|---|---|---|
| Sold To | SoldTo |
Sold_To_Code__c |
| Bill To | BillTo |
Bill_To_Code__c |
What Salesforce sends for the Sold To¶
timestamp is UTC, yyyy-MM-dd'T'HH:mm:ss'Z', with no fractional seconds.
{
"salesforceRecordId": "001000000000001AAA",
"accountType": "SoldTo",
"erpCode": "DIST001",
"timestamp": "2026-11-05T12:00:01Z"
}
ERP stores 001000000000001AAA on Sold To DIST001, and copies that same Id onto every Bill To whose parent code is DIST001, into parentSalesforceId.
What Salesforce sends for the Bill To¶
{
"salesforceRecordId": "001000000000002AAA",
"accountType": "BillTo",
"erpCode": "BILL001",
"timestamp": "2026-11-05T12:00:02Z"
}
ERP stores 001000000000002AAA on Bill To BILL001 only. The parent Id is not set by the Bill To callback. accountType may also be Sold To or Bill To with a space. Both forms map to the same handler. Any other value returns 400 with accountType must be Sold To or Bill To.
| JSON field | Type | Required | Salesforce source | Stored in ERP |
|---|---|---|---|---|
salesforceRecordId |
string | yes | Account.Id, 18 characters |
Sold To or Bill To salesforceRecordId |
accountType |
string | yes | SoldTo or BillTo |
selects which table is updated |
erpCode |
string | yes | Sold_To_Code__c or Bill_To_Code__c |
lookup key |
timestamp |
string, UTC | no | Datetime.now() in GMT |
a missing timestamp is stored as the current UTC time for the request only. It is not written onto the account row. A value that is not a time fails JSON binding and returns 400 invalid_json |
What ERP does¶
- Require
salesforceRecordIdanderpCode. - Normalize
accountType. - Load the Sold To or Bill To by
erpCode. - If that row already stores this same Id, return success and do nothing else. A repeat delivery is accepted.
- If that row already stores a different Id, return 409.
- For a Sold To: save the Id on
DIST001, then copy it onto every Bill To whose parent code isDIST001. - For a Bill To: save the Id on
BILL001only. - Write a callback integration log. The reference is
callback soldTo erpCode=DIST001orcallback billTo erpCode=BILL001. An idempotent repeat appends(idempotent).
Order between the two jobs is not fixed. If the Bill To callback arrives first, BILL001.salesforceRecordId is set and parentSalesforceId stays empty until the Sold To callback runs.
What ERP sends back¶
HTTP 200. The body echoes the code, not the Salesforce Id.
Sold To:
Bill To:
Any status from 200 to 299 is success. The job sets ERP_Sync_Status__c to Synced, sets SF_Record_ID_Sent_to_ERP__c to true, and stamps ERP_Last_Sync_Timestamp__c. The integration log status is Synced. Operation is Create on the first attempt and Retry when retryCount > 0.
Retries¶
A non-2xx or a callout exception is a failure. The log stores HTTP {status}: {body}, or the exception message. While retries remain, the Account stays Pending and the job chains another attempt. The waits are 1 minute, then 2, then 4. MAX_RETRIES is 3, so the first attempt plus three retries is the limit. The exhausted attempt sets ERP_Sync_Status__c to Failed and inserts a second Integration_Log__c with Alert_Type__c = Callback Dead Letter, Operation__c = Retry, and Payload_Reference__c starting with deadLetter=true. That log is what fires the admin email and bell. A later save that finds ERP_Sync_Status__c = Failed does not enqueue another callback. A manual retry has to set the status back to Pending first.
Callback errors¶
| HTTP | error |
message |
|---|---|---|
| 400 | invalid_json |
invalid request body |
| 400 | validation_error |
salesforceRecordId is required |
| 400 | validation_error |
erpCode is required |
| 400 | validation_error |
accountType must be Sold To or Bill To |
| 404 | not_found |
sold to account not found |
| 404 | not_found |
bill to account not found |
| 409 | conflict |
sold to account already linked to a different salesforce record id |
| 409 | conflict |
bill to account already linked to a different salesforce record id |
| 500 | internal_error |
internal error: ... |
A 404 means the code in the callback is not in this ERP database. A 409 means the row is already linked to a different Account Id, and a retry of the same body will keep failing until that link is cleared.
Records after both callbacks¶
| ERP row | Field | Value |
|---|---|---|
Sold To DIST001 |
salesforceRecordId |
001000000000001AAA |
Bill To BILL001 |
salesforceRecordId |
001000000000002AAA |
Bill To BILL001 |
parentSalesforceId |
001000000000001AAA |
Later Sold To and Bill To updates from ERP use these Ids in the PATCH path. They do not look the Account up by code again, except for the onboarding pre-checks on ERP → Salesforce.