Disclaimer: This is unofficial, community-created documentation for Epicor Prophet 21 APIs. It is not affiliated with, endorsed by, or supported by Epicor Software Corporation. All product names, trademarks, and registered trademarks are property of their respective owners. Use at your own risk.
Added February 2026 — Originally contributed by @sibinfrancisaj. PUT/POST behavior verified via live API testing.
P21 provides an Inventory REST API at /api/inventory/parts for CRUD operations on inventory items (inv_mast). This is a separate API from the Entity API at /api/entity/ — it uses its own base path and has different behavior.
The Inventory REST API is significant because it provides:
- Read access to
inv_loc(inventory location) records via extended properties - Write access to append new
inv_locandinventory_supplierrecords, or update existing ones, via PUT - Direct item-level CRUD without sessions or stateful workflows
- Reading inventory item details including location-specific data (GL accounts, costs, stock levels)
- Adding existing items to new companies/locations (multi-company workflows)
- Creating new inventory items
- Checking item availability and pricing
- No
/newtemplate —GET /api/inventory/parts/newreturns 404 - List endpoint hangs —
GET /api/inventory/parts/without$querytries to load all items and times out - Not all items accessible — Some items in
inv_mast(via OData) return 404 from this API
https://{hostname}/api/inventory/partsExample: https://play.p21server.com/api/inventory/parts
| Method | Path | Description | Verified |
|---|---|---|---|
GET |
/api/inventory/parts/ping |
Health check | Yes |
GET |
/api/inventory/parts/{ItemId} |
Get single item | Yes |
PUT |
/api/inventory/parts/{ItemId} |
Update item (append locations/suppliers) | Yes |
POST |
/api/inventory/parts |
Create new item (see 307 redirect note) | Yes |
GET |
/api/inventory/parts/{ItemId}/availability |
Item availability | Not tested |
GET |
/api/inventory/parts/{ItemId}/v2/price |
Single item pricing (V2) | Yes |
GET |
/api/inventory/v2/parts/v2/price/{ItemId} |
Single item pricing (alternative path) | Yes |
POST |
/api/inventory/parts/itemsAvailability |
Batch availability | Not tested |
POST |
/api/inventory/parts/prices |
Batch pricing | Not tested |
| Feature | Entity API | Inventory REST API |
|---|---|---|
| Base path | /api/entity/{resource} |
/api/inventory/parts |
| Key format | Composite (ACME_10) or numeric |
String ItemId (WIDGET-001) |
/new template |
Yes (customers, vendors, contacts) | No — "new" is treated as an item ID |
| List endpoint | Works (returns 307 redirect) | Hangs without filtering — needs $query |
| Record accessibility | All records accessible | Some items return 404 despite existing in inv_mast |
| Write support | PUT for updates | PUT for updates, POST for creates |
Python
resp = client.get(f"{base_url}/api/inventory/parts/WIDGET-001")
resp.raise_for_status()
item = resp.json()
print(f"{item['ItemId']}: {item['ItemDesc']}")C#
using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
var resp = await client.GetAsync($"{baseUrl}/api/inventory/parts/WIDGET-001");
resp.EnsureSuccessStatusCode();
var item = JObject.Parse(await resp.Content.ReadAsStringAsync());
Console.WriteLine($"{item["ItemId"]}: {item["ItemDesc"]}");Sample Response:
{
"ItemId": "WIDGET-001",
"ItemDesc": "Standard Widget Assembly",
"Delete": "N",
"Weight": 0.0,
"NetWeight": 0.0,
"ClassId1": "",
"ClassId2": "",
"Serialized": "N",
"ShortCode": "",
"TrackLots": "N",
"Price1": 0.0,
"Price2": 0.0,
"ExtendedDesc": "",
"DefaultSellingUnit": "1",
"DefaultPurchasingUnit": "1",
"InvMastUid": 15,
"Keywords": "Standard Widget Assembly",
"BaseUnit": "1",
"UserDefinedFields": {},
"ObjectName": "inv_mast"
}Note: Response truncated for brevity. Full response contains 60+ fields from the
inv_masttable. Withoutextendedproperties, all child collections (Locations, Suppliers, etc.) arenull.
Use extendedproperties to include child collections (inv_loc, inventory_supplier, etc.):
GET /api/inventory/parts/WIDGET-001?extendedproperties=*
Authorization: Bearer <ACCESS_TOKEN>Or fetch only what you need:
GET /api/inventory/parts/WIDGET-001?extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure
Authorization: Bearer <ACCESS_TOKEN>Python
resp = client.get(
f"{base_url}/api/inventory/parts/WIDGET-001",
params={"extendedproperties": "*"}
)
resp.raise_for_status()
item = resp.json()
# Access nested Locations (inv_loc data)
if item.get("Locations"):
for loc in item["Locations"]["list"]:
print(f"Loc: {loc['LocationId']}, Qty: {loc['QtyOnHand']}")C#
var resp = await client.GetAsync(
$"{baseUrl}/api/inventory/parts/WIDGET-001?extendedproperties=*");
resp.EnsureSuccessStatusCode();
var item = JObject.Parse(await resp.Content.ReadAsStringAsync());
// Access nested Locations (inv_loc data)
var locations = item["Locations"]?["list"] as JArray;
if (locations != null)
{
foreach (var loc in locations)
{
Console.WriteLine($"Loc: {loc["LocationId"]}, Qty: {loc["QtyOnHand"]}");
}
}With extendedproperties=*, child collections are populated as {"list": [...]} objects:
{
"ItemId": "WIDGET-001",
"ItemDesc": "Standard Widget Assembly",
"InvMastUid": 15,
"ObjectName": "inv_mast",
"Locations": {
"list": [
{
"ItemId": "WIDGET001",
"LocationId": 1,
"QtyOnHand": 0.0,
"CompanyId": "ACME",
"GlAccountNo": "1300-000",
"RevenueAccountNo": "4000-000",
"CosAccountNo": "5000-000",
"Sellable": "Y",
"Stockable": "Y",
"ProductGroupId": "MISC",
"MovingAverageCost": 0.0,
"StandardCost": 0.0,
"ReplenishmentMethod": "Min/Max",
"ObjectName": "inv_loc"
}
]
},
"Suppliers": {
"list": [
{
"ItemId": "WIDGET-001",
"SupplierId": 10,
"SupplierPartNo": "",
"ListPrice": 0.0,
"Cost": 0.0,
"ObjectName": "inventory_supplier"
}
]
},
"UnitsOfMeasure": {
"list": [
{
"ItemId": "WIDGET-001",
"UnitOfMeasure": "1",
"UnitSize": 1.0,
"ObjectName": "item_uom"
}
]
},
"LocationSuppliers": { "list": [] },
"Lot": null,
"LocationMSPs": { "list": [] },
"Service": null,
"ServiceContracts": null,
"Notes": { "list": [] },
"MSDS": null,
"RestrictedClasses": null,
"AltCodes": { "list": [] }
}Significant: The
Locationsextended property returns fullinv_locrecords including GL accounts, product groups, costs, and all inventory location fields. This provides read access toinv_locdata that is difficult to obtain through other APIs.
| Property | ObjectName | Description |
|---|---|---|
Locations |
inv_loc |
Warehouse stock levels, GL accounts, costs, product groups |
Suppliers |
inventory_supplier |
Vendor/supplier information, costs, lead times |
UnitsOfMeasure |
item_uom |
UOM definitions and conversion factors |
LocationSuppliers |
inventory_supplier_x_loc |
Supplier-location specific data |
Lot |
— | Lot tracking information |
LocationMSPs |
inv_loc_msp |
Location-specific pricing |
Service |
— | Service-related data |
ServiceContracts |
— | Linked service contracts |
Notes |
— | Item notes |
MSDS |
— | Material Safety Data Sheets |
RestrictedClasses |
— | Class restrictions |
AltCodes |
alternate_code |
Alternate item codes |
Key fields from GET /api/inventory/parts/{ItemId} (maps to inv_mast table):
| Field | Type | Description |
|---|---|---|
ItemId |
string | Item identifier |
ItemDesc |
string | Item description |
ExtendedDesc |
string | Extended description |
Keywords |
string | Search keywords |
ShortCode |
string | Short code |
ClassId1...ClassId5 |
string | Classification fields |
Weight / NetWeight |
decimal | Item weight |
Price1...Price10 |
decimal | Base pricing structure |
DefaultSellingUnit |
string | Default selling UOM |
DefaultPurchasingUnit |
string | Default purchasing UOM |
BaseUnit |
string | Base unit of measure |
TrackLots |
string | Lot tracking flag (Y/N) |
Serialized |
string | Serialized flag (Y/N) |
InvMastUid |
int | Internal unique identifier |
DefaultPurchaseDiscGroup |
string | Default purchase discount group (item-level) |
DefaultSalesDiscountGroup |
string | Default sales discount group (item-level) |
UserDefinedFields |
object | User-defined fields |
ObjectName |
string | Always "inv_mast" |
Key fields on inv_loc records within Locations.list (requires extendedproperties=Locations):
| Field | Type | Description |
|---|---|---|
LocationId |
int | Warehouse/location identifier |
CompanyId |
string | Company the location belongs to |
ProductGroupId |
string | Product group for this location |
Sellable |
string | Whether item is sellable at this location (Y/N) |
Stockable |
string | Whether item is stockable at this location (Y/N) |
GlAccountNo |
string | GL inventory account |
RevenueAccountNo |
string | GL revenue account |
CosAccountNo |
string | GL cost-of-sale account |
PurchaseDiscountGroup |
string | Purchase discount group (location-level) |
SalesDiscountGroup |
string | Sales discount group (location-level) |
QtyOnHand |
decimal | Current quantity on hand |
MovingAverageCost |
decimal | Moving average cost |
StandardCost |
decimal | Standard cost |
ReplenishmentMethod |
string | Replenishment method (e.g., "Min/Max") |
Delete |
string | Soft-delete flag (Y/N) — see Location Soft-Delete |
ObjectName |
string | Always "inv_loc" |
Note:
PurchaseDiscountGroupandSalesDiscountGrouponinv_locare separate from the item-levelDefaultPurchaseDiscGroupandDefaultSalesDiscountGrouponinv_mast. Values can differ between levels — the location-level fields override the item-level defaults for that specific location.
PUT /api/inventory/parts/{ItemId} accepts the full item payload and processes changes including appended child records and modifications to existing records.
Verified behavior:
- Sending back the same data unchanged returns 200 (idempotent)
- Appending new
inv_locrecords inLocations.listtriggers P21 business logic validation (company validation, GL account checks) - Modifying fields on existing
inv_locrecords applies the changes (see Updating Existing Location Fields) - Invalid data produces descriptive P21 error messages
POST /api/inventory/parts creates a new inventory item. If the ItemId already exists, P21 returns an error:
POST /api/inventory/parts
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
{
"ItemId": "WIDGET-001",
"ItemDesc": "Standard Widget Assembly"
}Error Response (duplicate item):
{
"ErrorMessage": "Error updating WIDGET-001: Error updating inv_mast: The proposed item ID already exists in the database.",
"ErrorType": "P21.Common.Exceptions.Prophet21Exception"
}This happens because inv_mast (Inventory Master) is the global definition of the item. The ItemId must be unique across all companies. Company-specific data lives in inv_loc and inventory_supplier, which are child records of inv_mast.
Contributed by @sibinfrancisaj. Append mechanism verified via live API testing (February 2026).
In a multi-company P21 environment, inventory items are shared across companies but require distinct configuration (Locations, Suppliers, GL accounts) for each company. Since ItemId is globally unique, you cannot POST an existing item to add it to a new company — you must append the new company's data to the existing item via PUT.
- GET the existing item with
extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure - Append new Location and Supplier objects to the existing
listarrays - PUT the updated payload back to the API
GET /api/inventory/parts/WIDGET-001?extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure
Authorization: Bearer <ACCESS_TOKEN>Add the new company's Location and Supplier records to the existing arrays. Do not remove existing entries — include all original records plus the new ones.
PUT /api/inventory/parts/WIDGET-001
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
{
"ItemId": "WIDGET-001",
"InvMastUid": 15,
"ItemDesc": "Standard Widget Assembly",
"ObjectName": "inv_mast",
"Locations": {
"list": [
{
"ItemId": "WIDGET-001",
"LocationId": 1,
"CompanyId": "ACME",
"ObjectName": "inv_loc"
},
{
"ItemId": "WIDGET-001",
"LocationId": 2,
"CompanyId": "ACME-WEST",
"GlAccountNo": "1300-000",
"RevenueAccountNo": "4000-000",
"CosAccountNo": "5000-000",
"Sellable": "Y",
"Stockable": "Y",
"ObjectName": "inv_loc"
}
]
},
"Suppliers": {
"list": [
{
"ItemId": "WIDGET-001",
"SupplierId": 10,
"ObjectName": "inventory_supplier"
},
{
"ItemId": "WIDGET-001",
"SupplierId": 20,
"DivisionId": 2,
"LeadTimeDays": 5,
"ObjectName": "inventory_supplier"
}
]
}
}On success, the API returns the updated item object (HTTP 200).
These errors confirm the API processes appended records through P21 business logic:
Invalid company:
{
"ErrorMessage": "Error updating WIDGET-001: Error updating inv_mast: The company \"FAKE99\" could not be retrieved. - Potential reasons: 1)The company does not exist. 2)The company has been deleted.",
"ErrorType": "P21.Common.Exceptions.Prophet21Exception"
}Invalid GL account for company:
{
"ErrorMessage": "Error updating WIDGET-001: Error updating inv_mast: This account doesn't exist for company ACME-WEST.",
"ErrorType": "P21.Common.Exceptions.Prophet21Exception"
}These errors prove the API is actively processing the appended Location records — validating the CompanyId and GL accounts against P21's chart of accounts.
The GET -> Modify -> PUT pattern also works for updating fields on existing inv_loc records, not just appending new ones.
Verified writable fields: Sellable, ProductGroupId, PurchaseDiscountGroup, SalesDiscountGroup
P21 validates changed values through business logic. For example, setting an invalid ProductGroupId returns:
{
"ErrorMessage": "Error updating WIDGET-001: Error updating inv_mast: Product group ID does not exist for this company ID.",
"ErrorType": "P21.Common.Exceptions.Prophet21Exception"
}Python
import httpx
BASE_URL = "https://play.p21server.com"
TOKEN = "<ACCESS_TOKEN>"
with httpx.Client(
headers={"Authorization": f"Bearer {TOKEN}"},
follow_redirects=True,
) as client:
# 1. GET current item with Locations
ext = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure"
resp = client.get(
f"{BASE_URL}/api/inventory/parts/WIDGET-001",
params={"extendedproperties": ext},
)
resp.raise_for_status()
item = resp.json()
# 2. Modify fields on existing location
for loc in item["Locations"]["list"]:
if loc["LocationId"] == 1 and loc["CompanyId"] == "ACME":
loc["Sellable"] = "N"
loc["ProductGroupId"] = "MISC"
loc["PurchaseDiscountGroup"] = "BULK"
loc["SalesDiscountGroup"] = "RETAIL"
break
# 3. PUT back
resp = client.put(f"{BASE_URL}/api/inventory/parts/WIDGET-001", json=item)
resp.raise_for_status()
print(f"Updated: {resp.status_code}")C#
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
var handler = new HttpClientHandler { AllowAutoRedirect = true };
using var client = new HttpClient(handler);
client.DefaultRequestHeaders.Add("Authorization", "Bearer <ACCESS_TOKEN>");
var baseUrl = "https://play.p21server.com";
// 1. GET current item with Locations
var resp = await client.GetAsync(
$"{baseUrl}/api/inventory/parts/WIDGET-001?extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure");
resp.EnsureSuccessStatusCode();
var item = JObject.Parse(await resp.Content.ReadAsStringAsync());
// 2. Modify fields on existing location
var locations = item["Locations"]?["list"] as JArray;
var target = locations?.FirstOrDefault(
l => (int)l["LocationId"] == 1 && (string)l["CompanyId"] == "ACME");
if (target != null)
{
target["Sellable"] = "N";
target["ProductGroupId"] = "MISC";
target["PurchaseDiscountGroup"] = "BULK";
target["SalesDiscountGroup"] = "RETAIL";
}
// 3. PUT back
var content = new StringContent(item.ToString(), Encoding.UTF8, "application/json");
var putResp = await client.PutAsync($"{baseUrl}/api/inventory/parts/WIDGET-001", content);
putResp.EnsureSuccessStatusCode();
Console.WriteLine($"Updated: {(int)putResp.StatusCode}");Important: Always include all existing child records (Locations, Suppliers, etc.) in the PUT payload. Omitting records may cause P21 to remove them.
POST /api/inventory/parts requires a minimal set of fields. P21 auto-derives GL accounts from ProductGroupId + LocationId, so you do not need to specify them explicitly.
Required fields:
ItemId(string, unique across all companies)ItemDesc(string, max 40 characters — see Common Issues)Locationswith at least one entry:LocationId+ProductGroupId(P21 infersCompanyIdfrom the location if omitted)Supplierswith at least one entry:SupplierId+DivisionIdLocationSupplierslinking the location and supplier:LocationId+SupplierId+PrimarySupplier
Python
import httpx
BASE_URL = "https://play.p21server.com"
TOKEN = "<ACCESS_TOKEN>"
payload = {
"ItemId": "WIDGET-002",
"ItemDesc": "Small Widget Assembly",
"Locations": {
"list": [
{
"LocationId": 1,
"ProductGroupId": "MISC",
"ObjectName": "inv_loc"
}
]
},
"Suppliers": {
"list": [
{
"SupplierId": 10,
"DivisionId": 1,
"ObjectName": "inventory_supplier"
}
]
},
"LocationSuppliers": {
"list": [
{
"LocationId": 1,
"SupplierId": 10,
"PrimarySupplier": "Y",
"ObjectName": "inventory_supplier_x_loc"
}
]
},
"ObjectName": "inv_mast"
}
with httpx.Client(
headers={"Authorization": f"Bearer {TOKEN}"},
follow_redirects=True,
) as client:
resp = client.post(f"{BASE_URL}/api/inventory/parts/", json=payload)
resp.raise_for_status()
print(f"Created: {resp.json()['ItemId']}")C#
using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
var handler = new HttpClientHandler { AllowAutoRedirect = true };
using var client = new HttpClient(handler);
client.DefaultRequestHeaders.Add("Authorization", "Bearer <ACCESS_TOKEN>");
var baseUrl = "https://play.p21server.com";
var payload = new JObject
{
["ItemId"] = "WIDGET-002",
["ItemDesc"] = "Small Widget Assembly",
["ObjectName"] = "inv_mast",
["Locations"] = new JObject
{
["list"] = new JArray
{
new JObject
{
["LocationId"] = 1,
["ProductGroupId"] = "MISC",
["ObjectName"] = "inv_loc"
}
}
},
["Suppliers"] = new JObject
{
["list"] = new JArray
{
new JObject
{
["SupplierId"] = 10,
["DivisionId"] = 1,
["ObjectName"] = "inventory_supplier"
}
}
},
["LocationSuppliers"] = new JObject
{
["list"] = new JArray
{
new JObject
{
["LocationId"] = 1,
["SupplierId"] = 10,
["PrimarySupplier"] = "Y",
["ObjectName"] = "inventory_supplier_x_loc"
}
}
}
};
var content = new StringContent(payload.ToString(), Encoding.UTF8, "application/json");
var resp = await client.PostAsync($"{baseUrl}/api/inventory/parts/", content);
resp.EnsureSuccessStatusCode();
var result = JObject.Parse(await resp.Content.ReadAsStringAsync());
Console.WriteLine($"Created: {result["ItemId"]}");Note:
CompanyIdis optional on Location records — P21 infers the default company from theLocationId. GL accounts (GlAccountNo,RevenueAccountNo,CosAccountNo) are auto-derived from theProductGroupIdand location configuration.
Cause: Using POST for an item that already exists in inv_mast.
Fix: Use the GET → Append → PUT workflow described above.
Cause: The GlAccountNo, RevenueAccountNo, or CosAccountNo in your new Location record is not valid for the target company.
Fix: Look up valid GL accounts for the target company before constructing the Location payload.
The ItemDesc field on inv_mast has a 40-character maximum. Behavior differs by method:
- POST with >40 chars fails with a misleading error:
"Required value missing for Item Description"(the value is present, just too long) - PUT with >40 chars silently discards the value — no error, but the description is not updated
Always validate before sending:
Python
MAX_ITEM_DESC_LENGTH = 40
def validate_item_desc(desc: str) -> str:
"""Validate ItemDesc length before API call."""
if len(desc) > MAX_ITEM_DESC_LENGTH:
raise ValueError(
f"ItemDesc '{desc}' is {len(desc)} chars (max {MAX_ITEM_DESC_LENGTH}). "
"POST will fail with misleading error; PUT will silently discard."
)
return descC#
const int MaxItemDescLength = 40;
static string ValidateItemDesc(string desc)
{
if (desc.Length > MaxItemDescLength)
{
throw new ArgumentException(
$"ItemDesc '{desc}' is {desc.Length} chars (max {MaxItemDescLength}). " +
"POST will fail with misleading error; PUT will silently discard.");
}
return desc;
}POST /api/inventory/parts (without trailing slash) returns 307 Temporary Redirect to /api/inventory/parts/. Most HTTP clients do not follow redirects on POST by default.
Fix: Either add a trailing slash to the URL, or configure your client to follow redirects:
Python
import httpx
# Option 1: Trailing slash
resp = client.post(f"{BASE_URL}/api/inventory/parts/", json=payload)
# Option 2: follow_redirects
client = httpx.Client(
headers={"Authorization": f"Bearer {TOKEN}"},
follow_redirects=True,
)
resp = client.post(f"{BASE_URL}/api/inventory/parts", json=payload)C#
// Option 1: Trailing slash
var resp = await client.PostAsync($"{baseUrl}/api/inventory/parts/", content);
// Option 2: AllowAutoRedirect (default is true for HttpClientHandler)
var handler = new HttpClientHandler { AllowAutoRedirect = true };
using var client = new HttpClient(handler);
var resp = await client.PostAsync($"{baseUrl}/api/inventory/parts", content);Note:
GETandPUT(which include the ItemId in the URL path) are not affected.
To remove an item from a location without deleting the inv_loc record, set the Delete flag to "Y". This is a soft-delete — the record still exists in the database but is excluded from business operations (ordering, selling, etc.).
Python
import httpx
BASE_URL = "https://play.p21server.com"
TOKEN = "<ACCESS_TOKEN>"
with httpx.Client(
headers={"Authorization": f"Bearer {TOKEN}"},
follow_redirects=True,
) as client:
# 1. GET item with locations
ext = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure"
resp = client.get(
f"{BASE_URL}/api/inventory/parts/WIDGET-001",
params={"extendedproperties": ext},
)
resp.raise_for_status()
item = resp.json()
# 2. Set Delete flag on target location
for loc in item["Locations"]["list"]:
if loc["LocationId"] == 2 and loc["CompanyId"] == "ACME":
loc["Delete"] = "Y"
break
# 3. PUT back
resp = client.put(f"{BASE_URL}/api/inventory/parts/WIDGET-001", json=item)
resp.raise_for_status()
print(f"Soft-deleted location: {resp.status_code}")C#
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
var handler = new HttpClientHandler { AllowAutoRedirect = true };
using var client = new HttpClient(handler);
client.DefaultRequestHeaders.Add("Authorization", "Bearer <ACCESS_TOKEN>");
var baseUrl = "https://play.p21server.com";
// 1. GET item with locations
var resp = await client.GetAsync(
$"{baseUrl}/api/inventory/parts/WIDGET-001?extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure");
resp.EnsureSuccessStatusCode();
var item = JObject.Parse(await resp.Content.ReadAsStringAsync());
// 2. Set Delete flag on target location
var locations = item["Locations"]?["list"] as JArray;
var target = locations?.FirstOrDefault(
l => (int)l["LocationId"] == 2 && (string)l["CompanyId"] == "ACME");
if (target != null)
{
target["Delete"] = "Y";
}
// 3. PUT back
var content = new StringContent(item.ToString(), Encoding.UTF8, "application/json");
var putResp = await client.PutAsync($"{baseUrl}/api/inventory/parts/WIDGET-001", content);
putResp.EnsureSuccessStatusCode();
Console.WriteLine($"Soft-deleted location: {(int)putResp.StatusCode}");Note: To restore a soft-deleted location, set
Deleteback to"N"using the same pattern.
Units of Measure (UnitsOfMeasure) are defined at the inv_mast level and shared across all companies. You typically do not need to add company-specific UOMs — standard units like "EA", "BOX", etc. apply globally. Ensure existing UOMs are included in your PUT payload.
Python
import httpx
BASE_URL = "https://play.p21server.com"
API = f"{BASE_URL}/api/inventory/parts"
def process_item(
client: httpx.Client, item_id: str,
new_location: dict, new_supplier: dict,
):
"""Add an existing item to a new company/location via GET -> Append -> PUT."""
# 1. Check if item exists
try:
resp = client.get(
f"{API}/{item_id}",
params={"extendedproperties": "Locations,Suppliers"},
)
resp.raise_for_status()
current_item = resp.json()
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
# Item doesn't exist — create it with POST
payload = {"ItemId": item_id, **new_location, **new_supplier}
resp = client.post(API, json=payload)
resp.raise_for_status()
return resp.json()
raise
# 2. Check if company/location already linked
existing_companies = {
loc.get("CompanyId")
for loc in current_item.get("Locations", {}).get("list", [])
}
if new_location.get("CompanyId") in existing_companies:
print(f"Item {item_id} already linked to {new_location['CompanyId']}")
return current_item
# 3. Append new records
current_item["Locations"]["list"].append(new_location)
current_item["Suppliers"]["list"].append(new_supplier)
# 4. PUT updated payload
resp = client.put(f"{API}/{item_id}", json=current_item)
resp.raise_for_status()
return resp.json()C#
const string BaseUrl = "https://play.p21server.com";
const string Api = BaseUrl + "/api/inventory/parts";
async Task<JObject> ProcessItemAsync(
HttpClient client, string itemId, JObject newLocation, JObject newSupplier)
{
// 1. Check if item exists
var resp = await client.GetAsync($"{Api}/{itemId}?extendedproperties=Locations,Suppliers");
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Item doesn't exist — create it with POST
var createPayload = new JObject { ["ItemId"] = itemId };
createPayload.Merge(newLocation);
createPayload.Merge(newSupplier);
var createContent = new StringContent(
createPayload.ToString(), Encoding.UTF8, "application/json");
var createResp = await client.PostAsync(Api, createContent);
createResp.EnsureSuccessStatusCode();
return JObject.Parse(await createResp.Content.ReadAsStringAsync());
}
resp.EnsureSuccessStatusCode();
var currentItem = JObject.Parse(await resp.Content.ReadAsStringAsync());
// 2. Check if company/location already linked
var locations = currentItem["Locations"]?["list"] as JArray ?? new JArray();
var existingCompanies = locations
.Select(loc => loc["CompanyId"]?.ToString())
.Where(c => c != null)
.ToHashSet();
var targetCompany = newLocation["CompanyId"]?.ToString();
if (existingCompanies.Contains(targetCompany))
{
Console.WriteLine($"Item {itemId} already linked to {targetCompany}");
return currentItem;
}
// 3. Append new records
locations.Add(newLocation);
var suppliers = currentItem["Suppliers"]?["list"] as JArray ?? new JArray();
suppliers.Add(newSupplier);
// 4. PUT updated payload
var putContent = new StringContent(
currentItem.ToString(), Encoding.UTF8, "application/json");
var putResp = await client.PutAsync($"{Api}/{itemId}", putContent);
putResp.EnsureSuccessStatusCode();
return JObject.Parse(await putResp.Content.ReadAsStringAsync());
}For large datasets (thousands of items):
- Batch size — Process items in chunks (e.g., 500–1000) to avoid overwhelming the API
- Concurrency — Use multiple workers if the API permits, but be cautious of
inv_masttable locking - Error logging — Log failures with item IDs and error messages for manual review
- Retry logic — P21 may return transient lock errors; retry with a short delay (1–2 seconds)
Added April 2026 — Community-sourced discovery. Credit: Felipe Maurer, John Kennedy.
The Inventory REST API provides multiple pricing endpoints for retrieving customer-specific pricing.
Two URL patterns are available for single-item pricing:
GET /api/inventory/parts/{ItemId}/v2/price?companyId=ACME&customerId=10&salesLocId=100&sourceLocId=100&uom=EA&priceUom=EA&unitQuantity=1
Authorization: Bearer {token}
Accept: application/jsonGET /api/inventory/v2/parts/v2/price/{ItemId}?companyid=ACME&customerId=10&sourceLocId=100&salesLocId=100
Authorization: Bearer {token}
Accept: application/jsonBoth endpoints accept the same query parameters and return customer-specific pricing.
Required parameters:
companyId— Company IDcustomerId— Customer ID for pricing lookupsalesLocId— Sales location IDsourceLocId— Source/ship location IDuom— Unit of measure (e.g.,EA)priceUom— Price unit of measure (e.g.,EA)unitQuantity— Quantity for price break calculation
The pricing endpoint returns both pricing AND availability data for the requested location in a single response:
{
"UnitPrice": 15.750000000,
"BaseUnitPrice": 15.750000000,
"UOM": "EA",
"UOMUnitSize": 1.000000000,
"PricePageUid": 0,
"PriceUOM": "EA",
"PriceUnitSize": 1.000000000,
"ExtendedPrice": 15.750000000,
"CalcValue": 1.000000000,
"UnitCommissionCost": 8.500000000,
"UnitOtherCost": 8.500000000,
"UnitSalesCost": 8.500000000,
"LotCosted": "N",
"ItemId": "WIDGET-001",
"CompanyId": null,
"LocationId": 100,
"QuantityAvailable": 250.000000000,
"QuantityOnHand": 300.000000000,
"QuantityAllocated": 50.000000000,
"QuantityNonPickable": 0.0,
"QuantityQuarantined": 0.0,
"QuantityFrozen": 0.0,
"LocationType": "Standard"
}Note: The response includes both pricing AND availability data for the requested location.
QuantityAvailable=QuantityOnHand-QuantityAllocated(minus non-pickable, quarantined, and frozen).CompanyIdmay benullin the response even when specified in the request.
Key fields:
| Field | Description |
|---|---|
UnitPrice |
Customer-specific unit price after price page evaluation |
BaseUnitPrice |
Base unit price before customer-specific adjustments |
ExtendedPrice |
UnitPrice x unitQuantity from request |
UnitCommissionCost / UnitOtherCost / UnitSalesCost |
Cost breakdown for margin calculations |
PricePageUid |
Price page that determined the price (0 = no price page matched) |
QuantityAvailable |
Available to sell (on hand minus allocated/holds) |
QuantityOnHand |
Physical quantity at the location |
LocationType |
Location type (e.g., "Standard") |
When the item is not valid or not defined at the requested location, the API returns the standard P21 error envelope:
{
"DateTimeStamp": "/Date(1776347610527)/",
"ErrorMessage": "Item is not valid or not defined at this location",
"ErrorType": "P21.Business.Common.BusinessException",
"HostName": "p21web-22",
"InnerException": null
}Note: The
DateTimeStampuses the Microsoft JSON date format (/Date(milliseconds)/). TheErrorTypefor pricing errors isP21.Business.Common.BusinessException, distinct from theP21.Common.Exceptions.Prophet21Exceptionused by item CRUD errors.
When item IDs contain special characters, URL encoding is required:
| Character | Encoding | Status |
|---|---|---|
# |
%23 |
Works — e.g., ORDER%23TEST |
/ |
%2F |
Broken — returns 404 "Endpoint not found" |
& |
%26 |
Use standard URL encoding |
+ |
%2B |
Use standard URL encoding |
Known Issue: Forward slash (
/) in item IDs cannot be URL-encoded for the pricing endpoints. The API (or IIS) interprets%2Fas a literal path separator, returning 404. There is no known workaround for items containing/in their ID. (Credit: John Kennedy, confirmed by Felipe Maurer)
-
No
/newtemplate — Unlike the Entity API, there is no template endpoint. You must know the required fields for POST (see Minimum Create Payload). -
List endpoint performance — Always use
$queryfiltering. The unfiltered list endpoint attempts to load all inventory and times out. -
Item accessibility — Some items that exist in
inv_mast(visible via OData) return 404 from this API. This may be related to item status or configuration. -
Forward slash in item IDs — The
%2FURL encoding for/is interpreted as a path separator by IIS, causing 404 errors on pricing and other endpoints that include the item ID in the URL path. This affects both pricing endpoint URL patterns.
- Entity API — CRUD for customers, vendors, contacts, addresses
- Authentication — Token generation
- API Selection Guide — Which API to use when
- OData API — Read-only queries on any table including
inv_mastandinv_loc - Error Handling — Common P21 error patterns