On this page

Set an Item's Primary Bin or Primary Supplier at a Location

Update an item's primary bin or primary supplier for one stocking location via the Item service's nested Form → List → detail pattern — with a mandatory read-back, because the primary-supplier write can silently no-op.

API: Transaction (Item), Interactive fallback · Service: Item · Deep dive: Item Service — Nested Location Edits, Item Service Gotchas, Worked Example: "Item Issues Detected" · Full schema: definitions/Item.json

The Item service (Item Maintenance window) supports nested DataElement navigation that mirrors the UI: select the item, select a location row, then edit that location's detail. It works because the Item window's tabs aren't gated behind row selection — a good template for any nested edit.

Prerequisites

Payload

Primary bin (Form → List → Form). Status: "New" with populated Keys updates the existing keyed record — it does not create a new item.

{
    "Name": "Item",
    "UseCodeValues": false,
    "Transactions": [{
        "Status": "New",
        "DataElements": [
            { "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["item_id"],
              "Rows": [{ "Edits": [ {"Name": "item_id", "Value": "WIDGET-001"} ] }] },
            { "Name": "TABPAGE_17.invloclist", "Type": "List", "Keys": ["location_id"],
              "Rows": [{ "Edits": [ {"Name": "location_id", "Value": "10"} ] }] },
            { "Name": "TABPAGE_18.inv_loc_detail", "Type": "Form", "Keys": ["location_id"],
              "Rows": [{ "Edits": [ {"Name": "bin", "Value": "A01-02"} ] }] }
        ]
    }]
}

Primary supplier (Form → List → List). Same window, one level different — swap the third element for the supplier list:

{ "Name": "SUPPLIER_X_LOCATION.supplier_x_location", "Type": "List", "Keys": ["supplier_id"],
  "Rows": [{ "Edits": [
      {"Name": "supplier_id", "Value": "10050"},
      {"Name": "primary_supplier", "Value": "ON"}
  ] }] }

What the supplier write does (verified on a 68-item production run): primary_supplier maps to inventory_supplier_x_loc.primary_supplier (a Y/N flag) — not inv_loc.primary_supplier_id. Setting it ON makes P21 auto-unset the previous primary at that location and update inv_loc.primary_supplier_id to the new supplier. The flag is the field to write; inv_loc.primary_supplier_id is the field to read when verifying.

Complete example

Sets the primary supplier, then performs the mandatory OData verification of inv_loc.primary_supplier_idSucceeded = 1 alone proves nothing here (see Gotchas). For the primary-bin variant, swap in the TABPAGE_18.inv_loc_detail element from the payload above and verify inv_loc.primary_bin the same way.

"""Set an item's primary supplier at a location, then verify inv_loc over OData."""
import re

import httpx

# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com"   # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False                        # True once you trust the cert chain
ITEM_ID = "WIDGET-001"
LOCATION_ID = "10"
SUPPLIER_ID = "10050"                     # must already have a row at LOCATION_ID
# ---------------------------------------------------------------------------


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",
    }

    payload = {
        "Name": "Item",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",  # updates the keyed record; does not create a new item
            "DataElements": [
                {"Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["item_id"],
                 "Rows": [{"Edits": [{"Name": "item_id", "Value": ITEM_ID}]}]},
                {"Name": "TABPAGE_17.invloclist", "Type": "List", "Keys": ["location_id"],
                 "Rows": [{"Edits": [{"Name": "location_id", "Value": LOCATION_ID}]}]},
                {"Name": "SUPPLIER_X_LOCATION.supplier_x_location", "Type": "List",
                 "Keys": ["supplier_id"],
                 "Rows": [{"Edits": [
                     {"Name": "supplier_id", "Value": SUPPLIER_ID},
                     {"Name": "primary_supplier", "Value": "ON"},
                 ]}]},
            ],
        }],
    }

    resp = client.post(f"{ui_server}/api/v2/transaction",
                       headers=headers, json=payload)
    resp.raise_for_status()
    result = resp.json()
    summary = result["Summary"]
    print(f"Succeeded: {summary['Succeeded']}, Failed: {summary['Failed']}")
    if summary["Failed"] > 0 or summary["Succeeded"] == 0:
        # A hard failure is NOT the silent no-op — read the Messages and stop here.
        for msg in result.get("Messages") or []:
            # watch for 'Unexpected response window: Item Issues Detected'
            print(f"  {msg}")
        raise SystemExit("Write failed")

    # MANDATORY verification (success path) — a silent no-op still reports
    # Succeeded = 1. Write target is the inventory_supplier_x_loc flag;
    # READ inv_loc.primary_supplier_id.
    mast = client.get(
        f"{BASE_URL}/odataservice/odata/table/inv_mast",
        params={"$filter": f"item_id eq '{ITEM_ID}'", "$select": "inv_mast_uid"},
        headers=headers,
    )
    mast.raise_for_status()
    inv_mast_uid = mast.json()["value"][0]["inv_mast_uid"]

    loc = client.get(
        f"{BASE_URL}/odataservice/odata/table/inv_loc",
        params={
            "$filter": f"inv_mast_uid eq {inv_mast_uid} and location_id eq {LOCATION_ID}",
            "$select": "primary_supplier_id",
        },
        headers=headers,
    )
    loc.raise_for_status()
    actual = str(loc.json()["value"][0]["primary_supplier_id"])

    if actual == SUPPLIER_ID:
        print(f"VERIFIED: primary_supplier_id = {actual}")
    else:
        # Most likely cause: no inventory_supplier_x_loc row at this location.
        # Add the location supplier row first, then set the flag again.
        print(f"SILENT NO-OP: primary_supplier_id is {actual}, expected {SUPPLIER_ID}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com";   // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001";
const string LocationId = "10";
const string SupplierId = "10050";     // must already have a row at LocationId
// ---------------------------------------------------------------------------

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);

