On this page
- Overview
- Base URL
- Endpoints
- Comparison with Entity API
- Reading Items
- Writing Items
- Multi-Company Inventory Workflow
- Location-Append & Update Gotchas (Verified at Scale)
- Updating Existing Location Fields
- Minimum Create Payload
- Cloning an Existing Item
- Common Issues
- Automation Example
- Pricing Endpoints
- Known Limitations
- Related
Inventory REST API
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.
Overview
P21 provides inventory item CRUD at /api/inventory/parts (backed by inv_mast). This is part of the same REST API as the /api/entity/ endpoints — a different endpoint family with its own base path and behavior, documented separately for readability only. (In Epicor's naming, "Entity API" is an umbrella term covering the whole REST API plus the eCommerce SOAP API — see Terminology.)
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_loc and inventory_supplier records, or update existing ones, via PUT
- Direct item-level CRUD without sessions or stateful workflows
When to Use
- 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
Limitations
- 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
Base URL
https://{hostname}/api/inventory/parts
Example: https://play.example.com/api/inventory/parts
Endpoints
| 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 (JSON or XML, see Batch Pricing) | Yes |
POST |
/api/inventory/v2/parts/prices |
Batch pricing (alternative path, identical behavior) | Yes |
Comparison with Entity API
Both endpoint families belong to the same REST API (see Terminology — Epicor's Naming); this table contrasts how the two families behave:
| Feature | /api/entity/ endpoints |
/api/inventory/parts |
|---|---|---|
| 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 |
Reading Items
Basic GET
"""Read a single inventory item via the Inventory REST API."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # item to look up
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
resp = client.get(f"{BASE_URL}/api/inventory/parts/{ITEM_ID}", headers=headers)
resp.raise_for_status()
item = resp.json()
print(f"{item['ItemId']}: {item['ItemDesc']}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // item to look up
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var resp = await client.GetAsync($"{BaseUrl}/api/inventory/parts/{ItemId}");
resp.EnsureSuccessStatusCode();
var item = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine($"{item.GetProperty("ItemId")}: {item.GetProperty("ItemDesc")}");
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
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.
GET with Extended Properties
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>
"""Read an inventory item plus its inv_loc, supplier, and UOM child records."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # item to look up
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{ITEM_ID}",
headers=headers,
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']}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // item to look up
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var resp = await client.GetAsync($"{BaseUrl}/api/inventory/parts/{ItemId}?extendedproperties=*");
resp.EnsureSuccessStatusCode();
var item = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
// Access nested Locations (inv_loc data)
var locations = item["Locations"]?["list"]?.AsArray();
if (locations != null)
{
foreach (var loc in locations)
{
Console.WriteLine($"Loc: {loc!["LocationId"]}, Qty: {loc["QtyOnHand"]}");
}
}
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
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.
Extended Properties Reference
| 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 |
Data Fields (Partial)
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" |
Location-Level Fields (inv_loc)
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.
Writing Items
PUT — Update Existing Item
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_loc records in Locations.list triggers P21 business logic validation (company validation, GL account checks)
- Modifying fields on existing inv_loc records applies the changes (see Updating Existing Location Fields)
- Invalid data produces descriptive P21 error messages
POST — Create New Item
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.
Multi-Company Inventory Workflow
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.
The Pattern: GET → Append → 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
Step 1: GET the Item
GET /api/inventory/parts/WIDGET-001?extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure
Authorization: Bearer <ACCESS_TOKEN>
Step 2: Append New Company Data
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.
Step 3: PUT the Updated Payload
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).
Verified Error Messages
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.
Location-Append & Update Gotchas (Verified at Scale)
Findings from a production run appending 138 items × 2 locations (276 rows) through PUT /api/inventory/parts/{ItemId} with the GET → append → PUT pattern, each verified by SQL read-back (issue #110 and #112, 2026-08-14):
LocationSuppliersappend works — and sets the location's primary supplier. Appending aninventory_supplier_x_locobject alongside the appendedLocationsentry in the same PUT creates the supplier-x-loc row and setsinv_loc.primary_supplier_id:
json
{"ItemId": "...", "LocationId": 10, "SupplierId": 10050,
"PrimarySupplier": "Y", "ObjectName": "inventory_supplier_x_loc"}
This is a simpler alternative to the Transaction API's SUPPLIER_X_LOCATION flag pattern and avoids its silent-no-op gotcha — the same PUT can also append the item-level Suppliers entry when the item–supplier link doesn't exist yet (verified: one call created inventory_supplier + the x_loc row + the primary flag together).
-
GL accounts are required when the target location has no defaults. "GL auto-derives" is true only when the location/company has an
inventory_defaultsrow. Appending to a freshly created location without one fails withRequired value missing for Revenue Account (for Inventory Location) on row N. Two fixes: create the defaults row first via the ItemDefaults Transaction service, or always sendGlAccountNo/RevenueAccountNo/CosAccountNocopied from a same-branch template row. -
Kit items reject
Buy: "Y"on appended locations —Error updating inv_mast: A Kit item cannot be marked as a Buy item.— even when the item's olderinv_locrows carrybuy = Y(legacy data). OmitBuy/Makefor kit/assembly items and let P21 default them. -
ReplenishmentMethod: "OP/OQ"requires a positive order quantity. Copying an OP/OQ template withoutorder_quantityfails withThe order quantity must be greater than zero when using the order point/order quantity inventory method... Invalid datawindow row specified. Fall back toMin/Maxon appended rows. -
Transient 500s happen at scale. One item of 138 failed with an opaque
Exception has been thrown by the target of an invocationand succeeded unchanged on retry. Bulk scripts should retry once before reporting failure. -
PrimaryBinis writable onLocationsobjects. GET → modify → PUT ofLocations.list[].PrimaryBin(a bin-id string) persists toinv_loc.primary_bin— verified across all 276 rows. This complements the Transaction API primary-bin path and handles both of an item's locations in one PUT. The bin must already exist at that location (create bins — and on a fresh location, their zones first — per PutawayZone/PickZone + create-bins). -
An appended location's discount groups come from the location defaults, not from the item. A location added to an existing item gets its
SalesDiscountGroupandPurchaseDiscountGroupfrom the target location's defaults — often the literal stringDEFAULT— instead of copying them from the item's existing locations.ProductGroupIddoes carry over. Nothing warns you. Send both discount groups explicitly on every appendedLocationsentry, and read them back. This was observed in production data across many items whose second location was added after the first (September 2026); the mechanism is inferred, not traced (#176). To repair existing rows, PUT the corrected groups back — see Updating Existing Location Fields.
Also confirmed on appended Locations entries: Stockable, Sellable, ProductGroupId, SalesDiscountGroup, PurchaseDiscountGroup, TaxGroupId, TrackBins, ReplenishmentMethod, and ReplenishmentLocation are all honored — and re-PUT with an already-present location is a safe no-op, so bulk runs are idempotent and re-runnable.
Updating Existing Location Fields
The GET -> Modify -> PUT pattern also works for updating fields on existing inv_loc records, not just appending new ones.
Verified writable fields: Sellable, Stockable, ProductGroupId, PurchaseDiscountGroup, SalesDiscountGroup, ReplenishmentLocation, PrimaryBin (see Location-Append & Update Gotchas — PrimaryBin verified across 276 rows; the bin must already exist at that location)
Stockable on an existing inv_loc row was verified on production 26.1 (October 2026) by SQL read-back of inv_loc.stockable (#175). Re-run on play with a runnable example — see ListPrice below.
ProductGroupIdand the location's GL accounts. Through the Transaction APIItemservice, a location's product-group change rewrites its GL, revenue, and COS accounts to the target group's defaults — see 03 § Changing a Location's Product Group Re-Syncs Its GL Accounts. Whether this PUT does the same was not tested; snapshot the three account columns before a product-group change here too, and compare after.
Updating a Supplier's Cost (Suppliers.list[].Cost)
The same GET → modify → PUT round trip updates inventory_supplier.cost on an existing supplier row. GET /api/inventory/parts/{ItemId}?extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure, change Suppliers.list[n].Cost, and PUT the full payload back → HTTP 200.
A full-row before/after diff showed exactly this:
| Table | What changed |
|---|---|
inventory_supplier (the edited row) |
cost to the new value; date_cost_last_modified set by P21 (it was NULL); date_last_modified and last_maintained_by (the API user) updated. Nothing else |
inv_loc (every location on the item) |
Only date_last_modified and last_maintained_by — the PUT restamps every location's audit columns even when no location field changed |
If something downstream keys off inv_loc.date_last_modified (a sync job, a "recently changed" report), expect every location of the item to show up after a supplier-cost PUT. Items whose ID contains / cannot take this path — the GET itself 404s; use the Interactive Item window instead.
Credit: @mrwuss — verified on production 26.1, October 2026 (#170).
ListPrice is writable the same way. Change Suppliers.list[n].ListPrice in the same GET → modify → PUT round trip and P21 writes inventory_supplier.list_price. The SQL read-back matched the value sent exactly on every item tested.
Credit: @mrwuss — verified on production 26.1, October 2026, with SQL read-back (#175).
Runnable example (ListPrice + Stockable): inventory_update_list_price_stockable.py and examples/csharp/Recipes/InventoryUpdateListPriceStockable.cs (Recipes menu 28) GET the item with extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure, change one supplier's ListPrice and one location's Stockable (Y/N) in that payload, PUT it back, and verify through both the REST GET and OData (inventory_supplier.list_price, inv_loc.stockable). They then PUT the original values back and verify the restore. The C# version parses the GET with dates left as strings, so the PUT sends them back unchanged. Python and C# examples run on play 26.1.5950.0, 2026-10-10, on a throwaway item: change and restore both read back exactly.
This API is the recommended path for inv_loc modifications generally: the Interactive API's Item window keeps its location GL account fields (TABPAGE_24) read-only, so they cannot be edited there either.
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"
}
Example: Update Location Fields
"""GET -> modify -> PUT existing inv_loc fields, then re-GET to confirm the write."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # item to update
TARGET_LOCATION_ID = 1 # inv_loc.LocationId to modify
TARGET_COMPANY_ID = "ACME" # inv_loc.CompanyId to modify
EXTENDED_PROPS = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure"
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
# 1. GET current item with Locations
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{ITEM_ID}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
item = resp.json()
# 2. Modify fields on the existing location -- do not drop the other records
for loc in item["Locations"]["list"]:
if loc["LocationId"] == TARGET_LOCATION_ID and loc["CompanyId"] == TARGET_COMPANY_ID:
loc["Sellable"] = "N"
loc["ProductGroupId"] = "MISC"
loc["PurchaseDiscountGroup"] = "BULK"
loc["SalesDiscountGroup"] = "RETAIL"
break
# 3. PUT the whole item back
resp = client.put(f"{BASE_URL}/api/inventory/parts/{ITEM_ID}", headers=headers, json=item)
resp.raise_for_status()
print(f"PUT status: {resp.status_code}")
# 4. Re-GET to prove what actually landed -- HTTP 200 alone does not confirm a write
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{ITEM_ID}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
after = resp.json()
for loc in after["Locations"]["list"]:
if loc["LocationId"] == TARGET_LOCATION_ID and loc["CompanyId"] == TARGET_COMPANY_ID:
print(
f"Sellable={loc['Sellable']} ProductGroupId={loc['ProductGroupId']} "
f"PurchaseDiscountGroup={loc['PurchaseDiscountGroup']} "
f"SalesDiscountGroup={loc['SalesDiscountGroup']}"
)
break
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // item to update
const int TargetLocationId = 1; // inv_loc.LocationId to modify
const string TargetCompanyId = "ACME"; // inv_loc.CompanyId to modify
const string ExtendedProps = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure";
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 1. GET current item with Locations
var getUrl = $"{BaseUrl}/api/inventory/parts/{ItemId}?extendedproperties={ExtendedProps}";
var getResp = await client.GetAsync(getUrl);
getResp.EnsureSuccessStatusCode();
var item = JsonNode.Parse(await getResp.Content.ReadAsStringAsync())!;
// 2. Modify fields on the existing location -- do not drop the other records
var locations = item["Locations"]!["list"]!.AsArray();
var target = locations.FirstOrDefault(
l => (int)l!["LocationId"]! == TargetLocationId && (string)l["CompanyId"]! == TargetCompanyId);
if (target != null)
{
target["Sellable"] = "N";
target["ProductGroupId"] = "MISC";
target["PurchaseDiscountGroup"] = "BULK";
target["SalesDiscountGroup"] = "RETAIL";
}
// 3. PUT the whole item back
var putContent = new StringContent(item.ToJsonString(), Encoding.UTF8, "application/json");
var putResp = await client.PutAsync($"{BaseUrl}/api/inventory/parts/{ItemId}", putContent);
putResp.EnsureSuccessStatusCode();
Console.WriteLine($"PUT status: {(int)putResp.StatusCode}");
// 4. Re-GET to prove what actually landed -- HTTP 200 alone does not confirm a write
var afterResp = await client.GetAsync(getUrl);
afterResp.EnsureSuccessStatusCode();
var after = JsonNode.Parse(await afterResp.Content.ReadAsStringAsync())!;
var afterTarget = after["Locations"]!["list"]!.AsArray().FirstOrDefault(
l => (int)l!["LocationId"]! == TargetLocationId && (string)l["CompanyId"]! == TargetCompanyId);
if (afterTarget != null)
{
Console.WriteLine(
$"Sellable={afterTarget["Sellable"]} ProductGroupId={afterTarget["ProductGroupId"]} " +
$"PurchaseDiscountGroup={afterTarget["PurchaseDiscountGroup"]} " +
$"SalesDiscountGroup={afterTarget["SalesDiscountGroup"]}");
}
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Important: Always include all existing child records (Locations, Suppliers, etc.) in the PUT payload. Omitting records may cause P21 to remove them.
Minimum Create Payload
POST /api/inventory/parts requires a minimal set of fields. P21 derives the location's GL accounts from ProductGroupId + LocationId when the location has an inventory_defaults row — see GL accounts when the location has no defaults.
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+DivisionId(seeDivisionId— the1in the examples is a placeholder)LocationSupplierslinking the location and supplier:LocationId+SupplierId+PrimarySupplier
"""Create a new inventory item with the minimum required fields, then re-GET it."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
NEW_ITEM_ID = "WIDGET-002" # must not already exist in inv_mast
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
payload = {
"ItemId": NEW_ITEM_ID,
"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",
}
# POST returns 307 without a trailing slash -- see "POST Returns 307 Redirect" below
resp = client.post(f"{BASE_URL}/api/inventory/parts/", headers=headers, json=payload)
resp.raise_for_status()
created = resp.json()
print(f"Created: {created['ItemId']}")
# Re-GET to confirm what actually landed -- HTTP 200 alone does not confirm a write
resp = client.get(f"{BASE_URL}/api/inventory/parts/{NEW_ITEM_ID}", headers=headers)
resp.raise_for_status()
confirmed = resp.json()
print(f"Confirmed: {confirmed['ItemId']}: {confirmed['ItemDesc']}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string NewItemId = "WIDGET-002"; // must not already exist in inv_mast
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var payload = new JsonObject
{
["ItemId"] = NewItemId,
["ItemDesc"] = "Small Widget Assembly",
["ObjectName"] = "inv_mast",
["Locations"] = new JsonObject
{
["list"] = new JsonArray
{
new JsonObject
{
["LocationId"] = 1,
["ProductGroupId"] = "MISC",
["ObjectName"] = "inv_loc",
}
}
},
["Suppliers"] = new JsonObject
{
["list"] = new JsonArray
{
new JsonObject
{
["SupplierId"] = 10,
["DivisionId"] = 1,
["ObjectName"] = "inventory_supplier",
}
}
},
["LocationSuppliers"] = new JsonObject
{
["list"] = new JsonArray
{
new JsonObject
{
["LocationId"] = 1,
["SupplierId"] = 10,
["PrimarySupplier"] = "Y",
["ObjectName"] = "inventory_supplier_x_loc",
}
}
}
};
// POST returns 307 without a trailing slash -- see "POST Returns 307 Redirect" below
var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json");
var resp = await client.PostAsync($"{BaseUrl}/api/inventory/parts/", content);
resp.EnsureSuccessStatusCode();
var created = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
Console.WriteLine($"Created: {created["ItemId"]}");
// Re-GET to confirm what actually landed -- HTTP 200 alone does not confirm a write
var confirmResp = await client.GetAsync($"{BaseUrl}/api/inventory/parts/{NewItemId}");
confirmResp.EnsureSuccessStatusCode();
var confirmed = JsonNode.Parse(await confirmResp.Content.ReadAsStringAsync())!;
Console.WriteLine($"Confirmed: {confirmed["ItemId"]}: {confirmed["ItemDesc"]}");
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Note:
CompanyIdis optional on Location records — P21 infers the default company from theLocationId. GL accounts (GlAccountNo,RevenueAccountNo,CosAccountNo) are derived from theProductGroupIdand the location's defaults, when it has them.
GL accounts when the location has no defaults
The derivation needs an inventory_defaults row for the target location. When the location has none — typically a location created recently — a create fails, and so does a location append:
Required value missing for Revenue Account (for Inventory Location) on row N
Fix it one of two ways:
- Create the location's defaults row first with the ItemDefaults Transaction service; after that, creates and appends derive GL as described above.
- Send
GlAccountNo,RevenueAccountNoandCosAccountNoon everyLocationsentry for that location, copied from an existing item at a comparable location.
Credit: @mrwuss — verified on play 26.1, August 2026 (#176).
DivisionId is often the supplier ID
Every Suppliers entry needs a DivisionId. The examples on this page use 1 and 2 as placeholders. In real data, inventory_supplier.division_id commonly equals the supplier's own supplier_id (supplier 100, division 100). Don't assume either: GET an existing item that already uses that supplier and copy its DivisionId.
Credit: @mrwuss — observed in production data 26.1, May 2026 (#176).
Cloning an Existing Item
To create an item that is set up like one you already have — same locations, product and discount groups, suppliers and costs, units of measure — GET the source item with its child records, strip the identity and stock fields, change the ID, and POST the result as a new item.
- GET the source item with
extendedproperties=Locations,Suppliers,LocationSuppliers,UnitsOfMeasure. - Drop the fields that belong to the source record, not to its setup:
| Where | Drop |
|---|---|
| Item | InvMastUid, UserDefinedFields (it carries the source's own InvMastUdUid and InvMastUid) |
Locations.list[] |
QtyOnHand, MovingAverageCost, PrimaryBin, PeriodFirstStocked, YearFirstStocked, PatternLikeInvMastUid, PatternLikeLocationId, UserDefinedFields |
Suppliers.list[] |
InventorySupplierUid, UserDefinedFields |
LocationSuppliers.list[] |
InventorySupplierXLocUid, UserDefinedFields |
UnitsOfMeasure.list[] |
ItemUomUid, UserDefinedFields |
- Set the new
ItemIdon the item and on every child row, the newItemDesc(40 characters or fewer — see ItemDesc Max 40 Characters), and the newSupplierPartNoon each supplier row. POST /api/inventory/parts/— with the trailing slash (POST Returns 307 Redirect).- Read the new item back. Check its locations, suppliers and units against the source.
Everything you don't drop is copied as-is, including supplier Cost and ListPrice, product and discount groups, GL accounts and replenishment settings. Edit them in the payload before the POST when the new item should differ. If the new ItemId already exists, the POST fails with The proposed item ID already exists in the database.
The create does not copy the source's item user-defined fields. With the header UserDefinedFields block sent unchanged, the clone got no inv_mast_ud row at all and the source's row was left untouched, so the examples drop the block. Set any UD values on the new item after it exists.
End-to-end files (runnable from the repo with a
.env; the source GET runs on a dry run, the POST needs--execute/ typingEXECUTE, and both re-GET the new item and compare its locations, suppliers, location-suppliers and units with the source):examples/python/recipes/clone_item.py·examples/csharp/Recipes/CloneItem.cs.
"""Clone an existing inventory item under a new ID (Inventory REST GET -> strip -> POST)."""
import copy
import json
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
SOURCE_ITEM_ID = "WIDGET-001" # item to copy
NEW_ITEM_ID = "WIDGET-003" # must not already exist in inv_mast
NEW_ITEM_DESC = "Widget Assembly, Large" # 40 characters or fewer
NEW_SUPPLIER_PART_NO = "WIDGET-003" # written to every supplier row
EXECUTE = False # False prints the payload; True POSTs it
EXTENDED_PROPS = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure"
# ---------------------------------------------------------------------------
# Fields that identify the source record or describe its stock, per child list.
DROP = {
"Locations": {"QtyOnHand", "MovingAverageCost", "PrimaryBin", "PeriodFirstStocked",
"YearFirstStocked", "PatternLikeInvMastUid", "PatternLikeLocationId",
"UserDefinedFields"},
"Suppliers": {"InventorySupplierUid", "UserDefinedFields"},
"LocationSuppliers": {"InventorySupplierXLocUid", "UserDefinedFields"},
"UnitsOfMeasure": {"ItemUomUid", "UserDefinedFields"},
}
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def build_clone(source: dict) -> dict:
"""Copy the source item, drop identity/stock fields, and re-key it to the new ID."""
# Top level: drop the source record's uid and UD block, and any null header fields.
payload = {k: v for k, v in copy.deepcopy(source).items()
if k not in ("InvMastUid", "UserDefinedFields")
and not (v is None and k[0].isupper())}
payload["ItemId"] = NEW_ITEM_ID
payload["ItemDesc"] = NEW_ITEM_DESC
for key, drop in DROP.items():
rows = []
for row in (source.get(key) or {}).get("list") or []:
row = {k: v for k, v in row.items() if k not in drop}
row["ItemId"] = NEW_ITEM_ID
if key == "Suppliers":
row["SupplierPartNo"] = NEW_SUPPLIER_PART_NO
rows.append(row)
payload[key] = {"list": rows}
return payload
if len(NEW_ITEM_DESC) > 40:
raise SystemExit("ItemDesc is over 40 characters")
with httpx.Client(verify=VERIFY_SSL, timeout=180, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
# 1. GET the source item with its child records
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{SOURCE_ITEM_ID}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
payload = build_clone(resp.json())
if not EXECUTE:
print(json.dumps(payload, indent=2))
print("DRY RUN -- nothing written. Review the payload, then set EXECUTE = True.")
raise SystemExit(0)
# 2. POST the clone -- trailing slash, or the POST 307s
resp = client.post(f"{BASE_URL}/api/inventory/parts/", headers=headers, json=payload)
if resp.status_code not in (200, 201):
raise SystemExit(f"POST {resp.status_code}: {resp.text[:500]}")
# 3. Re-GET to prove what actually landed -- HTTP 200 alone does not confirm a write
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{NEW_ITEM_ID}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
new = resp.json()
print(f"Created {new['ItemId']}: {new['ItemDesc']}")
for loc in new["Locations"]["list"]:
print(f" location {loc['LocationId']}: ProductGroupId={loc.get('ProductGroupId')} "
f"Stockable={loc.get('Stockable')} Sellable={loc.get('Sellable')}")
for sup in new["Suppliers"]["list"]:
print(f" supplier {sup['SupplierId']}: Cost={sup.get('Cost')} "
f"ListPrice={sup.get('ListPrice')} SupplierPartNo={sup.get('SupplierPartNo')}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string SourceItemId = "WIDGET-001"; // item to copy
const string NewItemId = "WIDGET-003"; // must not already exist in inv_mast
const string NewItemDesc = "Widget Assembly, Large"; // 40 characters or fewer
const string NewSupplierPartNo = "WIDGET-003"; // written to every supplier row
var execute = false; // false prints the payload; true POSTs it
const string ExtendedProps = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure";
// ---------------------------------------------------------------------------
// Fields that identify the source record or describe its stock, per child list.
var drop = new Dictionary<string, string[]>
{
["Locations"] = new[] { "QtyOnHand", "MovingAverageCost", "PrimaryBin", "PeriodFirstStocked",
"YearFirstStocked", "PatternLikeInvMastUid", "PatternLikeLocationId",
"UserDefinedFields" },
["Suppliers"] = new[] { "InventorySupplierUid", "UserDefinedFields" },
["LocationSuppliers"] = new[] { "InventorySupplierXLocUid", "UserDefinedFields" },
["UnitsOfMeasure"] = new[] { "ItemUomUid", "UserDefinedFields" },
};
if (NewItemDesc.Length > 40) throw new InvalidOperationException("ItemDesc is over 40 characters");
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(3) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 1. GET the source item with its child records
var getResp = await client.GetAsync(
$"{BaseUrl}/api/inventory/parts/{SourceItemId}?extendedproperties={ExtendedProps}");
getResp.EnsureSuccessStatusCode();
var source = JsonNode.Parse(await getResp.Content.ReadAsStringAsync())!.AsObject();
// Top level: drop the source record's uid and UD block, and any null header fields, then re-key.
var payload = new JsonObject();
foreach (var (key, value) in source)
{
if (key is "InvMastUid" or "UserDefinedFields" || drop.ContainsKey(key)) continue;
if (value is null && char.IsUpper(key[0])) continue;
payload[key] = value?.DeepClone();
}
payload["ItemId"] = NewItemId;
payload["ItemDesc"] = NewItemDesc;
foreach (var (key, fields) in drop)
{
var rows = new JsonArray();
if (source[key]?["list"] is JsonArray list)
{
foreach (var src in list)
{
var row = src!.DeepClone().AsObject();
foreach (var f in fields) row.Remove(f);
row["ItemId"] = NewItemId;
if (key == "Suppliers") row["SupplierPartNo"] = NewSupplierPartNo;
rows.Add(row);
}
}
payload[key] = new JsonObject { ["list"] = rows };
}
if (!execute)
{
Console.WriteLine(payload.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
Console.WriteLine("DRY RUN -- nothing written. Review the payload, then set execute = true.");
return;
}
// 2. POST the clone -- trailing slash, or the POST 307s
var content = new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json");
var postResp = await client.PostAsync($"{BaseUrl}/api/inventory/parts/", content);
if (!postResp.IsSuccessStatusCode)
{
var body = await postResp.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"POST {(int)postResp.StatusCode}: {body[..Math.Min(500, body.Length)]}");
}
// 3. Re-GET to prove what actually landed -- HTTP 200 alone does not confirm a write
var afterResp = await client.GetAsync(
$"{BaseUrl}/api/inventory/parts/{NewItemId}?extendedproperties={ExtendedProps}");
afterResp.EnsureSuccessStatusCode();
var created = JsonNode.Parse(await afterResp.Content.ReadAsStringAsync())!;
Console.WriteLine($"Created {created["ItemId"]}: {created["ItemDesc"]}");
foreach (var loc in created["Locations"]!["list"]!.AsArray())
Console.WriteLine($" location {loc!["LocationId"]}: ProductGroupId={loc["ProductGroupId"]} " +
$"Stockable={loc["Stockable"]} Sellable={loc["Sellable"]}");
foreach (var sup in created["Suppliers"]!["list"]!.AsArray())
Console.WriteLine($" supplier {sup!["SupplierId"]}: Cost={sup["Cost"]} " +
$"ListPrice={sup["ListPrice"]} SupplierPartNo={sup["SupplierPartNo"]}");
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Read the result back with SQL or OData as well (inv_mast, inv_loc, inventory_supplier, item_uom for the new item_id) — the re-GET shows what the API returns, not every column it wrote.
Credit: @mrwuss — a clone built this way was created on production 26.1, October 2026, and checked by SQL read-back (#175). Python and C# examples run on play 26.1.5950.0, 2026-10-10: both programs above and both repo files, each cloning a throwaway two-location, one-supplier, two-unit item under a new ID, with
inv_loc,inventory_supplier,inventory_supplier_x_locanditem_uomchecked against the source by SQL read-back. The source had no stock on hand, so the run shows the copied setup, not the stripping of a non-zeroQtyOnHand.
Common Issues
1. "Item ID already exists"
Cause: Using POST for an item that already exists in inv_mast.
Fix: Use the GET → Append → PUT workflow described above.
2. "Account doesn't exist for company"
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.
3. ItemDesc Max 40 Characters
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:
Full runnable version: Minimum Create Payload — run
validate_item_desc/ValidateItemDesconItemDescbefore calling POST or PUT.
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 desc
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;
}
Character set and whitespace (verified)
On a 26.1 tenant, the Inventory REST API enforces no character restriction on ItemDesc. Every printable ASCII symbol round-trips intact through GET → PUT → GET — including characters that commonly trip up other layers:
" ' ` & < > # / \ | , ; : . ( ) [ ] { } * + = % $ @ ! ? ~ ^ _ -
Extended/Unicode characters (e.g. é, ½, °) also store and read back unchanged.
⚠️ Version- and pipeline-dependent. This was verified on a 26.1 tenant. 25.x tenants and downstream consumers (reporting, label/barcode printing, EDI) may reject characters the 26.1 REST API accepts — the double-quote
"is a known offender. If you target 25.x or feed any reporting/printing flow, strip risky symbols (notably") from descriptions and part numbers before writing, rather than relying on the API's permissiveness.
Two behaviors are enforced at this layer:
- Length — exactly 40 characters is accepted; 41 or more is silently discarded on PUT (HTTP 200, but the value is not written and the previous description is retained). POST with >40 fails with the misleading
"Required value missing for Item Description". - Trailing whitespace is trimmed; leading whitespace is preserved.
If your environment rejects certain symbols in item descriptions, the restriction is not coming from the Inventory REST API. Look instead at the P21 desktop UI / DynaChange validation rules or the specific downstream consumer (EDI, label/barcode printing, report formatting).
Verified against Prophet21Play (26.1) by setting each candidate symbol on a live item via PUT and confirming the value on a fresh GET (then restoring the original description):
"""Probe whether candidate symbols survive a PUT -> GET round-trip on ItemDesc, then restore it."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # item to probe -- ItemDesc is overwritten, then restored
EXTENDED_PROPS = "Locations,Suppliers,LocationSuppliers"
CANDIDATE_SYMBOLS = ['"', "'", "&", "<", ">", "#", "/", "\\", "%", "@"]
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def get_item(client: httpx.Client, headers: dict, item_id: str) -> dict:
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{item_id}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
return resp.json()
def symbol_round_trips(client: httpx.Client, headers: dict, item_id: str, symbol: str) -> bool:
"""Probe whether a symbol survives a round-trip (PUT then GET)."""
part = get_item(client, headers, item_id)
test = f"AA{symbol}BB"
part["ItemDesc"] = test
client.put(f"{BASE_URL}/api/inventory/parts/{item_id}", headers=headers, json=part)
after = get_item(client, headers, item_id)["ItemDesc"]
return after == test # True => allowed; baseline value => silently discarded
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
original_desc = get_item(client, headers, ITEM_ID)["ItemDesc"]
for symbol in CANDIDATE_SYMBOLS:
allowed = symbol_round_trips(client, headers, ITEM_ID, symbol)
print(f"{symbol!r}: {'allowed' if allowed else 'discarded'}")
# Restore the original description
part = get_item(client, headers, ITEM_ID)
part["ItemDesc"] = original_desc
client.put(f"{BASE_URL}/api/inventory/parts/{ITEM_ID}", headers=headers, json=part)
restored = get_item(client, headers, ITEM_ID)["ItemDesc"]
print(f"Restored: {restored == original_desc}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // item to probe -- ItemDesc is overwritten, then restored
const string ExtendedProps = "Locations,Suppliers,LocationSuppliers";
string[] CandidateSymbols = { "\"", "'", "&", "<", ">", "#", "/", "\\", "%", "@" };
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var originalDesc = (await GetItemAsync(client, ItemId))["ItemDesc"]?.ToString();
foreach (var symbol in CandidateSymbols)
{
var allowed = await SymbolRoundTripsAsync(client, ItemId, symbol);
Console.WriteLine($"\"{symbol}\": {(allowed ? "allowed" : "discarded")}");
}
// Restore the original description
var part = await GetItemAsync(client, ItemId);
part["ItemDesc"] = originalDesc;
await client.PutAsync(
$"{BaseUrl}/api/inventory/parts/{ItemId}",
new StringContent(part.ToJsonString(), Encoding.UTF8, "application/json"));
var restored = (await GetItemAsync(client, ItemId))["ItemDesc"]?.ToString();
Console.WriteLine($"Restored: {restored == originalDesc}");
// --- helpers ---------------------------------------------------------------
static async Task<JsonNode> GetItemAsync(HttpClient client, string itemId)
{
var resp = await client.GetAsync(
$"{BaseUrl}/api/inventory/parts/{itemId}?extendedproperties={ExtendedProps}");
resp.EnsureSuccessStatusCode();
return JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
}
// Probe whether a symbol survives a round-trip (PUT then GET).
static async Task<bool> SymbolRoundTripsAsync(HttpClient client, string itemId, string symbol)
{
var part = await GetItemAsync(client, itemId);
var test = $"AA{symbol}BB";
part["ItemDesc"] = test;
await client.PutAsync(
$"{BaseUrl}/api/inventory/parts/{itemId}",
new StringContent(part.ToJsonString(), Encoding.UTF8, "application/json"));
var after = (await GetItemAsync(client, itemId))["ItemDesc"]?.ToString();
return after == test; // true => allowed; baseline value => silently discarded
}
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
4. POST Returns 307 Redirect
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:
Full runnable version: Minimum Create Payload — already POSTs to the trailing-slash URL with a redirect-following client.
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)
// 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.
5. Location Soft-Delete via Delete Flag
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.).
"""GET -> set Delete='Y' on one inv_loc -> PUT -> re-GET to confirm the soft-delete."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # item to update
TARGET_LOCATION_ID = 2 # inv_loc.LocationId to soft-delete
TARGET_COMPANY_ID = "ACME" # inv_loc.CompanyId to soft-delete
EXTENDED_PROPS = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure"
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
# 1. GET item with locations
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{ITEM_ID}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
item = resp.json()
# 2. Set Delete flag on the target location -- do not drop the other records
for loc in item["Locations"]["list"]:
if loc["LocationId"] == TARGET_LOCATION_ID and loc["CompanyId"] == TARGET_COMPANY_ID:
loc["Delete"] = "Y"
break
# 3. PUT the whole item back
resp = client.put(f"{BASE_URL}/api/inventory/parts/{ITEM_ID}", headers=headers, json=item)
resp.raise_for_status()
print(f"PUT status: {resp.status_code}")
# 4. Re-GET to prove what actually landed -- HTTP 200 alone does not confirm a write
resp = client.get(
f"{BASE_URL}/api/inventory/parts/{ITEM_ID}",
headers=headers,
params={"extendedproperties": EXTENDED_PROPS},
)
resp.raise_for_status()
after = resp.json()
for loc in after["Locations"]["list"]:
if loc["LocationId"] == TARGET_LOCATION_ID and loc["CompanyId"] == TARGET_COMPANY_ID:
print(f"Delete flag is now: {loc['Delete']}")
break
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // item to update
const int TargetLocationId = 2; // inv_loc.LocationId to soft-delete
const string TargetCompanyId = "ACME"; // inv_loc.CompanyId to soft-delete
const string ExtendedProps = "Locations,Suppliers,LocationSuppliers,UnitsOfMeasure";
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 1. GET item with locations
var getUrl = $"{BaseUrl}/api/inventory/parts/{ItemId}?extendedproperties={ExtendedProps}";
var getResp = await client.GetAsync(getUrl);
getResp.EnsureSuccessStatusCode();
var item = JsonNode.Parse(await getResp.Content.ReadAsStringAsync())!;
// 2. Set Delete flag on the target location -- do not drop the other records
var locations = item["Locations"]!["list"]!.AsArray();
var target = locations.FirstOrDefault(
l => (int)l!["LocationId"]! == TargetLocationId && (string)l["CompanyId"]! == TargetCompanyId);
if (target != null)
{
target["Delete"] = "Y";
}
// 3. PUT the whole item back
var putContent = new StringContent(item.ToJsonString(), Encoding.UTF8, "application/json");
var putResp = await client.PutAsync($"{BaseUrl}/api/inventory/parts/{ItemId}", putContent);
putResp.EnsureSuccessStatusCode();
Console.WriteLine($"PUT status: {(int)putResp.StatusCode}");
// 4. Re-GET to prove what actually landed -- HTTP 200 alone does not confirm a write
var afterResp = await client.GetAsync(getUrl);
afterResp.EnsureSuccessStatusCode();
var after = JsonNode.Parse(await afterResp.Content.ReadAsStringAsync())!;
var afterTarget = after["Locations"]!["list"]!.AsArray().FirstOrDefault(
l => (int)l!["LocationId"]! == TargetLocationId && (string)l["CompanyId"]! == TargetCompanyId);
if (afterTarget != null)
{
Console.WriteLine($"Delete flag is now: {afterTarget["Delete"]}");
}
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Note: To restore a soft-deleted location, set
Deleteback to"N"using the same pattern.
6. UOM Handling
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.
7. An incomplete create payload 500s, but the message names what's actually missing
There's no /new template (Limitations) and no field-level 422 — an under-specified POST /api/inventory/parts fails with an HTTP 500 whose ErrorMessage is nonetheless specific about the missing prerequisite, verified by omitting each of the Minimum Create Payload's required blocks in turn (26.1, September 2026):
| Payload has | ErrorMessage |
|---|---|
ItemId/ItemDesc only, no Locations |
No location or location default information could be found for item {ItemId}. |
Locations, no Suppliers |
No supplier or default information could be found for item {ItemId} at location {LocationId}. |
Locations and Suppliers, no LocationSuppliers |
The identical message — No supplier or default information could be found for item {ItemId} at location {LocationId}. |
The last row is the trap: the message doesn't distinguish "no supplier data anywhere" from "a Suppliers entry exists but isn't linked to the location via LocationSuppliers" — both read as if Suppliers were never sent at all. If you already have a Suppliers block and still get this error, check LocationSuppliers before re-checking Suppliers itself. Every failed attempt above left nothing behind — confirmed via a GET immediately after each one (404, not a partially-created record) — the create is atomic even though the error isn't a clean validation response.
Automation Example
"""Add an existing item to a new company/location via GET -> Append -> PUT."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "WIDGET-001" # item to append the new company/location to
API = f"{BASE_URL}/api/inventory/parts"
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def process_item(
client: httpx.Client, headers: dict, item_id: str,
new_location: dict, new_supplier: dict,
) -> 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}",
headers=headers,
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(f"{API}/", headers=headers, 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}", headers=headers, json=current_item)
resp.raise_for_status()
return resp.json()
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
new_location = {
"ItemId": ITEM_ID,
"LocationId": 2,
"CompanyId": "ACME-WEST",
"GlAccountNo": "1300-000",
"RevenueAccountNo": "4000-000",
"CosAccountNo": "5000-000",
"Sellable": "Y",
"Stockable": "Y",
"ObjectName": "inv_loc",
}
new_supplier = {
"ItemId": ITEM_ID,
"SupplierId": 20,
"DivisionId": 2,
"LeadTimeDays": 5,
"ObjectName": "inventory_supplier",
}
result = process_item(client, headers, ITEM_ID, new_location, new_supplier)
# Read-back what actually landed
linked = {loc["CompanyId"] for loc in result.get("Locations", {}).get("list", [])}
print(f"Item {ITEM_ID} now linked to companies: {sorted(linked)}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001"; // item to append the new company/location to
const string Api = BaseUrl + "/api/inventory/parts";
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var newLocation = new JsonObject
{
["ItemId"] = ItemId,
["LocationId"] = 2,
["CompanyId"] = "ACME-WEST",
["GlAccountNo"] = "1300-000",
["RevenueAccountNo"] = "4000-000",
["CosAccountNo"] = "5000-000",
["Sellable"] = "Y",
["Stockable"] = "Y",
["ObjectName"] = "inv_loc",
};
var newSupplier = new JsonObject
{
["ItemId"] = ItemId,
["SupplierId"] = 20,
["DivisionId"] = 2,
["LeadTimeDays"] = 5,
["ObjectName"] = "inventory_supplier",
};
var result = await ProcessItemAsync(client, ItemId, newLocation, newSupplier);
// Read-back what actually landed
var linked = result["Locations"]?["list"]?.AsArray()
.Select(loc => loc!["CompanyId"]?.ToString())
.Distinct()
.ToList() ?? new List<string?>();
Console.WriteLine($"Item {ItemId} now linked to companies: {string.Join(", ", linked)}");
// --- helpers ---------------------------------------------------------------
// Add an existing item to a new company/location via GET -> Append -> PUT.
static async Task<JsonNode> ProcessItemAsync(
HttpClient client, string itemId, JsonObject newLocation, JsonObject 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 JsonObject { ["ItemId"] = itemId };
foreach (var kv in newLocation) createPayload[kv.Key] = kv.Value?.DeepClone();
foreach (var kv in newSupplier) createPayload[kv.Key] = kv.Value?.DeepClone();
var createContent = new StringContent(
createPayload.ToJsonString(), Encoding.UTF8, "application/json");
var createResp = await client.PostAsync($"{Api}/", createContent);
createResp.EnsureSuccessStatusCode();
return JsonNode.Parse(await createResp.Content.ReadAsStringAsync())!;
}
resp.EnsureSuccessStatusCode();
var currentItem = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!;
// 2. Check if company/location already linked
var locations = currentItem["Locations"]?["list"]?.AsArray() ?? new JsonArray();
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.DeepClone());
var suppliers = currentItem["Suppliers"]?["list"]?.AsArray() ?? new JsonArray();
suppliers.Add(newSupplier.DeepClone());
// 4. PUT updated payload
var putContent = new StringContent(
currentItem.ToJsonString(), Encoding.UTF8, "application/json");
var putResp = await client.PutAsync($"{Api}/{itemId}", putContent);
putResp.EnsureSuccessStatusCode();
return JsonNode.Parse(await putResp.Content.ReadAsStringAsync())!;
}
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Batch Processing Tips
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)
Pricing Endpoints
Added April 2026 — Community-sourced discovery. Credit: Felipe Maurer, John Kennedy.
The Inventory REST API provides multiple pricing endpoints for retrieving customer-specific pricing.
Single Item 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/json
GET /api/inventory/v2/parts/v2/price/{ItemId}?companyid=ACME&customerId=10&sourceLocId=100&salesLocId=100
Authorization: Bearer {token}
Accept: application/json
Both endpoints accept the same query parameters and return customer-specific pricing.
Required parameters:
- companyId — Company ID
- customerId — Customer ID for pricing lookup
- salesLocId — Sales location ID
- sourceLocId — Source/ship location ID
- uom — Unit of measure (e.g., EA)
- priceUom — Price unit of measure (e.g., EA)
- unitQuantity — Quantity for price break calculation
Verified Pricing Response
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") |
Pricing Error Response
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-01",
"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.
URL Encoding for Special Characters
When item IDs contain special characters, URL encoding is required:
| Character | Encoding | Status |
|---|---|---|
# |
%23 |
Works — e.g., ORDER%23TEST |
/ |
%2F |
Route-dependent — /api/inventory/parts/{ItemId}... returns 404; three other routes work (below) |
& |
%26 |
Use standard URL encoding |
+ |
%2B |
Use standard URL encoding |
Known Issue: Forward slash (
/) in item IDs cannot be URL-encoded on the/api/inventory/parts/{ItemId}routes.%2Fis read as a path separator, and the request returns an HTML 404. (Credit: John Kennedy, confirmed by Felipe Maurer)Three routes do reach such items (play 26.1.5950.0, 2026-10-29, with an item ID of the form
ABC123/X0950):
Route Result GET /api/inventory/v2/parts/v2/price/{ItemId}with%2F200, the item's price. Needs companyId,customerId,salesLocIdandsourceLocId; withoutsourceLocIdit is HTTP 500 "Please provide a value for required parameter sourceLocId." Double-encoding (%252F) is HTTP 500 "Item is not valid or not defined at this location"GET /api/inventory/v2/parts/availability?itemId={ItemId}200, the item's availability rows; the ID is a query value, so %2Fis safeGET /api/inventory/parts/?$query=ItemId eq '{ItemId}'200, a one-element array holding the full part object (locations, suppliers, units of measure); the ID is a query value GET /api/inventory/parts/{ItemId},.../{ItemId}/v2/price,.../{ItemId}/availabilitywith%2F404 (Community lead: Felipe Maurer, P21WWUG topic; verified here.)
The update path is blocked too.
GET /api/inventory/parts/{ItemId}returns 404 for an item ID containing/(sent as%2F), so the GET → modify → PUT update path cannot reach such items. The$queryread above returns the same object, but a PUT from it was not tested; the three routes above are reads. For supplier cost and other item edits on them, use the InteractiveItemwindow. (@mrwuss, production 26.1, October 2026 — #171.)
Batch Pricing
Added August 2026 — Live-verified against Prophet21Play (26.1).
POST /api/inventory/parts/prices (and the identical POST /api/inventory/v2/parts/prices) price and check availability for multiple items in one call. Unlike single-item pricing, ItemId is in the request body, not the URL — so the forward-slash URL-encoding limitation above does not apply here.
POST /api/inventory/parts/prices?companyId=ACME&customerId=10&salesLocId=100
Authorization: Bearer {token}
Content-Type: application/json
Accept: application/json
[
{ "ItemId": "WIDGET-001", "SourceLocId": 100 },
{ "ItemId": "WIDGET-002", "SourceLocId": 100 }
]
Required query parameters (verified — omitting any one produces a distinct error naming exactly that parameter, checked in this order):
companyIdcustomerIdsalesLocId
P21 validates these one at a time and fails on the first missing one, so an incomplete request appears to fail "progressively" as you add parameters — each retry reveals the next missing one rather than listing all of them up front:
HTTP 500
{"ErrorMessage": "Please provide a value for required parameter companyId.", "ErrorType": "System.ArgumentException", ...}
Note it is an HTTP 500, not a 400 — a missing query parameter surfaces as a server error here, so do not filter these out of your logs as transient.
Three further query parameters are optional: shipToId, orderDate and jobNo — all three accepted without error on 26.1.5940.0. Note the names: the WCF method parameters are optionalShipToId / optionalOrderDate / optionalJobNo (that is how they appear in a stack trace), but the query string spells them without the optional prefix. The tenant's own help page gives the authoritative template:
GET {base}/api/inventory/parts/help/operations/GetItemPrices
→ .../parts/prices?companyId={}&customerId={}&salesLocId={}&shipToId={}&orderDate={}&jobNo={}
That help page is also where the XML request/response schemas come from, and it is per-tenant — prefer it over this table if the two ever disagree.
Request body — a JSON array or an XML ArrayOfItemPriceInfo of per-item objects. Verified minimum: ItemId + SourceLocId. Also accepted per item: CustomerPartNo, UnitQuantity, UnitSize, UOM, PriceUom (all optional — P21 defaults UOM/PriceUom to the item's base unit when omitted).
Response — a JSON array or an XML ArrayOfItemPrice, one entry per valid requested item, in the same shape as single-item pricing (pricing and availability fields together).
Unknown item IDs are dropped without a word. Three items sent, one of them nonexistent: HTTP 200 and two entries. One nonexistent item alone: HTTP 200 and
[]. Match the response to the request byItemId, never by position. (play 26.1.5950.0, 2026-10-29.)
Content-Type must match the body — this is not "XML only" (verified)
This endpoint is often mistaken for XML-only. It is not — both JSON and XML request/response bodies work. It's a WCF REST endpoint that selects its (de)serializer from the Content-Type header, not by inspecting the body, so sending a body that doesn't match Content-Type produces a confusing failure that looks like a format rejection:
Content-Type sent |
Actual body | Result |
|---|---|---|
application/json |
JSON | 200 — works |
application/xml |
XML | 200 — works |
(omitted, Accept: application/json) |
JSON | 200 — works (defaults to JSON) |
application/xml |
JSON | 400 — generic WCF "Request Error" HTML page pointing at /help; no P21 error, no mention of a header mismatch |
application/json |
XML | 400 — at least a real P21 error: Invalid JSON or request body for parameter 'itemInfo'. Unexpected character encountered while parsing value: <... |
Accept is negotiated independently of Content-Type: an XML body with Accept: application/json returns JSON, and vice versa. All of the above re-verified on 26.1.5940.0.
The XML body takes no namespace, and getting that wrong looks exactly like "XML is rejected".
<ArrayOfItemPriceInfo>must be bare — as the tenant's own.../parts/help/operations/GetItemPricespage shows it. Adding a plausible DataContract namespace (http://schemas.datacontract.org/2004/07/P21.Inventory.Model, the shape used elsewhere in this API) makes the element fail to bind and returns the same generic WCF 400 HTML page as the header mismatch above. Two different mistakes, one indistinguishable error — which is most of why this endpoint has its reputation.
The application/xml + JSON-body case is what creates the "XML only" impression: the response gives no hint that the body was even parsed, let alone that the fix is the Content-Type header rather than the body format. If a batch pricing POST comes back as that generic WCF "Request Error" page, check that Content-Type matches what you actually sent before concluding the endpoint rejects JSON.
"""Batch price + availability lookup via the Inventory REST API (JSON body)."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
COMPANY_ID = "ACME" # required query param
CUSTOMER_ID = "10" # required query param
SALES_LOC_ID = "100" # required query param
ITEMS = [
{"ItemId": "WIDGET-001", "SourceLocId": 100},
{"ItemId": "WIDGET-002", "SourceLocId": 100},
]
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
# Content-Type must match the body -- application/json here because ITEMS
# is sent as JSON. Sending application/xml with a JSON body 400s (see above).
"Content-Type": "application/json",
}
resp = client.post(
f"{BASE_URL}/api/inventory/parts/prices",
headers=headers,
params={"companyId": COMPANY_ID, "customerId": CUSTOMER_ID, "salesLocId": SALES_LOC_ID},
json=ITEMS,
)
resp.raise_for_status()
for price in resp.json():
print(
f"{price['ItemId']}: UnitPrice={price['UnitPrice']} "
f"QtyAvailable={price['QuantityAvailable']}"
)
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string CompanyId = "ACME"; // required query param
const string CustomerId = "10"; // required query param
const string SalesLocId = "100"; // required query param
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var items = new JsonArray
{
new JsonObject { ["ItemId"] = "WIDGET-001", ["SourceLocId"] = 100 },
new JsonObject { ["ItemId"] = "WIDGET-002", ["SourceLocId"] = 100 },
};
// Content-Type must match the body -- application/json here because items is
// sent as JSON. Sending application/xml with a JSON body 400s (see above).
var url = $"{BaseUrl}/api/inventory/parts/prices" +
$"?companyId={CompanyId}&customerId={CustomerId}&salesLocId={SalesLocId}";
var content = new StringContent(items.ToJsonString(), Encoding.UTF8, "application/json");
var resp = await client.PostAsync(url, content);
resp.EnsureSuccessStatusCode();
var prices = JsonNode.Parse(await resp.Content.ReadAsStringAsync())!.AsArray();
foreach (var price in prices)
{
Console.WriteLine(
$"{price!["ItemId"]}: UnitPrice={price["UnitPrice"]} " +
$"QtyAvailable={price["QuantityAvailable"]}");
}
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Sample response (one entry per valid requested item, same fields as single-item pricing):
[
{
"UnitPrice": 10.780000000,
"BaseUnitPrice": 10.780000000,
"UOM": "EA",
"UOMUnitSize": 1.000000000,
"PricePageUid": 0,
"PriceUOM": "EA",
"PriceUnitSize": 1.000000000,
"ExtendedPrice": 10.780000000,
"CalcValue": 1.000000000,
"UnitCommissionCost": 5.308224600,
"UnitOtherCost": 5.390000000,
"UnitSalesCost": 5.308224600,
"LotCosted": "N",
"ItemId": "WIDGET-001",
"CompanyId": null,
"LocationId": 100,
"QuantityAvailable": 41.000000000,
"QuantityOnHand": 41.000000000,
"QuantityAllocated": 0.000000000,
"QuantityNonPickable": 0.0,
"QuantityQuarantined": 0.0,
"QuantityFrozen": 0.0,
"LocationType": "Standard"
},
{
"ItemId": "WIDGET-002",
"PricePageUid": 813,
"...": "one object per requested item, same shape"
}
]
Note: As with single-item pricing,
CompanyIdin the response can benulleven though it was required in the request.
Equivalent XML request/response
The same call, sent as XML instead — only the Content-Type/Accept headers and body format change; the query parameters and URL are identical:
POST /api/inventory/parts/prices?companyId=ACME&customerId=10&salesLocId=100
Authorization: Bearer {token}
Content-Type: application/xml
Accept: application/xml
<ArrayOfItemPriceInfo>
<ItemPriceInfo>
<ItemId>WIDGET-001</ItemId>
<SourceLocId>100</SourceLocId>
</ItemPriceInfo>
<ItemPriceInfo>
<ItemId>WIDGET-002</ItemId>
<SourceLocId>100</SourceLocId>
</ItemPriceInfo>
</ArrayOfItemPriceInfo>
<?xml version="1.0"?>
<ArrayOfItemPrice xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ItemPrice>
<ItemId>WIDGET-001</ItemId>
<LocationId>100</LocationId>
<QuantityAvailable>41.000000000</QuantityAvailable>
<QuantityOnHand>41.000000000</QuantityOnHand>
<QuantityAllocated>0.000000000</QuantityAllocated>
<QuantityNonPickable>0</QuantityNonPickable>
<QuantityQuarantined>0</QuantityQuarantined>
<QuantityFrozen>0</QuantityFrozen>
<LocationType>Standard</LocationType>
<UnitPrice>10.780000000</UnitPrice>
<BaseUnitPrice>10.780000000</BaseUnitPrice>
<UOM>EA</UOM>
<UOMUnitSize>1.000000000</UOMUnitSize>
<PricePageUid>0</PricePageUid>
<PriceUOM>EA</PriceUOM>
<PriceUnitSize>1.000000000</PriceUnitSize>
<ExtendedPrice>10.780000000</ExtendedPrice>
<CalcValue>1.000000000</CalcValue>
<UnitCommissionCost>5.308224600</UnitCommissionCost>
<UnitOtherCost>5.390000000</UnitOtherCost>
<UnitSalesCost>5.308224600</UnitSalesCost>
<LotCosted>N</LotCosted>
</ItemPrice>
<!-- one <ItemPrice> per requested item -->
</ArrayOfItemPrice>
Full runnable version: adapt the Python/C# example above — swap the JSON body/headers for the XML shown here, no other change needed.
Known Limitations
-
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. -
The item header's default product group has no API write path.
inv_mast.default_product_groupis ignored on create and on PUT — the payload has no field for it, and nothing errors. The Transaction APIItemservice rejects it onTABPAGE_1.tp_1_dw_1too (see 03 § Item Service Gotchas). The location product group (Locations.list[].ProductGroupId,inv_loc.product_group_id) is writable through both APIs, and it is the one that sales, GL and pricing use. Verified on production and play 26.1, May and August 2026 (#177).
Related
- 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