ERP → Salesforce¶
ERP is the client. Salesforce is the server. Auth is the OAuth client credentials described under How the systems connect, against /services/data/v67.0. If ERP has no instance URL and no token, the local rows are still saved and these calls are not made.
POST /api/product-categories¶
A child category:
{
"categoryCode": "CAT-TEA-LOOSE",
"name": "Loose Tea",
"level": 2,
"parentCategoryCode": "CAT-TEA"
}
ERP saves the row, then upserts Salesforce, then answers HTTP 200. On a successful first create the body includes "syncStatus": "Synced" and "salesforceRecordId": "a0C000000000001AAA". A failed push still returns 200, with "syncStatus": "Failed". The same POST updates a code that already exists and pushes again.
| HTTP | error |
message |
|---|---|---|
| 400 | invalid_json |
invalid request body |
| 400 | validation_error |
category code is required |
| 400 | validation_error |
category name is required |
| 400 | validation_error |
category level must be >= 1 |
| 400 | validation_error |
parent category code is required for level > 1 |
| 400 | validation_error |
parent category not found |
| 400 | validation_error |
category cannot be its own parent |
PATCH Product_Category__c by code¶
The code is in the URL, not the body.
PATCH /services/data/v67.0/sobjects/Product_Category__c/Category_Code__c/CAT-TEA
Authorization: Bearer {access token}
Content-Type: application/json
A child adds the parent:
{
"Category_Name__c": "Loose Tea",
"Category_Level__c": 2,
"Parent_Category__r": {
"Category_Code__c": "CAT-TEA"
}
}
| Salesforce field | Example | Where ERP got it |
|---|---|---|
Category_Code__c |
CAT-TEA |
the URL. External id. Not in the body |
Category_Name__c |
Tea |
name |
Category_Level__c |
1 |
level |
Parent_Category__r.Category_Code__c |
CAT-TEA |
parentCategoryCode. Omitted when blank |
A first create is HTTP 201 with an id. ERP stores that id on the category. An update of a code Salesforce already has is HTTP 204 with an empty body. ERP treats the empty body as a failure: category upsert: empty id (status 204). The local row becomes Failed, and the id stored from the first create is left as it was. See Salesforce → ERP.
The log reference is categoryCode=CAT-TEA name="Tea" level=1 parent=. A category failure is written to the ERP integration log only. ERP does not post an Integration_Log__c for a category.
POST /api/product-categories/CAT-TEA/sync-to-salesforce sends the same PATCH again. Success is HTTP 200:
{
"erpCode": "CAT-TEA",
"accountType": "ProductCategory",
"status": "Synced",
"salesforceRecordId": "a0C000000000001AAA"
}
A Salesforce error on this retry is HTTP 500 internal_error with message internal server error.
POST /api/product-categories/sync-unsynced runs the same upsert for every category, ordered by level then code. The query returns rows already marked Synced, so a second pass hits the empty 204 and counts those as failed. The answer is HTTP 200, for example { "synced": 1, "failed": 0 }.
POST /api/products¶
CAT-TEA must already exist when categoryCode is sent.
{
"product": {
"productCode": "TEA-500",
"productName": "Assam Leaf 500g",
"description": "Loose leaf, 500 g pack",
"categoryCode": "CAT-TEA",
"uom": "Pack",
"packSize": 1,
"cartonSize": 20,
"status": "Active",
"cgstRate": 2.5,
"sgstRate": 2.5,
"igstRate": 5,
"utgstRate": 0,
"isBundle": false
}
}
ERP answers HTTP 201 with the product and "components": []. A successful push sets syncStatus to Synced and salesforceRecordId to the id Salesforce returned. A failed push still returns 201, with syncStatus Failed.
PATCH /api/products/TEA-500 sends the same product fields at the top level, without a product wrapper, and pushes again. The product code stays the one in the URL. Success is HTTP 200.
A code may be at most 50 characters. It must start with a letter or digit and may contain ., _, and -.
| HTTP | error |
message |
|---|---|---|
| 400 | invalid_json |
invalid request body |
| 400 | validation_error |
product code is required |
| 400 | validation_error |
product code must be at most 50 characters |
| 400 | validation_error |
product code must be alphanumeric and may include '.', '_' or '-'; spaces and other special characters are not allowed |
| 400 | validation_error |
status must be Active, End of Life (EOL), Discontinuing, or Inactive |
| 400 | validation_error |
CGST rate must be between 0 and 28 |
| 400 | validation_error |
category CAT-TEA not found |
| 409 | conflict |
product with this code already exists |
| 404 | not_found |
product not found |
The 409 is only on create. The 404 is a PATCH or a sync for a code ERP does not have. SGST, IGST, and UTGST use the same 0-to-28 message with their own names.
Leaving categoryCode blank is allowed. categoryName is then sent as Category_Code__c.
PATCH Product_Master__c by code¶
{
"Product_Name__c": "Assam Leaf 500g",
"Description__c": "Loose leaf, 500 g pack",
"Status__c": "Active",
"UOM__c": "Pack",
"Pack_Size__c": 1,
"Carton_Size__c": 20,
"CGST_Rate__c": 2.5,
"SGST_Rate__c": 2.5,
"IGST_Rate__c": 5,
"UTGST_Rate__c": 0,
"Is_Taxable__c": true,
"Is_Bundle__c": false,
"Category_Code__c": "CAT-TEA"
}
| Salesforce field | Example | Where ERP got it |
|---|---|---|
Product_Code__c |
TEA-500 |
the URL. External id. Not in the body |
Product_Name__c |
Assam Leaf 500g |
productName |
Description__c |
Loose leaf, 500 g pack |
description. Omitted when blank |
Status__c |
Active |
status |
UOM__c |
Pack |
uom, mapped onto the Salesforce picklist |
Pack_Size__c |
1 |
packSize |
Carton_Size__c |
20 |
cartonSize |
CGST_Rate__c |
2.5 |
cgstRate |
SGST_Rate__c |
2.5 |
sgstRate |
IGST_Rate__c |
5 |
igstRate |
UTGST_Rate__c |
0 |
utgstRate |
Is_Taxable__c |
true |
true when any GST rate is above 0 |
Is_Bundle__c |
false |
isBundle |
Category_Code__c |
CAT-TEA |
categoryCode, or categoryName when the code is blank |
uom is matched in lower case. pack becomes Pack. Also accepted: case → Case, carton → Carton, piece, pcs, ea, each, and bottle → Piece, kg → Kg, litre and liter → Litre, bag → Pack. Anything else, including a blank unit, is sent as OPEN POINT.
A first create is HTTP 201. ERP stores the id. A later update is HTTP 204 with an empty body, and ERP marks the product Failed: product upsert: empty id (status 204).
The log reference is productCode=TEA-500 name="Assam Leaf 500g" status=Active category=CAT-TEA isBundle=false.
POST /api/products/TEA-500/sync-to-salesforce sends the same PATCH. Success is HTTP 200:
{
"erpCode": "TEA-500",
"accountType": "Product",
"status": "Synced",
"salesforceRecordId": "a0P000000000001AAA"
}
POST /api/products/sync-unsynced upserts every product, including ones already Synced, so a second pass hits the empty 204. The answer is HTTP 200, for example { "synced": 1, "failed": 0 }.
Bundle TEA-GIFT¶
TEA-500 must already exist, and it must not itself be a bundle.
{
"product": {
"productCode": "TEA-GIFT",
"productName": "Assam Leaf Gift Pack",
"categoryCode": "CAT-TEA",
"uom": "Pack",
"packSize": 1,
"cartonSize": 10,
"status": "Active",
"cgstRate": 2.5,
"sgstRate": 2.5,
"igstRate": 5,
"utgstRate": 0,
"isBundle": true
},
"components": [
{ "componentProductCode": "TEA-500", "componentQuantity": 2 }
]
}
The product PATCH goes out first, with "Is_Bundle__c": true. When that upsert returns an id, ERP replaces the bundle lines.
It queries the lines already stored:
GET /services/data/v67.0/query?q=SELECT Id FROM Bundle_Component__c WHERE Bundle_Product__r.Product_Code__c='TEA-GIFT'
Each id from that query is deleted:
Salesforce answers HTTP 204 with an empty body. Then each component is created:
{
"Bundle_Product__r": { "Product_Code__c": "TEA-GIFT" },
"Component_Product__r": { "Product_Code__c": "TEA-500" },
"Component_Quantity__c": 2
}
Salesforce answers HTTP 201 with an id. ERP checks that the id is present and does not store it. A later sync deletes these rows and creates them again.
| HTTP | error |
message |
|---|---|---|
| 400 | validation_error |
bundle products must have at least one component |
| 400 | validation_error |
component product code is required |
| 400 | validation_error |
unknown component product code(s): TEA-500 |
| 400 | validation_error |
nested bundles are not permitted; component(s) are themselves bundles: TEA-GIFT |
| 400 | validation_error |
cannot mark as bundle; already used as a component of: TEA-GIFT |
Failed Integration Log¶
A failed product upsert or a failed bundle replace also posts Integration_Log__c. Log_Type__c is Inbound. Operation__c is Create on the first push and Update on a PATCH.
{
"Log_Type__c": "Inbound",
"Operation__c": "Update",
"Status__c": "Failed",
"ERP_Code__c": "TEA-500",
"Error_Message__c": "product upsert: empty id (status 204)",
"Timestamp__c": "2026-11-06T05:30:00.000Z",
"Payload_Reference__c": "productCode=TEA-500 name=\"Assam Leaf 500g\" status=Active category=CAT-TEA isBundle=false"
}