static object Element(string name, string type, string key,
    params (string Name, string Value)[] edits) => new
    {
        Name = name,
        Type = type,
        Keys = new[] { key },
        Rows = new object[]
        {
            new { Edits = edits.Select(e => new { e.Name, e.Value }).ToArray() },
        },
    };

var payload = new
{
    Name = "Item",
    UseCodeValues = false,
    Transactions = new object[]
    {
        new
        {
            Status = "New", // updates the keyed record; does not create a new item
            DataElements = new[]
            {
                Element("TABPAGE_1.tp_1_dw_1", "Form", "item_id", ("item_id", ItemId)),
                Element("TABPAGE_17.invloclist", "List", "location_id",
                    ("location_id", LocationId)),
                Element("SUPPLIER_X_LOCATION.supplier_x_location", "List", "supplier_id",
                    ("supplier_id", SupplierId), ("primary_supplier", "ON")),
            },
        },
    },
};

using var resp = await client.PostAsync(
    $"{uiServer}/api/v2/transaction",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
using var result = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var summary = result.RootElement.GetProperty("Summary");
var succeeded = summary.GetProperty("Succeeded").GetInt32();
var failed = summary.GetProperty("Failed").GetInt32();
Console.WriteLine($"Succeeded: {succeeded}, Failed: {failed}");
if (failed > 0 || succeeded == 0)
{
    // A hard failure is NOT the silent no-op — read the Messages and stop here.
    // Watch for 'Unexpected response window: Item Issues Detected'.
    if (result.RootElement.TryGetProperty("Messages", out var messages))
    {
        Console.Error.WriteLine($"  {messages}");
    }
    return;
}

// MANDATORY verification (success path) — a silent no-op still reports Succeeded = 1.
// Write target is the inventory_supplier_x_loc flag; READ inv_loc.primary_supplier_id.
var invMastUid = (await ODataAsync(client, "inv_mast", $"item_id eq '{ItemId}'",
    "inv_mast_uid"))[0].GetProperty("inv_mast_uid");

var locRows = await ODataAsync(client, "inv_loc",
    $"inv_mast_uid eq {invMastUid} and location_id eq {LocationId}",
    "primary_supplier_id");
var primarySupplier = locRows[0].GetProperty("primary_supplier_id");
var actual = primarySupplier.ValueKind == JsonValueKind.String
    ? primarySupplier.GetString()
    : primarySupplier.ToString();

if (actual == SupplierId)
{
    Console.WriteLine($"VERIFIED: primary_supplier_id = {actual}");
}
else
{
    // Most likely cause: no inventory_supplier_x_loc row at this location.
    // Add the location supplier row first, then set the flag again.
    Console.WriteLine($"SILENT NO-OP: primary_supplier_id is {actual}, expected {SupplierId}");
}

// --- helpers ---------------------------------------------------------------

static async Task<List<JsonElement>> ODataAsync(
    HttpClient client, string table, string filter, string select)
{
    using var response = await client.GetAsync(
        $"{BaseUrl}/odataservice/odata/table/{table}" +
        $"?$filter={Uri.EscapeDataString(filter)}&$select={select}");
    response.EnsureSuccessStatusCode();
    using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    return doc.RootElement.GetProperty("value").EnumerateArray()
        .Select(x => x.Clone()).ToList();
}

// 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;
}

Payload files: bin JSON · XML; supplier JSON · XML — validator-verified, see payloads README.

End-to-end files (runnable from the repo with a .env, dry-run by default): examples/python/recipes/set_primary_bin_supplier.py · examples/csharp/Recipes/SetPrimaryBinSupplier.cs. The snippet above is self-contained; the files use the repo's shared common / P21Examples.Common helpers like every other example.

Gotchas

Adding the missing location supplier row

When the supplier has no row at that location there is nothing to promote, and the flip is the silent no-op above. Create the row first — same window, one element swapped, and two things that are easy to get wrong:

{
    "Name": "Item",
    "UseCodeValues": false,
    "IgnoreDisabled": true,
    "Transactions": [{
        "Status": "New",
        "DataElements": [
            { "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["item_id"],
              "Rows": [{ "Edits": [ {"Name": "item_id", "Value": "WIDGET-001"} ] }] },
            { "Name": "TABPAGE_17.invloclist", "Type": "List", "Keys": ["location_id"],
              "Rows": [{ "Edits": [ {"Name": "location_id", "Value": "10"} ] }] },
            { "Name": "SUPPLIER_X_LOCATION.supplier_x_location", "Type": "List",
              "Keys": ["location_id", "supplier_id"],
              "Rows": [{ "Edits": [
                  {"Name": "location_id", "Value": "10"},
                  {"Name": "supplier_id", "Value": "10050"}
              ] }] }
        ]
    }]
}

P21 creates the item-level inventory_supplier record too when it is missing. The new row lands with primary_supplier = 'N'; run the flip afterwards to promote it. Read it back with POST /api/v2/transaction/get on Iteminventory_supplier_x_loc is not necessarily exposed over OData.

Payload files: JSON · XML

Verify

Not optional for the supplier write — the silent no-op makes read-back the only proof. Resolve inv_mast_uid from item_id, then read inv_loc:

GET /odataservice/odata/table/inv_mast?$filter=item_id eq 'WIDGET-001'&$select=inv_mast_uid
GET /odataservice/odata/table/inv_loc?$filter=inv_mast_uid eq {uid} and location_id eq 10&$select=primary_supplier_id

For the primary-bin variant, read back the bin field on the same inv_loc row.

Credit: Alex Westemeier — patterns and gotchas verified in production (July 2026).