On this page
- Overview
- Field Order Requirements
- Transaction API Alternative
- Calculation Method (VALUES Tab)
- Price Page Type (FORM Tab)
- Pricing Method (FORM Tab)
- Source Price (FORM Tab)
- Cost Type (COSTS Tab)
- Commission Cost Calculation Method (COSTS Tab)
- Discovery Method
- Price Breaks (Quantity-Based Pricing)
- Discount Group Pages (Type 213)
- Dollar-Based Price Breaks
- COSTS Tab Configuration
- Purchase-Side Pricing Services
- Related
Sales Price Page Dropdown Codes
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.
Overview
When working with the SalesPricePage window via the Interactive API, dropdown fields require specific display values (not codes). This document provides the mappings discovered through API testing.
Important: P21's code tables (code_p21) may not be accessible via OData in all environments. These values were discovered by testing the Interactive API directly.
Note: Code mappings may vary between P21 versions and configurations. Always verify codes in your specific environment.
Field Order Requirements
When creating or modifying price pages, fields must be set in a specific order. Setting fields out of order can cause validation failures or incorrect data.
Required Field Order for Page Creation
price_page_type_cd- Set page type FIRST (e.g., "Supplier / Product Group")company_id- Required BEFORE product_group_idproduct_group_idordiscount_group_id- Depends on page typesupplier_iddescriptionpricing_method_cd- Use display value "Source"source_price_cd- Use display value "Supplier List Price"effective_date/expiration_date- Switch to VALUES tab
calculation_method_cd- Use display value "Multiplier"calculation_value1(and additional break values if needed)
Example: Creating a Price Page
"""Create a SalesPricePage through the Interactive API, in the required field order."""
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
COMPANY_ID = "ACME"
PRODUCT_GROUP_ID = "HVAC"
SUPPLIER_ID = "10050"
DESCRIPTION = "P2-L5-10050-HVAC-WHOLESALE"
EFFECTIVE_DATE = "2025-01-01"
EXPIRATION_DATE = "2030-12-31"
MULTIPLIER = "0.85" # calculation_value1 -- tier 1, qty 1+
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
def change(tab: str, datawindow: str, field: str, value: str) -> None:
"""One field per call -- a batched /v2/change is non-atomic on 2026.1."""
r = client.put(
f"{ui_server}/api/ui/interactive/v2/change",
headers=headers,
json={
"WindowId": window_id,
"List": [{
"TabName": tab,
"DatawindowName": datawindow, # required since 25.2
"FieldName": field,
"Value": value,
}],
},
)
r.raise_for_status()
# A session must exist before any window can be opened.
session = client.post(
f"{ui_server}/api/ui/interactive/sessions",
headers=headers,
json={"ResponseWindowHandlingEnabled": False},
)
session.raise_for_status()
# ServiceName is the only reliable window identifier -- Name/Title can 400.
opened = client.post(
f"{ui_server}/api/ui/interactive/v2/window",
headers=headers,
json={"ServiceName": "SalesPricePage"},
)
opened.raise_for_status()
window_id = opened.json()["WindowId"]
print("Window opened:", window_id)
try:
# Step 1: page type FIRST -- it determines which fields are available
change("FORM", "form", "price_page_type_cd", "Supplier / Product Group")
# Step 2: company_id BEFORE product_group_id
change("FORM", "form", "company_id", COMPANY_ID)
# Step 3: product group
change("FORM", "form", "product_group_id", PRODUCT_GROUP_ID)
# Step 4: supplier
change("FORM", "form", "supplier_id", SUPPLIER_ID)
# Step 5: description
change("FORM", "form", "description", DESCRIPTION)
# Steps 6-7: pricing method and source price
change("FORM", "form", "pricing_method_cd", "Source")
change("FORM", "form", "source_price_cd", "Supplier List Price")
# Step 8: dates
change("FORM", "form", "effective_date", EFFECTIVE_DATE)
change("FORM", "form", "expiration_date", EXPIRATION_DATE)
# Step 9: switch to the VALUES tab (2026.1 binds PageName, not TabName)
tab = client.put(
f"{ui_server}/api/ui/interactive/v2/tab",
headers=headers,
json={"WindowId": window_id, "PageName": "VALUES"},
)
tab.raise_for_status()
# Steps 10-11: calculation method, then the tier-1 value
change("VALUES", "values", "calculation_method_cd", "Multiplier")
change("VALUES", "values", "calculation_value1", MULTIPLIER)
# Save -- v2 takes the bare WindowId string as the JSON body
saved = client.put(
f"{ui_server}/api/ui/interactive/v2/data",
headers=headers,
content=json.dumps(window_id),
)
saved.raise_for_status()
result = saved.json()
# ResultStatus: None=0, Success=1, Failure=2, Blocked=3
print("Save status:", result.get("Status"))
for message in result.get("Messages") or []:
print(" Message:", message)
# ---- read-back: HTTP 200 alone does not mean the save landed --------
read_back = client.get(
f"{ui_server}/api/ui/interactive/v2/data",
params={"id": window_id},
headers=headers,
)
read_back.raise_for_status()
# /v2/data returns the datawindows of the ACTIVE tab, and only a subset
# of them -- absence here is not proof that a field failed to save.
print(json.dumps(read_back.json())[:2000])
finally:
client.delete(
f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id},
headers=headers,
)
# Always end the session -- a leaked one blocks the next create with 409
client.delete(f"{ui_server}/api/ui/interactive/sessions", headers=headers)
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 CompanyId = "ACME";
const string ProductGroupId = "HVAC";
const string SupplierId = "10050";
const string Description = "P2-L5-10050-HVAC-WHOLESALE";
const string EffectiveDate = "2025-01-01";
const string ExpirationDate = "2030-12-31";
const string Multiplier = "0.85"; // calculation_value1 -- tier 1, qty 1+
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// A session must exist before any window can be opened.
var sessionResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/sessions",
Json(new { ResponseWindowHandlingEnabled = false }));
sessionResponse.EnsureSuccessStatusCode();
// ServiceName is the only reliable window identifier -- Name/Title can 400.
var openResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/v2/window",
Json(new { ServiceName = "SalesPricePage" }));
openResponse.EnsureSuccessStatusCode();
using var opened = JsonDocument.Parse(await openResponse.Content.ReadAsStringAsync());
var windowId = opened.RootElement.GetProperty("WindowId").GetString()!;
Console.WriteLine($"Window opened: {windowId}");
// One field per call -- a batched /v2/change is non-atomic on 2026.1.
async Task ChangeAsync(string tab, string datawindow, string field, string value)
{
var response = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/change",
Json(new
{
WindowId = windowId,
List = new[]
{
new
{
TabName = tab,
DatawindowName = datawindow, // required since 25.2
FieldName = field,
Value = value,
}
}
}));
response.EnsureSuccessStatusCode();
}
try
{
// Step 1: page type FIRST -- it determines which fields are available
await ChangeAsync("FORM", "form", "price_page_type_cd", "Supplier / Product Group");
// Step 2: company_id BEFORE product_group_id
await ChangeAsync("FORM", "form", "company_id", CompanyId);
// Step 3: product group
await ChangeAsync("FORM", "form", "product_group_id", ProductGroupId);
// Step 4: supplier
await ChangeAsync("FORM", "form", "supplier_id", SupplierId);
// Step 5: description
await ChangeAsync("FORM", "form", "description", Description);
// Steps 6-7: pricing method and source price
await ChangeAsync("FORM", "form", "pricing_method_cd", "Source");
await ChangeAsync("FORM", "form", "source_price_cd", "Supplier List Price");
// Step 8: dates
await ChangeAsync("FORM", "form", "effective_date", EffectiveDate);
await ChangeAsync("FORM", "form", "expiration_date", ExpirationDate);
// Step 9: switch to the VALUES tab (2026.1 binds PageName, not TabName)
var tabResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/tab",
Json(new { WindowId = windowId, PageName = "VALUES" }));
tabResponse.EnsureSuccessStatusCode();
// Steps 10-11: calculation method, then the tier-1 value
await ChangeAsync("VALUES", "values", "calculation_method_cd", "Multiplier");
await ChangeAsync("VALUES", "values", "calculation_value1", Multiplier);
// Save -- v2 takes the bare WindowId string as the JSON body
var saveResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/data", Json(windowId));
saveResponse.EnsureSuccessStatusCode();
using var result = JsonDocument.Parse(await saveResponse.Content.ReadAsStringAsync());
// ResultStatus: None=0, Success=1, Failure=2, Blocked=3
if (result.RootElement.TryGetProperty("Status", out var status))
Console.WriteLine($"Save status: {status}");
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
foreach (var message in messages.EnumerateArray())
Console.WriteLine($" Message: {message}");
}
// ---- read-back: HTTP 200 alone does not mean the save landed ----------
var readBackResponse = await client.GetAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={windowId}");
readBackResponse.EnsureSuccessStatusCode();
// /v2/data returns the datawindows of the ACTIVE tab, and only a subset of
// them -- absence here is not proof that a field failed to save.
var raw = await readBackResponse.Content.ReadAsStringAsync();
Console.WriteLine(raw.Length > 2000 ? raw[..2000] : raw);
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
// Always end the session -- a leaked one blocks the next create with 409
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions");
}
// --- helpers ---------------------------------------------------------------
static StringContent Json(object payload) =>
new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}
Why Order Matters
- Setting
product_group_idbeforeprice_page_type_cdwill fail validation - Setting
product_group_idbeforecompany_idmay cause lookup errors - The VALUES tab fields are only available after FORM tab fields are set
- Some fields become read-only after others are set
Transaction API Alternative
SalesPricePage is also available as a Transaction API service. Its full definition is
available from GET /api/v2/services, and the service exposes these data elements:
Two naming systems, both correct — don't mix them. The
DatawindowNamecolumn below (d_dw_price_page_main,d_dw_price_page_values,d_dw_price_page_cost) is what the Transaction definition reports. The Interactive API names the same datawindowsform,valuesandcosts— those short names are what the Interactive examples above send asDatawindowName, and they are whatGET /v2/windowreturns. Verified live on 26.1.5910.3 by selecting each tab and reading the window state. The tab page names (FORM,VALUES,COSTS,PO COST MULTIPLIERS,USED BY,TP_PRICE_PAGE_X_LOCATION,TIMESTAMPPRICE_PAGE) are shared by both APIs. See Get Window State.
| DataElement | Keys | DatawindowName |
|---|---|---|
FORM.form (Form) |
price_page_uid |
d_dw_price_page_main |
VALUES.values (Form) |
— | d_dw_price_page_values |
COSTS.costs (Form) |
— | d_dw_price_page_cost |
PO COST MULTIPLIERS.price_page_po_cost_calc (List) |
customer_id |
d_dw_price_page_po_cost_calc_dataentry |
USED BY.price_book_x_page (List) |
— | d_ds_price_book_x_page |
TP_PRICE_PAGE_X_LOCATION.price_page_x_location (List) |
— | d_dw_price_page_x_location_maint |
BusinessObjectName is price_page for the first three, and matches the element's own subject for the rest — price_page_po_cost_calc, price_book, price_page_location.
For updating an existing page, the Transaction API is much simpler than the Interactive
field-order sequence: FORM.form keys on price_page_uid, so it supports a keyed upsert via
POST /api/v2/transaction. Interactive remains the better choice for creating a page,
where the page type determines which subsequent fields are valid or required. In short, use
Interactive for create workflows and the Transaction API for keyed updates.
Creating a Page Through the Transaction API
A Transaction API create has also been verified, for an item-level page with a fixed price and a cost Value on the COSTS tab. It used three elements of SalesPricePage and then a second service for the book link:
| Step | Service · element | Fields |
|---|---|---|
| 1 | SalesPricePage · FORM.form |
item_id, description, price_page_type_cd, company_id, effective/expiration dates, contract_number, pricing_method_cd, source_price_cd, price, totaling_method_cd / totaling_basis_cd, strategic_price_applies_to_cd — in the field order above |
| 1 | SalesPricePage · COSTS.costs |
other_cost and commission_cost type / value / source / calculation method / calculation value |
| 1 | SalesPricePage · VALUES.values |
calculation_method_cd, calculation_value1 |
| 2 | SalesPriceBook · FORM.form keyed price_book_id, then LIST.list_detail keyed price_page_uid |
adds the price_page_x_book row |
- Omit
calculator_type. Its raw database value is refused ("Invalid calculator_type value: B" underUseCodeValues: false); the default lands as that same value anyway. - Link the page to its book immediately after the create. An unlinked page is invisible to customer pricing, and order lines priced in the gap never pick the page up later. Do the create and the link back to back in one run.
- Link through
SalesPriceBook, not through the page.SalesPriceBook·FORM.formkeyedprice_book_id, plus aLIST.list_detailrow keyedprice_page_uid, creates theprice_page_x_bookrow. The page's ownUSED BY.price_book_x_pageelement cannot do it: sendingprice_book_price_book_idthere fails cleanly withColumn is disabled: price_book_price_book_id— and withIgnoreDisabled: trueit reportsSucceeded: 1, drops the element from the response and writes no link row (Breaking Changes entry 8). Readprice_page_x_bookback after the link. The Interactive alternative is Linking Price Page to Price Book. - Unlink with the same
SalesPriceBookLIST.list_detailrow androw_status_flag=Delete. That physically deletes theprice_page_x_bookrow — it is not a soft delete. - Omit
strategic_price_applies_to_cdon create too. Sending it (Core) failed the whole transaction withColumn is disabled: strategic_price_applies_to_cd; left out, the page stored2636(Core) anyway. - The new
price_page_uidcomes back in the create response — the firstEditofFORM.forminResultscarries it. Read it there and feed it straight into the link. - To retire a throwaway page afterwards, send
SalesPricePage·FORM.formkeyedprice_page_uidwithrow_status_flag=Delete; the row stays, stored as700.
Runnable example: sales_price_page_create_link.py and examples/csharp/Recipes/SalesPricePageCreateLink.cs (Recipes menu 26) create the page, link it, read price_page and price_page_x_book back over OData, and optionally unlink and soft-delete it. Payloads: sales-price-page-create.json and sales-price-book-link.json. Python and C# examples run on play 26.1.5950.0, 2026-10-10: create, link, read-back, unlink and page delete all confirmed. The run linked to a book that was itself created through SalesPriceBook · FORM.form keyed price_book_id (price_book_id, price_book_description, row_status_flag = Active), in no library, so the test page could not reach customer pricing.
Credit: Alex Westemeier — create plus book link verified on play, then production, July 2026. The create was re-run here with the examples above on 26.1.5950.0 (October 2026). The book-link behavior (both elements, with and without
IgnoreDisabled) and the unlink: verified here by the maintainer on 26.1.5950.0 (October 2026), originally reported by Alex Westemeier.
Calculation Method (VALUES Tab)
The calculation_method_cd field on the VALUES tab controls how pricing calculations are applied.
| Code | Display Value | Description |
|---|---|---|
| 211 | Multiplier | Multiply by value (most common) |
| 228 | Difference | Subtract value from source price |
| 229 | Mark Up | Add markup percentage to cost |
| 230 | Percentage | Apply as percentage |
| 1292 | Fixed Price | Use fixed price value |
Usage Example:
Fragment -- it exists to contrast a display value with a code, not to run. Full runnable version: Example: Creating a Price Page.
# Correct - use display value
change("VALUES", "values", "calculation_method_cd", "Mark Up")
# Incorrect - do not use code
change("VALUES", "values", "calculation_method_cd", "229")
// Correct - use display value
await ChangeAsync("VALUES", "values", "calculation_method_cd", "Mark Up");
// Incorrect - do not use code
await ChangeAsync("VALUES", "values", "calculation_method_cd", "229");
Price Page Type (FORM Tab)
The price_page_type_cd field determines the page type. The column stores the code_p21 code_no; you send the display value.
price_page_type_cd |
Display Value (code_p21) |
Key Field |
|---|---|---|
| 212 | Item | item_id (price_page.inv_mast_uid is set) |
| 213 | Supplier / Discount Group | supplier_id + discount_group_id |
| 214 | Supplier / Product Group | supplier_id + product_group_id |
| 216 | Supplier | supplier_id only (no item, product group or discount group set) |
| 217 | Discount Group | discount_group_id |
| 218 | Product Group | product_group_id |
| 219 | Customer Part Number | customer part (price_page.inv_mast_uid is set) |
Key fields for every row were read from stored production pages joined to code_p21 (26.1, 2026-10-10); only 212–214 were also created through the API here. The service definition also accepts Supplier / Manufacturing Class, Price Family and Supplier/Price Family (see Discovery Method); their codes are not confirmed here. Customer and Customer / Item are not page types — neither is in the definition's ValidValues, and an earlier version of this table that listed them as 210 and 211 (plus a 215 "alt" discount-group type) was not backed by a code_p21 read.
The same number means different things in different columns.
code_p21is one code space, and a column stores whichevercode_noits dropdown points at.212is Item as a page type, andtotaling_method_cd = 212on a page is also Item, but212is not a calculation method. Map every code throughcode_p21, not through a table for another column — see 03 § Labels vs What the Database Stores.Note: Type 213 ("Supplier / Discount Group") uses
discount_group_idinstead ofproduct_group_id. The field order changes slightly for this type - see Discount Group Pages below.Credit: @mrwuss —
price_page.price_page_type_cdvalues read from production 26.1 and joined tocode_p21, October 2026; every212page hasinv_mast_uidset (#179).
Pricing Method (FORM Tab)
The pricing_method_cd field controls how the source price is used.
| Code | Display Value |
|---|---|
| 220 | Source |
| 221 | Price |
| 234 | Pricing Libraries |
| 300 | None |
Note: Corrected against live code_p21 reads (July 2026); earlier published values were misassigned. Code mappings may still vary between P21 versions — always verify in your environment.
Source Price (FORM Tab)
The source_price_cd field determines the base price source.
| Code | Display Value | Notes |
|---|---|---|
| 200 | Supplier List Price | Use with Multiplier (211) calculation method |
| 201 | Replacement Cost | Also known as "Primary Supplier Cost". Use with Mark Up (229) |
| 202 | Average Cost |
Source Price and Calculation Method Pairing
Using the wrong combination of source price and calculation method produces incorrect pricing. Follow these pairings:
| Calculation Method | Recommended Source Price | Why |
|---|---|---|
| Multiplier (211) | Supplier List Price (200) | Multiplier applies a factor to list price |
| Mark Up (229) | Replacement Cost / Primary Supplier Cost (201) | Mark up adds margin above cost |
Important: Source Price code 201 is labeled "Replacement Cost" in the dropdown but represents the Primary Supplier Cost in P21's pricing engine. This distinction matters when building pricing logic.
Cost Type (COSTS Tab)
| Code | Display Value |
|---|---|
| 220 | Source |
| 222 | Order |
| 227 | Value |
| 300 | None |
Note: Corrected against live code_p21 reads (July 2026); earlier published values were misassigned.
Commission Cost Calculation Method (COSTS Tab)
The commission_cost_calc_method_cd field on the COSTS tab. Its accepted display values are Difference, Mark up, Multiplier and Percentage (no Fixed Price).
commission_cost_calc_method_cd |
Display Value | Status |
|---|---|---|
| 211 | Multiplier | Confirmed — stored on production pages, and code_p21 211 is Multiplier |
| 212 | Difference | Not confirmed — conflicts with code_p21, where 212 is Item |
| 213 | Mark Up | Not confirmed — conflicts with code_p21, where 213 is Supplier / Discount Group |
| 214 | Percentage | Not confirmed — conflicts with code_p21, where 214 is Supplier / Product Group |
⚠️ The 212–214 rows are unverified and probably wrong. They are not page types (see Price Page Type), but they reuse page-type numbers, and
code_p21gives eachcode_noone description. The verified calc-method codes elsewhere areDifference=228,Mark up=229 andPercentage=230 (03 § Labels vs What the Database Stores). Before you interpret a storedcommission_cost_calc_method_cdother than 211, join it tocode_p21. Writes are unaffected: you send the display value. These rows came from the page-type table, where an earlier version of this page listed212as Difference; they were moved here because a calculation method is where Difference belongs, not because the codes were confirmed. Stored data cannot settle them: on play 26.1.5950.0 (2026-10-10) everyprice_pageandjob_price_linerow stores211.
Discovery Method
The fastest source is the Transaction definition, not the window.
GET /api/v2/definition/SalesPricePagereturns each code field's accepted display labels inValidValues— exactly the strings you send underUseCodeValues: false— even if you intend to drive the window interactively. The Interactive window state does not carry them: its field metadata is onlyName,Label,DataTypeandEnabled(verified 26.1.5910.3). Confirmed live from the definition:
Field Accepted display values price_page_type_cdItem · Supplier / Discount Group · Supplier / Product Group · Supplier / Manufacturing Class · Supplier · Discount Group · Product Group · Customer Part Number · Price Family · Supplier/Price Family pricing_method_cdSource · Price · None source_price_cdPrice 1–10 · Supplier List Price · Primary Supplier Cost · Standard Cost · Average Cost · Last Received PO Cost · Next Due in PO Cost · Other Cost · Strategic List Price · Strategic Cost calculation_method_cdDifference · Multiplier · Mark up · Percentage · Fixed Price All four are typed
Long— you send the label, not the number, whenUseCodeValuesisfalse. Your environment's list may differ; re-run the definition call against your own tenant rather than trusting this table.
The numeric codes below were discovered by:
- Opening the SalesPricePage window via Interactive API
- Setting each dropdown to different display values
- Reading the resulting code from window state
- Verifying against live database records
"""Discovery: set a dropdown by display value, then read the window state back."""
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
PRICE_PAGE_UID = "100011" # an existing page to probe
DISPLAY_VALUE = "Mark Up" # the dropdown label under test
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
def change(tab: str, datawindow: str, field: str, value: str) -> None:
"""One field per call -- a batched /v2/change is non-atomic on 2026.1."""
r = client.put(
f"{ui_server}/api/ui/interactive/v2/change",
headers=headers,
json={
"WindowId": window_id,
"List": [{
"TabName": tab,
"DatawindowName": datawindow, # required since 25.2
"FieldName": field,
"Value": value,
}],
},
)
r.raise_for_status()
session = client.post(
f"{ui_server}/api/ui/interactive/sessions",
headers=headers,
json={"ResponseWindowHandlingEnabled": False},
)
session.raise_for_status()
opened = client.post(
f"{ui_server}/api/ui/interactive/v2/window",
headers=headers,
json={"ServiceName": "SalesPricePage"},
)
opened.raise_for_status()
window_id = opened.json()["WindowId"]
try:
# Load a specific price page, then move to the tab that owns the field
change("FORM", "form", "price_page_uid", PRICE_PAGE_UID)
tab = client.put(
f"{ui_server}/api/ui/interactive/v2/tab",
headers=headers,
json={"WindowId": window_id, "PageName": "VALUES"},
)
tab.raise_for_status()
# Try setting a display value
change("VALUES", "values", "calculation_method_cd", DISPLAY_VALUE)
# Read the window state back and find the code the label resolved to
state = client.get(
f"{ui_server}/api/ui/interactive/v2/data",
params={"id": window_id},
headers=headers,
)
state.raise_for_status()
# Shape varies by window -- dump it and locate calculation_method_cd
print(json.dumps(state.json(), indent=2)[:3000])
finally:
client.delete(
f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id},
headers=headers,
)
client.delete(f"{ui_server}/api/ui/interactive/sessions", headers=headers)
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 PricePageUid = "100011"; // an existing page to probe
const string DisplayValue = "Mark Up"; // the dropdown label under test
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var sessionResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/sessions",
Json(new { ResponseWindowHandlingEnabled = false }));
sessionResponse.EnsureSuccessStatusCode();
var openResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/v2/window",
Json(new { ServiceName = "SalesPricePage" }));
openResponse.EnsureSuccessStatusCode();
using var opened = JsonDocument.Parse(await openResponse.Content.ReadAsStringAsync());
var windowId = opened.RootElement.GetProperty("WindowId").GetString()!;
// One field per call -- a batched /v2/change is non-atomic on 2026.1.
async Task ChangeAsync(string tab, string datawindow, string field, string value)
{
var response = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/change",
Json(new
{
WindowId = windowId,
List = new[]
{
new
{
TabName = tab,
DatawindowName = datawindow, // required since 25.2
FieldName = field,
Value = value,
}
}
}));
response.EnsureSuccessStatusCode();
}
try
{
// Load a specific price page, then move to the tab that owns the field
await ChangeAsync("FORM", "form", "price_page_uid", PricePageUid);
var tabResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/tab",
Json(new { WindowId = windowId, PageName = "VALUES" }));
tabResponse.EnsureSuccessStatusCode();
// Try setting a display value
await ChangeAsync("VALUES", "values", "calculation_method_cd", DisplayValue);
// Read the window state back and find the code the label resolved to
var stateResponse = await client.GetAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={windowId}");
stateResponse.EnsureSuccessStatusCode();
// Shape varies by window -- dump it and locate calculation_method_cd
var raw = await stateResponse.Content.ReadAsStringAsync();
Console.WriteLine(raw.Length > 3000 ? raw[..3000] : raw);
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions");
}
// --- helpers ---------------------------------------------------------------
static StringContent Json(object payload) =>
new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}
Price Breaks (Quantity-Based Pricing)
Price pages support up to 15 calculation values and 14 break quantities for quantity-based pricing.
Fields
| Field | Type | Label | Purpose |
|---|---|---|---|
calculation_value1 - calculation_value15 |
Decimal | - | Price or calculation value for each tier; tier 1 applies to quantity 1+ |
break1 - break14 |
Decimal | - | Quantity thresholds for tiers 2 through 15 |
uom1 - uom14 |
Char | UOM | Per-tier unit of measure for tiers 1 through 14 |
other_cost1 - other_cost15 |
Decimal | - | Per-tier other cost value |
calculation_method_cd |
Long | - | Calculation method |
values_currency_id |
Long | Calculation Currency ID | Calculation currency |
The VALUES tab repeats calculation_value{n}, break{n}, uom{n}, and
other_cost{n} for each tier. Tier 15 has calculation_value15 and other_cost15, but
does not have break15 or uom15.
Cross-Service Break-Field Names
The same tiered-price concept uses different field names across services. Sending the wrong spelling does not necessarily error; the tier can silently fail to land.
| Service | Tier 1 field | Tiers 2..n | Breaks | Per-tier UOM |
|---|---|---|---|---|
SalesPricePage |
calculation_value1 |
calculation_value2-calculation_value15 |
break1-break14 |
uom1-uom14 |
JobContractPricing |
calculation_value1 |
calculation_value2-calculation_value15 |
break1-break14 |
none |
SalesPriceBook (LIST.list_detail, read-back summary) |
calculation_value1 |
calculation_value2 |
break1 |
none |
PurchasePricingPageSupplier / ...Item / ...DiscGrp |
value1 |
value2-value15 |
break1-break14 |
uom1-uom14 |
On the purchase-side services, the API field is value{n}, while the underlying database
column is purchase_pricing_page.Calculation_Value{n}. The database column name and API
field name therefore disagree, and neither matches the field names used by the other three
services above.
Example: Setting Up Price Breaks
"""Set quantity-based price break tiers on an existing price page."""
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
PRICE_PAGE_UID = "100011" # the page whose tiers you are setting
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
def change(tab: str, datawindow: str, field: str, value: str) -> None:
"""One field per call -- a batched /v2/change is non-atomic on 2026.1."""
r = client.put(
f"{ui_server}/api/ui/interactive/v2/change",
headers=headers,
json={
"WindowId": window_id,
"List": [{
"TabName": tab,
"DatawindowName": datawindow, # required since 25.2
"FieldName": field,
"Value": value,
}],
},
)
r.raise_for_status()
session = client.post(
f"{ui_server}/api/ui/interactive/sessions",
headers=headers,
json={"ResponseWindowHandlingEnabled": False},
)
session.raise_for_status()
opened = client.post(
f"{ui_server}/api/ui/interactive/v2/window",
headers=headers,
json={"ServiceName": "SalesPricePage"},
)
opened.raise_for_status()
window_id = opened.json()["WindowId"]
try:
change("FORM", "form", "price_page_uid", PRICE_PAGE_UID)
tab = client.put(
f"{ui_server}/api/ui/interactive/v2/tab",
headers=headers,
json={"WindowId": window_id, "PageName": "VALUES"},
)
tab.raise_for_status()
# Base multiplier: 0.85 for qty 1+
change("VALUES", "values", "calculation_value1", "0.85")
# Price break at qty 6: 0.82 multiplier
change("VALUES", "values", "break1", "6")
change("VALUES", "values", "calculation_value2", "0.82")
# Price break at qty 25: 0.78 multiplier
change("VALUES", "values", "break2", "25")
change("VALUES", "values", "calculation_value3", "0.78")
# Price break at qty 100: 0.75 multiplier
change("VALUES", "values", "break3", "100")
change("VALUES", "values", "calculation_value4", "0.75")
# Save -- v2 takes the bare WindowId string as the JSON body
saved = client.put(
f"{ui_server}/api/ui/interactive/v2/data",
headers=headers,
content=json.dumps(window_id),
)
saved.raise_for_status()
result = saved.json()
# ResultStatus: None=0, Success=1, Failure=2, Blocked=3
print("Save status:", result.get("Status"))
for message in result.get("Messages") or []:
print(" Message:", message)
# ---- read-back: the only proof the tiers landed ---------------------
read_back = client.get(
f"{ui_server}/api/ui/interactive/v2/data",
params={"id": window_id},
headers=headers,
)
read_back.raise_for_status()
print(json.dumps(read_back.json())[:2000])
finally:
client.delete(
f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id},
headers=headers,
)
client.delete(f"{ui_server}/api/ui/interactive/sessions", headers=headers)
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 PricePageUid = "100011"; // the page whose tiers you are setting
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var sessionResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/sessions",
Json(new { ResponseWindowHandlingEnabled = false }));
sessionResponse.EnsureSuccessStatusCode();
var openResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/v2/window",
Json(new { ServiceName = "SalesPricePage" }));
openResponse.EnsureSuccessStatusCode();
using var opened = JsonDocument.Parse(await openResponse.Content.ReadAsStringAsync());
var windowId = opened.RootElement.GetProperty("WindowId").GetString()!;
// One field per call -- a batched /v2/change is non-atomic on 2026.1.
async Task ChangeAsync(string tab, string datawindow, string field, string value)
{
var response = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/change",
Json(new
{
WindowId = windowId,
List = new[]
{
new
{
TabName = tab,
DatawindowName = datawindow, // required since 25.2
FieldName = field,
Value = value,
}
}
}));
response.EnsureSuccessStatusCode();
}
try
{
await ChangeAsync("FORM", "form", "price_page_uid", PricePageUid);
var tabResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/tab",
Json(new { WindowId = windowId, PageName = "VALUES" }));
tabResponse.EnsureSuccessStatusCode();
// Base multiplier: 0.85 for qty 1+
await ChangeAsync("VALUES", "values", "calculation_value1", "0.85");
// Price break at qty 6: 0.82 multiplier
await ChangeAsync("VALUES", "values", "break1", "6");
await ChangeAsync("VALUES", "values", "calculation_value2", "0.82");
// Price break at qty 25: 0.78 multiplier
await ChangeAsync("VALUES", "values", "break2", "25");
await ChangeAsync("VALUES", "values", "calculation_value3", "0.78");
// Price break at qty 100: 0.75 multiplier
await ChangeAsync("VALUES", "values", "break3", "100");
await ChangeAsync("VALUES", "values", "calculation_value4", "0.75");
// Save -- v2 takes the bare WindowId string as the JSON body
var saveResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/data", Json(windowId));
saveResponse.EnsureSuccessStatusCode();
using var result = JsonDocument.Parse(await saveResponse.Content.ReadAsStringAsync());
// ResultStatus: None=0, Success=1, Failure=2, Blocked=3
if (result.RootElement.TryGetProperty("Status", out var status))
Console.WriteLine($"Save status: {status}");
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
foreach (var message in messages.EnumerateArray())
Console.WriteLine($" Message: {message}");
}
// ---- read-back: the only proof the tiers landed ------------------------
var readBackResponse = await client.GetAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={windowId}");
readBackResponse.EnsureSuccessStatusCode();
var raw = await readBackResponse.Content.ReadAsStringAsync();
Console.WriteLine(raw.Length > 2000 ? raw[..2000] : raw);
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions");
}
// --- helpers ---------------------------------------------------------------
static StringContent Json(object payload) =>
new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}
Mapping
| Quantity Range | Calculation Value |
|---|---|
| 1 to (break1-1) | calculation_value1 |
| break1 to (break2-1) | calculation_value2 |
| break2 to (break3-1) | calculation_value3 |
| ... | ... |
Notes
calculation_value1is always the base price (qty 1+)break1corresponds tocalculation_value2(notcalculation_value1)- Unused break/value fields should be 0 or null
- Maximum 14 break points (15 price levels including base)
Discount Group Pages (Type 213)
Discount group pages use discount_group_id instead of product_group_id. The field order differs from the standard product group page creation sequence.
Field Order for Type 213
price_page_type_cd- Set to "Supplier / Discount Group"company_id- Required before discount_group_iddiscount_group_id- Replacesproduct_group_idsupplier_iddescriptionpricing_method_cd- "Source"source_price_cd- "Supplier List Price"effective_date/expiration_date- Switch to VALUES tab
calculation_method_cdand values
Example: Creating a Discount Group Page
"""Create a type-213 (Supplier / Discount Group) price page."""
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
COMPANY_ID = "ACME"
DISCOUNT_GROUP_ID = "DG001" # replaces product_group_id on type 213
SUPPLIER_ID = "10050"
DESCRIPTION = "P2-L5-10050-DG001-WHOLESALE"
EFFECTIVE_DATE = "2025-01-01"
EXPIRATION_DATE = "2030-12-31"
MULTIPLIER = "0.85" # calculation_value1 -- tier 1, qty 1+
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
def change(tab: str, datawindow: str, field: str, value: str) -> None:
"""One field per call -- a batched /v2/change is non-atomic on 2026.1."""
r = client.put(
f"{ui_server}/api/ui/interactive/v2/change",
headers=headers,
json={
"WindowId": window_id,
"List": [{
"TabName": tab,
"DatawindowName": datawindow, # required since 25.2
"FieldName": field,
"Value": value,
}],
},
)
r.raise_for_status()
session = client.post(
f"{ui_server}/api/ui/interactive/sessions",
headers=headers,
json={"ResponseWindowHandlingEnabled": False},
)
session.raise_for_status()
opened = client.post(
f"{ui_server}/api/ui/interactive/v2/window",
headers=headers,
json={"ServiceName": "SalesPricePage"},
)
opened.raise_for_status()
window_id = opened.json()["WindowId"]
try:
# Step 1: page type to Discount Group
change("FORM", "form", "price_page_type_cd", "Supplier / Discount Group")
# Step 2: company_id first
change("FORM", "form", "company_id", COMPANY_ID)
# Step 3: discount group (NOT product_group_id)
change("FORM", "form", "discount_group_id", DISCOUNT_GROUP_ID)
# Step 4: supplier
change("FORM", "form", "supplier_id", SUPPLIER_ID)
# Steps 5-8: same as a product group page
change("FORM", "form", "description", DESCRIPTION)
change("FORM", "form", "pricing_method_cd", "Source")
change("FORM", "form", "source_price_cd", "Supplier List Price")
change("FORM", "form", "effective_date", EFFECTIVE_DATE)
change("FORM", "form", "expiration_date", EXPIRATION_DATE)
# Step 9: switch to the VALUES tab (2026.1 binds PageName, not TabName)
tab = client.put(
f"{ui_server}/api/ui/interactive/v2/tab",
headers=headers,
json={"WindowId": window_id, "PageName": "VALUES"},
)
tab.raise_for_status()
# Step 10: calculation method and values
change("VALUES", "values", "calculation_method_cd", "Multiplier")
change("VALUES", "values", "calculation_value1", MULTIPLIER)
# Save -- v2 takes the bare WindowId string as the JSON body
saved = client.put(
f"{ui_server}/api/ui/interactive/v2/data",
headers=headers,
content=json.dumps(window_id),
)
saved.raise_for_status()
result = saved.json()
# ResultStatus: None=0, Success=1, Failure=2, Blocked=3
print("Save status:", result.get("Status"))
for message in result.get("Messages") or []:
print(" Message:", message)
# ---- read-back: the only proof the page landed ----------------------
read_back = client.get(
f"{ui_server}/api/ui/interactive/v2/data",
params={"id": window_id},
headers=headers,
)
read_back.raise_for_status()
print(json.dumps(read_back.json())[:2000])
finally:
client.delete(
f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id},
headers=headers,
)
client.delete(f"{ui_server}/api/ui/interactive/sessions", headers=headers)
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 CompanyId = "ACME";
const string DiscountGroupId = "DG001"; // replaces product_group_id on type 213
const string SupplierId = "10050";
const string Description = "P2-L5-10050-DG001-WHOLESALE";
const string EffectiveDate = "2025-01-01";
const string ExpirationDate = "2030-12-31";
const string Multiplier = "0.85"; // calculation_value1 -- tier 1, qty 1+
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var sessionResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/sessions",
Json(new { ResponseWindowHandlingEnabled = false }));
sessionResponse.EnsureSuccessStatusCode();
var openResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/v2/window",
Json(new { ServiceName = "SalesPricePage" }));
openResponse.EnsureSuccessStatusCode();
using var opened = JsonDocument.Parse(await openResponse.Content.ReadAsStringAsync());
var windowId = opened.RootElement.GetProperty("WindowId").GetString()!;
// One field per call -- a batched /v2/change is non-atomic on 2026.1.
async Task ChangeAsync(string tab, string datawindow, string field, string value)
{
var response = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/change",
Json(new
{
WindowId = windowId,
List = new[]
{
new
{
TabName = tab,
DatawindowName = datawindow, // required since 25.2
FieldName = field,
Value = value,
}
}
}));
response.EnsureSuccessStatusCode();
}
try
{
// Step 1: page type to Discount Group
await ChangeAsync("FORM", "form", "price_page_type_cd", "Supplier / Discount Group");
// Step 2: company_id first
await ChangeAsync("FORM", "form", "company_id", CompanyId);
// Step 3: discount group (NOT product_group_id)
await ChangeAsync("FORM", "form", "discount_group_id", DiscountGroupId);
// Step 4: supplier
await ChangeAsync("FORM", "form", "supplier_id", SupplierId);
// Steps 5-8: same as a product group page
await ChangeAsync("FORM", "form", "description", Description);
await ChangeAsync("FORM", "form", "pricing_method_cd", "Source");
await ChangeAsync("FORM", "form", "source_price_cd", "Supplier List Price");
await ChangeAsync("FORM", "form", "effective_date", EffectiveDate);
await ChangeAsync("FORM", "form", "expiration_date", ExpirationDate);
// Step 9: switch to the VALUES tab (2026.1 binds PageName, not TabName)
var tabResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/tab",
Json(new { WindowId = windowId, PageName = "VALUES" }));
tabResponse.EnsureSuccessStatusCode();
// Step 10: calculation method and values
await ChangeAsync("VALUES", "values", "calculation_method_cd", "Multiplier");
await ChangeAsync("VALUES", "values", "calculation_value1", Multiplier);
// Save -- v2 takes the bare WindowId string as the JSON body
var saveResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/data", Json(windowId));
saveResponse.EnsureSuccessStatusCode();
using var result = JsonDocument.Parse(await saveResponse.Content.ReadAsStringAsync());
// ResultStatus: None=0, Success=1, Failure=2, Blocked=3
if (result.RootElement.TryGetProperty("Status", out var status))
Console.WriteLine($"Save status: {status}");
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
foreach (var message in messages.EnumerateArray())
Console.WriteLine($" Message: {message}");
}
// ---- read-back: the only proof the page landed -------------------------
var readBackResponse = await client.GetAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={windowId}");
readBackResponse.EnsureSuccessStatusCode();
var raw = await readBackResponse.Content.ReadAsStringAsync();
Console.WriteLine(raw.Length > 2000 ? raw[..2000] : raw);
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions");
}
// --- helpers ---------------------------------------------------------------
static StringContent Json(object payload) =>
new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}
OData Queries for Both Page Types
When querying price pages, check both product_group_id and discount_group_id:
"""List every active price page for one supplier -- product group and discount group."""
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
SUPPLIER_ID = "10050" # numeric -- no quotes in the filter
# ---------------------------------------------------------------------------
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",
}
# Get ALL active pages for a supplier (both types)
params = {
"$filter": (
f"supplier_id eq {SUPPLIER_ID} "
f"and row_status_flag eq 704 "
f"and (product_group_id ne null or discount_group_id ne null)"
),
"$select": (
"price_page_uid,description,price_page_type_cd,"
"product_group_id,discount_group_id,supplier_id"
),
}
# OData goes to BASE_URL directly -- no UI-server routing
response = client.get(
f"{BASE_URL}/odataservice/odata/table/price_page",
params=params, # httpx URL-encodes the query string
headers=headers,
)
response.raise_for_status()
rows = response.json()["value"]
print(f"{len(rows)} active pages for supplier {SUPPLIER_ID}")
for row in rows:
print(f" {row.get('price_page_uid')}"
f" type={row.get('price_page_type_cd')}"
f" pg={row.get('product_group_id')}"
f" dg={row.get('discount_group_id')}"
f" {row.get('description')}")
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 SupplierId = "10050"; // numeric -- no quotes in the filter
// ---------------------------------------------------------------------------
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);
// Get ALL active pages for a supplier (both types)
var filter = $"supplier_id eq {SupplierId} " +
"and row_status_flag eq 704 " +
"and (product_group_id ne null or discount_group_id ne null)";
var select = "price_page_uid,description,price_page_type_cd," +
"product_group_id,discount_group_id,supplier_id";
// OData goes to BaseUrl directly -- no UI-server routing
var queryUrl = $"{BaseUrl}/odataservice/odata/table/price_page" +
$"?$filter={Uri.EscapeDataString(filter)}&$select={Uri.EscapeDataString(select)}";
var response = await client.GetAsync(queryUrl);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var rows = document.RootElement.GetProperty("value");
Console.WriteLine($"{rows.GetArrayLength()} active pages for supplier {SupplierId}");
foreach (var row in rows.EnumerateArray())
{
Console.WriteLine(
$" {Field(row, "price_page_uid")}" +
$" type={Field(row, "price_page_type_cd")}" +
$" pg={Field(row, "product_group_id")}" +
$" dg={Field(row, "discount_group_id")}" +
$" {Field(row, "description")}");
}
// --- helpers ---------------------------------------------------------------
static string Field(JsonElement row, string name) =>
row.TryGetProperty(name, out var value) ? value.ToString() : "";
// 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 the token 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;
}
Dollar-Based Price Breaks
In addition to quantity-based breaks (documented above), price pages support dollar-based breaks where break values represent total order value rather than unit quantities.
Configuration Fields
Dollar-based breaks require additional fields on the VALUES tab:
| Field | Display Value | Code | Purpose |
|---|---|---|---|
totaling_method_cd |
Discount Group | 217 | Group items by discount group for break calculation |
totaling_basis_cd |
Supplier List Price | 200 | Use supplier list price as the dollar total basis |
Example: Dollar-Based Breaks
"""Configure dollar-based (order-value) price breaks on an existing price page."""
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
PRICE_PAGE_UID = "100011" # the page whose tiers you are setting
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
def change(tab: str, datawindow: str, field: str, value: str) -> None:
"""One field per call -- a batched /v2/change is non-atomic on 2026.1."""
r = client.put(
f"{ui_server}/api/ui/interactive/v2/change",
headers=headers,
json={
"WindowId": window_id,
"List": [{
"TabName": tab,
"DatawindowName": datawindow, # required since 25.2
"FieldName": field,
"Value": value,
}],
},
)
r.raise_for_status()
session = client.post(
f"{ui_server}/api/ui/interactive/sessions",
headers=headers,
json={"ResponseWindowHandlingEnabled": False},
)
session.raise_for_status()
opened = client.post(
f"{ui_server}/api/ui/interactive/v2/window",
headers=headers,
json={"ServiceName": "SalesPricePage"},
)
opened.raise_for_status()
window_id = opened.json()["WindowId"]
try:
change("FORM", "form", "price_page_uid", PRICE_PAGE_UID)
# Switch to VALUES tab
tab = client.put(
f"{ui_server}/api/ui/interactive/v2/tab",
headers=headers,
json={"WindowId": window_id, "PageName": "VALUES"},
)
tab.raise_for_status()
# Set calculation method
change("VALUES", "values", "calculation_method_cd", "Multiplier")
# Configure dollar-based totaling
change("VALUES", "values", "totaling_method_cd", "Discount Group")
change("VALUES", "values", "totaling_basis_cd", "Supplier List Price")
# Set dollar-based breaks (total order value thresholds)
# $0-$4,999: 0.85 multiplier
change("VALUES", "values", "calculation_value1", "0.85")
# $5,000-$9,999: 0.82 multiplier
change("VALUES", "values", "break1", "5000")
change("VALUES", "values", "calculation_value2", "0.82")
# $10,000-$14,999: 0.78 multiplier
change("VALUES", "values", "break2", "10000")
change("VALUES", "values", "calculation_value3", "0.78")
# $15,000-$19,999: 0.75 multiplier
change("VALUES", "values", "break3", "15000")
change("VALUES", "values", "calculation_value4", "0.75")
# $20,000+: 0.72 multiplier
change("VALUES", "values", "break4", "10050")
change("VALUES", "values", "calculation_value5", "0.72")
# Save -- v2 takes the bare WindowId string as the JSON body
saved = client.put(
f"{ui_server}/api/ui/interactive/v2/data",
headers=headers,
content=json.dumps(window_id),
)
saved.raise_for_status()
result = saved.json()
# ResultStatus: None=0, Success=1, Failure=2, Blocked=3
print("Save status:", result.get("Status"))
for message in result.get("Messages") or []:
print(" Message:", message)
# ---- read-back: the only proof the tiers landed ---------------------
read_back = client.get(
f"{ui_server}/api/ui/interactive/v2/data",
params={"id": window_id},
headers=headers,
)
read_back.raise_for_status()
print(json.dumps(read_back.json())[:2000])
finally:
client.delete(
f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id},
headers=headers,
)
client.delete(f"{ui_server}/api/ui/interactive/sessions", headers=headers)
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 PricePageUid = "100011"; // the page whose tiers you are setting
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var sessionResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/sessions",
Json(new { ResponseWindowHandlingEnabled = false }));
sessionResponse.EnsureSuccessStatusCode();
var openResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/v2/window",
Json(new { ServiceName = "SalesPricePage" }));
openResponse.EnsureSuccessStatusCode();
using var opened = JsonDocument.Parse(await openResponse.Content.ReadAsStringAsync());
var windowId = opened.RootElement.GetProperty("WindowId").GetString()!;
// One field per call -- a batched /v2/change is non-atomic on 2026.1.
async Task ChangeAsync(string tab, string datawindow, string field, string value)
{
var response = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/change",
Json(new
{
WindowId = windowId,
List = new[]
{
new
{
TabName = tab,
DatawindowName = datawindow, // required since 25.2
FieldName = field,
Value = value,
}
}
}));
response.EnsureSuccessStatusCode();
}
try
{
await ChangeAsync("FORM", "form", "price_page_uid", PricePageUid);
// Switch to VALUES tab
var tabResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/tab",
Json(new { WindowId = windowId, PageName = "VALUES" }));
tabResponse.EnsureSuccessStatusCode();
// Set calculation method
await ChangeAsync("VALUES", "values", "calculation_method_cd", "Multiplier");
// Configure dollar-based totaling
await ChangeAsync("VALUES", "values", "totaling_method_cd", "Discount Group");
await ChangeAsync("VALUES", "values", "totaling_basis_cd", "Supplier List Price");
// Set dollar-based breaks (total order value thresholds)
// $0-$4,999: 0.85 multiplier
await ChangeAsync("VALUES", "values", "calculation_value1", "0.85");
// $5,000-$9,999: 0.82 multiplier
await ChangeAsync("VALUES", "values", "break1", "5000");
await ChangeAsync("VALUES", "values", "calculation_value2", "0.82");
// $10,000-$14,999: 0.78 multiplier
await ChangeAsync("VALUES", "values", "break2", "10000");
await ChangeAsync("VALUES", "values", "calculation_value3", "0.78");
// $15,000-$19,999: 0.75 multiplier
await ChangeAsync("VALUES", "values", "break3", "15000");
await ChangeAsync("VALUES", "values", "calculation_value4", "0.75");
// $20,000+: 0.72 multiplier
await ChangeAsync("VALUES", "values", "break4", "10050");
await ChangeAsync("VALUES", "values", "calculation_value5", "0.72");
// Save -- v2 takes the bare WindowId string as the JSON body
var saveResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/data", Json(windowId));
saveResponse.EnsureSuccessStatusCode();
using var result = JsonDocument.Parse(await saveResponse.Content.ReadAsStringAsync());
// ResultStatus: None=0, Success=1, Failure=2, Blocked=3
if (result.RootElement.TryGetProperty("Status", out var status))
Console.WriteLine($"Save status: {status}");
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
foreach (var message in messages.EnumerateArray())
Console.WriteLine($" Message: {message}");
}
// ---- read-back: the only proof the tiers landed ------------------------
var readBackResponse = await client.GetAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={windowId}");
readBackResponse.EnsureSuccessStatusCode();
var raw = await readBackResponse.Content.ReadAsStringAsync();
Console.WriteLine(raw.Length > 2000 ? raw[..2000] : raw);
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions");
}
// --- helpers ---------------------------------------------------------------
static StringContent Json(object payload) =>
new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}
Quantity vs Dollar Breaks Comparison
| Aspect | Quantity Breaks | Dollar Breaks |
|---|---|---|
| Break values represent | Unit quantities | Total order value ($) |
totaling_method_cd |
Not set (default) | "Discount Group" (217) |
totaling_basis_cd |
Not set (default) | "Supplier List Price" (200) |
| Typical use case | Volume discounts per SKU | Order-level discounts |
| Break example | 6 units, 25 units, 100 units | $5K, $10K, $15K, $20K |
COSTS Tab Configuration
The COSTS tab is a separate tab on the SalesPricePage window that controls commission cost calculation. This determines how cost is calculated for commission purposes, separate from the selling price configured on the VALUES tab.
Accessing the COSTS Tab
"""Set commission cost calculation on the COSTS tab of an existing price page."""
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
PRICE_PAGE_UID = "100011" # the page whose COSTS tab you are setting
COMMISSION_COST_VALUE = "1.01" # 1.01 = pass-through costing, 1% margin
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
def change(tab: str, datawindow: str, field: str, value: str) -> None:
"""One field per call -- a batched /v2/change is non-atomic on 2026.1."""
r = client.put(
f"{ui_server}/api/ui/interactive/v2/change",
headers=headers,
json={
"WindowId": window_id,
"List": [{
"TabName": tab,
"DatawindowName": datawindow, # required since 25.2
"FieldName": field,
"Value": value,
}],
},
)
r.raise_for_status()
session = client.post(
f"{ui_server}/api/ui/interactive/sessions",
headers=headers,
json={"ResponseWindowHandlingEnabled": False},
)
session.raise_for_status()
opened = client.post(
f"{ui_server}/api/ui/interactive/v2/window",
headers=headers,
json={"ServiceName": "SalesPricePage"},
)
opened.raise_for_status()
window_id = opened.json()["WindowId"]
try:
change("FORM", "form", "price_page_uid", PRICE_PAGE_UID)
# Switch to COSTS tab
tab = client.put(
f"{ui_server}/api/ui/interactive/v2/tab",
headers=headers,
json={"WindowId": window_id, "PageName": "COSTS"},
)
tab.raise_for_status()
# Set commission cost calculation method -- COSTS codes differ from VALUES
change("COSTS", "costs", "commission_cost_calc_method_cd", "Multiplier")
# Set commission cost value (1.01 = pass-through costing with 1% margin)
change("COSTS", "costs", "commission_cost_value1", COMMISSION_COST_VALUE)
# Save -- v2 takes the bare WindowId string as the JSON body
saved = client.put(
f"{ui_server}/api/ui/interactive/v2/data",
headers=headers,
content=json.dumps(window_id),
)
saved.raise_for_status()
result = saved.json()
# ResultStatus: None=0, Success=1, Failure=2, Blocked=3
print("Save status:", result.get("Status"))
for message in result.get("Messages") or []:
print(" Message:", message)
# ---- read-back: the only proof the COSTS values landed --------------
read_back = client.get(
f"{ui_server}/api/ui/interactive/v2/data",
params={"id": window_id},
headers=headers,
)
read_back.raise_for_status()
print(json.dumps(read_back.json())[:2000])
finally:
client.delete(
f"{ui_server}/api/ui/interactive/v2/window",
params={"id": window_id},
headers=headers,
)
client.delete(f"{ui_server}/api/ui/interactive/sessions", headers=headers)
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 PricePageUid = "100011"; // the page whose COSTS tab you are setting
const string CommissionCostValue = "1.01"; // 1.01 = pass-through costing, 1% margin
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var sessionResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/sessions",
Json(new { ResponseWindowHandlingEnabled = false }));
sessionResponse.EnsureSuccessStatusCode();
var openResponse = await client.PostAsync(
$"{uiServer}/api/ui/interactive/v2/window",
Json(new { ServiceName = "SalesPricePage" }));
openResponse.EnsureSuccessStatusCode();
using var opened = JsonDocument.Parse(await openResponse.Content.ReadAsStringAsync());
var windowId = opened.RootElement.GetProperty("WindowId").GetString()!;
// One field per call -- a batched /v2/change is non-atomic on 2026.1.
async Task ChangeAsync(string tab, string datawindow, string field, string value)
{
var response = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/change",
Json(new
{
WindowId = windowId,
List = new[]
{
new
{
TabName = tab,
DatawindowName = datawindow, // required since 25.2
FieldName = field,
Value = value,
}
}
}));
response.EnsureSuccessStatusCode();
}
try
{
await ChangeAsync("FORM", "form", "price_page_uid", PricePageUid);
// Switch to COSTS tab
var tabResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/tab",
Json(new { WindowId = windowId, PageName = "COSTS" }));
tabResponse.EnsureSuccessStatusCode();
// Set commission cost calculation method -- COSTS codes differ from VALUES
await ChangeAsync("COSTS", "costs", "commission_cost_calc_method_cd", "Multiplier");
// Set commission cost value (1.01 = pass-through costing with 1% margin)
await ChangeAsync("COSTS", "costs", "commission_cost_value1", CommissionCostValue);
// Save -- v2 takes the bare WindowId string as the JSON body
var saveResponse = await client.PutAsync(
$"{uiServer}/api/ui/interactive/v2/data", Json(windowId));
saveResponse.EnsureSuccessStatusCode();
using var result = JsonDocument.Parse(await saveResponse.Content.ReadAsStringAsync());
// ResultStatus: None=0, Success=1, Failure=2, Blocked=3
if (result.RootElement.TryGetProperty("Status", out var status))
Console.WriteLine($"Save status: {status}");
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
foreach (var message in messages.EnumerateArray())
Console.WriteLine($" Message: {message}");
}
// ---- read-back: the only proof the COSTS values landed -----------------
var readBackResponse = await client.GetAsync(
$"{uiServer}/api/ui/interactive/v2/data?id={windowId}");
readBackResponse.EnsureSuccessStatusCode();
var raw = await readBackResponse.Content.ReadAsStringAsync();
Console.WriteLine(raw.Length > 2000 ? raw[..2000] : raw);
}
finally
{
await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");
await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions");
}
// --- helpers ---------------------------------------------------------------
static StringContent Json(object payload) =>
new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
// 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}
Common Pattern: Pass-Through Costing
For pages where commission cost should simply pass through at cost:
Fragment -- the pattern is just the two COSTS fields below, set to a
Multiplierof1.01. Full runnable version: Accessing the COSTS Tab.
change("COSTS", "costs", "commission_cost_calc_method_cd", "Multiplier")
change("COSTS", "costs", "commission_cost_value1", "1.01")
await ChangeAsync("COSTS", "costs", "commission_cost_calc_method_cd", "Multiplier");
await ChangeAsync("COSTS", "costs", "commission_cost_value1", "1.01");
Commission Cost Codes vs VALUES Tab Codes
The COSTS tab's commission_cost_calc_method_cd accepts four of the VALUES tab's five display values — there is no Fixed Price. You send the display value on both tabs, so the stored code does not change what you write.
| Tab · column | Code | Display Value |
|---|---|---|
VALUES · calculation_method_cd |
211 | Multiplier |
VALUES · calculation_method_cd |
228 | Difference |
VALUES · calculation_method_cd |
229 | Mark Up |
VALUES · calculation_method_cd |
230 | Percentage |
VALUES · calculation_method_cd |
1292 | Fixed Price |
COSTS · commission_cost_calc_method_cd |
211 | Multiplier |
COSTS · commission_cost_calc_method_cd |
212–214 (published earlier, not confirmed) | Difference · Mark Up · Percentage |
Warning: An earlier version of this table said "Difference is 228 on VALUES but 212 on COSTS."
code_p21gives 212 to the page type Item, so treat the COSTS-tab 212–214 codes as unverified — see Commission Cost Calculation Method.
Purchase-Side Pricing Services
The five-service picture includes sales price pages, sales price books, job contracts, and three purchase page variants: supplier, item, and discount group. Each service has its own key set.
All three page variants share one shape: a header Form on TABPAGE_1.tp_1_dw_1 carrying the keys, and an unkeyed values Form on TABPAGE_2.tp_2_dw_2 carrying calculation_type plus value1-value15, break1-break14, and uom1-uom14. They differ only in which field the key set adds, and in the values datawindow name.
The tier structure is identical to a sales price page — 15 value slots, 14 break thresholds, and a 15th catch-all tier with no break. Only the field names differ (value{n} here, calculation_value{n} there). The DB columns disagree with both: value1 maps to purchase_pricing_page.Calculation_Value1, uom1 to purchase_pricing_page.UOM1.
Where to find them in the desktop client (from frame_menu.service_name, verified 2026-08-11):
| Service | P21 window | Window class |
|---|---|---|
PurchasePricingPageSupplier |
Page Maintenance By Supplier ID | m_bysupplierid |
PurchasePricingPageSupplierItem |
Page Maintenance By Supplier ID/Item ID | m_bysupplieriditemid |
PurchasePricingPageSupplierDiscGrp |
Page Maintenance By Supplier ID/Discount Group | m_bysupplieriddiscountgroup |
frame_menu is the window-to-service map generally — query it over OData to find the service name behind any window, or the window behind any service. A NULL service_name means no Transaction or Interactive surface — the window may still be drivable through the ui/full surface if its new_ui_enabled/angular_enabled flags say the web client can open it.
All three key on the same five fields — company_id, purchase_pricing_book_id, supplier_id, effective_date, expiration_date — and the item and discount-group variants add one more:
| Service | Datawindows (header / values) | Extra key |
|---|---|---|
PurchasePricingPageSupplier |
d_pur_source_price_supplier…_values |
— |
PurchasePricingPageSupplierItem |
d_pur_source_price_supplier_item…_item_values |
inv_mast_item_id |
PurchasePricingPageSupplierDiscGrp |
d_pur_source_price_supplier_disc…_disc_values |
discount_group_id |
The values datawindow is always the header's name with _values appended.
Header Fields (TABPAGE_1.tp_1_dw_1)
| Field | Notes |
|---|---|
company_id |
Key |
purchase_pricing_book_id |
Key. Maps to purchase_pricing_page.pricing_book_id |
supplier_id |
Key |
effective_date · expiration_date |
Keys — a page is identified by its date range, so re-pricing means a new page, not an edit |
location_id |
|
pricing_description · contract_number |
|
pricing_method · source_price · price |
|
totaling_basis · totaling_method |
|
major_group_id |
|
delete_flag |
Header and Link Services
These carry no break fields — they exist to name a book or tie pages to it.
| Service · DataElement | Datawindow | Keys |
|---|---|---|
PurchasePricingBookFORM.form |
d_dw_purchase_pricing_book_form |
company_id, purchase_pricing_book_id |
SupplierPricingTABPAGE_1.tp_1_dw_1 |
d_supplier_pricing_hdr |
company_id, supplier_id |
SupplierPricingTABPAGE_17.tp_17_dw_17 (List) |
d_supplier_pricing_detail |
purchase_price_library_id |
SalesPriceBookFORM.form |
d_dw_price_book_form |
price_book_id |
SalesPriceBookLIST.list_detail (List) |
d_dw_price_page_x_book_grid |
price_page_uid |
SalesPriceBook's LIST.list_detail exposes calculation_value1, calculation_value2, break1, and other_cost_value as a read-back summary of each page in the book — a convenient way to see a book's pages without opening each one.
Only one purchase-side write is verified. Everything above is read from the service definitions and confirmed by a live read in this repo. The
PurchasePricingPageSupplierItemcreate and in-place edit below are verified, andPurchasePricingBook·FORM.formcreated a book (company_id,purchase_pricing_book_id,purchase_pricing_book_desc) on play 26.1.5950.0. Treat the supplier and discount-group page services as unproven until you have tested them in your own environment.
Editing a Supplier/Item Purchase Page (PurchasePricingPageSupplierItem)
Creates and in-place edits on PurchasePricingPageSupplierItem have been verified at batch scale (a couple of hundred pages edited, no duplicates, no errors):
"Status": "Edit"is not a valid status — HTTP 400 enum error, like every status other thanNew. Edit in place withStatus: "New"and key values that exactly match the existing page; the window loads it and applies your edits.- A changed key creates a new page. The dates are keys. Send a different
effective_dateorexpiration_dateand you silently create a second page instead of editing the first. To re-date a page, expire the old one and create a new one. - Resend the full field set. The window re-validates required fields on every save — leave out
totaling_basisand the edit fails "This column is required." Read the current values, mirror them, and change only what you mean to change. - Send window labels, not database codes. The table stores
PRICE/ITEM/MULTIPLIER; the API wantsPrice/Item/Multiplier. Map them before sending. - A page is current only while
delete_flag = 'N'andexpiration_dateis today or later. Many stale pages are date-expired withdelete_flagstillN. location_idcan be stored whileall_locations_flagstaysY; a location-specific page overrides an all-locations page for that location.- Quantity-break pages are
pricing_methodNone+calculation_typeFixed Price, with tiers invalue{n}/break{n}.
Credit: Alex Westemeier — create and in-place edit verified on play, July 2026.
Re-run on play 26.1.5950.0 (October 2026) against a throwaway book and page. The create and the in-place price edit both worked as described, and Status: "Edit" was HTTP 400. Two results differ from the list above:
- Only
effective_datebehaved as an identity key. Changingeffective_datecreated a second page. Changing onlyexpiration_datere-dated the existing page in place (samepurchase_pricing_page_uid) instead of creating a new one. Send both dates exactly as stored either way — but don't rely on a changedexpiration_dateto create a page. - A keys-plus-
priceedit also succeeded on this build, withtotaling_basisand the other header fields left out; the page kept its stored values. The required-field failure above was not reproduced. The example still mirrors the full field set, because that is what works on both builds. purchase_pricing_book_idhas a short length limit: a 12-character ID failed "The length of the data exceeds the maximum length for the purchase_pricing_book_id field"; an 8-character ID was accepted.
Runnable example: purchase_pricing_page_edit.py and examples/csharp/Recipes/PurchasePricingPageEdit.cs (Recipes menu 27) read the page over OData, map the stored codes to labels, change the price, post Status: "New" with the exact keys, and confirm the same purchase_pricing_page_uid carries the new price with no duplicate. Payload: purchase-pricing-page-edit.json. Python and C# examples run on play 26.1.5950.0, 2026-10-10.
"""Read a purchase pricing page and print its quantity-break tiers."""
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" # the five key fields below identify one page
PRICING_BOOK_ID = "PB-2026"
SUPPLIER_ID = "10050"
EFFECTIVE_DATE = "2026-01-01"
EXPIRATION_DATE = "2030-12-31"
# ---------------------------------------------------------------------------
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_ui_server(client: httpx.Client, token: str) -> str:
"""Transaction and Interactive calls go to the UI server, not BASE_URL."""
r = client.get(
f"{BASE_URL}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", r.text)
if not match:
raise ValueError(f"No Url in router response: {r.text[:200]}") from None
return match.group(1).rstrip("/")
TIER_FIELD = re.compile(r"^(value|break|uom)(\d+)$")
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
ui_server = get_ui_server(client, token)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
response = client.post(
f"{ui_server}/api/v2/transaction/get",
headers=headers,
json={
"ServiceName": "PurchasePricingPageSupplier",
"TransactionStates": [
{
"DataElementName": "TABPAGE_1.tp_1_dw_1",
"Keys": [
{"Name": "company_id", "Value": COMPANY_ID},
{"Name": "purchase_pricing_book_id", "Value": PRICING_BOOK_ID},
{"Name": "supplier_id", "Value": SUPPLIER_ID},
{"Name": "effective_date", "Value": EFFECTIVE_DATE},
{"Name": "expiration_date", "Value": EXPIRATION_DATE},
],
},
{"DataElementName": "TABPAGE_2.tp_2_dw_2", "Keys": []},
],
},
)
response.raise_for_status()
result = response.json()
# Fields come back as {"Name": ..., "Value": ...} entries inside each row's
# Edits array — not as top-level keys.
values = {}
for transaction in result.get("Transactions", []):
for element in transaction.get("DataElements", []):
for row in element.get("Rows", []):
for edit in row.get("Edits", []):
values[edit.get("Name")] = edit.get("Value")
print(f"calculation_type: {values.get('calculation_type', '')}")
# 15 value slots, but only 14 break thresholds and UOMs — tier 15 is the
# catch-all above the last break.
for tier in range(1, 16):
value, brk, uom = (values.get(f"value{tier}", ""),
values.get(f"break{tier}", ""),
values.get(f"uom{tier}", ""))
if any((value, brk, uom)):
print(f" tier {tier:>2}: value={value:<12} break={brk:<10} uom={uom}")
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 CompanyId = "ACME"; // the five key fields below
const string PricingBookId = "PB-2026"; // identify one pricing page
const string SupplierId = "10050";
const string EffectiveDate = "2026-01-01";
const string ExpirationDate = "2030-12-31";
// ---------------------------------------------------------------------------
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);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var body = JsonSerializer.Serialize(new
{
ServiceName = "PurchasePricingPageSupplier",
TransactionStates = new object[]
{
new
{
DataElementName = "TABPAGE_1.tp_1_dw_1",
Keys = new[]
{
new { Name = "company_id", Value = CompanyId },
new { Name = "purchase_pricing_book_id", Value = PricingBookId },
new { Name = "supplier_id", Value = SupplierId },
new { Name = "effective_date", Value = EffectiveDate },
new { Name = "expiration_date", Value = ExpirationDate },
},
},
new { DataElementName = "TABPAGE_2.tp_2_dw_2", Keys = Array.Empty<object>() },
},
});
using var response = await client.PostAsync(
$"{uiServer}/api/v2/transaction/get",
new StringContent(body, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
// Fields come back as {"Name": ..., "Value": ...} entries inside each row's
// Edits array — not as top-level keys.
var values = new Dictionary<string, string>();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (document.RootElement.TryGetProperty("Transactions", out var transactions))
{
foreach (var transaction in transactions.EnumerateArray())
{
if (!transaction.TryGetProperty("DataElements", out var elements)) continue;
foreach (var element in elements.EnumerateArray())
{
if (!element.TryGetProperty("Rows", out var rows)) continue;
foreach (var row in rows.EnumerateArray())
{
if (!row.TryGetProperty("Edits", out var edits)) continue;
foreach (var edit in edits.EnumerateArray())
{
var name = edit.GetProperty("Name").GetString();
if (name is not null)
values[name] = edit.GetProperty("Value").GetString() ?? "";
}
}
}
}
}
values.TryGetValue("calculation_type", out var calculationType);
Console.WriteLine($"calculation_type: {calculationType}");
// 15 value slots, but only 14 break thresholds and UOMs — tier 15 is the
// catch-all above the last break.
for (var tier = 1; tier <= 15; tier++)
{
values.TryGetValue($"value{tier}", out var value);
values.TryGetValue($"break{tier}", out var threshold);
values.TryGetValue($"uom{tier}", out var uom);
if (!string.IsNullOrEmpty(value) || !string.IsNullOrEmpty(threshold) || !string.IsNullOrEmpty(uom))
Console.WriteLine($" tier {tier,2}: value={value,-12} break={threshold,-10} uom={uom}");
}
// --- 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");
}
// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}
// Some middleware answers these two endpoints 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;
}