On this page

Transaction API

Disclaimer: This is unofficial, community-created documentation for Epicor Prophet 21 APIs. It is not affiliated with, endorsed by, or supported by Epicor Software Corporation. All product names, trademarks, and registered trademarks are property of their respective owners. Use at your own risk.

Original source: a large share of what this document verifies — upsert semantics and the Keys rules, IgnoreDisabled, report-service discovery, the Item window's nested location edits, the buy-side build → receive → vouch cycle — originates in Alex Westemeier's process work, re-verified here before publication. Section-level credits appear throughout.


Overview

The Transaction API is a stateless RESTful web service for bulk data manipulation in P21. It allows creating and updating records across any P21 window without maintaining session state.

Key Characteristics

When to Use


Endpoints

All Transaction API endpoints use the UI Server URL. First, obtain the UI Server URL:

GET https://{hostname}/api/ui/router/v1/?urlType=external

Note that the no-trailing-slash form can return a 307 redirect and the response may be XML on some middleware — use the trailing-slash form (/api/ui/router/v1/) and follow redirects; see 00-Authentication § UI Server URL.

Then use the returned URL as base:

Endpoint Method Purpose
/api/v2/services GET List available services (transaction business objects only — all m_* services, reports and m_storedprocedureexecutor alike, are hidden from the list but still callable via definition/defaults; see PDF Report Generation)
/api/v2/definition/{name} GET Get service schema and template
/api/v2/defaults/{name} GET Get default values for a service
/api/v2/basics/{name} GET Abbreviated field list for a service, as a ready-to-fill payload skeleton (see note below)
/api/v2/transaction/get POST Retrieve existing records
/api/v2/transaction POST Create or update records (sync)
/api/v2/transaction/async POST Async create/update (returns RequestId)
/api/v2/transaction/async/callback POST Async with callback URL
/api/v2/transaction/async?id={id} GET Check async request status
/api/v2/commands POST Process special commands (see Commands Endpoint)
/api/v2/process/pdfreport POST Generate PDF reports (see PDF Report Generation)

The three discovery endpoints, and which one answers your question. definition, defaults and basics take the same {service_name} path segment and are the routine way to learn a window's shape without opening P21. Between them: definition returns the full schema — every DataElement, field, DataType, KeyFields, and the accepted values for dropdown/code fields, which is where valid values for site-specific fields such as carrier_id come from (see Get Service Definition); defaults returns the service's default values and a payload template you can fill in and post back; basics returns the same element list carrying only each element's headline fields, already shaped as a Status: "New" TransactionSet with Keys prefilled from the element's KeyFields and IgnoreIfEmpty: true on every edit — fill in the values and POST it. The abbreviation is severe and that is the point: on Order all three return the same 102 elements, but basics carries 103 fields against definition/defaults' 1,266.

The community session's warning about basics holds in both directions, so treat it as a starting point rather than a contract — verified on 26.1 with Order:

basics also answers for report services (m_*) — and there it shines: a report window carries only a handful of criteria fields, so basics returns a ready-to-fill criteria skeleton in a few hundred bytes (m_picktickets: 601 bytes against definition's 15.8 KB), the API-side equivalent of reading the criteria names out of SQL Help (see PDF Report Generation). An unknown service name returns an empty HTTP 500, the same shape definition and defaults give. (Endpoint and behavior verified against a 26.1 tenant, August 2026; originally surfaced in a community session, Felipe Maurer, 2026.)

basics is computed, not curated — and that explains both of its flaws. For every element it returns exactly the element's KeyFields ∪ the fields the definition marks Required: true. Verified across 220 elements in four services (Order, Item, JobContractPricing, PurchaseOrder) with zero mismatches.

So basics inherits the Required flag's unreliability wholesale (see What Required actually means): it lists company_id on Order because the definition marks it required — and company_id is a disabled column that fails the save. It omits customer_id, source_loc_id and ship_to_id because the definition doesn't mark them required, even though a create needs all three. The endpoint isn't wrong; it is faithfully reflecting metadata that is.

Practical consequence: basics tells you nothing the definition doesn't — you can generate it offline from definitions/*.json and save the round-trip. Its value is the shape (a ready-to-fill skeleton with Keys prefilled), not the field selection.

Service Explorer: The P21 middleware includes a web-based Transaction API Service Explorer tool for browsing available services and their definitions interactively. Access it from the SOA Middleware admin pages.

Definition endpoint 500s for unavailable windows: GET /api/v2/definition/{name} can return HTTP 500 with "Window <<X>> is not available or user does not have permission to open it" for a service that /api/v2/services lists. Despite the wording, this is usually not a grantable permission problem — the same window fails for fully-privileged users in the Service Explorer. It means the window isn't available in that environment (unlicensed or undeployed module), and which services fail differs per environment. On one 25.2 test system, 238 of the 299 listed services had fetchable definitions. (Credit: Alex Westemeier)

Read-after-write verification: POST /api/v2/transaction/get is also the recommended way to verify that an Interactive API write actually persisted — a save can report success without persisting a sub-record, and the read-back is the only way to recover a server-generated key. See Verifying Writes in the Interactive API guide.


Authentication

Include the Bearer token in the Authorization header:

POST /api/v2/transaction HTTP/1.1
Host: {ui-server-host}
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Accept: application/json

See Authentication for token generation.


Request Structure

TransactionSet

The main request body for create/update operations:

Payload shape only. Full runnable version: Create Order.

{
    "Name": "ServiceName",
    "UseCodeValues": false,
    "Transactions": [
        {
            "Status": "New",
            "DataElements": [
                {
                    "Name": "TABPAGE_1.table_name",
                    "Type": "Form",
                    "Keys": [],
                    "Rows": [
                        {
                            "Edits": [
                                {"Name": "field_name", "Value": "field_value"}
                            ],
                            "RelativeDateEdits": []
                        }
                    ]
                }
            ]
        }
    ]
}

TransactionSet Fields

Field Required Description
Name Yes Service name (e.g., "Order", "SalesPricePage")
UseCodeValues No If true, use code values; if false (default), use display values
Transactions Yes Array of Transaction objects to process
IgnoreDisabled No If true, allow the transaction to proceed past disabled fields (see IgnoreDisabled below)
Query No Optional query filter for the service
FieldMap No Optional field name mappings
TransactionSplitMethod No "Standard" (default) or "NoSplit"
Parameters No Additional service-specific parameters
OnBehalfOf No Present in the service-definition template on 26.1.5950.0 (absent from definitions fetched on earlier 26.1 builds). No observed effect: a real username, a nonexistent username and an empty string were all accepted with Succeeded: 1 and no message, and the record's last_maintained_by stayed the token's own user every time. Whether it changes permission checks (Buyer ID, user-gated columns) is untested. Don't rely on it to act as another user — use that user's token. (play, 26.1.5950.0, 2026-10-11 — @mrwuss)

Transaction Fields

Field Description
Status "New" for create and update — it is the only string the enum accepts; responses echo "Passed"/"Failed"
DataElements Array of tabs/sections in the window
Documents Optional array of file attachments

Status: "New" is the only value the enum accepts

Status looks like it should have a create/update/delete vocabulary. It does not. P21.Transactions.Model.V2.TransactionStatus has exactly one string member — New — and the API's create/update distinction is carried by the keys, not by this field (see Upsert Semantics).

Enumerated on 26.1.5940.0 by posting each candidate with an empty DataElements list, so the probe measures only whether the model binder accepts the value and nothing can be written, and re-run unchanged on 26.1.5950.0:

Sent Result
"New", "new", "NEW" Accepted — case-insensitive
"Existing", "Update", "Updated", "Delete", "Deleted", "Modified", "Insert", "Change", "Upsert", "None", "Unchanged", "Current", … HTTP 400, every one
Any integer (-1, 0, 1, 2, 3, …) Accepted by the binder — see the warning below

The rejection is a clean model-binding failure that names the type, which is worth recognising on sight:

// POST /api/v2/transaction  with  "Status": "Existing"
HTTP 400
{"errors": {
   "Transactions[0].Status": [
     "Error converting value \"Existing\" to type 'P21.Transactions.Model.V2.TransactionStatus'. ..."]},
 "title": "One or more validation errors occurred.", "status": 400}

This used to be an HTTP 500. On builds before 26.1.5940.0 the same payload produced a NullReferenceException at ToInternalBeSpecification with no indication of the cause — a 500 that looked like a server fault rather than a bad request, and sent more than one integration hunting for a middleware bug. If you are on an older build you will still see the 500; the fix is the same either way. The 400 shape is unchanged on 26.1.5950.0.

The 400 body carries two errors, and the useful one is second. Alongside Transactions[0].Status the response also lists content: ["The content field is required."] — an artifact of the body failing to bind as a whole, not a separate problem with your payload. A client that logs only the first errors entry will report a missing body for what is actually a bad enum value.

Do not send Status as an integer. The binder accepts any integer because that is how .NET binds enums — it does not range-check, so out-of-range values bind silently to an undefined member rather than erroring. Every integer we probed returned HTTP 200. Send the string "New", which is the only value with defined behavior, and let a wrong value fail loudly at the binder instead of undefined-behaving inside the transaction.

The rule holds on /api/v2/process/pdfreport too. Report payloads bind to the same TransactionStatus enum, and the report services' own definition templates carry "Status": "New" and "Type": "Form". Verified on play 26.1.5950.0, 2026-10-10, with m_reprintpurchaseorders: "New", "new", 0, 1 and an omitted Status all returned the same PDF, "Type": "Form" and "Type": 0 returned byte-identical documents, and "Existing" returned the same HTTP 400 shown above. Earlier versions of this repo sent integer 0 on report payloads; it worked because 0 binds to New, but it is the unchecked integer path. Every report payload here now sends "New"; the Python and C# PO reprint examples (reprint_purchase_order.py, ReprintPurchaseOrder.cs) were re-run with it on the same build and date.

DataElement Fields

Field Description
Name Tab and table name (e.g., "TABPAGE_1.order")
Type "Form" for single record, "List" for grid/multiple rows
Keys Key field names for List-type elements (used to identify rows)
Rows Array of Row objects

Row / Edit Fields

Field Description
Edits Array of {Name, Value} pairs for field values
RelativeDateEdits Array of date edits using relative offsets (e.g., "today + 30 days") instead of absolute dates

Each Edit object supports:

Field Required Description
Name Yes Field name
Value Yes Field value
IgnoreIfEmpty No If true, skip this edit when Value is empty instead of sending a blank

What a blank stores depends on the field. A Value of null, "" or a single space returned Succeeded: 1 on all three fields tested, with or without IgnoreIfEmpty: false, but each field stored something different:

Field (service) null, "" or " " stores
inv_mast.extended_desc (Item) SQL NULL: OData eq null finds the row and eq '' does not
oe_hdr.delivery_instructions (Order) an empty string: eq '' finds the row and eq null does not
oe_hdr.po_no (Order) nothing: the old value stays

A blank is not a reliable way to clear a field, and you can't predict the result from the request. Read the field back, and filter with both eq '' and eq null when you look for empty values. (Verified on play 26.1.5950.0, 2026-10-29, on a test item and a test order, each restored afterward; other fields not checked.)


Payload Anatomy -- Types, Nesting, and Common Mistakes

Most first-integration failures are payload shape mistakes, not wrong endpoints: a string where an array is expected, a field at the wrong nesting level, or a boolean in quotes. JSON indentation is cosmetic -- what matters is the nesting and the type at every level. This skeleton annotates both:

{                                       // ROOT: object
  "Name": "JobContractPricing",         // string  — the service name
  "UseCodeValues": false,               // boolean — NOT the string "false"
  "IgnoreDisabled": true,               // boolean — ONLY valid at THIS level
  "Transactions": [                     // ARRAY of Transaction objects
    {
      "Status": "New",                  // string — "New" for create AND update
      "DataElements": [                 // ARRAY of DataElement objects
        {
          "Name": "FORM.d_dw_job_price_hdr",  // string — "ELEMENT.datawindow"
          "Type": "Form",               // string — "Form" or "List"
          "Keys": ["item_id"],          // ARRAY of strings — even for ONE key
          "Rows": [                     // ARRAY of Row objects — even for ONE row
            {
              "Edits": [                // ARRAY of Edit objects
                { "Name": "item_id", "Value": "WIDGET-001" }   // Value: STRING
              ],
              "RelativeDateEdits": []   // array (may be empty)
            }
          ]
        }
      ]
    }
  ]
}

Common mistakes and their symptoms

Mistake Wrong Right Symptom
Keys as a string "Keys": "item_id" "Keys": ["item_id"] Deserialization/validation error, or keys ignored
IgnoreDisabled inside a Transaction Transactions[0].IgnoreDisabled Top level, beside Name Silently ignored — Column is disabled: ... persists (details)
Boolean in quotes "UseCodeValues": "false" "UseCodeValues": false The string "false" is truthy-ish to some binders — behavior undefined
Rows/Edits as an object "Rows": { "Edits": ... } "Rows": [ { "Edits": [...] } ] Deserialization error or empty save
Value as a number "Value": 36.58 "Value": "36.58" Every verified example sends strings; other types are untested territory
Status: "Existing" — "Status": "New" Rejected — HTTP 400 on 26.1.5940.0 and later, HTTP 500 NullReferenceException on earlier builds. "New" is the only accepted value
Report payload to /transaction — POST /api/v2/process/pdfreport Returns Succeeded, emits nothing (details)
Wrong property case "transactions": [...] "Transactions": [...] Property silently unbound — behaves like it was never sent
Body wrapped in its template key {"TransactionSet": {"Name": ..., "Transactions": [...]}} {"Name": ..., "Transactions": [...]} HTTP 200, no messages, Summary 0/0/0 — nothing written. An all-zero Summary means the body was not parsed
Body starts at the transaction {"Status": "New", "DataElements": [...]} Wrap it in Name + Transactions Same: HTTP 200, 0/0/0, nothing written
No Name {"Transactions": [...]} "Name": "Order" HTTP 500, empty body
Fields in UI-cascade-breaking order price before pricing_method Match the UI order Value silently cleared while reporting Succeeded (details)

Two tools take the guesswork out:

bash python scripts/validate_payload.py my_payload.json python scripts/validate_payload.py my_payload.xml


Keys -- Row Identity and the Collapse Trap

The Keys array on a DataElement is not an authentication key, a consumer key, or a database primary key. It is how the Transaction API decides which rows in a List element are the same row. Most payloads never need it, which is why it goes unnoticed — but when it matters, the failure is silent: the API returns Succeeded, and the record is simply not what you sent.

Provenance: first described in a community conference session on the P21 APIs (Felipe Maurer, 2026), then verified end-to-end against a 26.1 tenant (August 2026) — the collapse, the Keys fix, the over-keying failure and the stable-key update below are each a live Order create or update with a /transaction/get read-back. It is the same rule as the independently verified Upsert Semantics and contract bin behavior, seen from the row-identity side.

The collapse: two rows in, one row out

Send an order with the same item twice at different quantities and no Keys:

{
    "Name": "TP_ITEMS.items",
    "Type": "List",
    "Keys": [],
    "Rows": [
        {"Edits": [{"Name": "oe_order_item_id", "Value": "WIDGET-001"},
                   {"Name": "unit_quantity",    "Value": "5"}]},
        {"Edits": [{"Name": "oe_order_item_id", "Value": "WIDGET-001"},
                   {"Name": "unit_quantity",    "Value": "10"}]}
    ]
}

You do not get two lines of WIDGET-001. You get one line, quantity 10 — the two rows were treated as one row and the last value written for each field won. No error, no warning, Succeeded: 1.

Two different item IDs in the same payload behave exactly as you expect, which is why this trap stays hidden until the day a real order legitimately carries the same item on more than one line (different lengths cut from one stock item, separate scheduled releases, split shipping dates).

Keys are a GROUP BY

If you know SQL, the model is straightforward: the API is grouping the rows you send, and Keys is the GROUP BY list. Rows that agree on every key field are one row; name a field whose value differs between the rows and they split apart.

For the payload above, the field that actually differs is the quantity:

    "Keys": ["unit_quantity"],

Now the same two rows produce two lines — quantity 5 as user_line_no 001 and quantity 10 as 002. Two different item IDs need no Keys at all: with Keys: [] they already come back as two lines.

Choosing a key

Over-keying breaks updates

Keys make rows unique in both directions. Because a keyed Status: "New" row is an upsert — update when the key matches, insert when it doesn't — a key set that is too specific stops matching the row you meant to change, and the "update" silently becomes a new line.

The classic case: keying on the quantity (the fix above) and then trying to change that quantity from 10 to 20. The new value doesn't match the existing row's key, so P21 appends a line instead of editing one. Verified against the two-line order created above:

{"Name": "TP_ITEMS.items", "Type": "List",
 "Keys": ["unit_quantity"],
 "Rows": [{"Edits": [{"Name": "oe_order_item_id", "Value": "WIDGET-001"},
                     {"Name": "unit_quantity",    "Value": "20"}]}]}

Succeeded: 1, no messages — and the order now has three lines (5, 10, 20) instead of two. Key on something stable when updating: the same edit keyed on user_line_no, with user_line_no sent in Edits, changes that line in place and leaves the line count alone.

{"Name": "TP_ITEMS.items", "Type": "List",
 "Keys": ["user_line_no"],
 "Rows": [{"Edits": [{"Name": "user_line_no",     "Value": "003"},
                     {"Name": "oe_order_item_id", "Value": "WIDGET-001"},
                     {"Name": "unit_quantity",    "Value": "33"}]}]}

Key on the discriminator when creating; key on something stable when updating.

Design for updates: assign your own line handles

user_line_no is caller-assignable at create time, which turns the stable-key advice from a debugging move into a design: give every line a handle you chose, and every later update is deterministic. Verified on 26.1 — an order created with the same item on handles 010 and 020 (Keys: ["user_line_no"], so it also solves the collapse), then updated by handle:

{"Name": "TP_ITEMS.items", "Type": "List",
 "Keys": ["user_line_no"],
 "Rows": [{"Edits": [{"Name": "user_line_no",     "Value": "020"},
                     {"Name": "oe_order_item_id", "Value": "WIDGET-001"},
                     {"Name": "unit_quantity",    "Value": "9"}]}]}

changes exactly that line in place — no phantom inserts, no dependence on P21's own numbering. Integrations that create-then-maintain order lines should assign handles on day one (gapped values like 010/020 leave room to insert between).

What the definition already tells you

Every element in definitions/{Service}.json carries a KeyFields array — the key fields P21 declares for that element. Read it before sending repeated rows: in the cases below the declared fields line up exactly with the behavior described above, which makes KeyFields the best available predictor of what your rows will be folded on. (Confirm with a read-back on a service you haven't tried — the correspondence is consistent across the services documented here, but the collapse itself was exercised live only on Order.) Across the committed definitions, 214 of 335 List elements declare key fields; the remaining third declare none.

Service Element Declared KeyFields
Order TP_ITEMS.items ["oe_order_item_id"]
JobContractPricing JOBPRICELINE.jobpriceline ["item_id", "line_no"]
Item TABPAGE_17.invloclist ["location_id"]
ConvertPOToVoucher TABPAGE_17.tp_17_dw_17 ["receipt_number", "line_number", "po_no"]

Order's item grid declaring oe_order_item_id is exactly why the collapse above happens on the item ID. And JOBPRICELINE declaring ["item_id", "line_no"] is the same rule from the other side — which is why the verified contract-line guidance says to select by item_id and to add line_no as a second key when the same item appears on multiple lines (see Editing Bin Quantities).

Read them straight out of the JSON:

python -c "import json;d=json.load(open('definitions/Order.json'));[print(e['Name'],e.get('KeyFields')) for e in d['TransactionDefinition']['DataElementDefinitions'] if e['Type']=='List']"

The live endpoint carries the same field — see Get Service Definition, which prints KeyFields per element.

Elements that declare no KeyFields never fold. The collapse runs on the element's declared key fields, so on the ~third of List elements that declare none, repeated rows simply append in order. Verified on Order → TABPAGE_RELEASE.tabpage_release (scheduled releases — declared keys: none): two release rows with Keys: [] landed as two rows, and re-sending the same row twice was treated as a second new row — it failed business validation (The release date must be after the previous release date) rather than folding into the first. So the collapse trap lives only on keyed elements; on keyless ones the trap inverts — nothing deduplicates for you, and a retried payload appends duplicates instead of matching existing rows.

Debugging a key problem

The symptom is always the same shape: the write succeeded and the data is wrong — a line missing, a quantity belonging to a different line, or an update that appeared as a new row. When that happens:

  1. Read the record back with POST /api/v2/transaction/get and compare it to what you sent, row for row.
  2. Look up the element's KeyFields in its definition — that is what your rows were folded on.
  3. Add the column that genuinely differs between your rows to Keys, resend, read back again.

Do this deliberately on a test system before you need it: send ten rows of the same item at ten quantities, predict the result, then compare. It is a fast way to build the instinct, and it is the one Transaction-API behavior that reads as the API being broken when it is doing exactly what it was told.


XML Payloads (Content Negotiation)

The Transaction API endpoints speak XML as well as JSON, in both directions, negotiated per-request with standard headers (verified live on 25.2, July 2026):

You want Headers
JSON in, JSON out Content-Type: application/json, Accept: application/json
XML in, XML out Content-Type: application/xml, Accept: application/xml
XML in, JSON out Content-Type: application/xml, Accept: application/json
JSON in, XML out Content-Type: application/json, Accept: application/xml

All four combinations are verified working on GET /definition, GET /services, POST /transaction, and POST /transaction/get. Error responses follow the Accept header too (RFC-7807 application/problem+xml / +json).

The XML request shape (DataContract)

The same TransactionSet as above, as a working XML body:

<?xml version="1.0" encoding="utf-8"?>
<TransactionSet xmlns="http://schemas.datacontract.org/2004/07/P21.Transactions.Model.V2">
  <IgnoreDisabled>false</IgnoreDisabled>
  <Name>JobContractPricing</Name>
  <Transactions>
    <Transaction>
      <DataElements>
        <DataElement>
          <Keys xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
            <a:string>item_id</a:string>
          </Keys>
          <Name>JOBPRICELINE.jobpriceline</Name>
          <Rows>
            <Row>
              <Edits>
                <Edit><Name>item_id</Name><Value>WIDGET-001</Value></Edit>
                <Edit><Name>pricing_method</Name><Value>Price</Value></Edit>
                <Edit><Name>price</Name><Value>36.58</Value></Edit>
              </Edits>
              <RelativeDateEdits />
            </Row>
          </Rows>
          <Type>List</Type>
        </DataElement>
      </DataElements>
      <Status>New</Status>
    </Transaction>
  </Transactions>
  <UseCodeValues>false</UseCodeValues>
</TransactionSet>

Three rules make or break XML bodies — all verified live:

  1. The root namespace is mandatory. Without xmlns="http://schemas.datacontract.org/2004/07/P21.Transactions.Model.V2" the body deserializes to null and the server returns 400 "The content field is required."
  2. Element order is ALPHABETICAL within each parent (WCF DataContract ordering) — note <Name> before <Transactions> before <UseCodeValues>, <Keys> before <Name> inside a DataElement, <Name> before <Value> inside an Edit. Violations are not politely rejected: a misordered top-level element returns HTTP 500, and a misordered element deeper down is silently dropped — the transaction then fails with "Object reference not set to an instance of an object."
  3. Keys items use the arrays namespace: <a:string> with xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays".

Don't hand-build the shape — ask the server for it. GET /api/v2/definition/{Service} with Accept: application/xml returns the service's Template as XML in exactly the required element order. Fill in the <Value>s and post it back.

Other verified XML specifics:


Common Services

Service P21 Window Purpose
Order Order Entry Create/edit sales orders
Invoice Invoice Entry Create/edit invoices
Customer Customer Maintenance Customer records
ShipTo Ship To Maintenance Ship-to records and their salesrep grid (see ShipTo Service — Creating a Ship-To)
Supplier Supplier Maintenance Supplier records
SalesPricePage Sales Price Page Maintenance Price page management — also drivable through the Transaction API, see 08 § Transaction API Alternative
PurchaseOrder Purchase Order Entry Create POs (Regular). Pick a non-Regular PO type via a type-specific service, not by setting po_hdr_po_type — see Purchase Order Types
RequisitionPurchaseOrder Purchase Order Entry (type preset to Requisition) Create requisition (internal / not-for-resale) POs — po_hdr.po_type = 'R'. Same window as PO Entry; easy to miss in /api/v2/services. See create-requisition-po
Shipping Shipping Confirm shipments and set the carrier tracking number — the only service that writes oe_pick_ticket.tracking_no, and it refuses an already-invoiced pick ticket. See Shipping Service — Carrier Tracking Number. Its delete_flag cannot reach orphaned tickets on completed orders — see Deleting a Pick Ticket.
InventoryMaster Inventory Maintenance Item records
Task Task Entry Create tasks/activities
m_storedprocedureexecutor Stored Procedure Executor Load and execute stored procedure definitions (see Stored Procedure Executor) (hidden from /api/v2/services — see the PDF Report Generation discovery note)
m_itemmerge / m_customermerge Item Merge / Customer Merge Merge one item or customer into another (hidden from /api/v2/services). A successful customer merge reports Failed: 1 — see Item Merge and Customer Merge

Report Services

Service P21 Window Purpose
m_reprintpurchaseorders PO Reprint Purchase order PDF prints and reprints; reprint_flag must equal po_hdr.printed (see PDF Report Generation)
m_reprintpicktickets Pick Ticket Reprint Pick ticket PDF reprints
m_picktickets Pick Ticket generation Creates the pick ticket and returns its PDF (see PDF Report Generation)

Production, Assembly & Labor Services

Service P21 Window Purpose
ProductionOrder Production Order Entry Create and manage production orders
Assembly Assembly Maintenance Assembly/BOM definitions for items (see Assembly Service)
JobContractPricing Job Contract Pricing Job contract price pages with quantity breaks (see JobContractPricing Service)
TimeEntry Time Entry Record labor hours against production orders
TimeEntrySO Time Entry (Service Order) Record labor hours against service orders
Labor Labor Maintenance Labor code definitions and rates
LaborProcess Labor Process Maintenance Labor process templates
WorkCenter Work Center Maintenance Work center definitions
Operation Operation Maintenance Operation definitions
PredefinedRouting Predefined Routing Routing templates
ProductionOrderProcessing Production Order Processing Process/complete production orders

See Production & Labor API for detailed field definitions and examples.

Purchase Order Types and the disabled po_hdr_po_type column

You cannot choose a PO type by setting po_hdr_po_type on the PurchaseOrder service — it is a disabled column and sending it fails with Column is disabled: po_hdr_po_type. The PO type is selected by choosing the type-specific service instead (each maps to the same PO Entry window, w_purchase_order_entry_sheet, with the type preset). RequisitionPurchaseOrder is the verified example; see create-requisition-po.

The po_hdr_po_type ValidValues in the definition carry display names only, no code list — the stored po_hdr.po_type letters are not in the definition. The mapping below comes from P21's own report views (which label each letter) and po_hdr.source_type, which records the program that created the PO:

Letter Display name Usual source_type What it is
B Regular 948 PO Entry A regular PO keyed by a buyer in PO Entry. Not "Backorder" — few B lines link to a sales order
S Regular 949 PO Generation A system-generated stock-replenishment PO. B and S both display "Regular"; the difference is manual vs generated
D Direct Ship 951 Order Entry Ships from the supplier straight to the customer on a sales order
P Special 951 Order Entry Bought for a specific sales order, received at your warehouse, then shipped
X Process PO 950 Process Transactions Outside processing
Q Vendor RFQ 951 Order Entry Request for quote; rarely used
N Non-Stock — Exists in older data
R Requisition — verified via RequisitionPurchaseOrder create + DB read-back

source_type stores a code_p21 number: 948 PO Entry, 949 PO Generation, 950 Process Transactions, 951 Order Entry. Don't confuse po_type with oe_line.disposition, the sales-line field where B really is Backorder and S is Special Order.

A deleted PO stays in the table. Deleting a PO sets po_hdr.delete_flag = 'Y' and po_hdr.complete = 'Y', and sets cancel_flag = 'Y' and complete = 'Y' on each of its po_line rows. Filter on delete_flag when you count open or historical POs. The PO's links to sales-order lines are soft-deleted too — see 02 § oe_line_po.

Credit: @mrwuss — R verified end to end on play 26.1.5894.1, July 2026. The other letters, source_type codes and the deleted-PO flags verified against production data 26.1, September 2026 (#178).


Response Format

Success Response

{
    "Messages": ["Transaction 1:: "],
    "Results": {
        "Name": "Order",
        "Transactions": [
            {
                "DataElements": [
                    {
                        "Name": "TABPAGE_1.order",
                        "Rows": [{
                            "Edits": [
                                {"Name": "order_no", "Value": "1001234"}
                            ]
                        }]
                    }
                ],
                "Status": "Passed"
            }
        ]
    },
    "Summary": {
        "Succeeded": 1,
        "Failed": 0,
        "Other": 0
    }
}

Error Response

{
    "Messages": [
        "Transaction 1:: Customer ID is required"
    ],
    "Results": null,
    "Summary": {
        "Succeeded": 0,
        "Failed": 1,
        "Other": 0
    }
}

Transactions pass/fail independently. In a bulk POST, each Transaction in the array is processed on its own: one failing does not roll back the others (no cascade), and Summary tallies the outcomes (Succeeded/Failed/Other). Check Results.Transactions[].Status ("Passed"/"Failed") to see which specific transactions landed — never the HTTP status, which is 200 either way. (Exception: transactions that each re-save the same shared header record can collide — see Upsert Semantics.)

What Failed actually guarantees

"Check Summary" is the rule this documentation repeats everywhere, and it is right — but it is worth being exact about what a failure count does and does not promise, because two different things get conflated. Verified on a 26.1 tenant (August 2026), each with a /transaction/get read-back:

Scope Behavior Verified by
Within one Transaction Atomic for edits to existing values. A failure anywhere rolls the whole Transaction back — including edits that already validated. Not atomic for creates: a failed create can leave soft-deleted rows and consume a document number — see A failed create leaves rows behind. A valid line-quantity edit paired with a disabled column, both in the same element and in a later element: Failed: 1, and the quantity was unchanged both times.
Across Transactions in one POST Independent. Earlier Transactions commit and stay committed. Transaction 1 (valid) + Transaction 2 (poisoned) → Summary: {"Failed": 1, "Succeeded": 1} and Transaction 1's quantity change persisted.
Downstream work a service triggers Not covered by either. Cascading business processes can complete before the Transaction's own validation fails. See the warning below.

The practical trap is the second row. A Summary with Failed ≥ 1 does not mean "nothing happened" — if Succeeded is also non-zero, part of your batch is live. A client that reads only Failed and retries the whole POST will re-apply everything that already worked. Branch on Results.Transactions[].Status per transaction, not on the tally.

⚠️ Atomicity covers the record, not the consequences. Some services do substantial downstream work on save — writing related records, completing linked documents, generating invoices. Those side effects are not rolled back by a later validation failure in the same Transaction.

The reported case is DirectShipConfirmation, which returned Summary: {"Failed": 1} — tripped by a disabled c_email_invoice column — after it had already written the inventory receipt, completed the purchase-order line, invoiced the sales-order line, and created the customer AR invoice. The failure was real; so was everything it had done first. (Reported by Alex Westemeier; not reproduced here — ordinary Order edits are atomic as the table above shows, so this is specific to services that cascade; failed Order creates are a separate case, below.)

The same shape appears in the Interactive API, where an in-window wizard can commit at an intermediate step: the PO/RFQ generation wizard creates and links the PO at cb_next, before cb_finish, so an abandoned wizard is not a no-op.

On any write that triggers downstream documents, read the record back — Failed is a reason to go and look, not proof that nothing landed.

A failed create leaves rows behind

The atomicity row above holds for edits to existing records. A Transaction that creates a document and then fails does not vanish: rows it already wrote stay in the table, flagged deleted, and the document number it drew is used up. Verified on the Order service on 26.1.5950.0:

How the create failed What is left in the database
Line 2 failed — "Ambiguous data detected (PopUp encountered)…" on its item ID An order number is consumed. oe_hdr with delete_flag = 'Y', completed = 'Y'; line 1 in oe_line with delete_flag = 'Y', disposition = 'C', qty_canceled = its quantity; one oe_hdr_salesrep row
Line 2 tripped a margin popup — "…profit percentage … greater than the acceptable limit … [Response: No]" The same pattern
Save-time required-column failure — source_loc_id omitted, "This column is required." The same pattern
Line 1 failed — "Contact Id is required" Nothing

The Interactive API behaves the same way once rows are committed mid-session:

What to do about it:

Credit: verified here by the maintainer on 26.1.5950.0 (October 2026), originally reported by Alex Westemeier (soft-deleted residue from failed appends and unsaved sessions) and in #169 (a stray header after a failed create).


Field Order Matters

For some services, the order of fields in the request is significant. The API processes fields sequentially and mirrors the window's UI cascades — some fields trigger validation, auto-population, or clearing of other fields, exactly as they would when typed into the window in that order.

Example: SalesPricePage

Fields must be set in this order: 1. price_page_type_cd - Triggers type-specific validation 2. company_id - Required before product group 3. product_group_id or discount_group_id 4. supplier_id 5. Other fields...

Example: JobContractPricing — pricing_method before price

Changing pricing_method clears the typed price, just like in the UI. If a line row's Edits send price before pricing_method, the line is created/updated with price = $0 — and the transaction still reports Succeeded. Order the Edits item_id, pricing_method, price. Verified live: reversing the order silently zeroes the price.

Rule of thumb: when a write "succeeds" but a value doesn't stick, suspect a field-order cascade. Order Edits the way a user would fill in the window, and verify written values with OData or POST /api/v2/transaction/get after the first run.

Credit: Alex Westemeier for the JobContractPricing ordering discovery.


Column is disabled Can Mean "Disabled For You"

This is the most-seen error in the whole API surface, and its wording invites the wrong conclusion. Column is disabled: <column> is not always a property of the field. The same payload, against the same tenant, at the same moment, can be accepted for one user and refused for another.

Verified on 26.1 (August 2026) with a controlled A/B — identical request sequence, identical field order, two consumer-key tokens differing only in the username they carried:

User disposition = 'D' on a line being entered
A real CSR login Accepted (Status: 1)
A service account Refused — Column is disabled: disposition

Both users could open the window, load the order, and set every other field on the line. Only this column differed, and nothing in the response says why.

Check allocation first. A re-test on play 26.1.5950.0 (2026-10-17) found a stronger cause for this particular refusal: disposition is disabled on any line that holds allocated stock, for every user. The same service account set D on unallocated lines, and on allocated lines after TP_EXTDINFO.extd_info.allocated_qty = 0. The general rule in this section still stands: Column is disabled can come from roles and DynaChange rules. Rule out the record's own state before you change a role. See Changing a saved line to direct ship.

What makes a column disabled for a user: DynaChange data-change rules scoped to a role, Application Security settings, window-level permissions, and screen versions, including the web client's Version Manager versions, which are stored apart from the classic ones (see 04 § The locked Units of Measure grid for where they live and a query that finds them) — the same machinery documented under DynaChange and Popup Handling and Application Security settings. The API surfaces all of it as one undifferentiated message.

Check the record's other flags. Another field on the same record can disable a column for every user. customer.job_pricing is disabled while customer.use_vendor_contracts_flag is Y; see Customer Service — Turning On Job/Contract Pricing.

What this means in practice:

A worked example of the split. Granting a service account a Buyer ID in User Maintenance cleared the To create a PO, you must have a valid Buyer ID. gate on c_create_po — but the same account still could not set disposition, which a CSR login set without trouble. One permission was configuration; the other is a different gate entirely. Do not assume a single setting unlocks a whole workflow — see Driving an In-Window Wizard.


IgnoreDisabled

IgnoreDisabled: true does more than suppress errors — it is the documented unlock for writing through system-disabled columns and disabled sub-tabs that the Transaction API otherwise cannot touch:

Two placement rules:

  1. IgnoreDisabled goes at the payload top level — alongside Name and Transactions. Placed inside a Transaction object it is silently ignored, and every transaction fails with Column is disabled: <column>.
  2. It applies to the whole TransactionSet — there is no per-transaction form.
{
    "Name": "JobContractPricing",
    "UseCodeValues": false,
    "IgnoreDisabled": true,
    "Transactions": [ ... ]
}

Caution: the flag lets edits through columns P21 normally protects. Only send fields you intend to change, and verify results after the first run.

Credit: Alex Westemeier for mapping the placement failure mode and the disabled-tab unlock behavior.

It is not a universal unlock — and it can hide the failure. On P21 26.1, writes to the JobContractPricing VALUES.values element are refused with General Exception: Tab page is disabled and cannot be selected. Adding IgnoreDisabled: true flips the response to Summary: {"Failed": 0, "Succeeded": 1} / Status: "Passed" and writes nothing — the echoed response even drops the affected DataElements, so the omission is invisible in the response body. The same false success reproduces on the JobContractPricing header column corp_address_id, on Order's LINE_NOTE.line_note, on ProductionOrder's header-note grid (PROD_ORDER_HDR_NOTE_TAB.prod_order_hdr_note_tab), and on SalesPricePage's USED BY.price_book_x_page link. That is five surfaces across four services, which marks it as platform behavior. Always read back after a write that used this flag. Detail: VALUES Writes Are Refused on 26.1 and Breaking Changes entry 8.


UseCodeValues

This setting controls how dropdown/checkbox values are interpreted:

UseCodeValues Pass Example
false (default) Display value "Cancelled": "ON"
true Code value "Cancelled": "Y"

Recommendation: Use false (display values) for better readability — this is also Epicor's own guidance: "It is recommended to use display value using UseCodeValues = false (which is the default)." The accepted display values for a field are published as ValidValues in its service definition. (Exception: some report services require code values — see PDF Report Generation.)

Labels vs What the Database Stores (code_p21)

For enum-style columns, the API's display labels come from the code_p21 table (language_id = 9), but the database stores the integer code_no — which is what OData reads return. When you verify a write via OData, map the numbers back to labels with:

SELECT code_no, code_description
FROM code_p21
WHERE language_id = '9';

Verified examples (JobContractPricing cost/pricing enums):

Enum Label → code_no
Cost type (*_cost_type_cd) Order=222, Source=220, Value=227, None=300
Pricing method (job_price_line.pricing_method) Price=221, Source=220, Pricing Libraries=234, None=300
Calc method (*_calc_method_cd) Multiplier=211, Percentage=230, Difference=228, Mark up=229
Row status (row_status_flag) Active=704, Delete=700

(Credit: Alex Westemeier — maps verified against code_p21.)

The mapping is for reading, not writing. row_status_flag is typed Long, which invites sending the integer — but under UseCodeValues: false the API rejects "700" and "704" alike with Invalid row_status_flag value. Use these numbers to interpret an OData read; send the label. See Customer Service — Removing a Salesrep Grid Row.


Reading One Record -- POST /transaction/get

POST /api/v2/transaction/get is the Transaction API's read side. It is a POST despite being called "get" — the key you are looking up travels in the body, not the query string, so a GET against this path is not the call you want.

Give it a service name and the key identifying one record, and it returns that record as a complete TransactionSet — every populated field across every tab of the window, in the same shape you would POST back to /transaction. Examples throughout this doc use it as the read-back after a write; the same call is also the fastest way to see what a window actually holds.

When this, and when OData

Both read data and neither writes. They differ in shape, and the choice is usually obvious once stated:

POST /transaction/get OData
Scope One record Many records
Breadth The whole window — every tab assembled for you One table or view per query
Joins Already done, exactly as the window does them Yours to do — chain queries by _uid or build a view
Use it for Inspecting or cloning a single order/item/customer, verifying a write Reporting, exports, bulk lookups, dashboards

The practical difference is the assembly. Pulling one item's full picture over OData means knowing that inv_mast, inv_loc, inventory_supplier and friends go together and querying each; /transaction/get returns what the window shows, already joined. Past one record, that advantage inverts and OData wins outright.

Clone an existing record

Because the response is shaped like a request, the read output is a ready-made template: read one record, change the key field, POST the result to /transaction. It is the practical way to duplicate a well-configured record rather than reconstructing it field by field — a "standard CSR" user copied to a new hire, a customer or location modeled on an existing one, an item cloned from its nearest sibling.

Expect to edit the payload rather than replaying it verbatim, and budget for these:

Reading several records in one call

TransactionStates is a list, and it behaves like one: give it N key sets and the response carries N Transactions, each a complete record.

{
  "ServiceName": "Order",
  "TransactionStates": [
    {"DataElementName": "TABPAGE_1.order", "Keys": [{"Name": "order_no", "Value": "1000001"}]},
    {"DataElementName": "TABPAGE_1.order", "Keys": [{"Name": "order_no", "Value": "1000002"}]}
  ]
}

Size the request with that in mind — one Order record is a few hundred KB of JSON, so this is a way to fetch a handful of records, not a bulk export. For anything wider, use OData.

There is no server-side subsetting (probed on 26.1): the response envelope's Query, FieldMap and TransactionSplitMethod fields are echo-only on this endpoint — sending them back populated, or adding element-list fields, changes nothing (byte-identical response). Keying a TransactionState on a List element (TP_ITEMS.items by oe_order_item_id) fails the read outright. You always get the whole window; filter client-side.

(Section contributed from a community session, Felipe Maurer, 2026; verified against a 26.1 tenant, August 2026 — 102 elements returned for one Order, and two TransactionStates returning two Transactions.)


Async Operations

For long-running operations, use the async endpoint. Async requests run in a dedicated session (avoiding session pool contamination) but have a limited queue.

Queue capacity: The server defaults to only 2 concurrent async requests (AsyncRequests.QueueCapacity in Web.config). Additional requests are queued and may time out under heavy load. Plan batch operations accordingly.

Submit Async Request

POST /api/v2/transaction/async

The body is the same TransactionSet you would POST to /transaction. The response (verified 26.1, August 2026 — HTTP 200, not the 202 documented for earlier versions) is a status wrapper, the same shape the status GET returns:

{
    "RequestId": "c27a95d8-4a4e-4183-9d86-7be1dd242f92",
    "RequestType": "V2 Transaction",
    "Key": "N/A",
    "StartDate": "2026-08-19T19:55:58.78-05:00",
    "CompletedDate": null,
    "Status": 3,
    "CallbackResult": null,
    "Messages": null
}

Check Status

GET /api/v2/transaction/async?id={RequestId}

Once the run finishes, CompletedDate is set and Status flips to 2:

{
    "RequestId": "c27a95d8-...",
    "CompletedDate": "2026-08-19T19:56:00.317",
    "Status": 2,
    "Messages": "{\"Results\": {...}, \"Messages\": [], \"Summary\": {\"Failed\": 0, \"Succeeded\": 1, \"Other\": 0}}"
}

Two traps in this wrapper, both verified by running a valid and an invalid transaction side by side:

(Epicor's SDK describes this field as the strings Active, Complete or Failed. On 26.1 it is an integer, and there is no Failed state — a failed transaction completes as 2 and reports its failure only in the inner envelope. Handle both spellings if you support older builds.) - The outcome is double-encoded. Messages is a JSON string containing the complete synchronous-style envelope (Results / Messages / Summary) — parse the wrapper, then json.loads(wrapper["Messages"]), and read Summary.Failed and the inner Messages there. The failed run's business-rule text appears only at that inner level.

What the immediate response does and does not tell you. The submit returns in milliseconds with a request ID, and that is an acknowledgement of queueing only — not of validation, and not of success. A transaction that the synchronous endpoint would have rejected returns a perfectly normal request ID here. The status GET is where the outcome lives, and it carries the same Messages the synchronous call would have returned — a request that failed shows its Failed count and the business-rule text ("You cannot cancel an order that is fully invoiced", for instance) only once you go and read it. Treat the request ID as something you must persist and follow up on; work submitted and never checked is work whose outcome nobody knows.

Callbacks instead of polling

/async makes you poll. /async/callback doesn't — it takes a webhook spec alongside the transaction and calls you when the work finishes. This is the endpoint to reach for when "persist the request ID and follow up" is awkward, which is most of the time.

The body wraps the ordinary TransactionSet in an envelope:

POST /api/v2/transaction/async/callback
{
  "Content": { "Name": "Order", "UseCodeValues": false, "Transactions": [ ... ] },
  "Callback": {
    "Url": "https://your-listener.example.com/p21-hook",
    "Method": "POST",
    "ContentType": "application/json",
    "Headers": [ {"Name": "X-Api-Key", "Value": "..."} ]
  }
}

CallbackResult on the status object then tells you how your endpoint responded — e.g. Success : 200 (OK). On a plain /async submit it stays null, which is why it looks like a dead field until you use callbacks.

(Envelope from the Epicor SDK Transaction API reference guide, served at {middleware}/docs/p21sdk/index.html#/transaction/reference-guide. Not exercised here — it needs a P21-reachable listener.)

There is no cancel. Once transactions are queued there is no endpoint to stop them — probed on a 26.1 tenant, every cancel-shaped route 404s (DELETE /api/v2/transaction/async/{id}, DELETE /api/v2/transaction/{id}, POST /api/v2/transaction/async/cancel), and DELETE /api/v2/transaction/async returns 405: the route exists, for POST only — a loop that submits 50,000 wrong requests will run all 50,000, and every one fires the same DynaChange rules, alerts and event rules a synchronous call would. This is the endpoint's real hazard: it removes the natural backpressure of waiting for each response, so a payload bug that a synchronous run would have surfaced on record one instead surfaces after the whole batch has landed. Validate the payload synchronously against a single record before submitting a batch async. (Community session, Felipe Maurer, 2026.)

With Callback

Use the callback endpoint to receive notification when complete:

{
    "Content": {
        "Name": "Order",
        "Transactions": [...]
    },
    "Callback": {
        "Url": "https://p21.example.com/webhook",
        "Method": "POST",
        "ContentType": "application/json",
        "Headers": [
            {"Name": "X-API-Key", "Value": "your-key"}
        ]
    }
}

Commands Endpoint

Some P21 services cannot use the standard /api/v2/transaction endpoint. These must use the commands endpoint instead:

POST /api/v2/commands

Services Requiring Commands Endpoint

Service Purpose
TransferPalletShipping Pallet transfer shipping
SupplierNotepad Supplier notes
VendorNotepad Vendor notes
ItemNotepad Item notes
CustomerPartNumberNotes Customer part number notes
RestateForeignCurrencyAccount Foreign currency restatement
ServiceNoteTemplate Service note templates
ReverseARPayment AR payment reversal
VATReturnWorksheet VAT return processing
SlabAdjustment Slab adjustments
ContainerBuilding Container building

Important: If you send these services to the standard /transaction endpoint, they will fail. Always check the service documentation or test with the Service Explorer to determine which endpoint to use.

Request Shape

/commands does not take a TransactionSet. It takes an ordered list of interactive commands — it is the Interactive API's window-driving model exposed as one stateless POST. That is exactly why these services live here: they need row selection, tool clicks and drag-and-drop controls that a declarative TransactionSet cannot express.

POST /api/v2/commands
{
  "Requests": [
    {"Action": 0, "DetailLevel": 0, "Args": {"ServiceName": "ItemNotepad"}},
    {"Action": 2, "DetailLevel": 0, "Args": {"List": [
        {"DatawindowName": "tp_1_dw_1", "FieldName": "inv_mast_item_id", "Value": "WIDGET-001"},
        {"DatawindowName": "tp_1_dw_1", "FieldName": "topic",            "Value": "API NOTE"},
        {"DatawindowName": "tp_1_dw_1", "FieldName": "note",             "Value": "Note text"}]}},
    {"Action": 5, "DetailLevel": 0, "Args": {"DatawindowName": "tp_17_dw_dragdrop", "Row": 2}},
    {"Action": 9, "DetailLevel": 0, "Args": {"ToolName": "cb_select", "Row": 0}},
    {"Action": 6, "DetailLevel": 0},
    {"Action": 1, "DetailLevel": 0}
  ]
}

Each entry runs in order against one implicit window; the response returns one Result per request, carrying the same Events the Interactive API emits:

{"Status": 1, "Results": [
  {"Result": {"WindowId": "...", "Status": 1, "Events": [{"Name": "windowopened", ...}], "Messages": []}, "Data": null},
  ...
]}

Watch the save entry's events for savesucceeded, and read the record back — Status: 1 on the envelope is not proof, exactly as on /transaction.

Action codes

Verified against a 26.1 tenant (August 2026) by running each value and reading the response:

Action Meaning Args
0 Open the window ServiceName
1 Close the window —
2 Change data (batched) List[] of DatawindowName / FieldName / Value
5 Select a row DatawindowName, Row
6 Save —
9 Run a tool (button) ToolName, Row

3 and 4 are real but unmapped: 4 with a DatawindowName returns Status: 2 (Failure), while 3 with any argument shape — and 4 with none — returns HTTP 204. 7, 8, and 10+ return an empty HTTP 500, the not-implemented signature seen elsewhere on 26.1.

A 204 empties the entire response, not just that step. One unrecognised action mid-batch and you get a zero-length body — no Results array, no per-step statuses, nothing to inspect about the steps that did run. Parse defensively: check for a body before calling .json(), and treat a 204 as "the batch told you nothing", not as success. DetailLevel was 0/1/2 across these probes with no observable difference; its effect is undocumented.

This is the sanctioned path for notepads. Verified end-to-end on 26.1 (August 2026): the payload above wrote an item note — including satisfying the mandatory drag-and-drop area selector via Action 5 (select a row in tp_17_dw_dragdrop) then Action 9 (cb_select) — returned savesucceeded, and the row was confirmed in the note table. See Limitations for how this relates to the /transaction endpoint's refusal of the same services.


Special Scenarios

Field and DataElement Ordering

Some services require specific ordering of DataElements or Edits within a request. The API processes them sequentially, and some fields trigger validation or auto-population of other fields.

The general rule: repeat the element pair, don't batch it

The Transaction API replays a payload the way an operator would work the window: it applies elements top to bottom, and a child element attaches to whichever parent row is current at that point in the sequence. So when several parent rows each need child data on another tab, repeat the pair per row rather than sending all the parents and then all the children:

Correct:  item A → its detail A → item B → its detail B
Wrong:    item A → item B → detail A → detail B

The second form doesn't error — it applies both details to whatever row was current, which is the last one. Verified on a 26.1 tenant with a two-line order and TP_EXTDINFO.extd_info: sending item A → item B → extd "EXT-FOR-A" → extd "EXT-FOR-B" returns Succeeded: 1 with no messages and lands both descriptions on line 2, last one winning, leaving line 1's extended_desc null. The interleaved sequence puts each description on its own line. An element may appear as many times as you need in a single transaction; a ten-line order that also sets extended info per line is ten repetitions of the pair, in order. This is the same rule the lot-item and break-line cases below are specific instances of. (General statement of the rule: community session, Felipe Maurer, 2026; verified August 2026.)

Credit Card Payment Orders: DataElements must appear in this order — the two payment elements go after every other element in the request, not in window order: 1. Order header (TABPAGE_1.order) 2. Items (TP_ITEMS.items) 3. Remittances (TP_REMITTANCES.remittances) — payment_type_id, payment_amount, payment_desc 4. CC Transaction Response (TP_CCTRANSACTIONRESPONSE.cctransactionresponse)

The CC element carries the result of an authorization performed elsewhere — the API takes the outcome, never card entry (see Limitations). Its fields: auth_amount, cc_authorized_number, cc_authorized_date, retrieval_ref_number, payment_account_id (the stored-payment token), cc_number (masked, e.g. ************6781), cc_expiration_date, payment_number. The token plausibly originates from the api/cardstorage REST family listed on the middleware's API Reference page; that link is unverified here.

Task — target_date before start_date: The Task window validates that start ≤ target, and both default to today. Setting start_date first validates it against a target that is still the default, and the transaction fails. Put target_date (and target_minute) earlier in the Edits array than start_date. Same class of bug as pricing_method before price — the API replays edits in order and each one validates as it lands.

The Task service form is Form.form, keyed on activity_trans_no, carrying subject, target_date/target_minute, start_date/start_minute, completed_flag, reminder_time_offset + reminder_time_offset_cd (e.g. "Minute(s)"), activity_id, assigned_to_id, contact_id, transaction_type_cd. (From the Epicor SDK Transaction API reference guide; the ordering rule is Epicor's own, not re-tested here.)

Customer — use_vendor_contracts_flag before job_pricing: On TABPAGE_7.tp_7_dw_7, job_pricing is disabled until use_vendor_contracts_flag is N. Put use_vendor_contracts_flag = N earlier in the Edits array than job_pricing = Y; see Customer Service — Turning On Job/Contract Pricing.

Multiple Lot Items: When creating items with lot tracking, interleave item and lot DataElements: 1. Item 1 → Lot 1 2. Item 2 → Lot 2 3. (not: Item 1 → Item 2 → Lot 1 → Lot 2)

SalesPricePage Fields: 1. price_page_type_cd — triggers type-specific validation 2. company_id — required before product group 3. product_group_id or discount_group_id 4. supplier_id 5. Other fields...


Examples

Get Service Definition

What Required actually means

The definition is the authoritative field map — element names, DataType, KeyFields, and the ValidValues behind dropdowns are all reliable. Required is not. It reflects the window's field metadata, not the API's contract, and it is wrong in both directions:

Marked required, must be omitted. Order's TABPAGE_1.order marks company_id as Required: true — and company_id is a disabled column that fails the whole transaction with Column is disabled: company_id. The same pattern is reported on PurchaseOrder (division_id) and ConvertPOToVoucher (company_id, branch_id, period, year_for_period), all of which default correctly when left out. Across the committed definitions, 571 of 7,539 fields (7.6%) carry the flag, so this is not a rare mislabel.

Marked optional, actually required. The reverse also happens — JobContractPricing's contract_no is marked optional and a create without it fails outright (see its service entry).

Derive the minimum payload empirically. Start from a known-good example, add fields until the write passes, and stop. Padding a payload to satisfy Required is a reliable way to hit Column is disabled — and because a Transaction is atomic, one padded field fails the entire thing.

This is also why GET /api/v2/basics/{name} both omits fields you need and includes fields you cannot write: it is generated as KeyFields ∪ Required, so it inherits every one of these errors.

Probing a field in isolation can give a false negative. Whether a column is disabled is decided after the window loads its record, so a minimal probe that fails validation earlier never reaches the check. Sending company_id to ConvertPOToVoucher with no valid PO fails on No receipts have been selected — looking, misleadingly, like an accepted field. Send the same field in a complete, otherwise-valid payload and you get the truth: Column is disabled: company_id. Both observed here on the same tenant an hour apart. Test disabled-ness in context, never in isolation.

(Verified on a 26.1 tenant, August 2026. The PurchaseOrder / ConvertPOToVoucher cases were reported by Alex Westemeier and are confirmed here — including the company_id refusal, once probed in a full voucher payload.)

"""Print a Transaction API service definition -- elements, keys, and field names."""
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
SERVICE_NAME = "Order"                    # any name from GET /api/v2/services
# ---------------------------------------------------------------------------


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

    response = client.get(
        f"{ui_server}/api/v2/definition/{SERVICE_NAME}", headers=headers
    )
    response.raise_for_status()
    definition = response.json()

    # definition["Template"] is a blank payload template for creating records.
    # The elements live under TransactionDefinition.DataElementDefinitions.
    elements = definition["TransactionDefinition"]["DataElementDefinitions"]
    print(f"{SERVICE_NAME}: {len(elements)} DataElements")
    for element in elements:
        print(f"  {element.get('Name')}"
              f"  Type={element.get('Type')}"
              f"  Datawindow={element.get('DatawindowName')}"
              f"  Keys={element.get('KeyFields')}")

    # The API field Name is frequently NOT the underlying column name --
    # DbColumnName is what maps a field back to the table you know.
    first = elements[0]
    print(f"\nFirst 10 fields on {first.get('Name')}:")
    for field in first.get("FieldDefinitions", [])[:10]:
        print(f"  {str(field.get('Name')):<30}"
              f" db={str(field.get('DbColumnName')):<30}"
              f" type={field.get('DataType')} required={field.get('Required')}")
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 ServiceName = "Order";                    // any name from GET /api/v2/services
// ---------------------------------------------------------------------------

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 response = await client.GetAsync($"{uiServer}/api/v2/definition/{ServiceName}");
response.EnsureSuccessStatusCode();

using var definition = JsonDocument.Parse(await response.Content.ReadAsStringAsync());

// definition.RootElement.GetProperty("Template") is a blank payload template for
// creating records. The elements live under TransactionDefinition.DataElementDefinitions.
var elements = definition.RootElement
    .GetProperty("TransactionDefinition")
    .GetProperty("DataElementDefinitions");

Console.WriteLine($"{ServiceName}: {elements.GetArrayLength()} DataElements");
foreach (var element in elements.EnumerateArray())
{
    Console.WriteLine(
        $"  {element.GetProperty("Name")}" +
        $"  Type={element.GetProperty("Type")}" +
        $"  Datawindow={element.GetProperty("DatawindowName")}" +
        $"  Keys={element.GetProperty("KeyFields")}");
}

// The API field Name is frequently NOT the underlying column name --
// DbColumnName is what maps a field back to the table you know.
var first = elements[0];
Console.WriteLine($"\nFirst 10 fields on {first.GetProperty("Name")}:");
foreach (var field in first.GetProperty("FieldDefinitions").EnumerateArray().Take(10))
{
    Console.WriteLine(
        $"  {field.GetProperty("Name"),-30}" +
        $" db={field.GetProperty("DbColumnName"),-30}" +
        $" type={field.GetProperty("DataType")} required={field.GetProperty("Required")}");
}

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

The definition is the authoritative schema map for a service. The response shape is {"Name": ..., "TransactionDefinition": {"KeyDefinitions": [...], "DataElementDefinitions": [...]}, "Template": {...}} — the elements live under TransactionDefinition.DataElementDefinitions. Each element carries:

Field Description
Name DataElement name used in payloads (e.g., TABPAGE_7.tp_7_dw_7)
DatawindowName Underlying datawindow (e.g., d_update_po_hdr_notes_po_entry)
Type Form or List
KeyFields Fields that identify a row in Keys (e.g., ["note_id"])
FieldDefinitions[] Every writable field — Name, DbColumnName, DataType, Required
ParentText, BusinessObjectName Display/back-end context for the element

Use it to discover which tab/datawindow a given table lives on and exactly which column names and required fields a write needs. The API field Name is frequently not what you'd guess from the underlying table column — check DbColumnName in FieldDefinitions to map between the two.

Warning — don't derive TABPAGE_N from the visible tab order: TABPAGE_N names are not sequential with the tabs visible in the P21 UI — windows carry many disabled/hidden tab pages (PurchaseOrder has 37), so the grid that looks like the second tab can be TABPAGE_17 (tp_17_dw_17). When cross-referencing the definition against live Interactive calls, match on the datawindow name (tp_N_dw_N / d_...) or read the Interactive window's TabPageList (GET /api/ui/interactive/v2/window?id={windowId}) — never count tabs on screen. On the servers tested (25.2/26.x), the Interactive window's TABPAGE_N names matched the definition's 1:1.

Create Order

"""Create a sales order, then read the created order back by its order_no."""
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
CUSTOMER_ID = "100198"                    # customer the order is placed for
SOURCE_LOC_ID = "100"                     # effectively required -- see the gotchas below
SALES_LOC_ID = "100"                      # selling location
ITEM_ID = "WIDGET-001"                    # item on the first line
QUANTITY = "1"                            # unit quantity for that line
# ---------------------------------------------------------------------------


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("/")


def walk(node):
    """Yield every {"Name": ..., "Value": ...} pair anywhere in a response."""
    if isinstance(node, dict):
        if "Name" in node and "Value" in node:
            yield node["Name"], node["Value"]
        for value in node.values():
            yield from walk(value)
    elif isinstance(node, list):
        for item in node:
            yield from walk(item)


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": "Order",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",
            "DataElements": [
                {
                    "Name": "TABPAGE_1.order",
                    "Type": "Form",
                    "Keys": [],
                    "Rows": [{
                        "Edits": [
                            {"Name": "customer_id", "Value": CUSTOMER_ID},
                            # Omit source_loc_id and the save fails with a
                            # "Jurisdiction ID for Order Header Tax" error.
                            {"Name": "sales_loc_id", "Value": SALES_LOC_ID},
                            {"Name": "source_loc_id", "Value": SOURCE_LOC_ID},
                        ],
                        "RelativeDateEdits": []
                    }]
                },
                {
                    "Name": "TP_ITEMS.items",
                    "Type": "List",
                    "Keys": [],
                    "Rows": [{
                        "Edits": [
                            {"Name": "oe_order_item_id", "Value": ITEM_ID},
                            {"Name": "unit_quantity", "Value": QUANTITY}
                        ],
                        "RelativeDateEdits": []
                    }]
                }
            ]
        }]
    }

    response = client.post(f"{ui_server}/api/v2/transaction", headers=headers, json=payload)
    response.raise_for_status()          # HTTP 200 does NOT mean the write succeeded
    result = response.json()
    print("Summary:", result.get("Summary"))
    for transaction in result.get("Results", {}).get("Transactions", []):
        print("  Transaction status:", transaction.get("Status"))
    for message in result.get("Messages") or []:
        print("  Message:", message)

    # ---- read-back: the only proof the order landed -------------------------
    # The generated order_no comes back in the result rows.
    order_no = next((value for name, value in walk(result) if name == "order_no"), None)
    print("Created order_no:", order_no)

    if order_no:
        read_back = client.post(
            f"{ui_server}/api/v2/transaction/get",
            headers=headers,
            json={
                "ServiceName": "Order",
                "TransactionStates": [{
                    "DataElementName": "TABPAGE_1.order",   # KeyFields: ["order_no"]
                    "Keys": [{"Name": "order_no", "Value": order_no}],
                }],
            },
        )
        read_back.raise_for_status()

        wanted = {"order_no", "customer_id", "order_date"}
        for name, value in walk(read_back.json()):
            if name in wanted:
                print(f"  {name} = {value}")
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 CustomerId = "100198";                    // customer the order is for
const string SourceLocId = "100";                      // effectively required -- see gotchas
const string SalesLocId = "100";                       // selling location
const string ItemId = "WIDGET-001";                    // item on the first line
const string Quantity = "1";                           // unit quantity for that line
// ---------------------------------------------------------------------------

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 payload = new
{
    Name = "Order",
    UseCodeValues = false,
    Transactions = new[] {
        new {
            Status = "New",
            DataElements = new object[] {
                new {
                    Name = "TABPAGE_1.order",
                    Type = "Form",
                    Keys = Array.Empty<string>(),
                    Rows = new[] {
                        new { Edits = new[] {
                            new { Name = "customer_id", Value = CustomerId },
                            // Omit source_loc_id and the save fails with a
                            // "Jurisdiction ID for Order Header Tax" error.
                            new { Name = "sales_loc_id", Value = SalesLocId },
                            new { Name = "source_loc_id", Value = SourceLocId },
                        }}
                    }
                },
                new {
                    Name = "TP_ITEMS.items",
                    Type = "List",
                    Keys = Array.Empty<string>(),
                    Rows = new[] {
                        new { Edits = new[] {
                            new { Name = "oe_order_item_id", Value = ItemId },
                            new { Name = "unit_quantity", Value = Quantity }
                        }}
                    }
                }
            }
        }
    }
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/transaction",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();     // HTTP 200 does NOT mean the write succeeded

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine($"Summary: {result.RootElement.GetProperty("Summary")}");
if (result.RootElement.TryGetProperty("Results", out var results)
    && results.TryGetProperty("Transactions", out var resultTransactions))
{
    foreach (var transaction in resultTransactions.EnumerateArray())
        Console.WriteLine($"  Transaction status: {transaction.GetProperty("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 order landed ---------------------------
// The generated order_no comes back in the result rows.
string? orderNo = null;
foreach (var (name, value) in Walk(result.RootElement))
{
    if (name == "order_no") { orderNo = value; break; }
}
Console.WriteLine($"Created order_no: {orderNo}");

if (!string.IsNullOrEmpty(orderNo))
{
    var getPayload = new
    {
        ServiceName = "Order",
        TransactionStates = new[]
        {
            new
            {
                DataElementName = "TABPAGE_1.order",     // KeyFields: ["order_no"]
                Keys = new[] { new { Name = "order_no", Value = orderNo } },
            }
        }
    };

    var readBackResponse = await client.PostAsync(
        $"{uiServer}/api/v2/transaction/get",
        new StringContent(JsonSerializer.Serialize(getPayload), Encoding.UTF8, "application/json"));
    readBackResponse.EnsureSuccessStatusCode();

    using var readBack = JsonDocument.Parse(await readBackResponse.Content.ReadAsStringAsync());
    var wanted = new HashSet<string> { "order_no", "customer_id", "order_date" };
    foreach (var (name, value) in Walk(readBack.RootElement))
    {
        if (wanted.Contains(name))
            Console.WriteLine($"  {name} = {value}");
    }
}

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

// Yield every {"Name": ..., "Value": ...} pair anywhere in a response.
static IEnumerable<(string Name, string Value)> Walk(JsonElement node)
{
    if (node.ValueKind == JsonValueKind.Object)
    {
        if (node.TryGetProperty("Name", out var name) && node.TryGetProperty("Value", out var value))
            yield return (name.ToString(), value.ToString());
        foreach (var property in node.EnumerateObject())
            foreach (var pair in Walk(property.Value))
                yield return pair;
    }
    else if (node.ValueKind == JsonValueKind.Array)
    {
        foreach (var item in node.EnumerateArray())
            foreach (var pair in Walk(item))
                yield return pair;
    }
}

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

Order Service Gotchas

All verified live (credit: Alex Westemeier):

json {"Name": "TP_TAX.tp_tax", "Type": "List", "Keys": ["jurisdiction_id"], "Rows": [{"Edits": [{"Name": "jurisdiction_id", "Value": "KS"}], "RelativeDateEdits": []}]}

Add it to the order's DataElements after the header element; the value is any valid tax_jurisdiction.jurisdiction_id. - requested_date must be after order_date. The same date trips a date-cascade prompt, which the stateless API can't answer. - company_id is a disabled column on the Order window — don't send it. - DynaChange prompts are auto-answered with the default (usually "No"), which silently discards the affected line — e.g. "order line does not have a PO Cost… proceed? [No]". On multi-item orders the remaining lines then cascade-fail. This is a P21 configuration matter (exempt the rule for the API user, or fix the data), not something a payload change can work around — see DynaChange and Popup Handling. - Assembly items explode through the Transaction API on 26.1.5950.0. There is no prompt to answer: an assembly item on TP_ITEMS.items is saved the way the Interactive "add as assembly?" Yes answer saves it. On play 26.1.5950.0, 2026-10-10, a quote = OFF Order create with one kit item (production_order_processing = N) saved a B parent line plus N component lines, and one with a production-order assembly (production_order_processing = Y, auto_create_prod_order = N) saved an assembly = 'P' line (disposition = 'P') plus its component lines. Earlier versions of this page said the prompt is auto-answered No and the explode is lost; that is not what this build does. What you cannot do here is choose: a plain stock line for an assembly item (the Interactive No, cb_2) needs the Interactive API — see Sales Order Entry with Assembly Lines. Whether an auto_create_prod_order = Y item also creates and links its production order on this path was not tested. - RMAs are not Order records. An order with rma_flag = 'Y' is refused outright: General Exception: You cannot retrieve an RMA from the Order Entry/Front Counter window. Use the RMA service. - The same item on two lines collapses to one with Keys: [] — TP_ITEMS.items folds rows on its declared key (oe_order_item_id), last value wins, Succeeded: 1, no warning. Add Keys: ["unit_quantity"] (or another differing column) when an order legitimately repeats an item — see Keys — Row Identity and the Collapse Trap. - The created order_no comes back in the result rows; check Summary.Succeeded, not the HTTP status. - A document with oe_hdr.completed = 'T' refuses every write — the record-lock prompt fires and the stateless API answers it No. See Order Service — Reassigning the Salesrep for the pre-screen. The holder can be your own API user: a client that was killed mid-request, or an Interactive session that was never ended, keeps the order locked until the server finishes — see Locks and orphaned sessions.

Order Service — Line Appends, Cancels, Holds, and Item Lookup

Field-verified on the Order service. Unless marked otherwise, credit Alex Westemeier — verified on play and production, September 2026. The append, cancel and Hold-release bullets were re-run here with the examples below (play 26.1.5950.0, 2026-10-10); the others were not.

The append, keyed on the saved order, with every new line a row of one element:

{ "Name": "Order", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "TABPAGE_1.order", "Type": "Form", "Keys": ["order_no"],
      "Rows": [{ "Edits": [ {"Name": "order_no", "Value": "1001234"} ], "RelativeDateEdits": [] }] },
    { "Name": "TP_ITEMS.items", "Type": "List", "Keys": [],
      "Rows": [
        { "Edits": [ {"Name": "oe_order_item_id", "Value": "ITEM-001"},
                     {"Name": "unit_quantity",    "Value": "2"} ], "RelativeDateEdits": [] },
        { "Edits": [ {"Name": "oe_order_item_id", "Value": "ITEM-002"},
                     {"Name": "unit_quantity",    "Value": "3"} ], "RelativeDateEdits": [] }
      ] }
  ]}]
}

The Hold release swaps the second element for TP_SHIPINFO.shipinfo (Form, Keys: [], edit packing_basis = Partial); the cancel drops it and adds cancel_flag = ON to the header row.

Runnable: order_append_hold_cancel.py · OrderAppendHoldCancel.cs (Recipes menu 24) · payloads order-append-lines.json, order-release-hold.json, order-cancel.json. Modes append, release-hold, cancel. Before writing they refuse an order that is deleted, canceled or held open (completed = 'T'), and an append item that is not an exact live item ID or is already on the order; afterwards they read oe_hdr and the live (delete_flag = 'N') oe_line rows back over OData. Python and C# examples run on play 26.1.5950.0, 2026-10-10, each on its own throwaway order (created on Hold): append two lines, release the Hold, cancel.

Order Service — Reassigning the Salesrep

There is no salesrep column on oe_hdr. The rep on a sales order or quote lives in the oe_hdr_salesrep grid, which the Order service exposes as TP_SALESREPS.tp_salesreps — Type: List, keyed on salesrep_id in the service definition. Per-line splits live in oe_line_salesrep. Verified live on production 26.1 across 66 successful writes (sales orders and quotes).

Add-then-retire works the way it does for ship-tos, and this grid has a real delete_flag — unlike customer_salesrep, which needs row_status_flag: "Delete":

{
  "Name": "Order",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [
      {
        "Name": "TABPAGE_1.order",
        "Type": "Form",
        "Keys": ["order_no"],
        "Rows": [{"Edits": [{"Name": "order_no", "Value": "123456"}], "RelativeDateEdits": []}]
      },
      {
        "Name": "TP_SALESREPS.tp_salesreps",
        "Type": "List",
        "Keys": ["salesrep_id"],
        "Rows": [
          {"Edits": [
            {"Name": "salesrep_id", "Value": "1001"},
            {"Name": "primary_salesrep", "Value": "Y"},
            {"Name": "commission_split", "Value": "100"}
          ], "RelativeDateEdits": []},
          {"Edits": [
            {"Name": "salesrep_id", "Value": "1002"},
            {"Name": "delete_flag", "Value": "Y"}
          ], "RelativeDateEdits": []}
        ]
      }
    ]
  }]
}

TABPAGE_1.order only loads the document — order_no is its sole edit and nothing on the order itself is modified. Have the incoming rep inherit the outgoing row's primary_salesrep and commission_split rather than hardcoding 100, or a split-commission order silently becomes a single-rep order.

Failure detail is in the top-level Messages, not on the transaction

Results.Transactions[0] comes back Status: "Failed" with its own Messages set to null. The reason is in the sibling top-level Messages array on the response object. Read the wrong one and the API looks like it failed for no stated reason, which sends you hunting for a payload bug that isn't there.

oe_hdr.completed = 'T' is what blocks the write

If the document is in an in-progress editing state, the write raises a record-lock prompt that the stateless API auto-answers No:

Transaction 1:: General Exception: Order 123456 may currently be edited by JSMITH.
Please verify with that user first, otherwise your change to this order may not be saved
successfully. Do you want to continue to retrieve? [Response: No]

completed is not a boolean — it has a rarely-seen third state. Company-wide on production (September 2026): Y 639,247 · N 184,408 · T 308. In a 66-write run, the single failure was the single T.

Credit: @mrwuss — filed as #156; element name, keys, delete_flag, and the completed distribution re-verified here against the service definition and the production catalogue.


Service Reference

JobContractPricing Service

The JobContractPricing service creates and updates job contract price pages -- customer-specific pricing agreements with optional quantity breaks. It has 25 DataElements; the key ones are documented below.

Service Definition

GET /api/v2/definition/JobContractPricing

Header -- FORM.d_dw_job_price_hdr (Form)

Field Type Required Description
company_id Char Yes Company ID
contract_no Char Yes Contract number. The definition marks it optional, but a create without it fails: Required value missing for Contract No (for Job/Contract Hdr) on row 1. P21 does not assign one on this path — see Example: Create a Job Contract
customer_id Decimal Yes Customer ID
taker Char No Order taker / salesperson
end_date Datetime No Contract end date
corp_address_id Long No Corporate address ID (read-only after initial save)
ship_to_id Long No Ship-to address ID
job_no Char No Associated job number
approved Char No Approval flag
cancelled Char No Cancellation flag
consignment_flag Char No Consignment contract flag

Important: corp_address_id must be set during initial creation — it is read-only once the contract is saved. Verified on 26.1.5910.3 (2026-08-11): changing it on a saved contract fails with General Exception: Column is disabled: corp_address_id, and adding IgnoreDisabled: true does not unlock it — the transaction then reports Succeeded: 1 while leaving the value untouched (see entry 8).

Customer/Ship To -- CUSTOMER_SHIP_TO.customer_ship_to (List)

Field Type Description
customer_id Decimal Customer ID
ship_to_id Long Ship-to address ID
activation_date Datetime Ship-to activation date
expiration_date Datetime Ship-to expiration date
address_name Char Ship-to address name
Attaching a ship-to to an existing contract

Contract pricing follows this grid, not the header. On a saved contract, job_price_hdr.customer_id and ship_to_id are NULL; the customers and ship-tos the contract serves are rows in job_price_customer_shipto. A sales order line gets the contract price only when the order's customer and ship-to are a row here — a ship-to missing from the grid prices as if the contract did not exist.

To add one, load the header by job_no + contract_no (header Keys: [], both in Edits, no end_date) and send a CUSTOMER_SHIP_TO.customer_ship_to row keyed on customer_id + ship_to_id:

Runnable: contract_shipto_line_no.py attach-ship-to · ContractShipToLineNo.cs (Recipes menu 23) · payload contract-attach-ship-to.json. Both read the grid and the header back over OData before and after.

{
  "Name": "JobContractPricing", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "FORM.d_dw_job_price_hdr", "Type": "Form", "Keys": [],
      "Rows": [{ "Edits": [
        {"Name": "job_no",      "Value": "1001"},
        {"Name": "contract_no", "Value": "JOB-1001"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "CUSTOMER_SHIP_TO.customer_ship_to", "Type": "List",
      "Keys": ["customer_id", "ship_to_id"],
      "Rows": [{ "Edits": [
        {"Name": "customer_id", "Value": "100198"},
        {"Name": "ship_to_id",  "Value": "100199"}
      ], "RelativeDateEdits": [] }] }
  ]}]
}

Read back job_price_customer_shipto for the contract (the new row has row_status_flag 704), then confirm on a test order: a blank-price line for that ship-to should now fill in the contract price.

IgnoreDisabled is not needed on 26.1.5950.0 — the grid row landed without it, and the header was not re-saved (job_price_hdr.date_last_modified unchanged). The run credited below sent IgnoreDisabled: true; if your build refuses the row as disabled, add it at the payload top level and then never trust Passed alone.

The customer must have Job/Contract Pricing enabled. A ship-to whose customer has customer.job_pricing = 'N' fails "Job/Contract Pricing is not enabled for Customer ID …". Turn it on through the Customer service, and send use_vendor_contracts_flag = N before job_pricing = Y: while the customer uses vendor contracts, job_pricing is Column is disabled. A customer created through the API usually has use_vendor_contracts_flag = Y from the defaults. See Customer Service — Turning On Job/Contract Pricing.

Credit: Alex Westemeier — verified on play, September 2026 (a blank-price order to the newly added ship-to auto-priced from the contract). Re-run here: Python and C# examples run on play 26.1.5950.0, 2026-10-10 — a ship-to attached to each of two throwaway contracts, confirmed by the job_price_customer_shipto read-back. The order-pricing step was not re-run.

Line Items -- JOBPRICELINE.jobpriceline (List, 29 fields)

Field Type Required Description
item_id Char Yes Item ID
uom Char Yes Unit of measure
pricing_method Char Yes Pricing method (see valid values below)
price Decimal Conditional Fixed price (for non-break lines only)
multiplier Decimal Conditional Price multiplier (for break lines only)
source_price Char Conditional Source price reference (for break lines only)
customer_part_no Char No Customer's part number

pricing_method valid values:

Value Use Case
Pricing Libraries Use pricing library rules
Source Source-based pricing with quantity breaks
Price Fixed price (no breaks)
None No pricing

Non-break vs break lines:

Values/Breaks -- VALUES.values (Form, 46 fields)

The VALUES DataElement defines quantity break tiers for a line item.

Stop before you build a payload around this element. On P21 26.1 every attempt to write VALUES.values through the Transaction API is refused, and IgnoreDisabled: true turns that refusal into a silent no-op. Read VALUES Writes Are Refused on 26.1 first — the field reference below is accurate, but the write path is not currently usable.

Field Type Description
calculation_method_cd Long Calculation method (see valid values below). definitions/JobContractPricing.json types this Long, not Char — with UseCodeValues: false you still send the display label
break1 through break14 Decimal Break threshold quantities
calculation_value1 through calculation_value15 Decimal Price/value at each tier
other_cost1 through other_cost15 Decimal Other cost at each tier

calculation_method_cd valid values: Difference, Multiplier, Mark up, Percentage, Fixed Price

Every tier field is numbered from 1. There is no unsuffixed calculation_value or other_cost — the first tier is calculation_value1 / other_cost1. Verified against definitions/JobContractPricing.json (both FieldDefinitions and the payload Template) and the live GET /api/v2/definition/JobContractPricing. The element also has no per-tier uom field — that is SalesPricePage, not this service.

Break Tier Structure

The service supports 15 price levels: 14 break thresholds (break1-break14) plus one catch-all tier. Break values represent the starting quantity of the next tier (advance thresholds).

Rules: - break1 should NOT be 0 -- it defines where the second tier starts - The last active tier has its break set to 0, signaling no further advance - calculation_value1 is the first tier; calculation_value2 through calculation_value14 are tiers 2-14; calculation_value15 is the 15th tier, which has no break threshold (there is no break15)

Example -- 3 tiers with Fixed Price method:

Tier Quantity Range Field Value Break Field Break Value
1 1-9 calculation_value1 10.00 break1 10
2 10-49 calculation_value2 8.50 break2 50
3 50+ calculation_value3 7.00 break3 0

Tier 1 applies for quantities 1-9 (below break1=10). Tier 2 applies for 10-49 (below break2=50). Tier 3 applies for 50+ (break3=0 means no further advance).

Multi-Line Break Interleaving

VALUES is Type: Form (single row), so it applies to the current JOBPRICELINE cursor position. For contracts with multiple break lines, you must send a SEPARATE JOBPRICELINE DataElement (1 row) followed by its own VALUES DataElement for each line. Putting all lines in a single multi-row JOBPRICELINE causes only the last line to receive breaks.

Correct interleaving:

DataElements:
  1. FORM.d_dw_job_price_hdr (header)
  2. JOBPRICELINE.jobpriceline (Line A -- 1 row)
  3. VALUES.values (breaks for Line A)
  4. JOBPRICELINE.jobpriceline (Line B -- 1 row)
  5. VALUES.values (breaks for Line B)

Incorrect (only Line B gets breaks):

DataElements:
  1. FORM.d_dw_job_price_hdr (header)
  2. JOBPRICELINE.jobpriceline (Lines A and B -- 2 rows)
  3. VALUES.values (breaks -- applies only to last row)
VALUES Writes Are Refused on 26.1

Warning -- verified live on a P21 26.1 tenant, 2026-08-11. Every write path to VALUES.values is refused by the server, and IgnoreDisabled: true converts the refusal into a silent no-op that reports success. The break-tier documentation above describes the element's real schema; it does not currently describe a working write.

All three paths fail with the same error:

General Exception: Tab page is disabled and cannot be selected

which also surfaces per-element as:

VALUES.values: Error processing data element: values : Tab page is disabled and cannot be selected
Path attempted Result
Update VALUES on a line of an existing contract Refused
Insert a new line (keyed upsert on item_id) onto an existing contract, with VALUES in the same transaction Refused — atomically; the line is not created either
Create a brand-new contract (header + fully specified Source line + VALUES) in one transaction Refused — atomically; the contract is not created either

The control, so you can size the damage. The identical create transaction with the VALUES.values DataElement removed succeeds: Summary: {"Succeeded": 1}, the contract is created, the Source-priced line is created, both confirmed by read-back. Contract and line creation through the Transaction API work fine. It is specifically the VALUES.values DataElement that is refused.

Other things checked, all on the same tenant:

Whether this is a 26.1 regression or long-standing behavior is unknown — no earlier build was available to compare against. Do not read it as a regression; read it as the behavior of the build in front of you, and re-test on yours.

Takeaway beyond this element: IgnoreDisabled: true is not a universal unlock. It genuinely unlocks some disabled columns and tabs (see IgnoreDisabled), but on this path it only converts a loud failure into a quiet one. Always read back after a write that used it — via POST /api/v2/transaction/get or OData — rather than trusting Succeeded.

Commission Costs

The JOBPRICECOST DataElement includes commission_cost_value and related commission fields. These columns are disabled by default -- without special handling the API returns "Column is disabled: commission_cost_value".

They are writable with IgnoreDisabled: true at the payload top level (see IgnoreDisabled). Key the element by item_id and set the cost type before the value -- verified live, including in the same transaction as a line insert:

Payload shape only -- drop this DataElement into a full program. Full runnable version: Updating an Existing Contract.

{
    "Name": "JOBPRICECOST.jobpricecost",
    "Type": "Form",
    "Keys": ["item_id"],
    "Rows": [{
        "Edits": [
            {"Name": "item_id", "Value": "WIDGET-001"},
            {"Name": "commission_cost_type_cd", "Value": "Value"},
            {"Name": "commission_cost_value", "Value": "17.19"}
        ]
    }]
}

commission_cost_type_cd accepts the display labels Order, Source, Value, None (with UseCodeValues: false). Setting only the commission cost leaves the element's other_cost_* fields (other_cost_type_cd, other_cost_value, other_cost_source_cd, other_cost_calc_method_cd, other_cost_calc_value) untouched.

Credit: Alex Westemeier verified the IgnoreDisabled commission-cost write path. The Interactive API (JobContractPricing window) remains an alternative.

commission_cost_value is disabled unless the type is Value. Switching a line back to type Order while still sending the value fails with Column is disabled: commission_cost_value. Drop the value field when you restore type Order. (@mrwuss, play 26.1, October 2026.)

Duplicate item_ids on one contract: key the line by line_no. JOBPRICECOST.jobpricecost has no line_no key, so it binds to whichever line JOBPRICELINE selected. Key JOBPRICELINE by item_id on a contract that carries the same item twice and both elements silently hit the first matching line. Select the line with Keys: ["line_no"] (and line_no in Edits) first; the cost form then binds to that line. (Credit: Alex Westemeier — verified on production, July 2026.)

{
  "Name": "JobContractPricing", "UseCodeValues": false, "IgnoreDisabled": true,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "FORM.d_dw_job_price_hdr", "Type": "Form", "Keys": [],
      "Rows": [{ "Edits": [
        {"Name": "job_no",      "Value": "1001"},
        {"Name": "contract_no", "Value": "JOB-1001"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "JOBPRICELINE.jobpriceline", "Type": "List", "Keys": ["line_no"],
      "Rows": [{ "Edits": [
        {"Name": "line_no",      "Value": "2"},
        {"Name": "qty_maximum",  "Value": "50"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "JOBPRICECOST.jobpricecost", "Type": "Form", "Keys": ["item_id"],
      "Rows": [{ "Edits": [
        {"Name": "item_id",                 "Value": "WIDGET-001"},
        {"Name": "commission_cost_type_cd", "Value": "Value"},
        {"Name": "commission_cost_value",   "Value": "7.50"}
      ] }] }
  ]}]
}

Re-run here on play 26.1.5950.0, 2026-10-10, against a throwaway contract carrying one item on lines 1 and 2: a qty_maximum edit keyed by item_id changed line 1 only; keyed by line_no = 2 it changed line 2 only, with and without IgnoreDisabled; and the payload above set line 2's quantity and Value commission cost while line 1 stayed untouched. IgnoreDisabled is there only for the commission cost. line_no works as a selector but cannot insert: a line_no the contract does not have fails "Column is disabled: line_no". To add a second line for an item already on the contract, over-key the row (for example Keys: ["item_id", "price"] with a price no existing line has) — see Keys — Row Identity and the Collapse Trap. Runnable: contract_shipto_line_no.py update-line · ContractShipToLineNo.cs · payload contract-update-line-by-line-no.json — both resolve the line's item_id for the cost row and print which lines changed.

How Contract Costs Reach the Order and the Invoice

The contract's cost settings are not copied to an order once and forgotten, and they are not re-read every time either. Which one happens depends on the cost type and on whether the order line was bound to the contract when it was entered.

Binding happens once, at line entry

A sales order line picks up the contract when it is entered: P21 fills the contract price and stamps oe_line.job_price_hdr_uid. That binding is permanent for the line.

Credit: Alex Westemeier — binding rules verified on play, September 2026. Partly re-run here: the Python and C# rebate runbook (play 26.1.5950.0, 2026-10-10) entered a Transaction API order line with unit_price unset, for a customer and ship-to already on an approved contract. The line read back with oe_line.job_price_hdr_uid = the contract, and its invoice line carried the contract price and, as cogs_amount, the contract's other cost. Not re-run: changing the contract cost after entry (the re-read at invoicing) and the unbound line that never binds.

Order-entry effect of each commission cost setting

Verified on play 26.1 by entering real (non-quote) order lines interactively against one contract line, then invoicing them. The line in the first table had a sales price above both costs, a location moving average cost (MAC) and a lower supplier PO cost.

Contract line setting Order line result
Default: commission_cost_type_cd = Order, multiplier 1.02 sales_cost = MAC; commission_cost = MAC × 1.02
JOBPRICELINE.po_cost set to the PO cost No effect on a stock or blank-disposition line: sales_cost = MAC, po_cost 0, commission_cost = MAC × 1.02
JOBPRICECOST type Value, commission_cost_value = the PO cost, multiplier 1.02 commission_cost = value × 1.02, independent of sales_cost
Same, then sales_cost edited down to the PO cost through the Order service commission_cost unchanged (value × 1.02); order_cost_edited = Y; other_cost stays MAC
JOBPRICECOST type Source, source Primary Supplier Cost, multiplier 1.02 commission_cost 0.00 when inventory_supplier.cost is 0 — there is no fallback, so a blank supplier cost gives a zero commission cost

And at invoicing:

Line Contract commission cost invoice_line.commission_cost
Stock line Value × 1.02 value × 1.02 — not based on COGS
Stock line (control) Order × 1.02 COGS × 1.02 — recalculated at invoicing
Direct-ship line Value × 1.02 value × 1.02

So a Value commission cost is multiplied by commission_cost_calc_value; a multiplier of 1.00 cannot show the difference. An Order-type commission cost on the order line is computed once at entry and is not recalculated when the order cost changes later (for example a disposition change that replaces MAC with the PO cost) — but the invoice recalculates it from COGS.

Credit: @mrwuss — verified on play 26.1, October 2026 (entry and invoice), with supporting production observations (#169). An earlier round of this test ran in quote mode by accident — see Set quote to OFF first; only the results re-confirmed on real orders are shown here.

Rebate prerequisites (other cost below actual + a rebate-tracking vendor)

For a contract-priced line to write a supplier rebate at invoice, both of these must hold:

  1. The bound contract line carries an other cost below the line's actual cost. Two settings do this: other cost type Value (other_cost_type_cd = Value, other_cost_value = the rebated cost), or type Source with source Average Cost and method Difference, which gives a fixed rebate per unit (below).
  2. The line's supplier belongs to a vendor with Track Rebates on. vendor_rebate.vendor_id is oe_line.supplier_id, which P21 usually stamps at entry from the item's primary supplier at the line's source location (oe_line.source_loc_id). On a transfer line, the source location differs from the ship location (ship_loc_id). The sales location's primary supplier does not normally decide it, and on a transfer line neither does the ship location's. Read oe_line.supplier_id back after entry rather than assuming it.

The source-location rule. On a play transfer line (disposition T, set by P21; sales and ship location 10, source 20), supplier_id was location 20's primary supplier and sales_cost was location 20's moving average. In a read-only review of production lines whose ship and source locations have different primary suppliers, most lines, not all, carried the source location's primary. That review compared today's primaries, not the primaries at entry. Not tested: a line-level supplier override, and a transfer line carried through to an invoice (the transfer has to ship and be received first).

Then at invoice, invoice_line.cogs_amount = the contract's other cost, a vendor_rebate row is written for the difference, and GL posts the sale at moving average plus a Rebate Journal entry that moves the difference to the vendor's rebate receivable (GL flow). Setting only the other cost, with a supplier that does not track rebates, lowers COGS but writes no rebate. Creating the vendor: Vendor Service. The end-to-end setup: recipe: rebate-assembly-program.

vendor_rebate amounts are extended for the line, not per unit. On a two-unit line, base_cost, after_rebate_cost and rebate_due were each twice the one-unit values, and rebate_due = base_cost − after_rebate_cost.

Credit: Alex Westemeier — rebate trigger proven end to end on play, September 2026. Re-run here with the Python and C# rebate runbook (play 26.1.5950.0, 2026-10-10). Checked again on 2026-10-17 with separate Interactive order entries whose source location differed from the sales location, not the runbooks (#201): across nine invoices for three different suppliers, every vendor_rebate.vendor_id equalled its oe_line.supplier_id. Not re-run: the other-cost-only case with a non-tracking supplier.

Rebates on assembled parents: production orders, build-to-stock and kits

The contract line goes on the parent item, and the accrual follows the parent line, whether the unit was built for the order, built to stock, or is a kit:

Parent line How the unit came to exist Parent vendor_rebate at invoice
assembly = 'P', production order linked to the sales line (prod_order_line_link row) Built for the order Yes
assembly = 'P', sold from stock Built to stock (no prod_order_line_link row) Yes
assembly = 'N' (plain stock line of the assembled item) Built to stock Yes
assembly = 'B' (kit), parent's supplier does not track rebates Exploded at order entry No — the components accrue from their own rebate pages

On the production-order and stock lines, the rebate row sits on the parent invoice line with the parent's supplier as vendor, and cogs_amount is the contract's other cost. The contract's Value costs also replace the rollup on the parent: commission_cost and other_cost read the contract values, not the pre-rebate rollup an assembly line carries without a contract cost.

Kit parent on a contract. With a contract line on an assembly = 'B' parent whose own supplier does not track rebates:

Not tested: a kit whose parent primary supplier also tracks rebates (whether parent and components then both accrue). The Transaction Item flip of the parent's primary supplier stopped on Item Issues Detected, and the Interactive Item window's PUT /v2/change of primary_supplier on the location supplier grid did not stage.

Components consumed by a production order do not accrue. In a read-only review of a production tenant's data (26.1, October 2026), component rebate pages accrued only on kit components. A production order issues its components at moving average, the finished good receives the pre-rebate rollup, and no component reaches an invoice line at the page cost. That is why a production-order or build-to-stock assembly needs its rebate on the parent's contract line. (Reported in #201; a production observation, not re-run on play.)

Verified on play 26.1.5950.0, 2026-10-17 (#201), by reading the invoices back over OData: three build-to-stock production orders with no prod_order_line_link row, whose units were later sold on contract-bound lines (assembly = 'P' and 'N'), and one linked production order. Each invoice wrote a parent vendor_rebate row. The kit case was a fresh order and invoice with the parent added to the contract — @mrwuss

A fixed per-unit rebate: Source + Average Cost + Difference

When the supplier pays a fixed amount per unit rather than bringing cost down to a fixed price, set the contract line's other cost to Source, source Average Cost, method Difference, with the rebate as the calculation value. The other cost is then the moving average at the line's source location (source_loc_id) minus the rebate, and the accrual stays exactly the rebate as moving average drifts.

Send the rebate as a positive number. Difference subtracts the calculation value: 20.00 gives moving average − 20.00; -20.00 gives moving average + 20.00, a cost above actual.

Payload shape only — drop this DataElement into a full program. Full runnable version: Updating an Existing Contract. item_id is disabled on this element, so send IgnoreDisabled: true and read the line back.

{
    "Name": "JOBPRICECOST.jobpricecost",
    "Type": "Form",
    "Keys": ["item_id"],
    "Rows": [{
        "Edits": [
            {"Name": "item_id", "Value": "WIDGET-001"},
            {"Name": "other_cost_type_cd", "Value": "Source"},
            {"Name": "other_cost_source_cd", "Value": "Average Cost"},
            {"Name": "other_cost_calc_method_cd", "Value": "Difference"},
            {"Name": "other_cost_calc_value", "Value": "20.00"}
        ]
    }]
}

With UseCodeValues: false on the write, job_price_line reads back the system code values:

Column Value
other_cost_type_cd 220 (Source)
other_cost_source_cd 203 (Average Cost)
other_cost_calc_method_cd 228 (Difference)
other_cost_calc_value 20

Effect, for one unit at a source-location moving average of 50.00:

Where Value
oe_line.other_cost at entry 30.00 (50.00 − 20.00)
invoice_line.cogs_amount 30.00
vendor_rebate.base_cost / after_rebate_cost / rebate_due 50.00 / 30.00 / 20.00

A bound line re-reads the contract at invoice, as above: a line entered while the value was -20.00 (entry other_cost 70.00) invoiced at 30.00 once the contract was corrected to 20.00. To restore the line to a Value type, send other_cost_type_cd = Value and other_cost_value; P21 resets the source and method codes.

Verified on play 26.1.5950.0, 2026-10-17 (#201): a fresh order and invoice on a contract line set this way. The moving average had risen by nearly half since earlier invoices on the same contract line, and the accrual was still exactly the calculation value. The negative-value case is from the first run the same day (entry other_cost = moving average + the value) — @mrwuss

The GL flow of a contract rebate

One invoice line, one unit, sold at 15.00 on a sale booked at location 10 (oe_hdr.location_id), with location 20 as both the source and the ship location; moving average 50.00, contract other cost 30.00, rebate 20.00. The invoice's GL entry (one transaction_number, source = the invoice number) held:

Journal Account Amount What it is
SJ Accounts receivable (location 10) +15.00 The sale
SJ Sales (location 10) −15.00
SJ COGS (location 10) +50.00 Full moving average
SJ Inventory (location 20) −50.00 Stock relieved at location 20
SJ Inter-location clearing (location 20) +50.00 Present because location 20 is not the sales location
SJ Inter-location clearing (location 10) −50.00
RJ Rebate receivable (location 10) +20.00 The vendor_rebate.rebate_due
RJ COGS (location 10) −20.00

Net COGS = 30.00 = invoice_line.cogs_amount = vendor_rebate.after_rebate_cost. The Sales Journal books COGS at the full moving average; the Rebate Journal (journal_id RJ) brings it down, and it posts only with a vendor_rebate row. Without a rebate-tracking supplier there is no RJ pair and no receivable. The receivable posts at the order's sales location (oe_hdr.location_id), not the source location. customer.default_rebate_location_id, empty for this customer, is reported to override that location; the override was not tested. Read the whole entry with GET /api/accounting/gl/{transactionNumber}, or over OData with gl?$filter=source eq '{invoice_no}'.

Verified on play 26.1.5950.0, 2026-10-17: the GL rows for the fixed-rebate invoice above, read over OData and through accounting/gl, matched line for line, and the RJ pair equalled rebate_due — @mrwuss

Updating an Existing Contract

Use Status = "New" to update existing contracts -- there is no separate "Update" or "Existing" status. The Transaction API distinguishes create from update by whether the FORM key fields land on an existing record:

Credit: verified here by the maintainer on 26.1.5950.0 (October 2026), originally reported by Alex Westemeier. This replaces earlier guidance in this section that end_date had to be sent on every update. Re-checked on play 26.1.5950.0, 2026-10-10: with end_date omitted, line edits left job_price_hdr.date_last_modified unchanged; the same edit with the unchanged end_date added moved the header's date_last_modified and every line's. The contract_shipto_line_no.py / ContractShipToLineNo.cs examples send no end_date and print Header re-saved: no after the write.

Empirically verified 2026-05-14: 173 successful price updates against contract JOB-1001 on a production tenant. Each call returned HTTP 200 with Summary.Succeeded = 1, and OData confirmed each job_price_line.price matched the submitted value.

Example -- update one line's price:

"""Update one line's price on an existing job contract, then read it back."""
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"                       # FORM key fields go in Edits, not Keys
CONTRACT_NO = "JOB-1001"
JOB_NO = "1001"                             # unique per header; survives renewals
ITEM_ID = "WIDGET-001"                    # the line to update
UOM = "EA"
PRICE = "36.58"
# ---------------------------------------------------------------------------


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("/")


def walk(node):
    """Yield every {"Name": ..., "Value": ...} pair anywhere in a response."""
    if isinstance(node, dict):
        if "Name" in node and "Value" in node:
            yield node["Name"], node["Value"]
        for value in node.values():
            yield from walk(value)
    elif isinstance(node, list):
        for item in node:
            yield from walk(item)


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": "JobContractPricing",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",                          # still "New" for updates
            "DataElements": [
                {
                    "Name": "FORM.d_dw_job_price_hdr",
                    "Type": "Form",
                    "Keys": [],                       # empty
                    "Rows": [{
                        "Edits": [
                            {"Name": "company_id",  "Value": COMPANY_ID},
                            {"Name": "contract_no", "Value": CONTRACT_NO},
                            {"Name": "job_no",      "Value": JOB_NO},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
                {
                    "Name": "JOBPRICELINE.jobpriceline",
                    "Type": "List",
                    "Keys": ["item_id"],
                    "Rows": [{
                        "Edits": [
                            # pricing_method BEFORE price -- reversing them zeroes the price
                            {"Name": "item_id",        "Value": ITEM_ID},
                            {"Name": "uom",            "Value": UOM},
                            {"Name": "pricing_method", "Value": "Price"},
                            {"Name": "price",          "Value": PRICE},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
            ],
        }],
    }

    response = client.post(f"{ui_server}/api/v2/transaction", headers=headers, json=payload)
    response.raise_for_status()          # HTTP 200 does NOT mean the write succeeded
    result = response.json()
    print("Summary:", result.get("Summary"))
    for transaction in result.get("Results", {}).get("Transactions", []):
        print("  Transaction status:", transaction.get("Status"))
    for message in result.get("Messages") or []:
        print("  Message:", message)

    # ---- read-back: the only proof the price landed -------------------------
    read_back = client.post(
        f"{ui_server}/api/v2/transaction/get",
        headers=headers,
        json={
            "ServiceName": "JobContractPricing",
            "TransactionStates": [
                {
                    "DataElementName": "FORM.d_dw_job_price_hdr",
                    "Keys": [{"Name": "contract_no", "Value": CONTRACT_NO}],
                },
                {"DataElementName": "JOBPRICELINE.jobpriceline", "Keys": []},
            ],
        },
    )
    read_back.raise_for_status()

    wanted = {"contract_no", "job_no", "item_id", "pricing_method", "price"}
    for name, value in walk(read_back.json()):
        if name in wanted:
            print(f"  {name} = {value}")
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";              // FORM key fields go in Edits, not Keys
const string ContractNo = "JOB-1001";
const string JobNo = "1001";                    // unique per header; survives renewals
const string ItemId = "WIDGET-001";           // the line to update
const string Uom = "EA";
const string Price = "36.58";
// ---------------------------------------------------------------------------

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 payload = new
{
    Name = "JobContractPricing",
    UseCodeValues = false,
    Transactions = new[]
    {
        new
        {
            Status = "New",                          // still "New" for updates
            DataElements = new object[]
            {
                new
                {
                    Name = "FORM.d_dw_job_price_hdr",
                    Type = "Form",
                    Keys = Array.Empty<string>(),     // empty
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "company_id", Value = CompanyId },
                                new { Name = "contract_no", Value = ContractNo },
                                new { Name = "job_no", Value = JobNo },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                },
                new
                {
                    Name = "JOBPRICELINE.jobpriceline",
                    Type = "List",
                    Keys = new[] { "item_id" },
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                // pricing_method BEFORE price -- reversing them zeroes the price
                                new { Name = "item_id", Value = ItemId },
                                new { Name = "uom", Value = Uom },
                                new { Name = "pricing_method", Value = "Price" },
                                new { Name = "price", Value = Price },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                }
            }
        }
    }
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/transaction",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();     // HTTP 200 does NOT mean the write succeeded

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine($"Summary: {result.RootElement.GetProperty("Summary")}");
if (result.RootElement.TryGetProperty("Results", out var results)
    && results.TryGetProperty("Transactions", out var resultTransactions))
{
    foreach (var transaction in resultTransactions.EnumerateArray())
        Console.WriteLine($"  Transaction status: {transaction.GetProperty("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 price landed ---------------------------
var getPayload = new
{
    ServiceName = "JobContractPricing",
    TransactionStates = new object[]
    {
        new
        {
            DataElementName = "FORM.d_dw_job_price_hdr",
            Keys = new[] { new { Name = "contract_no", Value = ContractNo } },
        },
        new { DataElementName = "JOBPRICELINE.jobpriceline", Keys = Array.Empty<object>() },
    }
};

var readBackResponse = await client.PostAsync(
    $"{uiServer}/api/v2/transaction/get",
    new StringContent(JsonSerializer.Serialize(getPayload), Encoding.UTF8, "application/json"));
readBackResponse.EnsureSuccessStatusCode();

using var readBack = JsonDocument.Parse(await readBackResponse.Content.ReadAsStringAsync());
var wanted = new HashSet<string>
{
    "contract_no", "job_no", "item_id", "pricing_method", "price"
};
foreach (var (name, value) in Walk(readBack.RootElement))
{
    if (wanted.Contains(name))
        Console.WriteLine($"  {name} = {value}");
}

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

// Yield every {"Name": ..., "Value": ...} pair anywhere in a response.
static IEnumerable<(string Name, string Value)> Walk(JsonElement node)
{
    if (node.ValueKind == JsonValueKind.Object)
    {
        if (node.TryGetProperty("Name", out var name) && node.TryGetProperty("Value", out var value))
            yield return (name.ToString(), value.ToString());
        foreach (var property in node.EnumerateObject())
            foreach (var pair in Walk(property.Value))
                yield return pair;
    }
    else if (node.ValueKind == JsonValueKind.Array)
    {
        foreach (var item in node.EnumerateArray())
            foreach (var pair in Walk(item))
                yield return pair;
    }
}

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

Notes:

json { "ServiceName": "JobContractPricing", "TransactionStates": [{ "DataElementName": "FORM.d_dw_job_price_hdr", "Keys": [{"Name": "contract_no", "Value": "JOB-1001"}] }] } - Per-line latency observed at ~0.8s. For bulk updates, single-line calls are easier to retry on failure than batches. - Expired contracts are editable here as long as you do not send end_date — the "end date must be equal to or greater than today" check runs only when end_date is in the payload. See the end_date bullet above. - Identify renewals by job_no. Contract renewals can leave the same contract_no on two header rows; job_no is unique. Include it in the FORM Edits whenever it's known.

Upsert Semantics -- Keyed Rows Insert When Absent

Status: "New" with a keyed List row is an upsert: if the key matches an existing row it updates that row, and if it doesn't match, P21 inserts a new row. This means the Transaction API can add brand-new lines to an existing contract -- no Interactive API needed. Verified live: 81 new lines added to an existing contract in one run (price, pricing_method, and commission cost all confirmed in the database with unique line_no values).

The payload is identical to the update example above -- the only difference is whether item_id already exists on the contract.

Concurrency gotcha -- one transaction per POST when inserting lines. Every transaction re-saves the shared FORM header. If you batch several line-insert transactions into one POST:

Submit each insert as its own POST -- each one then sees the current max line_no and increments it correctly. (This applies to inserts that re-save the same header; editing existing keyed rows -- prices, bin quantities -- batches fine in one POST.)

Credit: Alex Westemeier verified the upsert behavior and the header-collision failure mode.

Editing Bin Quantities on an Existing Contract

Contract bin quantities (BINS.bins -- min_qty, max_qty, reorder_qty, capacity) live on a sub-tab that is normally disabled until a parent row is selected, which the stateless Transaction API cannot do. IgnoreDisabled: true unlocks it (see IgnoreDisabled). One POST, batchable across many bins:

Payload shape only. Full runnable version -- same POST, same response checks: Updating an Existing Contract.

{
    "Name": "JobContractPricing",
    "UseCodeValues": false,
    "IgnoreDisabled": true,
    "Transactions": [{
        "Status": "New",
        "DataElements": [
            {
                "Name": "FORM.d_dw_job_price_hdr", "Type": "Form", "Keys": [],
                "Rows": [{"Edits": [
                    {"Name": "job_no", "Value": "1001"},
                    {"Name": "customer_id", "Value": "100198"},
                    {"Name": "ship_to_id", "Value": "200"}
                ]}]
            },
            {
                "Name": "JOBPRICELINE.jobpriceline", "Type": "List", "Keys": ["item_id"],
                "Rows": [{"Edits": [
                    {"Name": "item_id", "Value": "WIDGET-001"}
                ]}]
            },
            {
                "Name": "BINS.bins", "Type": "List",
                "Keys": ["contract_bin_id", "customer_id", "ship_to_id"],
                "Rows": [{"Edits": [
                    {"Name": "contract_bin_id", "Value": "A01-02"},
                    {"Name": "customer_id", "Value": "100198"},
                    {"Name": "ship_to_id", "Value": "200"},
                    {"Name": "min_qty", "Value": "30"},
                    {"Name": "max_qty", "Value": "100"},
                    {"Name": "reorder_qty", "Value": "40"},
                    {"Name": "capacity", "Value": "100"}
                ]}]
            }
        ]
    }]
}

Gotchas (all verified live):

Credit: Alex Westemeier discovered and verified the IgnoreDisabled bins path (single and multi-bin batches, database-confirmed). The Interactive API (select ship-to row, then line, then BINS tab) also works as a slower fallback.

Known Limitations

Example: Create a Job Contract with Break and Non-Break Lines

This example cannot succeed on P21 26.1. It sends a VALUES.values DataElement, and every write to that element is refused — atomically, so the transaction creates no contract and no lines. See VALUES Writes Are Refused on 26.1. The example is kept because it is the correct payload shape (header → line → its VALUES, interleaved per break line) and because the same transaction with the VALUES.values element deleted does succeed. Delete DataElement 4 to get a runnable version.

Separately -- and unrelated to that hazard -- two ordinary validation requirements that this example originally got wrong, both verified on 26.1:

"""Create a job contract with a fixed-price line and a break line (VALUES refused on 26.1)."""
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"
CONTRACT_NO = "JOB-1001"                  # required -- P21 does not assign it here
CUSTOMER_ID = "100198"
# ---------------------------------------------------------------------------


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("/")


def walk(node):
    """Yield every {"Name": ..., "Value": ...} pair anywhere in a response."""
    if isinstance(node, dict):
        if "Name" in node and "Value" in node:
            yield node["Name"], node["Value"]
        for value in node.values():
            yield from walk(value)
    elif isinstance(node, list):
        for item in node:
            yield from walk(item)


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

    # Create a contract with one fixed-price line and one break line
    payload = {
        "Name": "JobContractPricing",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",
            "DataElements": [
                # 1. Contract header
                {
                    "Name": "FORM.d_dw_job_price_hdr",
                    "Type": "Form",
                    "Keys": [],
                    "Rows": [{
                        "Edits": [
                            {"Name": "company_id", "Value": COMPANY_ID},
                            {"Name": "contract_no", "Value": CONTRACT_NO},   # required
                            {"Name": "customer_id", "Value": CUSTOMER_ID},
                            {"Name": "corp_address_id", "Value": "1"},
                            {"Name": "end_date", "Value": "2027-12-31"},
                            {"Name": "approved", "Value": "ON"},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
                # 2. Fixed-price line (no breaks)
                {
                    "Name": "JOBPRICELINE.jobpriceline",
                    "Type": "List",
                    "Keys": ["item_id"],
                    "Rows": [{
                        "Edits": [
                            {"Name": "item_id", "Value": "WIDGET-001"},
                            {"Name": "uom", "Value": "EA"},
                            {"Name": "pricing_method", "Value": "Price"},
                            {"Name": "price", "Value": "25.00"},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
                # 3. Break line -- JOBPRICELINE (1 row)
                {
                    "Name": "JOBPRICELINE.jobpriceline",
                    "Type": "List",
                    "Keys": ["item_id"],
                    "Rows": [{
                        "Edits": [
                            {"Name": "item_id", "Value": "WIDGET-002"},
                            {"Name": "uom", "Value": "EA"},
                            {"Name": "pricing_method", "Value": "Source"},
                            {"Name": "source_price", "Value": "Supplier List Price"},
                            {"Name": "multiplier", "Value": "1"},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
                # 4. Break tiers for WIDGET-002 (must follow its JOBPRICELINE).
                #    DELETE THIS ELEMENT to get a version that succeeds on 26.1.
                {
                    "Name": "VALUES.values",
                    "Type": "Form",
                    "Keys": [],
                    "Rows": [{
                        "Edits": [
                            {"Name": "calculation_method_cd", "Value": "Fixed Price"},
                            # Tier 1: qty 1-9 @ $10.00
                            {"Name": "calculation_value1", "Value": "10.00"},
                            {"Name": "break1", "Value": "10"},
                            # Tier 2: qty 10-49 @ $8.50
                            {"Name": "calculation_value2", "Value": "8.50"},
                            {"Name": "break2", "Value": "50"},
                            # Tier 3: qty 50+ @ $7.00
                            {"Name": "calculation_value3", "Value": "7.00"},
                            {"Name": "break3", "Value": "0"},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
            ],
        }],
    }

    response = client.post(f"{ui_server}/api/v2/transaction", headers=headers, json=payload)
    response.raise_for_status()          # HTTP 200 does NOT mean the write succeeded
    result = response.json()
    print("Summary:", result.get("Summary"))
    for transaction in result.get("Results", {}).get("Transactions", []):
        print("  Transaction status:", transaction.get("Status"))
    for message in result.get("Messages") or []:
        print("  Message:", message)

    if (result.get("Summary") or {}).get("Succeeded", 0) > 0:
        txn = result["Results"]["Transactions"][0]
        for edit in txn["DataElements"][0]["Rows"][0]["Edits"]:
            if edit["Name"] == "contract_no":
                # Only a confirmation of the number YOU supplied above.
                print(f"Contract #: {edit['Value']}")
                break

    # ---- read-back: on 26.1 this prints nothing -- the VALUES refusal is
    # atomic, so no contract and no lines were created.
    read_back = client.post(
        f"{ui_server}/api/v2/transaction/get",
        headers=headers,
        json={
            "ServiceName": "JobContractPricing",
            "TransactionStates": [{
                "DataElementName": "FORM.d_dw_job_price_hdr",
                "Keys": [{"Name": "contract_no", "Value": CONTRACT_NO}],
            }],
        },
    )
    read_back.raise_for_status()

    wanted = {"contract_no", "job_no", "customer_id", "end_date"}
    for name, value in walk(read_back.json()):
        if name in wanted:
            print(f"  {name} = {value}")
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 ContractNo = "JOB-1001";       // required -- P21 does not assign it here
const string CustomerId = "100198";
// ---------------------------------------------------------------------------

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

// Create a contract with one fixed-price line and one break line
var payload = new
{
    Name = "JobContractPricing",
    UseCodeValues = false,
    Transactions = new[]
    {
        new
        {
            Status = "New",
            DataElements = new object[]
            {
                // 1. Contract header
                new
                {
                    Name = "FORM.d_dw_job_price_hdr",
                    Type = "Form",
                    Keys = Array.Empty<string>(),
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "company_id", Value = CompanyId },
                                new { Name = "contract_no", Value = ContractNo },
                                new { Name = "customer_id", Value = CustomerId },
                                new { Name = "corp_address_id", Value = "1" },
                                new { Name = "end_date", Value = "2027-12-31" },
                                new { Name = "approved", Value = "ON" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                },
                // 2. Fixed-price line (no breaks)
                new
                {
                    Name = "JOBPRICELINE.jobpriceline",
                    Type = "List",
                    Keys = new[] { "item_id" },
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "item_id", Value = "WIDGET-001" },
                                new { Name = "uom", Value = "EA" },
                                new { Name = "pricing_method", Value = "Price" },
                                new { Name = "price", Value = "25.00" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                },
                // 3. Break line -- JOBPRICELINE (1 row)
                new
                {
                    Name = "JOBPRICELINE.jobpriceline",
                    Type = "List",
                    Keys = new[] { "item_id" },
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "item_id", Value = "WIDGET-002" },
                                new { Name = "uom", Value = "EA" },
                                new { Name = "pricing_method", Value = "Source" },
                                new { Name = "source_price", Value = "Supplier List Price" },
                                new { Name = "multiplier", Value = "1" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                },
                // 4. Break tiers for WIDGET-002 (must follow its JOBPRICELINE).
                //    DELETE THIS ELEMENT to get a version that succeeds on 26.1.
                new
                {
                    Name = "VALUES.values",
                    Type = "Form",
                    Keys = Array.Empty<string>(),
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "calculation_method_cd", Value = "Fixed Price" },
                                // Tier 1: qty 1-9 @ $10.00
                                new { Name = "calculation_value1", Value = "10.00" },
                                new { Name = "break1", Value = "10" },
                                // Tier 2: qty 10-49 @ $8.50
                                new { Name = "calculation_value2", Value = "8.50" },
                                new { Name = "break2", Value = "50" },
                                // Tier 3: qty 50+ @ $7.00
                                new { Name = "calculation_value3", Value = "7.00" },
                                new { Name = "break3", Value = "0" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                }
            }
        }
    }
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/transaction",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();     // HTTP 200 does NOT mean the write succeeded

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine($"Summary: {result.RootElement.GetProperty("Summary")}");
if (result.RootElement.TryGetProperty("Results", out var results)
    && results.TryGetProperty("Transactions", out var resultTransactions))
{
    foreach (var transaction in resultTransactions.EnumerateArray())
        Console.WriteLine($"  Transaction status: {transaction.GetProperty("Status")}");

    if (result.RootElement.GetProperty("Summary").GetProperty("Succeeded").GetInt32() > 0)
    {
        var edits = resultTransactions[0]
            .GetProperty("DataElements")[0]
            .GetProperty("Rows")[0]
            .GetProperty("Edits");
        foreach (var edit in edits.EnumerateArray())
        {
            if (edit.GetProperty("Name").GetString() == "contract_no")
            {
                // Only a confirmation of the number YOU supplied above.
                Console.WriteLine($"Contract #: {edit.GetProperty("Value")}");
                break;
            }
        }
    }
}
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
    foreach (var message in messages.EnumerateArray())
        Console.WriteLine($"  Message: {message}");
}

// ---- read-back: on 26.1 this prints nothing -- the VALUES refusal is
// atomic, so no contract and no lines were created.
var getPayload = new
{
    ServiceName = "JobContractPricing",
    TransactionStates = new[]
    {
        new
        {
            DataElementName = "FORM.d_dw_job_price_hdr",
            Keys = new[] { new { Name = "contract_no", Value = ContractNo } },
        }
    }
};

var readBackResponse = await client.PostAsync(
    $"{uiServer}/api/v2/transaction/get",
    new StringContent(JsonSerializer.Serialize(getPayload), Encoding.UTF8, "application/json"));
readBackResponse.EnsureSuccessStatusCode();

using var readBack = JsonDocument.Parse(await readBackResponse.Content.ReadAsStringAsync());
var wanted = new HashSet<string> { "contract_no", "job_no", "customer_id", "end_date" };
foreach (var (name, value) in Walk(readBack.RootElement))
{
    if (wanted.Contains(name))
        Console.WriteLine($"  {name} = {value}");
}

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

// Yield every {"Name": ..., "Value": ...} pair anywhere in a response.
static IEnumerable<(string Name, string Value)> Walk(JsonElement node)
{
    if (node.ValueKind == JsonValueKind.Object)
    {
        if (node.TryGetProperty("Name", out var name) && node.TryGetProperty("Value", out var value))
            yield return (name.ToString(), value.ToString());
        foreach (var property in node.EnumerateObject())
            foreach (var pair in Walk(property.Value))
                yield return pair;
    }
    else if (node.ValueKind == JsonValueKind.Array)
    {
        foreach (var item in node.EnumerateArray())
            foreach (var pair in Walk(item))
                yield return pair;
    }
}

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

Assembly Service

The Assembly service creates assembly/BOM (bill of materials) definitions for existing inventory items. It defines which components make up an assembled product, along with routing steps and cost estimates. It has 15 DataElements; the key ones are documented below.

See also: Production & Labor API for production order workflows that consume assembly definitions.

Service Definition

GET /api/v2/definition/Assembly

Header -- TABPAGE_1.assemblyhdr (Form, 36 fields)

Key: inv_mast_item_id

Field Type Required Description
inv_mast_item_id Char Yes Item ID (must exist in inventory)
pricing_option Char No Pricing option for the assembly
default_disposition Char No Default disposition code
production_order_processing Char No Production order processing flag
copy_item_id Char No Copy BOM from existing assembly (cc_ prefix = computed/client column)
revision_level Char No Assembly revision level
allow_disassembly Char No Allow disassembly flag
hose_assembly_flag Char No Hose assembly indicator

Important: inv_mast_item_id must reference an existing inventory item. Non-existent items return "This item ID is not valid". Items that already have assembly definitions are blocked from re-creation.

Components/BOM -- TABPAGE_17.tp_17_dw_17 (List, 20 fields)

Key: item_id_service_labor_id

Field Type Required Description
item_id_service_labor_id Char Yes Component item ID or labor ID
quantity (qty_needed) Decimal No Quantity needed per assembly
component_type Char No Component type (see valid values below)
operation_cd Char No Operation code
unit_of_measure Char No UOM (auto-populated from item master if omitted)
backflush_flag Char No Backflush flag

component_type valid values: Hose fitting/adaptor, Hose sleeve, Hose/cable, None

These values are hose-assembly-specific. For non-hose assemblies, omit component_type entirely -- it defaults to empty (IgnoreIfEmpty: true).

unit_of_measure: Not required (IgnoreIfEmpty: true). When omitted, P21 auto-populates from the item master -- standard P21 behavior.

Routing -- ROUTING_TABPAGE.process (Form) + ROUTING_TABPAGE.stage_x_process (List, 22 fields)

Field Type Description
process_code Char Process/routing code
sequence_no Long Operation sequence number
cost Decimal Cost for this routing step
cost_type Char Cost type classification
estimated_hours Decimal Estimated hours for this step

Part + Assembly Creation Workflow

Assembly definitions are metadata attached to existing inventory items. Creating a new assembly-item from scratch requires two steps:

  1. Create the item via Inventory REST API (POST /api/inventory/parts)
  2. Create the assembly definition via Transaction API (Assembly service)

The Assembly service does NOT create new inventory items -- it adds BOM metadata to an item that already exists.

Create through the Transaction API works on a plain item, and fails on an item that is already a component of another assembly.

Case Result
Item is not a component anywhere Header TABPAGE_1.assemblyhdr with inv_mast_item_id + component rows on TABPAGE_17.tp_17_dw_17 → Succeeded: 1; assembly_hdr and one assembly_line per component written
Item is already a component of an existing assembly The window asks "…is a component of an existing assembly, continue?"; the Transaction API answers No and the save throws Cannot insert the value NULL into column 'inv_mast_uid', table '…assembly_hdr' (SQL error 3621, "No changes made to database"). Nothing is left behind
Same, with production_order_processing = OFF Refused earlier and more clearly, on the header element: "This item is another assembly item's component. You cannot change it to be a bypass assembly item." Nothing is left behind

For the second case use the Interactive Assembly window: set TABPAGE_1.assemblyhdr.inv_mast_item_id, answer the popup Yes (cb_1), fill row 1 of tp_17_dw_17 (it already exists — add_row + select_row for each further component), set the header flags, save. Check assembly_line before the first write: a component_inv_mast_uid match tells you which path the item needs. Leave copy_item_id out — it was not tested.

Edits to an existing assembly work through the Transaction API, with keyed Status: "New" and no IgnoreDisabled: a header flag keyed on inv_mast_item_id; a component's quantity keyed on item_id_service_labor_id; and removing a component with delete_flag = ON on its row.

{ "Name": "Assembly", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "TABPAGE_1.assemblyhdr", "Type": "Form", "Keys": ["inv_mast_item_id"],
      "Rows": [{ "Edits": [ {"Name": "inv_mast_item_id", "Value": "ITEM-001"} ],
                 "RelativeDateEdits": [] }] },
    { "Name": "TABPAGE_17.tp_17_dw_17", "Type": "List", "Keys": ["item_id_service_labor_id"],
      "Rows": [{ "Edits": [
        {"Name": "item_id_service_labor_id", "Value": "COMP-A"},
        {"Name": "quantity",                 "Value": "5"}
      ], "RelativeDateEdits": [] }] }
  ]}]
}

Runnable: assembly_create_edit.py · AssemblyCreateEdit.cs (Recipes menu 22) · payloads assembly-create.json, assembly-edit-component.json, assembly-delete-component.json. Modes create, set-flag, set-qty, delete-component; create checks assembly_hdr and assembly_line first and stops on an item that already has an assembly or is already a component. Every mode reads assembly_hdr and assembly_line back over OData.

Credit: verified here by the maintainer on 26.1.5950.0 (October 2026), originally reported by Alex Westemeier. Python and C# examples run on play 26.1.5950.0, 2026-10-10: each created an assembly (two components, production_order_processing OFF) on a throwaway item made with the Inventory REST API, set production_order_processing to ON, changed one component's quantity from 2 to 5, and deleted the other component — every step confirmed by the OData read-back. The component-of-another-assembly refusals above were re-run on the same build.

Defaults and Disabled Fields on Create

Send production_order_processing explicitly. On a create that omits it, the new assembly came back with production_order_processing = ON, which makes it a production-order assembly rather than a kit (see 12 § Assembly Behavior Flags). The ON default may come from a system setting rather than the service, so it can differ on your tenant. The field is writable on create — send ON or OFF deliberately and read assembly_hdr back.

item_desc and prorate_cost_by are disabled on create. Sending either aborts the transaction:

TABPAGE_1.assemblyhdr: Error processing data element: assemblyhdr : Column is disabled: item_desc

item_desc comes from the item itself (inv_mast.item_desc); change it on the item. The service fails on the first disabled field it meets, so a payload copied from an existing assembly's read fails one field at a time. Create with the minimum — header inv_mast_item_id plus production_order_processing, and item_id_service_labor_id + quantity per component row — then set other header fields with a second keyed Status: "New" Transaction on inv_mast_item_id. A follow-up Transaction is also how you change production_order_processing on an assembly created with the wrong value.

Credit: @mrwuss — verified on production 26.1, May 2026 (#180).

Known Limitations

Example: Create an Assembly Definition

"""Create an assembly/BOM definition on an existing inventory item, then read it back."""
import re

import httpx

# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com"   # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False                        # True once you trust the cert chain
ITEM_ID = "WIDGET-001"                    # must ALREADY exist in inventory
COMPONENT_A = "COMPONENT-A"               # component items, also pre-existing
COMPONENT_B = "COMPONENT-B"
# ---------------------------------------------------------------------------


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("/")


def walk(node):
    """Yield every {"Name": ..., "Value": ...} pair anywhere in a response."""
    if isinstance(node, dict):
        if "Name" in node and "Value" in node:
            yield node["Name"], node["Value"]
        for value in node.values():
            yield from walk(value)
    elif isinstance(node, list):
        for item in node:
            yield from walk(item)


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

    # Create assembly definition for an existing item.
    # The Assembly service does NOT create the inventory item itself.
    payload = {
        "Name": "Assembly",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",
            "DataElements": [
                # Assembly header
                {
                    "Name": "TABPAGE_1.assemblyhdr",
                    "Type": "Form",
                    "Keys": ["inv_mast_item_id"],
                    "Rows": [{
                        "Edits": [
                            {"Name": "inv_mast_item_id", "Value": ITEM_ID},
                            {"Name": "allow_disassembly", "Value": "ON"},
                        ],
                        "RelativeDateEdits": [],
                    }],
                },
                # BOM components
                {
                    "Name": "TABPAGE_17.tp_17_dw_17",
                    "Type": "List",
                    "Keys": ["item_id_service_labor_id"],
                    "Rows": [
                        {
                            "Edits": [
                                {
                                    "Name": "item_id_service_labor_id",
                                    "Value": COMPONENT_A,
                                },
                                {"Name": "quantity", "Value": "2"},
                                {"Name": "operation_cd", "Value": "ASSY"},
                            ],
                            "RelativeDateEdits": [],
                        },
                        {
                            "Edits": [
                                {
                                    "Name": "item_id_service_labor_id",
                                    "Value": COMPONENT_B,
                                },
                                {"Name": "quantity", "Value": "1"},
                                {"Name": "operation_cd", "Value": "ASSY"},
                            ],
                            "RelativeDateEdits": [],
                        },
                    ],
                },
            ],
        }],
    }

    response = client.post(f"{ui_server}/api/v2/transaction", headers=headers, json=payload)
    response.raise_for_status()          # HTTP 200 does NOT mean the write succeeded
    result = response.json()
    print("Summary:", result.get("Summary"))
    for transaction in result.get("Results", {}).get("Transactions", []):
        print("  Transaction status:", transaction.get("Status"))
    for message in result.get("Messages") or []:
        print("  Message:", message)

    # ---- read-back: the only proof the definition landed --------------------
    read_back = client.post(
        f"{ui_server}/api/v2/transaction/get",
        headers=headers,
        json={
            "ServiceName": "Assembly",
            "TransactionStates": [
                {
                    "DataElementName": "TABPAGE_1.assemblyhdr",  # Keys: inv_mast_item_id
                    "Keys": [{"Name": "inv_mast_item_id", "Value": ITEM_ID}],
                },
                {"DataElementName": "TABPAGE_17.tp_17_dw_17", "Keys": []},
            ],
        },
    )
    read_back.raise_for_status()

    wanted = {"inv_mast_item_id", "allow_disassembly",
              "item_id_service_labor_id", "quantity"}
    for name, value in walk(read_back.json()):
        if name in wanted:
            print(f"  {name} = {value}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com";   // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001";                    // must ALREADY exist in inventory
const string ComponentA = "COMPONENT-A";               // components, also pre-existing
const string ComponentB = "COMPONENT-B";
// ---------------------------------------------------------------------------

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

// Create assembly definition for an existing item.
// The Assembly service does NOT create the inventory item itself.
var payload = new
{
    Name = "Assembly",
    UseCodeValues = false,
    Transactions = new[]
    {
        new
        {
            Status = "New",
            DataElements = new object[]
            {
                // Assembly header
                new
                {
                    Name = "TABPAGE_1.assemblyhdr",
                    Type = "Form",
                    Keys = new[] { "inv_mast_item_id" },
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "inv_mast_item_id", Value = ItemId },
                                new { Name = "allow_disassembly", Value = "ON" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                },
                // BOM components
                new
                {
                    Name = "TABPAGE_17.tp_17_dw_17",
                    Type = "List",
                    Keys = new[] { "item_id_service_labor_id" },
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "item_id_service_labor_id", Value = ComponentA },
                                new { Name = "quantity", Value = "2" },
                                new { Name = "operation_cd", Value = "ASSY" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        },
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "item_id_service_labor_id", Value = ComponentB },
                                new { Name = "quantity", Value = "1" },
                                new { Name = "operation_cd", Value = "ASSY" },
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                }
            }
        }
    }
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/transaction",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();     // HTTP 200 does NOT mean the write succeeded

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine($"Summary: {result.RootElement.GetProperty("Summary")}");
if (result.RootElement.TryGetProperty("Results", out var results)
    && results.TryGetProperty("Transactions", out var resultTransactions))
{
    foreach (var transaction in resultTransactions.EnumerateArray())
        Console.WriteLine($"  Transaction status: {transaction.GetProperty("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 definition landed ----------------------
var getPayload = new
{
    ServiceName = "Assembly",
    TransactionStates = new object[]
    {
        new
        {
            DataElementName = "TABPAGE_1.assemblyhdr",       // Keys: inv_mast_item_id
            Keys = new[] { new { Name = "inv_mast_item_id", Value = ItemId } },
        },
        new { DataElementName = "TABPAGE_17.tp_17_dw_17", Keys = Array.Empty<object>() },
    }
};

var readBackResponse = await client.PostAsync(
    $"{uiServer}/api/v2/transaction/get",
    new StringContent(JsonSerializer.Serialize(getPayload), Encoding.UTF8, "application/json"));
readBackResponse.EnsureSuccessStatusCode();

using var readBack = JsonDocument.Parse(await readBackResponse.Content.ReadAsStringAsync());
var wanted = new HashSet<string>
{
    "inv_mast_item_id", "allow_disassembly", "item_id_service_labor_id", "quantity"
};
foreach (var (name, value) in Walk(readBack.RootElement))
{
    if (wanted.Contains(name))
        Console.WriteLine($"  {name} = {value}");
}

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

// Yield every {"Name": ..., "Value": ...} pair anywhere in a response.
static IEnumerable<(string Name, string Value)> Walk(JsonElement node)
{
    if (node.ValueKind == JsonValueKind.Object)
    {
        if (node.TryGetProperty("Name", out var name) && node.TryGetProperty("Value", out var value))
            yield return (name.ToString(), value.ToString());
        foreach (var property in node.EnumerateObject())
            foreach (var pair in Walk(property.Value))
                yield return pair;
    }
    else if (node.ValueKind == JsonValueKind.Array)
    {
        foreach (var item in node.EnumerateArray())
            foreach (var pair in Walk(item))
                yield return pair;
    }
}

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

Item Service -- Nested Location Edits

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. This is the Transaction-API equivalent of "select parent row → edit child detail," and it works because the Item window's tabs aren't gated behind row selection. It's a good template for any nested edit.

The two payloads below are shapes, not programs -- swap either into the payload of a complete example. Full runnable version: Create Order.

Set an item's primary bin at a location (Form → List → Form)

{
    "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"} ] }] }
        ]
    }]
}

Status: "New" with populated Keys updates the existing keyed record (it does not create a new item).

Do not repeat location_id in the detail form's Edits. It identifies the row and belongs in Keys only; as an edit it is a disabled column and fails the whole transaction — General Exception: Column is disabled: location_id, Failed: 1 (verified 26.1.5940.0, August 2026). IgnoreDisabled: true silences the error, which is the wrong fix: it is the flag whose success proves nothing. Drop the field instead. The same applies to track_bins and every other edit on this form.

Set an item's primary supplier at a location (Form → List → List)

Same window, one level different — the third element is 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 this writes, and the cascade (verified on a 68-item production run):

Adding a Location Supplier Row (the Prerequisite Insert)

The flip above promotes a supplier that is already on the location's supplier list. When the row does not exist there is nothing to promote, and the transaction reports success while doing nothing — the silent no-op below. This is the payload that creates the missing row, verified on 26.1.5940.0 (August 2026):

{ "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"}
  ] }] }

Two differences from the flip, and both are load-bearing:

P21 creates the item-level inventory_supplier record too when it is missing, so one call covers both. The new row lands with primary_supplier = 'N'; promote it with the flip payload afterwards.

This is the flag's other outcome. The same IgnoreDisabled: true that genuinely inserts here is the one that reports success while writing nothing on other elements — see Breaking Changes entry 8. Nothing in either response tells you which you got. Read the row back.

Reading it back: inventory_supplier_x_loc is not necessarily exposed over OData (404 on the tested tenant). POST /api/v2/transaction/get on Item returns SUPPLIER_X_LOCATION.supplier_x_location with every location's rows, which is the reliable check.

Enabling Bin Tracking at a Location (track_bins)

inv_loc.track_bins is a checkbox on the same location detail form, so it takes the checkbox spelling — "ON" / "OFF", not Y/N:

{ "Name": "TABPAGE_18.inv_loc_detail", "Type": "Form", "Keys": ["location_id"],
  "Rows": [{ "Edits": [ {"Name": "track_bins", "Value": "ON"} ] }] }

Verified on 26.1.5940.0 (August 2026), and at scale across ~14,700 item-locations in a test environment. What to expect:

Item Window Element Map (Locations)

The location-side elements chain Form → List → Form/List, and the tab numbers are not guessable:

Data element Type Carries
TABPAGE_1.tp_1_dw_1 Form the item header — item_id
TABPAGE_17.invloclist List the location rows; selects which location the elements below apply to
TABPAGE_18.inv_loc_detail Form that location's detail — bin, track_bins, …
TABPAGE_23.tp_23_dw_23 Form that location's purchase_class_id (ABC class)
SUPPLIER_X_LOCATION.supplier_x_location List that location's supplier rows — supplier_id, primary_supplier
TABPAGE_24.inv_loc_accounts List that location's gl_account_no, revenue_account_no, cos_account_no — disabled under the Transaction API (see below)

Changing a Location's Product Group Re-Syncs Its GL Accounts

Editing product_group_id on TABPAGE_18.inv_loc_detail also rewrites that location's inventory (asset), revenue, and COS accounts to the target product group's defaults (product_group.asset_account_no, revenue_account_no, cos_account_no). Nothing in the payload asks for it, nothing in the response mentions it, and Summary.Succeeded is 1.

{ "Name": "TABPAGE_18.inv_loc_detail", "Type": "Form", "Keys": ["location_id"],
  "Rows": [{ "Edits": [ {"Name": "product_group_id", "Value": "PG-B"} ] }] }
Step inv_loc.product_group_id gl_account_no / revenue_account_no / cos_account_no
Before PG-A 11110-01 / 33300-01 / 44400-01 (the PG-A defaults)
Move to PG-B PG-B 11110-02 / 33300-02 / 44400-02 (the PG-B defaults)
Move back to PG-A PG-A 11110-01 / 33300-01 / 44400-01

audit_trail records all four columns in the same second, with created_by set to the acting user.

⚠️ The re-sync compares the target group's defaults with the location's current accounts, not with the source group's defaults. A location that carries its own accounts (for example, branch-specific accounts where the product group holds one default set) can be reset to the group defaults by a move between two groups whose defaults are identical. In that test the reset hit some of the items moved, not all; what decides which was not determined. When the location's accounts already equal the target defaults, only product_group_id changes.

This is the Transaction API counterpart of the Item window's "update GL accounts?" prompt. The Transaction API answers it with its default (Yes), and there is no way to answer No. In the Interactive API the prompt is a w_message you can drive yourself with ResponseWindowHandlingEnabled: true — see 04 § Response Windows.

You cannot restore the accounts in the same transaction. The location accounts element TABPAGE_24.inv_loc_accounts (keys location_id; fields gl_account_no, revenue_account_no, cos_account_no) is in the Item definition, but any edit to it fails and aborts the whole transaction, product-group edit included:

General Exception: Column is disabled: gl_account_no

To move product groups without losing location accounts:

  1. Before the move, snapshot inv_loc.gl_account_no, revenue_account_no, and cos_account_no for every location you will touch.
  2. Compare the source and target groups' default accounts in product_group. If they differ, the move changes where revenue and COS post; get accounting sign-off first.
  3. Make the move.
  4. Read the three columns back. Where they changed and the change was not intended, write the snapshot values back by SQL, then read them back again to confirm. The SQL write is unsupported by Epicor and leaves no application-level audit record, so test it on a non-production tenant first and log the before-image yourself.

Whether the Inventory REST API's ProductGroupId update re-syncs accounts the same way was not tested.

Credit: @mrwuss — verified on play 26.1, September 2026, with a consumer-key token acting as a named user; the SQL restore in step 4 left no location differing from its snapshot (#166).

Item Service Gotchas

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

Location Service — Creating a Warehouse

Location / TABPAGE_1.tp_1_dw_1 (business object location, datawindow d_location) is Warehouse/Location Maintenance. Keys are id (→ location.location_id) + company_id; id also fans out to a shared-keyspace address row (same mail_-prefix convention as everywhere else address is joined).

Creating a new warehouse — id is server-generated, and you cannot request one

A location_id is never supplied by the caller — every way of trying lands on a different failure mode:

What you send Result
Keys: ["id", "company_id"], id = a specific new number (e.g. 99999) Refused: General Exception: Enter a valid ID or leave ID blank. DataElement: tp_1_dw_1, Column: id, Value: 99999
Keys: ["id", "company_id"], id = "" (blank) False success — Succeeded: 1, the response even echoes back id: "0" — but nothing is created or updated. location_id 0 is a pre-existing reserved row on every tenant tested; the write silently no-ops against it (confirmed by re-reading both location and address before and after — neither changed)
Keys: [] (no key fields at all — id absent from the payload entirely, not just blank) Succeeds, and P21 assigns a real, unused location_id (verified on a test tenant), with location_type auto-set to 1330

The working create, in full:

{
  "Name": "Location",
  "UseCodeValues": false,
  "IgnoreDisabled": false,
  "Transactions": [
    {
      "Status": "New",
      "DataElements": [
        {
          "Name": "TABPAGE_1.tp_1_dw_1",
          "Type": "Form",
          "Keys": [],
          "Rows": [
            {
              "Edits": [
                {"Name": "location_name",         "Value": "New Warehouse"},
                {"Name": "central_phone_number",  "Value": "5155550100"},
                {"Name": "company_id",            "Value": "ACME"},
                {"Name": "branch_id",             "Value": "10"}
              ]
            }
          ]
        }
      ]
    }
  ]
}

id, central_phone_number, company_id, and branch_id are the four Required: true fields on this element — location_name is not required by the definition but is the only human-readable identifier, so treat it as required in practice. branch_id must be an existing branch.branch_id. The generated location_id comes back the same way a Customer create's generated key does — as an Edits entry named id inside the Passed transaction's own DataElements[].Rows[].Edits[], not a separate event.

Updating an existing location — company_id is disabled after create

Once a location exists, re-keying on id alone (Keys: ["id"], or Keys: ["id", "company_id"] with company_id simply absent from Edits) updates it normally. Including company_id in Edits on an update — even unchanged, even still listed in Keys — is refused:

General Exception: Column is disabled: company_id

company_id is accepted only on the initial create; keep it out of every subsequent Edits list. delete_flag (ValidValues: ["ON", "OFF"], column location.delete_flag) soft-deletes a location the same way — omit company_id from that request too.

GET /api/entity/locations 404s (not one of the Entity API's four entities), and the Interactive Location window's tools are Save/Clear/Close — no New — so the Keys: [] create above is the only verified path to a new warehouse number. Out of scope here: inv_loc (item-at-location, see Item Service — Nested Location Edits) and location-append via the Inventory REST API. (Filed as #161.)

BinLocation Service -- Creating Bins

The BinLocation service is the Bin Location Maintenance window: its form element FORM.form is business object bin (datawindow d_dw_bin_form), and every field in the payload is a real field on that screen. Bulk bin creation is a clean Transaction API use case — verified in production at hundreds of bins per run.

Payload shape only. Full runnable version -- same endpoint and response checks: Create Order. A complete bin-creation walkthrough lives in recipes/create-bins.md.

{
  "Name": "BinLocation",
  "UseCodeValues": false,
  "IgnoreDisabled": true,
  "Transactions": [
    {
      "Status": "New",
      "DataElements": [
        { "Name": "FORM.form", "Type": "Form",
          "Keys": ["company_id", "location_id", "bin_id"],
          "Rows": [ { "Edits": [
            {"Name": "company_id",      "Value": "ACME"},
            {"Name": "location_id",     "Value": "10"},
            {"Name": "bin_id",          "Value": "A01-02-03"},
            {"Name": "bin_type",        "Value": "SHELF"},
            {"Name": "putaway_zone_id", "Value": "ZONE-A"},
            {"Name": "pick_zone_id",    "Value": "ZONE-A"},
            {"Name": "bin_length", "Value": "10"}, {"Name": "bin_width", "Value": "10"}, {"Name": "bin_height", "Value": "11"},
            {"Name": "warehouse_sequence", "Value": "1"}, {"Name": "putaway_zone_sequence", "Value": "1"}, {"Name": "pick_zone_sequence", "Value": "1"},
            {"Name": "max_unique_items", "Value": "0"},
            {"Name": "pick_locked_flag", "Value": "OFF"}, {"Name": "put_locked_flag", "Value": "OFF"},
            {"Name": "full_flag", "Value": "OFF"}, {"Name": "frozen_flag", "Value": "OFF"},
            {"Name": "consolidation_bin_flag", "Value": "OFF"}, {"Name": "stage_bin_flag", "Value": "OFF"}, {"Name": "door_bin_flag", "Value": "OFF"}
          ] } ] }
      ]
    }
  ]
}

Status: "New" with the three-field key makes this a create when the (company_id, location_id, bin_id) combination doesn't exist yet.

BinLocation Gotchas

Credit: Alex Westemeier — pattern verified in production (July 2026), including the IgnoreDisabled placement failure mode.

Locking, Unlocking, and Deleting Bins

Same service, same element, same three-field key — the keyed Status: "New" upsert edits the existing bin.

Operation Edits IgnoreDisabled
Lock pick_locked_flag and/or put_locked_flag = ON Not needed — both flags are enabled columns. Creation only needs it because it sends system columns such as frozen_flag
Unlock the same flags = OFF Not needed
Delete delete_flag = ON Not needed on 26.1.5950.0 — the delete and its restore both landed without it. The production run credited below sent it at the payload top level; if your build answers Column is disabled: delete_flag, add it there and read back
Restore delete_flag = OFF Not needed
{ "Name": "BinLocation", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "FORM.form", "Type": "Form",
      "Keys": ["company_id", "location_id", "bin_id"],
      "Rows": [{ "Edits": [
        {"Name": "company_id",       "Value": "ACME"},
        {"Name": "location_id",      "Value": "10"},
        {"Name": "bin_id",           "Value": "A01-02-03"},
        {"Name": "pick_locked_flag", "Value": "ON"},
        {"Name": "put_locked_flag",  "Value": "ON"}
      ], "RelativeDateEdits": [] }] }
  ]}]
}

Runnable: bin_lock_delete.py · BinLockDelete.cs (Recipes menu 20) · payloads bin-lock.json, bin-delete.json. Modes lock, unlock, delete, restore; the scripts print the current flags first, refuse to lock or delete a bin that holds stock or is a primary bin, and read every bin back through p21_view_bin.

Verified here: Python and C# examples run on play 26.1.5950.0, 2026-10-10 — lock, unlock, delete and restore on throwaway bins, each confirmed by the p21_view_bin read-back, none with IgnoreDisabled.

Credit: Alex Westemeier — lock and delete verified on play and production, July 2026 (thousands of bins, zero silent skips after SQL verification). The examples above re-ran lock, unlock, delete and restore on single throwaway bins; the bulk run, the production result and the silent-skip count rest on that report.

PutawayZone / PickZone Services — Creating Bin Zones

A freshly created P21 location has no bin zones, and BinLocation (above) requires putaway_zone_id/pick_zone_id codes that resolve within the location — so on a new location the bin recipe fails until zones exist. The PutawayZone and PickZone services create them (verified in production, August 2026).

Both are single-form services — business object bin_zone, element FORM.form, keys location_id + bin_zone_id:

{
  "Name": "PutawayZone",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [
      { "Name": "FORM.form", "Type": "Form",
        "Keys": ["location_id", "bin_zone_id"],
        "Rows": [ { "Edits": [
          {"Name": "company_id",  "Value": "ACME"},
          {"Name": "location_id", "Value": "10"},
          {"Name": "bin_zone_id", "Value": "ZONE-A"},
          {"Name": "zone_desc",   "Value": "Zone A"}
        ] } ] }
    ]
  }]
}

(From issue #112, verified in production 2026-08-14.)

Shipping Service -- Carrier Tracking Number

Shipping is the only service that writes oe_pick_ticket.tracking_no. A scan of all 299 services returned by GET /api/v2/services (240 returned a definition; the rest answer with the unavailable-window HTTP 500) found the column in exactly one writable place. Verified on a P21 26.1 tenant, 2026-08-11.

The element

Property Value
DataElement TABPAGE_1.tp_1_dw_1
BusinessObjectName oe_pick_ticket
DatawindowName d_ship
KeyFields ['pick_ticket_no']
Field DataType Label Notes
tracking_no Char Carrier Tracking Number Writes oe_pick_ticket.tracking_no
carrier_id Decimal Carrier Carries ValidValues — with UseCodeValues: false you send the carrier's display name, not the id
create_invoice Char Confirm Shipment ValidValues: ON / OFF
delete_flag Char Delete ValidValues: ON / OFF — see Deleting a Pick Ticket

The limitation: invoiced pick tickets refuse the write

Once the pick ticket has been invoiced, the write is refused:

Summary: {"Failed": 1, "Succeeded": 0}
General Exception: This pick ticket has already been invoiced.

The error is attributed to DataElement: tp_1_dw_1, Column: pick_ticket_no — not to tracking_no. That attribution is the tell: the gate fires at record selection, before any field-level validation, so there is no edit you can drop from the payload to get past it. Sending tracking_no alone fails exactly the same way. It is not a screen version either: the only web-client screen version on the Shipping window that names tracking_no changes its color, not its enabled state (checked with the screen-version query, filtered on m.object_name).

Other services expose a tracking column, but none writes this one

Each of these writes a different column — none of them reaches oe_pick_ticket.tracking_no:

The Order shipment grids are not a post-invoice back door

They look like one — both are keyed grids on the Order service that survive invoicing:

Editing c_tracking_no on either returns General Exception: Column is disabled: c_tracking_no. It is a computed display column, not storage. Keying on invoice_no does not help.

Workarounds

  1. Set tracking_no in the same transaction that sets create_invoice ("Confirm Shipment"). This is the normal path and it works — but only when the tracking number already exists at confirm time. The example below does exactly this.
  2. A user-defined field on oe_pick_ticket_ud, writable through the UDT Service API. Caveat: it does not populate the native oe_pick_ticket.tracking_no, so customer portals, EDI, and third-party shipping integrations that read the native column will not see it.

Open question: company.edit_tracking_number_flag

company.edit_tracking_number_flag (varchar(1)) is P21's own switch for tracking-number editing. Treat this as unproven, not as a finding. On the system under test it was 'N'; flipping it to 'Y' and retrying produced identical errors. That is not a disproof — the SOA middleware pools PowerBuilder sessions and reads company settings at session creation, so the change plausibly requires a middleware restart, which the test environment could not perform. If you can restart middleware, this is the first thing to re-test.

Example: set the tracking number while confirming a shipment

"""Set carrier + tracking number on a pick ticket while confirming its shipment."""
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
PICK_TICKET_NO = "123456"                 # must NOT be invoiced yet
CARRIER = "ACME FREIGHT"                  # carrier DISPLAY name (UseCodeValues: false)
TRACKING_NO = "TRACK-0000000000001"       # the carrier's tracking number
# ---------------------------------------------------------------------------


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("/")


def walk(node):
    """Yield every {"Name": ..., "Value": ...} pair anywhere in a response."""
    if isinstance(node, dict):
        if "Name" in node and "Value" in node:
            yield node["Name"], node["Value"]
        for value in node.values():
            yield from walk(value)
    elif isinstance(node, list):
        for item in node:
            yield from walk(item)


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

    # Status "New" with a populated Keys array updates the keyed pick ticket.
    payload = {
        "Name": "Shipping",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",
            "DataElements": [{
                "Name": "TABPAGE_1.tp_1_dw_1",
                "Type": "Form",
                "Keys": ["pick_ticket_no"],
                "Rows": [{
                    "Edits": [
                        {"Name": "pick_ticket_no", "Value": PICK_TICKET_NO},
                        {"Name": "carrier_id", "Value": CARRIER},
                        {"Name": "tracking_no", "Value": TRACKING_NO},
                        {"Name": "create_invoice", "Value": "ON"},   # Confirm Shipment
                    ],
                    "RelativeDateEdits": [],
                }],
            }],
        }],
    }

    response = client.post(f"{ui_server}/api/v2/transaction", headers=headers, json=payload)
    response.raise_for_status()          # HTTP 200 does NOT mean the write succeeded
    result = response.json()
    print("Summary:", result.get("Summary"))
    for message in result.get("Messages") or []:
        print("  Message:", message)

    # ---- read-back: the only proof the value landed -------------------------
    read_back = client.post(
        f"{ui_server}/api/v2/transaction/get",
        headers=headers,
        json={
            "ServiceName": "Shipping",
            "TransactionStates": [{
                "DataElementName": "TABPAGE_1.tp_1_dw_1",
                "Keys": [{"Name": "pick_ticket_no", "Value": PICK_TICKET_NO}],
            }],
        },
    )
    read_back.raise_for_status()

    wanted = {"pick_ticket_no", "carrier_id", "tracking_no", "invoice_no"}
    for name, value in walk(read_back.json()):
        if name in wanted:
            print(f"  {name} = {value}")
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 PickTicketNo = "123456";                  // must NOT be invoiced yet
const string Carrier = "ACME FREIGHT";                 // carrier DISPLAY name
const string TrackingNo = "TRACK-0000000000001";       // the carrier's tracking number
// ---------------------------------------------------------------------------

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

// Status "New" with a populated Keys array updates the keyed pick ticket.
var payload = new
{
    Name = "Shipping",
    UseCodeValues = false,
    Transactions = new[]
    {
        new
        {
            Status = "New",
            DataElements = new[]
            {
                new
                {
                    Name = "TABPAGE_1.tp_1_dw_1",
                    Type = "Form",
                    Keys = new[] { "pick_ticket_no" },
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "pick_ticket_no", Value = PickTicketNo },
                                new { Name = "carrier_id", Value = Carrier },
                                new { Name = "tracking_no", Value = TrackingNo },
                                new { Name = "create_invoice", Value = "ON" },   // Confirm Shipment
                            },
                            RelativeDateEdits = Array.Empty<object>(),
                        }
                    }
                }
            }
        }
    }
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/transaction",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();     // HTTP 200 does NOT mean the write succeeded

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Console.WriteLine($"Summary: {result.RootElement.GetProperty("Summary")}");
if (result.RootElement.TryGetProperty("Messages", out var messages))
{
    foreach (var message in messages.EnumerateArray())
        Console.WriteLine($"  Message: {message}");
}

// ---- read-back: the only proof the value landed ---------------------------
var getPayload = new
{
    ServiceName = "Shipping",
    TransactionStates = new[]
    {
        new
        {
            DataElementName = "TABPAGE_1.tp_1_dw_1",
            Keys = new[] { new { Name = "pick_ticket_no", Value = PickTicketNo } },
        }
    }
};

var readBackResponse = await client.PostAsync(
    $"{uiServer}/api/v2/transaction/get",
    new StringContent(JsonSerializer.Serialize(getPayload), Encoding.UTF8, "application/json"));
readBackResponse.EnsureSuccessStatusCode();

using var readBack = JsonDocument.Parse(await readBackResponse.Content.ReadAsStringAsync());
var wanted = new HashSet<string> { "pick_ticket_no", "carrier_id", "tracking_no", "invoice_no" };
foreach (var (name, value) in Walk(readBack.RootElement))
{
    if (wanted.Contains(name))
        Console.WriteLine($"  {name} = {value}");
}

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

// Yield every {"Name": ..., "Value": ...} pair anywhere in a response.
static IEnumerable<(string Name, string Value)> Walk(JsonElement node)
{
    if (node.ValueKind == JsonValueKind.Object)
    {
        if (node.TryGetProperty("Name", out var name) && node.TryGetProperty("Value", out var value))
            yield return (name.ToString(), value.ToString());
        foreach (var property in node.EnumerateObject())
            foreach (var pair in Walk(property.Value))
                yield return pair;
    }
    else if (node.ValueKind == JsonValueKind.Array)
    {
        foreach (var item in node.EnumerateArray())
            foreach (var pair in Walk(item))
                yield return pair;
    }
}

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

Shipping Service — Deleting a Pick Ticket

The same Shipping header element carries the window's Delete checkbox: delete_flag (oe_pick_ticket.delete_flag, ValidValues ON/OFF), beside create_invoice (Confirm Shipment). Send delete_flag = ON with create_invoice = OFF:

{
  "Name": "Shipping",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [{
      "Name": "TABPAGE_1.tp_1_dw_1",
      "Type": "Form",
      "Keys": ["pick_ticket_no"],
      "Rows": [{ "Edits": [
        {"Name": "pick_ticket_no", "Value": "123456"},
        {"Name": "create_invoice", "Value": "OFF"},
        {"Name": "delete_flag", "Value": "ON"}
      ] }]
    }]
  }]
}

This works only on a ticket the Shipping window can still retrieve. It cannot reach an orphaned pick ticket: one that was printed but never invoiced, left behind on an order that is now complete.

Orphaned ticket Response
Order completed; the line closed (partially invoiced, the rest canceled) Record specified was not found. Try again. on tp_1_dw_1.pick_ticket_no
Order completed; the line fully invoiced Same
Order completed; the ticket lists assembly components whose parent order line was deleted Parent line cannot be found for item ITEM-001; parent item <line_no> may not exist at location %s. (the unformatted %s is in P21's own message)

Each returns Summary.Failed = 1 and changes nothing: no delete, no invoice. Both refusals fire before any edit is applied, so no change to the payload gets past them:

The P21 client uses the same window logic, so it cannot delete these tickets either. The remaining route is a soft delete in SQL on the oe_pick_ticket header row (delete_flag = 'Y'). A direct SQL write is unsupported by Epicor, bypasses P21's business logic, and leaves no application-level audit record — try it on a non-production tenant first. Guard it to tickets with invoice_no IS NULL whose order has oe_hdr.completed = 'Y', and keep a before-image so you can roll it back. On tickets for completed orders, this had no allocation, inventory, or invoice effect.

Credit: @mrwuss — verified on play 26.1, September 2026, with a SQL read-back after each call; production held tickets in the same states (#165).


PurchaseOrder Service — Creating a PO

The buy-side counterpart to Order, and just as clean: one stateless POST, no session. Verified on a 26.1 tenant (August 2026) — PO 500101, confirmed in po_hdr/po_line.

{
  "Name": "PurchaseOrder", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": [],
      "Rows": [{ "Edits": [
        {"Name": "location_id",    "Value": "40"},
        {"Name": "vendor_id",      "Value": "99001"},
        {"Name": "buyer_id",       "Value": "829"},
        {"Name": "order_date",     "Value": "08/20/2026"},
        {"Name": "required_date",  "Value": "08/25/2026"},
        {"Name": "external_po_no", "Value": "TAG-ME"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "TABPAGE_17.tp_17_dw_17", "Type": "List", "Keys": ["item_id"],
      "Rows": [{ "Edits": [
        {"Name": "item_id",            "Value": "WIDGET-001"},
        {"Name": "unit_quantity",      "Value": "2"},
        {"Name": "unit_of_measure",    "Value": "EA"},
        {"Name": "pricing_unit",       "Value": "EA"},
        {"Name": "unit_price_display", "Value": "6.55"}
      ], "RelativeDateEdits": [] }] }
  ]}]
}

Field mappings, each confirmed by read-back:

You send Lands in Note
unit_price_display po_line.unit_price Not unit_price. The field is labelled "PO Price"
buyer_id po_hdr.requested_by An employee/user number (829), not a login name
(omitted) division_id defaults — mirrored supplier_id Marked Required: true; see What Required actually means
(omitted) company_id defaults Also marked required; also omitted
external_po_no po_hdr.external_po_no Useful for tagging test POs

PurchaseOrder Service — Editing a Completed PO

A header edit on a completed PO (po_hdr.complete = 'Y') fails with Failed: 1 and "Save is not enabled.": the window's Save is greyed out, as it is in the client. The client's workaround works through the API too. Add one blank row to the lines grid in the same transaction, and the header edit saves; the blank row is thrown away.

{
  "Name": "PurchaseOrder",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [
      {"Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["po_no"],
       "Rows": [{"Edits": [{"Name": "po_no", "Value": "500100"}]}]},
      {"Name": "TABPAGE_3.tp_3_dw_3", "Type": "Form", "Keys": [],
       "Rows": [{"Edits": [{"Name": "po_desc", "Value": "Revised description"}]}]},
      {"Name": "TABPAGE_17.tp_17_dw_17", "Type": "List", "Keys": [],
       "Rows": [{"Edits": [{"Name": "line_no", "Value": ""}, {"Name": "item_id", "Value": ""}]}]}
    ]
  }]
}
Payload Result
po_no + po_desc only Failed: 1, "Save is not enabled.", nothing written
The same + the blank TABPAGE_17.tp_17_dw_17 row Succeeded: 1; po_hdr.po_desc updated, po_line count unchanged

(Verified on play 26.1.5950.0, 2026-10-29, on a completed test PO. Community lead: John Kennedy and Spencer Smith, P21WWUG forum.)

PurchaseOrderReceipt Service — Receiving a PO

The Transaction API can receive a PO — verified on 26.1 (August 2026), receipt 7000100 against PO 500101, with nothing but a header:

{ "Name": "TABPAGE_1.tp_1_dw_po", "Type": "Form", "Keys": [],
  "Rows": [{ "Edits": [
    {"Name": "po_no",       "Value": "500101"},
    {"Name": "receive_all", "Value": "Y"}
  ], "RelativeDateEdits": [] }] },
{ "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": [],
  "Rows": [{ "Edits": [{"Name": "external_reference_no", "Value": "TAG-ME"}], "RelativeDateEdits": [] }] }

receive_all: "Y" sets every line to its remaining quantity. The receipt lands in inventory_receipts_hdr (receipt_type 'P') with lines in inventory_receipts_line — note that table uses line_number, not line_no.

It only works when every line's item has a usable primary bin. The receipt window's Bin tab is contextual to the selected items-grid row, so flat TABPAGE_BIN.tabpage_bin rows land on whichever line is current rather than the item named in the row — the same context rule that governs order lines and extended info. When a line needs an explicit bin, the stateless API cannot address it and you must drive the window through the Interactive API.

Two failure signatures tell you that's where you are: The sum of bin quantity for <item> does not equal the received quantity — often because the item's inv_loc.primary_bin is '0', which is put-locked (bin.put_locked_flag='Y') so nothing can be auto-assigned — and, if you try supplying bin rows anyway, the same bin-sum error simply moving to a different line. The grid column ufc_inv_loc_primary_bin shows which rows lack a usable bin, so you can decide per PO which path to take.

Direct-ship POs (po_type='D') cannot be received here at all — the window refuses because the linked sales-order lines are 'T' status. That is by design; nothing is physically received on a direct ship.

(The bin-context analysis and the Interactive fallback are Alex Westemeier's; the header-only TAPI path was an open question in his notes and is verified here.)

ConvertPOToVoucher Service — Vouching a Receipt

Step three of build → receive → vouch, and another clean one-POST. Verified on 26.1 (August 2026) — voucher 8000100 against receipt 7000100, with the receipt line flipping to vouch_complete='Y', qty_vouched=2.

Send the five header fields and nothing else. company_id, branch_id, period and year_for_period are all marked Required: true in the definition and are all disabled columns — sending company_id fails the transaction with Column is disabled: company_id, verified. They default correctly (branch from the PO). This is the sharpest example in the repo of why the Required flag is not a contract.

(Cycle and payload shapes from Alex Westemeier; all three steps re-verified end-to-end here.)

Vendor Service — Creating a Vendor (and Its Supplier)

Create the vendor first. Saving it creates the same-ID supplier, its division, and the vendor_supplier link for you; creating the supplier first is a dead end.

Runnable: vendor_create.py · VendorCreate.cs (Recipes menu 21) · payload vendor-create.json. Both read back address, vendor, supplier and vendor_supplier over OData.

{
  "Name": "Vendor", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["company_id", "id"],
      "Rows": [{ "Edits": [
        {"Name": "company_id",           "Value": "ACME"},
        {"Name": "vendor",               "Value": "ON"},
        {"Name": "name",                 "Value": "Example Vendor"},
        {"Name": "mail_address1",        "Value": "100 Main St"},
        {"Name": "mail_city",            "Value": "Springfield"},
        {"Name": "mail_state",           "Value": "IL"},
        {"Name": "mail_postal_code",     "Value": "62701"},
        {"Name": "ap_account_no",        "Value": "2222200"},
        {"Name": "default_terms_id",     "Value": "Net 30 Days"},
        {"Name": "track_rebates",        "Value": "ON"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "REBATE_ACCOUNTS.d_vendor_rebate_accounts", "Type": "Form", "Keys": [],
      "Rows": [{ "Edits": [
        {"Name": "rebate_account_no",           "Value": "1111100"},
        {"Name": "rebate_allowance_account_no", "Value": "3333300"}
      ], "RelativeDateEdits": [] }] }
  ]}]
}
Field Rule
vendor Must be ON. Without it: "To add a vendor, the vendor flag must be checked."
id Leave it out and P21 numbers the vendor from the address sequence; the new ID comes back in the result rows (TABPAGE_1.tp_1_dw_1 → id). Unlike the Location service's blank-ID false success, this is a real create — verified below. A typed ID is accepted only if that ID already exists in address (the keyspace that customers, suppliers, vendors and locations share); any other typed ID fails "Enter a valid ID or leave ID blank." (Failed: 1, nothing written). The same rule, with the same message, applies to the Supplier and Location windows. To give a vendor the same ID as a location, create the location first (blank ID), then create the vendor with that ID
default_terms_id The terms description (Net 30 Days), not the terms ID — the ID fails Invalid default_terms_id value
currency_id Omit it. The ID is rejected (Invalid currency_id value); the default is the company currency
ap_account_no, name Required
track_rebates ON for a vendor that books supplier rebates — the second half of the rebate prerequisites. The rebate receivable is re-suffixed to the sales location at posting

Credit: Alex Westemeier — verified on play, September 2026 (vendor plus auto-created supplier, created on an existing address ID). Re-run here: Python and C# examples run on play 26.1.5950.0, 2026-10-10 — blank-ID creates with track_rebates and both rebate accounts, each read back as an address, vendor (track_rebates = 'Y', terms stored as the terms ID), supplier and vendor_supplier (primary_vendor = 'Y') row with the same ID; a typed ID with no address row failed as described.

RMA Service — Orders the Order Service Refuses

Return Merchandise Authorizations live in oe_hdr alongside ordinary sales orders, but the Order service will not touch them. Loading one by order_no fails immediately:

General Exception: You cannot retrieve an RMA from the Order Entry/Front Counter window.
DataElement: order, Column: order_no

The RMA service is the same window for the return side. It publishes an identical TABPAGE_1.order Form keyed on order_no, carrying the same header fields (taker, po_no, dates, and so on), so a payload written for Order generally works against RMA unchanged — you swap the service name.

Verified on a 26.1 tenant (August 2026) against the same RMA order: Order refused it with the message above; RMA loaded the record and proceeded to ordinary validation.

Detect before you dispatch. There is no way to tell from the order number alone. Read oe_hdr.rma_flag over OData and route:

GET {base}/odataservice/odata/view/p21_view_oe_hdr?$filter=rma_flag eq 'Y'&$select=order_no,rma_flag

This matters most for bulk header maintenance — reassigning taker when a salesperson leaves, retagging orders, any sweep over open orders. RMAs are a normal part of such a set, and the failure message doesn't obviously mean "use a different service", so a batch job hits it as an unexplained per-record error.

Credit: Alex Westemeier — found while reassigning takers at scale, where the RMA rows in an open-order set failed as a group.

Customer Service — Turning On Job/Contract Pricing

customer.job_pricing (Customer service, TABPAGE_7.tp_7_dw_7) is disabled while customer.use_vendor_contracts_flag is Y. The two settings exclude each other, so the Transaction and Interactive APIs both refuse the field:

General Exception: Column is disabled: job_pricing

Cause. A customer created through the API takes use_vendor_contracts_flag = Y from the defaults template: on the test tenant, GET /api/v2/defaults/Customer returns use_vendor_contracts_flag ON and job_pricing OFF. A customer whose box was cleared in the client accepts the same edit, which is why it works on one customer and fails on another. A screen version and the user's role are not involved, and IgnoreDisabled: true does not help: it reports success and writes nothing.

Fix. Set use_vendor_contracts_flag = N first, in the same create or edit, then job_pricing = Y. The order of the two edits matters: with job_pricing first, the transaction fails with the error above.

How the tab's fields depend on each other (Interactive Customer window, TABPAGE_7):

State Disabled
use_vendor_contracts_flag = Y job_pricing, always_use_job_price, allow_non_job_item, prompt_for_non_job_item, allow_exceed_job_qty, allow_item_level_contract_flag
use_vendor_contracts_flag = N, job_pricing = N The five job settings after job_pricing
job_pricing = Y use_vendor_contracts_flag; prompt_for_non_job_item stays disabled

To switch a customer back to vendor contracts, set job_pricing = N first.

Create a customer with Job/Contract Pricing on:

{
  "Name": "Customer",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [
      { "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": [],
        "Rows": [{ "Edits": [
          {"Name": "customer_name",    "Value": "ACME Industrial Supply"},
          {"Name": "salesrep_id",      "Value": "100"},
          {"Name": "mail_address1",    "Value": "123 Main St"},
          {"Name": "mail_city",        "Value": "Des Moines"},
          {"Name": "mail_state",       "Value": "IA"},
          {"Name": "mail_postal_code", "Value": "50309"},
          {"Name": "mail_country",     "Value": "USA"}
        ], "RelativeDateEdits": [] }] },
      { "Name": "SHIP_TO_GENERAL.ship_to_general", "Type": "Form", "Keys": [],
        "Rows": [{ "Edits": [
          {"Name": "default_branch", "Value": "10"}
        ], "RelativeDateEdits": [] }] },
      { "Name": "TABPAGE_7.tp_7_dw_7", "Type": "Form", "Keys": [],
        "Rows": [{ "Edits": [
          {"Name": "use_vendor_contracts_flag", "Value": "N"},
          {"Name": "job_pricing",               "Value": "Y"}
        ], "RelativeDateEdits": [] }] }
    ]
  }]
}

To change an existing customer, send company_id and customer_id on TABPAGE_1.tp_1_dw_1 in place of the create fields, followed by the same TABPAGE_7.tp_7_dw_7 element. In the Interactive Customer window, load the customer, select TABPAGE_7, change use_vendor_contracts_flag to N, then job_pricing to Y, and save.

Read the result back over OData:

GET {base}/odataservice/odata/table/customer?$filter=customer_id eq {customer_id}&$select=customer_id,use_vendor_contracts_flag,job_pricing

Verified on play 26.1.5950.0, 2026-10-17, on new test customers: the one-transaction create above, the same two edits on a customer created with the defaults, and the Interactive Customer window. Each saved job_pricing = Y and read back use_vendor_contracts_flag = N. The edits in the reverse order failed with Column is disabled: job_pricing.

Customer Service — Removing a Salesrep Grid Row

The Customer service's CUSTOMERSALESREP.customersalesrep grid (List, key salesrep_id, business object customer_salesrep) has no delete_flag. Sending one fails the whole transaction:

General Exception: Invalid column name: delete_flag

That reads as "this grid has no delete", and the natural workaround is to leave the retiring rep in place demoted to primary_salesrep_flag: "OFF" / commission_percentage: "0". It isn't necessary. row_status_flag is the delete mechanism — it has always been in the element, one field below the flags everyone does use:

Field DataType ValidValues
salesrep_id Char —
salesrep_name Char —
commission_percentage Decimal —
primary_salesrep_flag Char ON, OFF
row_status_flag Long Active, Delete
compute_1 — — (computed display column, always empty)

Send the label, not the code. row_status_flag is typed Long and the column stores code_p21 integers — 704 = Active, 700 = Delete — but under UseCodeValues: false the API takes only the label. Both integers are rejected, in both directions:

Value sent UseCodeValues Result
"Delete" false Passed — row moves 704 → 700
"Active" false Passed — row moves 700 → 704
"700" false Failed — Invalid row_status_flag value: 700
"704" false Failed — Invalid row_status_flag value: 704
"Inactive" false Failed — Invalid row_status_flag value: Inactive

ValidValues is authoritative about the labels too. row_status_flag carries a third state elsewhere in P21 — 705 = Inactive, see 02 § Active Record Filter — and this grid does not take it. Two states here, and only the two the definition lists.

{
  "Name": "CUSTOMERSALESREP.customersalesrep",
  "Type": "List",
  "Keys": ["salesrep_id"],
  "Rows": [{
    "Edits": [
      { "Name": "salesrep_id",     "Value": "100" },
      { "Name": "row_status_flag", "Value": "Delete" }
    ],
    "RelativeDateEdits": []
  }]
}

It is a soft delete, and both read surfaces still return the row

A deleted row is not removed from customer_salesrep; it sits at row_status_flag = 700 indefinitely, keeping whatever commission_percentage it had. On one 26.1 instance the table holds 12,060 rows at 700 against 20,201 at 704 — so an unfiltered read reports long-retired reps as if they were live, at roughly a 3:5 ratio to the real ones. This is the same accumulation documented for price_page in 02 § Active Record Filter; the salesrep grid is simply another table where it bites.

Filter on both read paths:

You cannot delete the only primary, and row order decides whether the transaction passes

Deleting a customer's sole primary rep fails the transaction:

General Exception: This salesrep is set up as the primary salesrep for this record.
You cannot delete it. DataElement: customersalesrep, Column: row_status_flag, Value: Delete

This is the same guard that blocks demoting the last primary (Primary salesrep is required.) — the window refuses to leave a customer with no primary rep. So a reassignment has to promote the incoming rep and delete the outgoing one in one transaction, and the rows are applied in payload order, so the promotion has to come first:

Row order in Rows Result
promote new (primary_salesrep_flag: "ON"), then delete old Passed
delete old, then promote new Failed — You cannot delete it

Sending row_status_flag: "Active" on a row at 700 revives it — 700 → 704 — which is how the same payload shape reinstates a rep who comes back, and how the promote row above works when the incoming rep already has a deleted row on the customer.

The sibling grid on ShipTo does not work this way

TABPAGE_SALESREP.tabpage_salesrep on the ShipTo service holds the same concept and does have a delete_flag (Char, ValidValues: ["ON", "OFF"], column ship_to_salesrep.delete_flag). Because it is a Char flag it is lenient about representation: "ON" and "Y" both pass and both land as delete_flag = 'Y'. Prefer "ON" to match the definition. Two grids, one concept, two different delete mechanisms — see the reassign-salesrep recipe for both sides in one worked payload.

The general rule: read ValidValues before assuming a grid can't delete

delete_flag is the common pattern, not the universal one. Before concluding that a grid row can only be neutralized, pull the element out of its definition and read every field's ValidValues — a value pair like Active/Delete on a field you'd otherwise skim past is the delete mechanism:

python -c "import json;d=json.load(open('definitions/Customer.json'));[print(f['Name'],f['DataType'],f['ValidValues']) for e in d['TransactionDefinition']['DataElementDefinitions'] if e['Name']=='CUSTOMERSALESREP.customersalesrep' for f in e['FieldDefinitions']]"

The same ValidValues array is what tells you to send Delete rather than 700, and ON rather than Y. It is the most under-read part of the definition response.

(From issue #140, independently re-verified on 26.1 — the definition, every write case in the tables above, the primary guard, row ordering, the revive path, both read surfaces and the code_p21 mapping, 2026-08-25.)

ShipTo Service — Creating a Ship-To

ShipTo with Status: "New", Keys: [] on TABPAGE_1.shiptomain and no address_id creates a new ship-to on an existing customer; P21 assigns the address_id and returns it in the result rows. The fields are spread across five elements, and two fields you would look for on the main form are not there — salesrep_id and ship_to_name both fail with Invalid column name on TABPAGE_1.shiptomain:

Element Fields
TABPAGE_1.shiptomain (Form) company_id, customer_id, address_name, default_branch, terms_id, invoice_type, tax_group_id, phys_* address
TABPAGE_2.tp_2_dw_2 (Form) mail_* address
TABPAGE_5.tp_5_dw_5 (Form) central_phone_number
TABPAGE_OEOPTIONS.tabpage_oeoptions (Form) preferred_location_id, packing_basis, pick_ticket_type
TABPAGE_SALESREP.tabpage_salesrep (List, key salesrep_id) salesrep_id, primary_salesrep, commission_percentage, delete_flag

Full payload, Python and C#: recipe: create-ship-to.

Credit: @mrwuss — verified on play 26.1, September 2026 (#182).

Schema change on 26.1.5950.0: the ShipTo definition gained TABPAGE_OEOPTIONS.tabpage_oeoptions.service_location_id (and its _desc), absent from the definition fetched on an earlier 26.1 build. Not exercised here. (definition refresh, 2026-10-11)

Salesrep Service — Editing a Rep's Name and Email

The Salesrep service is the write surface for a salesrep's own contact record (the contacts row where salesrep = 'Y'). Verified against P21 26.1 in production (2026-08-11): a misspelled rep name and its email corrected in one transaction, Status: "Passed", confirmed by read-after-write against contacts.

{
  "Name": "Salesrep",
  "UseCodeValues": false,
  "IgnoreDisabled": true,
  "Transactions": [{
    "Status": "New",
    "DataElements": [
      { "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form",
        "Keys": ["contact_id"],
        "Rows": [{ "Edits": [
          { "Name": "contact_id", "Value": "4431", "IgnoreIfEmpty": false },
          { "Name": "first_name", "Value": "John",  "IgnoreIfEmpty": true },
          { "Name": "last_name",  "Value": "Smith", "IgnoreIfEmpty": true }
        ] }] },
      { "Name": "TABPAGE_2.tp_2_dw_2", "Type": "Form", "Keys": [],
        "Rows": [{ "Edits": [
          { "Name": "contacts_email_address", "Value": "jsmith@example.com", "IgnoreIfEmpty": true }
        ] }] }
    ]
  }]
}
Data element Keys Notable fields
TABPAGE_1.tp_1_dw_1 contact_id salutation, first_name, mi, last_name, title, login_id, address_id, commission_class_id, sales_manager_id, inside_salesrep_flag, delete_flag
TABPAGE_2.tp_2_dw_2 — contacts_email_address, direct_phone, cellular, direct_fax, central_phone_number

Gotchas:

(From issue #109, verified in production 2026-08-11.)

Contact Service — Linking a New Contact to an Existing Address

A Contact create takes an address_id. Set it to an existing address and the new contact belongs to it: no new address row. For the customer tested, whose own address is also its ship-to, P21 added the ship-to link itself. Leave it out and P21 creates a new address row for the contact alone.

{
  "Name": "Contact",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [{
      "Name": "FORM.contact",
      "Type": "Form",
      "Keys": ["contact_id"],
      "Rows": [{"Edits": [
        {"Name": "first_name", "Value": "Jane"},
        {"Name": "last_name",  "Value": "Smith"},
        {"Name": "address_id", "Value": "100198"}
      ]}]
    }]
  }]
}

contact_id is keyed but not sent; the new ID comes back in the FORM.contact result row.

Create contacts.address_id Other rows written
With address_id = a customer's address (also its ship-to) That address A contacts_x_ship_to row linking the contact to that ship-to (row_status_flag 704)
Without address_id A new address row, named after the contact None

(Verified on play 26.1.5950.0, 2026-10-29, with two throwaway contacts read back over OData. Only first_name, last_name and address_id were sent; no address-validation popup or message came back. Community lead: Pat Merlino and Felipe Maurer, P21WWUG forum.)

StrategicPricingLibrary Service — Adding a Price Book at a Position

A price library's books are the LIST_DETAIL.list_detail grid, and sequence_number sets each book's place. Send the grid as a List keyed on price_book_id, with price_book_description as well as the ID and the position:

{
  "Name": "StrategicPricingLibrary",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [
      {"Name": "FORM.form", "Type": "Form", "Keys": ["price_library_id"],
       "Rows": [{"Edits": [{"Name": "price_library_id", "Value": "ACME_OEM"}]}]},
      {"Name": "LIST_DETAIL.list_detail", "Type": "List", "Keys": ["price_book_id"],
       "Rows": [{"Edits": [
         {"Name": "price_book_id",          "Value": "BOOK-OEM"},
         {"Name": "price_book_description", "Value": "OEM book"},
         {"Name": "sequence_number",        "Value": "0"}
       ]}]}
    ]
  }]
}

(Verified on play 26.1.5950.0, 2026-10-29, on a throwaway library. Community lead: P21WWUG forum.)

ItemDefaults Service — Per-Location Item Defaults

The ItemDefaults service is the Item Defaults Maintenance window (Inventory > Inventory Management > System), business object inventory_defaults, keyed on company_id + location_id. Status: "New" creates the per-company/location defaults record that new inv_loc rows inherit — GL accounts, replenishment location/method, discount groups, tax group, units, stockable/buy/make flags. Verified in Play and production (2026-08-14).

Why you care: a location without an inventory_defaults row breaks downstream flows — Inventory REST location-appends demand explicit GL (Required value missing for Revenue Account, see 11 § Location-Append & Update Gotchas), and new inv_loc rows land with replenishment_location = NULL. Creating a brand-new item at such a location fails the same way — see 11 § GL accounts when the location has no defaults. Create the defaults row first and these problems disappear. The row's discount groups also matter: a location appended to an existing item takes its discount groups from these defaults, not from the item's other locations (11 § Location-Append & Update Gotchas, item 7).

Element map (from GET /api/v2/definition/ItemDefaults):

Data element Keys Carries
TABPAGE_1.tp_1_dw_1 (Form) company_id, location_id general: tax_group_id, sales_discount_group_id, purchase_discount_group_id, unit_id/unit_size (sales pricing unit), default_base_unit, stockable/buy/make (ON/OFF), track_bins…
TABPAGE_2.tp_2_dw_2 (Form) — GL: asset_account_no, revenue_account_no, cos_account_no
TABPAGE_3.tp_3_dw_3 (Form) company_id, location_id replenishment: replenishment_location_id, replenishment_method (e.g. "Min/Max"), price_unit_id/price_unit_size (purchase pricing unit)

Gotcha — the pricing units are required. unit_id/unit_size on TABPAGE_1 and price_unit_id/price_unit_size on TABPAGE_3 must be sent — omitting them fails the whole transaction with 'Sales Pricing Unit' is a required column, even though the window shows them as ordinary defaults. Send e.g. EA / 1.

(From issue #111, verified 2026-08-14.)

InventoryCostEdit Service — Changing an Item's Average Cost

The InventoryCostEdit service is the Edit Inventory Cost window. It sets inv_loc.moving_average_cost for one item at one location and posts the value difference to the GL. It is a clean Transaction API case: one POST per item, no popups.

Payload shape only. Full runnable version: recipe: inventory-cost-edit.

{
  "Name": "InventoryCostEdit", "UseCodeValues": false,
  "Transactions": [{ "Status": "New", "DataElements": [
    { "Name": "QUERY.query", "Type": "List", "Keys": ["location_id", "item_id"],
      "Rows": [{ "Edits": [
        {"Name": "location_id", "Value": "10"},
        {"Name": "item_id",     "Value": "WIDGET-001"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "FORM.form", "Type": "Form", "Keys": ["location_id", "item_id"],
      "Rows": [{ "Edits": [
        {"Name": "location_id", "Value": "10"},
        {"Name": "item_id",     "Value": "WIDGET-001"},
        {"Name": "new_cost",    "Value": "0.02"}
      ], "RelativeDateEdits": [] }] },
    { "Name": "FORM.account", "Type": "Form", "Keys": ["account_no"],
      "Rows": [{ "Edits": [
        {"Name": "account_no",      "Value": "1111100"},
        {"Name": "year_for_period", "Value": "2026"},
        {"Name": "period",          "Value": "12"}
      ], "RelativeDateEdits": [] }] }
  ]}]
}
Element What it does
QUERY.query (List) Selects the item/location row, keyed location_id + item_id
FORM.form (Form) Same keys; carries new_cost
FORM.account (Form) The offset GL account plus the posting period: account_no, year_for_period, period — all three required

Gotchas:

What it writes — and what it doesn't:

Credit: Alex Westemeier — verified on play (about a thousand items in one run, GL reconciled per item to the cent) and on production, September 2026. Re-run here with stock on hand on play 26.1.5950.0, 2026-10-10: a throwaway item was given 10 units at one location with an InventoryAdjustment, then the Python repo file moved its cost from 0.00 to 1.00 and the C# repo file from 1.00 to 0.50. Each run posted one IC GL pair in the period sent, described Cost Edit - <old> TO <new>: +10.00 to the inventory account and −10.00 to the offset account, then −5.00 and +5.00. Each wrote one audit_trail row with the old and new cost and no inv_tran row (the item's only inv_tran row is the adjustment's IA), and inv_cost_edit stayed empty. The amounts were whole cents, so the rounding account was not exercised. The account_no, period-order and period-zero failures, the rounding account and the equivalence with a hand edit in the window rest on the original report. Runnable: recipe: inventory-cost-edit.

Item Merge and Customer Merge (m_itemmerge, m_customermerge)

Item Merge and Customer Merge run through POST /api/v2/transaction as the hidden menu services m_itemmerge and m_customermerge. Neither appears in GET /api/v2/services, but both return a definition from GET /api/v2/definition/{name} and run end to end. A merge is one-way: the source record is soft-deleted and its history is moved, rewritten, or deleted as the lists below describe. Test on a non-production tenant first, and resolve both IDs yourself before every call.

Runnable: item_customer_merge.py · ItemCustomerMerge.cs (Recipes menu 25) · payloads item-merge.json, customer-merge.json. Before posting, both resolve each ID exactly against a live record (delete_flag = 'N') and refuse a merged-away or alternate-code ID; afterwards they decide success from the read-back — the source's delete_flag, item_merge_audit / customer_merge_audit, and for items the new alternate_code row — never from the customer merge's Summary.

Discovery notes:

Item merge payload

FORM.form, keyed on source_item_id and target_item_id. The source is merged into the target, and the target survives:

{
  "Name": "m_itemmerge",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [{
      "Name": "FORM.form", "Type": "Form",
      "Keys": ["source_item_id", "target_item_id"],
      "Rows": [{ "Edits": [
        {"Name": "source_item_id",         "Value": "ITEM-002"},
        {"Name": "target_item_id",         "Value": "ITEM-001"},
        {"Name": "create_alt_code_flag",   "Value": "ON"},
        {"Name": "keep_restrictions_flag", "Value": "OFF"},
        {"Name": "schedule_merge_flag",    "Value": "OFF"}
      ] }]
    }]
  }]
}

create_alt_code_flag = ON makes the source item ID an alternate code on the target. Read Merged item IDs resolve through alternate codes before you use it.

Customer merge payload

FORM.form, keyed on source_customer_id and target_customer_id:

{
  "Name": "m_customermerge",
  "UseCodeValues": false,
  "Transactions": [{
    "Status": "New",
    "DataElements": [{
      "Name": "FORM.form", "Type": "Form",
      "Keys": ["source_customer_id", "target_customer_id"],
      "Rows": [{ "Edits": [
        {"Name": "company_id",          "Value": "ACME"},
        {"Name": "source_customer_id",  "Value": "CUST-101"},
        {"Name": "target_customer_id",  "Value": "CUST-100"},
        {"Name": "credit_limit_type",   "Value": "Use Highest Credit Limit"},
        {"Name": "keep_cust_as_shipto", "Value": "ON"}
      ] }]
    }]
  }]
}

credit_limit_type takes Use Highest Credit Limit or Keep the Target Customer Credit Limit (stored as H / K in temp_customer_merge). For keep_cust_as_shipto, see What a customer merge does to history.

Merge responses and errors

Case Result
Item: source does not exist Failed: Invalid item ID. DataElement: form, Column: source_item_id
Item: source and target are the same Failed: Source Item ID and Target Item ID cannot be the same.
Item: target_item_id omitted ⚠️ Succeeded: 1 and nothing happens — a silent no-op
Item: either item has a bill of materials Failed: Unable to merge item X into Y. Reasons: Assembly items cannot be merged.
Item: the two items carry different GL accounts at a shared location The same message, with Asset / Revenue / COS Account is set differently for each item at location <location_id>. Every reason is listed in one message
Item: schedule_merge_flag = ON Failed: To schedule an Item Merge, the Item Merge Schedule task must be active... (the task was inactive on the test tenant)
Item: create_alt_code_flag = "Y" Accepted and treated as ON
Item: success Succeeded: 1, no messages; 6–22 seconds per merge
Customer: an ID that does not exist or was already merged Failed: Invalid customer ID.
Customer: source and target are the same Failed: Target Customer and Source Customer cannot be the same.
Customer: a credit_limit_type that is not one of the two labels Failed: Invalid credit_limit_type value: Highest
Customer: company_id omitted The merge runs with the default company
Customer: every successful merge ⚠️ Failed: 1 with History is not available for w_customer_merge_maint window — but the merge is committed

⚠️ A customer merge reports failure when it succeeds. Every successful m_customermerge call returned Failed: 1 with the History is not available message, and every one of them was committed: customer_merge_audit shows Merge was Successful. Decide success from customer_merge_audit and the source customer's delete_flag, never from Summary. Do not treat that message as a reason to retry: the source is already merged, so a retry fails with Invalid customer ID.

Merged item IDs resolve through alternate codes

With create_alt_code_flag = ON, the source item ID becomes an alternate code on the target, and both ID fields resolve alternate codes. A later request that names an already-merged item acts on whichever live item now holds that code, and still reports success:

Request What actually ran
ITEM-003 into ITEM-002, where ITEM-002 was already merged into ITEM-001 ITEM-003 into ITEM-001
ITEM-002 into ITEM-004, where ITEM-002 was already merged into ITEM-001 ITEM-001 into ITEM-004 — a different, live item and its whole history were merged away

item_merge_audit records the resolved IDs, not the ones you sent. Before every call, resolve both IDs against inv_mast.item_id exactly, with delete_flag = 'N', and do not post when either one is not a live item ID. This matters most in a batch, where an earlier merge can turn a later row's ID into an alternate code. Customer merge does not have this problem: a merged customer ID is rejected.

What an item merge does to history

Measured by comparing row counts and sums before and after, across every table keyed on inv_mast_uid or item_id:

What a customer merge does to history

Merge audit surfaces

Read these to confirm what a merge did: item_merge_audit, temp_inv_mast_merge, scheduled_inv_mast_merge, customer_merge_audit, temp_customer_merge, and audit_trail (source_area_cd 1314 and 1327 for item merges). The stored procedures behind both windows (p21_merge_items*, p21_merge_inv_mast_records_mult_app, p21_merge_customers_app, p21_merge_mult_customers) exist, but reading their source needs VIEW DEFINITION.

Credit: @mrwuss — verified on play 26.1, September 2026: successful item and customer merges plus every error case above, each with before-and-after row counts and sums (#168). The alternate-code resolution was found through two merges that ran on items other than the ones named. Examples run here: Python and C# on play 26.1.5950.0, 2026-10-10, each merging one throwaway item into another and one throwaway customer into another, all four created for the run. Each item merge returned Succeeded: 1 (the whole Python run took about 11 seconds) and read back as described (source delete_flag = 'Y' with the Item Merged description, an item_merge_audit row Successfully merged, and the source ID as an alternate_code on the target). Each customer merge returned Failed: 1 with the History is not available message and read back as committed (customer_merge_audit Merge was Successful., source delete_flag = 'Y').

PDF Report Generation

The Transaction API includes a dedicated endpoint for generating PDF documents -- purchase orders, pick tickets, and other printable reports. The endpoint returns the rendered PDF as a base64-encoded string in the response body.

Endpoint: POST {ui_server}/api/v2/process/pdfreport

Wrong-endpoint trap: POST /api/v2/transaction accepts an m_* report payload and returns Succeeded — but emits nothing. A report is a process, not a record edit; it must go to POST /api/v2/process/pdfreport. (Credit: Alex Westemeier — "this was the single biggest gotcha.")

Verified Report Services

Service Name Report Type
m_reprintpurchaseorders Purchase Order reprints
m_reprintpicktickets Pick Ticket reprints
m_picktickets Pick ticket generation — creates the pick ticket record and returns its PDF (see worked example below)

Discovery: The m_* report services are hidden from GET /api/v2/services — that endpoint lists only the transaction business objects (299 on a 25.2 test system), and ?type=report returns an empty list (verified live; ?type=window returns the same transaction list, other ?type= values return HTTP 400 "Service Type is invalid."). The report services are still fully callable: GET /api/v2/definition/{service_name} and GET /api/v2/defaults/{service_name} both work for them. To discover callable report names, probe the definition endpoint directly, or pull candidate names from the window_x_menu table — the callable service name is the last /-segment of menu_name:

sql SELECT DISTINCT RIGHT(menu_name, CHARINDEX('/', REVERSE(menu_name) + '/') - 1) AS callable_name FROM window_x_menu WHERE menu_name LIKE 'm[_]%';

Probe each candidate with GET /api/v2/definition/{name} — the ones that return 200 are callable. On a 25.2 test system this yields ~157 callable report services, including m_picktickets, m_reprintpicktickets, m_productionorders, m_orderacknowledgements, m_invoices, m_packinglists, and m_customerstatements.

Credit: Alex Westemeier identified that report services are hidden from the services list and worked out the window_x_menu discovery path.

Third discovery path — ask P21 for the window name. If you can open the report in the desktop client, you don't need to probe for its name at all: right-click any field in the report window and choose SQL Help, which names the window (the m_* string) along with the field you clicked. Feed that name to definition/defaults for the field list, or straight to pdfreport. Because report windows carry only a handful of criteria fields, reading the criteria names out of SQL Help is often faster than pulling the whole definition — unlike a transaction window such as Order, where hand-collecting field names is not practical. (Community session, Felipe Maurer, 2026.)

Report windows are still windows. The endpoint runs the same PowerBuilder report window a user runs, with the same gates:

Request Structure

The payload follows the standard TransactionSet format. Report-specific criteria go in the DataElement's Edits array:

Payload shape only. Full runnable version: Example: Generate and Save a PO Reprint.

{
    "Name": "m_reprintpurchaseorders",
    "Transactions": [{
        "DataElements": [{
            "Keys": [],
            "Name": "TABPAGE_1.poreportcriteriadw",
            "Rows": [{
                "Edits": [
                    {"Name": "company_id", "Value": "ACME"},
                    {"Name": "beg_po_no", "Value": "500100"},
                    {"Name": "end_po_no", "Value": "500100"},
                    {"Name": "reprint_flag", "Value": "<po_hdr.printed: Y or N>"}
                ]
            }],
            "Type": "Form"
        }],
        "Status": "New"
    }],
    "UseCodeValues": false
}

Constants that apply to every report payload: Status is "New" — the same rule as every Transaction API payload — Type is "Form", and the DataElement carries Keys: []. Get the criteria field names from GET /api/v2/definition/{service_name} and default values from GET /api/v2/defaults/{service_name}.

The DataElement name comes from the definition, not from TABPAGE_1

The DataElement Name is the window's tab page and datawindow control joined by a dot, and report windows do not share one. The print-a-form windows use a TABPAGE_1.* name, but the report windows that render a Crystal report name their criteria element CRITERIA.criteria and add a REPORT.report list. A copied TABPAGE_1.tp_1_dw_1 sent to one of those returns an empty HTTP 500 at once, with nothing that names the element. Read the name from DataElementDefinitions[].Name in GET /api/v2/definition/{service_name}, or copy it from the Template in the same response.

Element names read from GET /api/v2/definition/{service_name} for each service:

Service Criteria DataElement Other elements in the definition
m_unvoucheredpos CRITERIA.criteria REPORT.report (List)
m_vendorinvoicereport CRITERIA.criteria REPORT.report (List)
m_reprintpurchaseorders TABPAGE_1.poreportcriteriadw TIMESTAMP.timestamp
m_reprintpicktickets, m_orderacknowledgements, m_productionorders TABPAGE_1.tp_1_dw_1 TIMESTAMP.timestamp
m_invoices, m_packinglists TABPAGE_1.tp_1_dw_1 TABPAGE_2.* (errors), REPRINTINVOICESELECTIONTABPG.reprintinvoiceselection

On m_unvoucheredpos the name was matched without regard to case: criteria.criteria and CRITERIA.criteria returned the same report. Only the criteria element is needed. This payload returned an UnvoucheredPOReport_*.pdf for one PO:

{
    "Name": "m_unvoucheredpos",
    "UseCodeValues": false,
    "Transactions": [{
        "Status": "New",
        "DataElements": [{
            "Keys": [],
            "Name": "CRITERIA.criteria",
            "Type": "Form",
            "Rows": [{
                "Edits": [
                    {"Name": "include", "Value": "All POs"},
                    {"Name": "include_unvouched_returns", "Value": "Yes"},
                    {"Name": "beg_po_no", "Value": "500100"},
                    {"Name": "end_po_no", "Value": "500100"}
                ]
            }]
        }]
    }]
}

include (All POs, Inventory POs Only, Requisition POs Only) and include_unvouched_returns (Yes, No, Only) are labels, so the payload keeps UseCodeValues: false. The window's company_id came from its defaults and was not sent. Bound the PO or receipt-date range, as the wide-open report warning above advises; the verification run used a single PO.

(User feedback, sender not named; verified on play 26.1.5950.0, 2026-10-29. The report said a one-tab report window repeats its tab name. m_unvoucheredpos has two tabs, so the element name follows the kind of report window, not the number of tabs.)

UseCodeValues requirements vary per report service. m_reprintpurchaseorders works with UseCodeValues: false (as above), but a production-order m_picktickets run requires UseCodeValues: true with code values — e.g. create_pick_ticket_type must be the code "P"; the display label "Production Order" is rejected, and UseCodeValues: false returns HTTP 500. A sales-order run of the same service is the reverse: the label "Sales Order" with UseCodeValues: false works, and true with "S" or "O" returned an empty HTTP 500 (play 26.1.5950.0, 2026-10-17). When a report errors on seemingly-correct criteria, try the other setting: UseCodeValues: true with the code values from the service's definition (ValidValues), or false with the labels.

m_invoices needs the API print flag, and a date range that covers the invoice

The invoice print window has a criteria field just for API callers, allow_api_to_print_and_reprint_invoice, whose default is N. Without Y the call is an empty HTTP 500. With it, an invoice inside the window's default date range prints from three fields (invoice_no, reprint_flag and the flag). The example also sends the date pair, which a recent invoice needs:

{
    "Name": "m_invoices",
    "UseCodeValues": false,
    "Transactions": [{
        "Status": "New",
        "DataElements": [{
            "Keys": [],
            "Name": "TABPAGE_1.tp_1_dw_1",
            "Type": "Form",
            "Rows": [{"Edits": [
                {"Name": "invoice_no", "Value": "2001234"},
                {"Name": "reprint_flag", "Value": "ON"},
                {"Name": "allow_api_to_print_and_reprint_invoice", "Value": "Y"},
                {"Name": "dt_beg_date2", "Value": "01/01/2000"},
                {"Name": "dt_end_date2", "Value": "12/31/2030"}
            ]}]
        }]
    }]
}

The date pair matters for recent invoices. The window defaults dt_end_date2 to a date that can be days old (GET /api/v2/defaults/m_invoices returned 2026-10-23 on 2026-10-29), so an invoice dated after it is outside the run. That also fails as an empty HTTP 500, not as an empty result. Send dt_beg_date2 and dt_end_date2 wide enough to cover the invoice date.

Criteria sent Invoice dated inside the default range Invoice dated after it
invoice_no, reprint_flag empty 500 empty 500
+ allow_api_to_print_and_reprint_invoice Y PDF (IN… INVOICE.pdf, about 139 KB) empty 500
+ a wide dt_beg_date2 / dt_end_date2 — PDF

(Verified on play 26.1.5950.0, 2026-10-29, with two invoices. Community lead: P21WWUG forum, which reported [] rather than a 500 for the date case on an older build.)

reprint_flag must match the PO's po_hdr.printed

m_reprintpurchaseorders is the Print Purchase Orders window in both of its modes. The reprint_flag criterion is not a "reprint please" switch. It selects which population of POs the run considers, and that population is fixed by each PO's own print history:

reprint_flag Renders POs where A PO in the other state
N po_hdr.printed = 'N' (never printed) Empty HTTP 500
Y po_hdr.printed = 'Y' (printed at least once) Empty HTTP 500

A mismatch fails deterministically: every attempt returns the same empty-bodied 500, which looks identical to the transient report-engine fault below. A payload that hardcodes reprint_flag: "Y" therefore fails on every PO that nobody has printed yet, and retrying does not help.

To print any PO reliably:

  1. Read the header over OData: GET /odataservice/odata/table/po_hdr?$filter=po_no eq 500100&$select=po_no,printed (po_no is numeric, so do not quote it).
  2. Send reprint_flag equal to that printed value.
  3. On an empty 500, re-read printed before you retry. Another user or process can print the PO between your read and your call, which flips the flag you need.

A first print (N) on an approved PO marks it printed: po_hdr.printed changes to 'Y', so the next run for that PO needs Y. The report payload never requests Transmit Document Links, and no transmission was observed during verification.

The definition lists ValidValues ON/OFF for reprint_flag; the verified calls sent Y/N with UseCodeValues: false.

Credit: @mrwuss — verified on play 26.1, September 2026, across seven POs: every never-printed PO failed with Y on repeated attempts and succeeded with N (#162). Re-run on play 26.1.5950.0, 2026-10-10, with the examples below and their repo files: each language printed a never-printed PO (N), the read-back showed printed flipped to Y, and a second run on the same PO (Y) returned the reprint. Sending N for a printed PO returned the empty-bodied 500 on both attempts.

An empty 5xx has several causes — and is usually transient

This endpoint runs at production volume: one integration generates PO PDFs for supplier emails all day, logging 154 successes against 3 empty 500s and 1 dropped connection in a single afternoon — and every affected PO succeeded moments later on retry.

So an empty-bodied 5xx here is not a signal that your payload is wrong. It has at least four unrelated causes, and the response body cannot tell them apart:

Cause How to tell
Transient report-engine fault (most common) The identical request succeeds seconds later. Retry before investigating anything else.
Bad criteria — e.g. a company_id that doesn't exist Deterministic: every attempt fails. Verified on Play 26.1 — a wrong company_id returns an empty 500, not a useful message.
reprint_flag doesn't match the PO's print history (m_reprintpurchaseorders) — Y on a PO with po_hdr.printed = 'N', or N on one with 'Y' Deterministic for that PO. Read po_hdr.printed and send it as reprint_flag; re-read it before each retry, because it can change between attempts — see reprint_flag must match the PO's po_hdr.printed.
The record isn't printable, or report generation isn't available in that environment Deterministic. On the Play tenant at 26.1.5910.3 every report returned an empty 500 — m_reprintpurchaseorders and m_reprintpicktickets, criteria straight from each service's own /defaults, both UseCodeValues settings, and six Accept variants — while /definition and /defaults for those same services returned 200. The same payload shape works in production, and on that same Play tenant at 26.1.5950.0 m_reprintpurchaseorders and m_reprintpicktickets both print normally, so treat a blanket failure like that as an environment property, not a payload bug.

Retry idempotent report calls. Generating a PDF reads data and emits a document; running it twice costs latency, not correctness. The production integration uses 3 attempts with a 0.5 s × attempt backoff, which covers the observed fault rate without masking a real outage.

Do the existence check yourself, first. A missing record and a transient fault both surface as an unhelpful 5xx, so read the record over OData before calling the report. That turns "not found" into a clear error of your own and leaves the 5xx meaning only "the report engine faulted".

Classify the error envelope before the status code. Unlike /transaction, which reports failure through Summary/Messages, this endpoint returns P21's ErrorType/ErrorMessage envelope — and it can arrive on a 200 as well as a 4xx/5xx. Parse the body and check for ErrorMessage first; if you branch on response.status_code alone you will mask the one message that explains the failure (for example No records to print for this range.).

Response

The response is a JSON array (even for a single document). Each element contains document metadata and the base64-encoded PDF content. Decode the DocumentData field and write the bytes to a .pdf file.

Verified success response (generalized from live PO reprint):

[
  {
    "ClientId": "66666666-7777-8888-9999-aaaaaaaaaaaa",
    "RequestId": null,
    "DocumentType": 1,
    "DocumentId": "PO500100 PURCHASE_ORDER",
    "DocumentFormat": 5,
    "DocumentName": "PO500100 PURCHASE_ORDER",
    "FileName": "PO500100 PURCHASE_ORDER.pdf",
    "DocumentContentType": "application/pdf",
    "DocumentData": "JVBERi0xLjQK... (base64-encoded PDF bytes, ~150KB for a typical PO)",
    "ResponseStatus": {
      "StatusCode": "Success",
      "Message": "Form request '' for Form ID PO500100 PURCHASE_ORDER has completed.",
      "StackTrace": null
    },
    "Batch": null,
    "DocumentAssociations": []
  }
]

Key notes:

Error response (e.g., PO not found):

{
    "DateTimeStamp": "/Date(1776344580327)/",
    "ErrorMessage": "Unexpected results generating document request from criteria. --> Messages returned during document request processing: <No records to print for this range.",
    "ErrorType": "P21.UI.BulkEditor.BulkEditException",
    "HostName": "p21web-01",
    "InnerException": null
}

Note: Error responses use the standard P21 error envelope (with ErrorType and ErrorMessage), not the Summary/Messages format used by the /transaction endpoint.

Example: Generate and Save a PO Reprint

"""Print (or reprint) a PO as a PDF and save it to disk."""
import base64
import os
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"
PO_NO = "500100"                          # single PO: beg_po_no == end_po_no
# ---------------------------------------------------------------------------


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("/")


def get_printed_flag(client: httpx.Client, token: str) -> str:
    """reprint_flag must equal po_hdr.printed, or the report returns an empty 500."""
    r = client.get(
        f"{BASE_URL}/odataservice/odata/table/po_hdr",
        params={"$filter": f"po_no eq {PO_NO}", "$select": "po_no,printed"},  # numeric: no quotes
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
    )
    r.raise_for_status()
    rows = r.json().get("value", [])
    if not rows:
        raise SystemExit(f"PO {PO_NO} not found -- check it before calling the report")
    return "Y" if rows[0].get("printed") == "Y" else "N"


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

    # Read the PO's print history first; re-read it before any retry, because
    # another user can print the PO between this read and the report call.
    reprint_flag = get_printed_flag(client, token)

    # Generate the PO PDF -- reports go to /process/pdfreport, never /transaction
    payload = {
        "Name": "m_reprintpurchaseorders",
        "Transactions": [{
            "DataElements": [{
                "Keys": [],
                "Name": "TABPAGE_1.poreportcriteriadw",
                "Rows": [{
                    "Edits": [
                        {"Name": "company_id", "Value": COMPANY_ID},
                        {"Name": "beg_po_no", "Value": PO_NO},
                        {"Name": "end_po_no", "Value": PO_NO},
                        {"Name": "reprint_flag", "Value": reprint_flag},
                    ]
                }],
                "Type": "Form",
            }],
            "Status": "New",
        }],
        "UseCodeValues": False,
    }

    response = client.post(
        f"{ui_server}/api/v2/process/pdfreport", headers=headers, json=payload
    )
    response.raise_for_status()
    result = response.json()

    # Response is a JSON array -- even for a single document
    if isinstance(result, list) and len(result) > 0:
        doc = result[0]
        status = doc.get("ResponseStatus", {}).get("StatusCode")
        if status == "Success" and doc.get("DocumentData"):
            pdf_bytes = base64.b64decode(doc["DocumentData"])
            filename = doc.get("FileName", f"PO_{PO_NO}.pdf")
            with open(filename, "wb") as f:
                f.write(pdf_bytes)
            # read-back: what actually landed on disk
            print(f"Saved {filename} ({os.path.getsize(filename)} bytes)")
        else:
            msg = doc.get("ResponseStatus", {}).get("Message", "Unknown error")
            print(f"Report failed: {msg}")
    else:
        # Errors use the standard P21 envelope (ErrorType / ErrorMessage)
        print("No documents returned")
        print(f"Response: {result}")
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 PoNo = "500100";                          // single PO: beg == end
// ---------------------------------------------------------------------------

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

// Read the PO's print history first; re-read it before any retry, because
// another user can print the PO between this read and the report call.
var reprintFlag = await GetPrintedFlagAsync(client);

// Generate the PO PDF -- reports go to /process/pdfreport, never /transaction
var payload = new
{
    Name = "m_reprintpurchaseorders",
    Transactions = new[]
    {
        new
        {
            DataElements = new[]
            {
                new
                {
                    Keys = Array.Empty<string>(),
                    Name = "TABPAGE_1.poreportcriteriadw",
                    Rows = new[]
                    {
                        new
                        {
                            Edits = new[]
                            {
                                new { Name = "company_id", Value = CompanyId },
                                new { Name = "beg_po_no", Value = PoNo },
                                new { Name = "end_po_no", Value = PoNo },
                                new { Name = "reprint_flag", Value = reprintFlag },
                            }
                        }
                    },
                    Type = "Form"
                }
            },
            Status = "New"
        }
    },
    UseCodeValues = false
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/process/pdfreport",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());

// Response is a JSON array -- even for a single document
if (result.RootElement.ValueKind == JsonValueKind.Array && result.RootElement.GetArrayLength() > 0)
{
    var doc = result.RootElement[0];
    var status = doc.GetProperty("ResponseStatus").GetProperty("StatusCode").GetString();
    var documentData = doc.TryGetProperty("DocumentData", out var data) ? data.GetString() : null;

    if (status == "Success" && !string.IsNullOrEmpty(documentData))
    {
        var pdfBytes = Convert.FromBase64String(documentData);
        var filename = doc.GetProperty("FileName").GetString() ?? $"PO_{PoNo}.pdf";
        await File.WriteAllBytesAsync(filename, pdfBytes);
        // read-back: what actually landed on disk
        Console.WriteLine($"Saved {filename} ({new FileInfo(filename).Length} bytes)");
    }
    else
    {
        var message = doc.GetProperty("ResponseStatus").GetProperty("Message").GetString();
        Console.WriteLine($"Report failed: {message ?? "Unknown error"}");
    }
}
else
{
    // Errors use the standard P21 envelope (ErrorType / ErrorMessage)
    Console.WriteLine("No documents returned");
    Console.WriteLine($"Response: {result.RootElement}");
}

// --- 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('/');
}

// reprint_flag must equal po_hdr.printed, or the report returns an empty 500.
static async Task<string> GetPrintedFlagAsync(HttpClient client)
{
    var filter = Uri.EscapeDataString($"po_no eq {PoNo}");   // numeric: no quotes
    var response = await client.GetAsync(
        $"{BaseUrl}/odataservice/odata/table/po_hdr?$filter={filter}&$select=po_no,printed");
    response.EnsureSuccessStatusCode();
    using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    var rows = body.RootElement.GetProperty("value");
    if (rows.GetArrayLength() == 0)
        throw new InvalidOperationException($"PO {PoNo} not found -- check it before calling the report");
    return rows[0].TryGetProperty("printed", out var printed) && printed.GetString() == "Y" ? "Y" : "N";
}

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

End-to-end files (runnable from the repo with a .env, dry-run by default; they add the retry with a printed re-read before each attempt, check every document, and read printed back after the print): examples/python/recipes/reprint_purchase_order.py · examples/csharp/Recipes/ReprintPurchaseOrder.cs. Payload file: reprint-purchase-order-pdf.json.

Python and C# examples run on play 26.1.5950.0, 2026-10-10: both programs above and both repo files, each against one never-printed PO and then the same PO once printed. All eight runs saved a one-page PDF of the requested PO.

Credit: Jeff Poss discovered the /api/v2/process/pdfreport endpoint and payload structure.

Example: Generate a Production-Order Pick Ticket (m_picktickets)

Running m_picktickets creates the pick-ticket record at the given location_id and returns the PDF in a single call. This matters for production orders that are built at one location while their components stock at another — the ProductionOrder transaction print flag only emits at the make location (see PDFs from the /transaction endpoint below), but this report generates the ticket at whatever location you specify.

Payload shape only -- note UseCodeValues: true and the code values. Full runnable version (swap in this payload): Example: Generate and Save a PO Reprint. End-to-end walkthrough: recipes/generate-pick-ticket-pdf.md.

POST /api/v2/process/pdfreport

{
  "Name": "m_picktickets",
  "UseCodeValues": true,
  "Transactions": [
    {
      "Status": "New",
      "DataElements": [
        {
          "Keys": [],
          "Type": "Form",
          "Name": "TABPAGE_1.tp_1_dw_1",
          "Rows": [{ "Edits": [
            { "Name": "create_pick_ticket_type", "Value": "P" },
            { "Name": "beg_prod_order", "Value": "1000123" },
            { "Name": "end_prod_order", "Value": "1000123" },
            { "Name": "location_id",    "Value": "10" }
          ] }]
        }
      ]
    }
  ]
}

Every one of these is required to make it fire:

The response is the standard document array; the PDF is base64 at [0].DocumentData (FileName like "PPT<nnn> PRODUCTION_PICK_TICKET.pdf", where <nnn> is a document counter, not the pick-ticket number). Side effect: the pick-ticket row now exists in P21 at that location and can be confirmed/completed like any other; find its number in prod_pick_ticket_hdr (filter on prod_order_number) — see generate-pick-ticket-pdf § Verify.

For any other report, swap Name and the criteria Edits (field names from GET /api/v2/definition/{name}); the endpoint, Status: "New", Type: "Form", Keys: [], and the DocumentData extraction stay the same.

Credit: Alex Westemeier — verified end-to-end (report run → pick ticket row created → PDF returned → ticket confirmed and completed).

Sales-order pick tickets through m_picktickets — two traps that return HTTP 200 and []. A sales-order run takes create_pick_ticket_type = "Sales Order" with UseCodeValues: false, order_no + order_number (the same order) and location_id. (1) Send all three date ranges wide — dt_beg_date2/dt_end_date2 (sales order required date), beg_order_date/end_order_date and beg_pick_date/end_pick_date. An omitted range takes its value from GET /api/v2/defaults/m_picktickets; on play those defaults ended on a fixed past date and silently excluded current orders, and sending only the pick-date range was not enough. (2) An order whose kit or assembly parent line cannot fully allocate is skipped entirely; the client's Pick Ticket window says "No records exist to print for this range." for it too. Either way the response is HTTP 200 with an empty array and no ticket. Verified on play 26.1.5950.0, 2026-10-17. Payload, complete Python and C# programs and the oe_pick_ticket read-back: generate-pick-ticket-pdf § Sales-order pick ticket.

PDFs from the /transaction endpoint (print flags)

The regular POST /api/v2/transaction endpoint can also return generated PDFs: when a service exposes print flags (e.g. ProductionOrder with print_pick_ticket = ON and print_form = ON on TABPAGE_1.tp_1_dw_1), the successful transaction response carries the rendered documents at Results.Transactions[].Documents[].DocumentData (base64, one entry per document).

Caveats (verified on ProductionOrder):

Credit: Alex Westemeier.

Merging Several Report PDFs into One File

The Crystal-rendered PDFs these endpoints return all name their embedded font subsets with the same six-letter tag (for example AAAAAB+Arial). Concatenate several of them and viewers and print drivers can draw a page with another page's glyphs — in the reported case, a Code 39 barcode printed as plain Arial text. When you merge, rename each font subset to a tag that is unique per document and per font.

Credit: Alex Westemeier — observed on production print runs, September 2026; not re-run here.


GL Dimensions in the API

GL dimensions (P21's dimensional-accounting tags) attach to accounting lines, not to purchase orders. If you need a PO pre-tagged with a dimension, the PO tables can't carry it — the tag must be applied downstream at vouchering, invoicing, or journal-entry time.

Where dimensions live in the schema:

Table Role
gl_dimen_type / gl_dimen_type_x_value Dimension types (record_type_cd 932 user / 933 system) and their valid values
gl (gl_dimen_type_uid, gl_dimension_id) One dimension per GL distribution row
apinv_line (+ recur_apinv_line) One dimension per AP voucher line — the manual-tagging-at-voucher-entry surface
invoice_line One dimension per AR invoice line
oe_hdr.gl_dimension_project_no Project dimension on an order header (hidden field, added via Field Chooser)
trans_x_gl_dimension / gl_trans_x_dimension Multi-dimension tags at transaction / journal-entry level
po_hdr / po_line No dimension columns at all — POs are outside the dimension model

Dimensions are labels, not postings: P21 lets you edit them after posting by design, with dedicated audit-trail tables.

Which services expose the fields: the voucher-creation services ConvertPOToVoucher and VoucherByItem both carry the dimension fields (gl_dimen_type_id, gl_dimen_type_uid, gl_dimension_id, gl_dimension_desc, gl_dimen_type_desc) plus a TP_TRANS_X_GL_DIMENSION.tp_trans_x_gl_dimension List — the transaction-level multi-dimension grid. VoucherByItem additionally exposes the dimension fields on its line grid (TABPAGE_17.tp_17_dw_17). So voucher-creation automation can apply GL dimensions at vouchering time. Full schema: ConvertPOToVoucher.json, VoucherByItem.json.

Gotchas:

Verified on 26.1.5894.1 (play), July 2026.


Stored Procedure Executor

The m_storedprocedureexecutor service provides Transaction API access to P21's Stored Procedure Executor, allowing you to discover and load stored procedure definitions configured in the P21 UI.

Discovery

GET {ui_server}/api/v2/definition/m_storedprocedureexecutor
GET {ui_server}/api/v2/defaults/m_storedprocedureexecutor

Loading a Stored Procedure Definition

Use POST /api/v2/transaction/get with the stored_procedure_def_uid key to retrieve a specific stored procedure definition and its parameters:

"""Load a stored procedure definition and print its fields and parameters."""
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
SP_UID = "12345"                          # from the P21 Stored Procedure Executor UI
# ---------------------------------------------------------------------------


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 = {
        "ServiceName": "m_storedprocedureexecutor",
        "TransactionStates": [{
            "DataElementName": "DEFINITION.stored_procedure_def",
            "Keys": [{
                "Name": "stored_procedure_def_uid",
                "Value": SP_UID,
            }],
        }],
    }

    response = client.post(
        f"{ui_server}/api/v2/transaction/get", headers=headers, json=payload
    )
    response.raise_for_status()
    result = response.json()

    # The response includes the SP definition and its argument_list parameters
    for txn in result.get("Transactions", []):
        for de in txn.get("DataElements", []):
            print(f"DataElement: {de['Name']}")
            for row in de.get("Rows", []):
                for edit in row.get("Edits", []):
                    print(f"  {edit['Name']}: {edit['Value']}")
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 SpUid = "12345";              // from the P21 Stored Procedure Executor UI
// ---------------------------------------------------------------------------

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 payload = new
{
    ServiceName = "m_storedprocedureexecutor",
    TransactionStates = new[]
    {
        new
        {
            DataElementName = "DEFINITION.stored_procedure_def",
            Keys = new[]
            {
                new { Name = "stored_procedure_def_uid", Value = SpUid }
            }
        }
    }
};

var response = await client.PostAsync(
    $"{uiServer}/api/v2/transaction/get",
    new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());

// The response includes the SP definition and its argument_list parameters
if (result.RootElement.TryGetProperty("Transactions", out var transactions))
{
    foreach (var txn in transactions.EnumerateArray())
    {
        if (!txn.TryGetProperty("DataElements", out var dataElements)) continue;
        foreach (var de in dataElements.EnumerateArray())
        {
            Console.WriteLine($"DataElement: {de.GetProperty("Name")}");
            if (!de.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())
                    Console.WriteLine($"  {edit.GetProperty("Name")}: {edit.GetProperty("Value")}");
            }
        }
    }
}

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

Verified Service Definition

The definition endpoint returns the following structure for the DEFINITION.stored_procedure_def DataElement:

Field Type Required Description
stored_procedure_def_uid Long Key Unique identifier for the SP definition
stored_procedure_description Char No Human-readable description of the stored procedure
stored_procedure_default_timeout Long No Default execution timeout (seconds)
row_status_flag Long No Record status (ValidValues: Active, Delete)
stored_procedure Char Yes The stored procedure name to execute

Note: The key field is stored_procedure_def_uid, which is a Long (not a string). The stored_procedure field is the only required field besides the key.

Endpoint Status

Tip: The defaults endpoint returns the full field structure (~30KB). Use the definition and defaults endpoints to discover available fields before constructing payloads.

Key Notes


DynaChange and Popup Handling

The Transaction API respects and enforces all DynaChange configurations -- menu changes, screen changes, required user-defined fields, and on-event business rules all fire during TAPI processing, just as they would in the P21 desktop client. (Credit: Felipe Maurer)

Source: Community-verified patterns. Tested on P21 version 25.2. Applies to all Transaction API endpoints. Discovery date: April 2026 (documented); pattern in production use by multiple organizations.

When a TAPI workflow triggers a popup dialog (e.g., a DynaChange rule showing a confirmation), the transaction may fail or behave unexpectedly. The recommended pattern is to deploy Popup Suppression rules on the API user's profile to handle these dialogs without needing the Interactive API.

Key characteristics: - Suppression rules can be conditional -- configure them to fire only for the TAPI user's profile, leaving desktop users unaffected - Suppression rules are configured in P21's DynaChange module (not via the API itself) - This approach avoids the complexity of opening an Interactive API session just to dismiss a dialog

Limitations

Scenario Workaround
Visual Rules with response/callback attributes (Community-reported) These break TAPI -- cause "Column is disabled" errors. Remove or disable these rules for the API user's profile. (Credit: Brad Vandenbogaerde)
Wizard-type popups requiring user input (Verified) Must use the Interactive API (IAPI) -- TAPI cannot provide multi-step input
"Column is disabled" errors (Community-reported) Often caused by DynaChange business rules, not by the API itself. Check the user's DynaChange profile for rules that disable fields or trigger response attributes. (Credit: Justin Cassidy)
In-window wizards launched from another window — the PO wizard and assembly decoder from Order Entry, credit-card entry (an embedded merchant-gateway form, not a P21 window) (Community-reported) Not drivable from TAPI. Use the Interactive API — worked example: 04 § Driving an In-Window Wizard, which drives the PO wizard end to end (and notes that it commits at cb_next, before you finish). For credit cards, the documented TAPI path takes a token you generated elsewhere — the entry form itself is out of reach. (Community session, Felipe Maurer, 2026)
Drag-and-drop windows No TAPI path. Verified on the standalone *Notepad services (26.1, Aug 2026): their mandatory area selector is a drag-and-drop control, and no payload satisfies it — omit the areas element and the save fails with You must select at least one area where this note will display.; send a row into the Selected Areas list (TABPAGE_17.tp_17_dw_17) and it fails with Column is disabled: area. IgnoreDisabled: true changes neither. Both the Commands endpoint and the Interactive API drive the same picker — it is an ordinary row-select plus cb_select/cb_selectall tool click, which is exactly what those two surfaces can express and /transaction cannot. (Community session, Felipe Maurer, 2026; verified August 2026)
Mandatory notes blocking a save (Community-reported) A mandatory note diverts the window to the notes tab and strands the transaction. A user setting on the API user's profile — the option to receive mandatory notes as prompts/alerts rather than as a hard stop (bottom-left of the login/user settings) — lets the transaction proceed. Weigh this before enabling it: mandatory notes usually exist for a reason, and this suppresses them for that user. (Community session, Felipe Maurer, 2026)

Notes on the Order window: the elements are published, and they are all disabled. The Order definition looks encouraging — it publishes LINE_NOTE.line_note and HDR_NOTE.hdr_note as ordinary List DataElements keyed on note_id, each with note, topic and notepad_class_desc fields, plus TP_ITEMNOTES.tp_itemnotes keyed on note_uid. None of it is writable through /transaction. Tested against a 26.1 tenant on a live order (August 2026), every column of both note elements is refused, one at a time, whichever you send: Column is disabled: topic, Column is disabled: notepad_class_desc, Column is disabled: note. TP_ITEMNOTES refuses a step earlier still, with Tab page is disabled and cannot be selected.

And IgnoreDisabled: true makes it worse, not better. The same LINE_NOTE write that fails loudly without the flag returns Succeeded: 1 with it — and the note is still empty on read-back. That is Breaking Changes entry 8 exactly: the flag swallows the refusal and reports success on a write that wrote nothing.

But "the Transaction API can't do notes" is still too broad — the answer is /api/v2/commands. The standalone ItemNotepad / CustomerNotepad / SupplierNotepad / VendorNotepad services are on the commands-only list precisely because their windows need row selection and tool clicks that a TransactionSet cannot express — including the mandatory drag-and-drop area selector that defeats /transaction. Verified end-to-end on 26.1 (August 2026): a six-step /commands payload wrote an item note, satisfied the area selector with Action 5 + Action 9 cb_select, returned savesucceeded, and the row was confirmed in the note table. See Commands Endpoint § Request Shape.

So there are three surfaces and three different answers, and it is worth being precise about which is which:

Path Order header/line notes Standalone item/customer/supplier notes
POST /api/v2/transaction No — every column disabled No — drag-and-drop area selector
POST /api/v2/commands n/a (Order is not a commands service) Yes — verified
Interactive API Yes — verified Yes — verified

For notes attached to an order (header or line), the Interactive API is the only path. For the standalone notepads, /commands is the stateless one-POST option and Interactive is the alternative. (Community session, Felipe Maurer, 2026, for the wizard claim; all three rows verified live, August 2026.)

Item Issues Detected Popup Root Cause and Data Fix

Verified on production P21, 2026-08-10.

The Unexpected response window: Item Issues Detected abort that kills Item-service transactions (see Item Service Gotchas) is not random and not environment luck. It is a DynaChange business rule with apply_during_save_flag = 'Y', which fires on every save of the Item window — including the save the Transaction API performs internally. The rule raises a w_rule_callback_response modal, the Transaction API has no way to answer it, and the transaction aborts with the edit discarded.

Two consequences worth stating plainly:

Step 1 -- Identify the responsible rule

Join business_rule to business_rule_data_element for the window you are writing to. On the system under test the rule was:

Attribute Value on the system under test
rule_name ItemDefaults
window_name w_inventory_sheet
window_title Item Maintenance
class_name d_inventory2
apply_during_save_flag Y

The rule uid on that system was 25. That is environment-specific — rule uids are assigned per site and are not a universal identifier. Look the rule up by window_name on your own system; the name, uid, and field list will all differ.

Its business_rule_data_element rows covered:

Step 2 -- Find the rows that trip it

On that system the specific trigger was an inventory_supplier row with cost = 0 AND list_price = 0. Either value being non-zero satisfies the rule (confirmed in both directions).

The zero/zero row is one trigger, not the only one. On production (October 2026) the same rule fired on an Interactive Item save for an item with no zero/zero row: it had two supplier rows, one with a cost and a zero list price, the other with a zero cost and a list price. Editing the first row's cost (TABPAGE_6.tp_6_dw_6 row selected, TABPAGE_7.tp_7_dw_7.cost changed) raised the popup. A per-row check that needs both values non-zero, or that compares cost with list price, would explain it — that is not confirmed. Two more things from that run:

(@mrwuss, production 26.1, October 2026 — #171.)

It is the zero rows that matter, not the primary supplier row. An item can have one supplier row carrying a real cost and still be blocked by a different supplier row on the same item with both values at zero. Find them all:

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET LOCK_TIMEOUT 10000;

SELECT m.item_id, s.inventory_supplier_uid, s.supplier_id, s.cost, s.list_price
FROM inventory_supplier s WITH (NOLOCK)
JOIN inv_mast m WITH (NOLOCK) ON m.inv_mast_uid = s.inv_mast_uid
WHERE s.delete_flag = 'N'
  AND ISNULL(s.cost, 0) = 0
  AND ISNULL(s.list_price, 0) = 0;

Step 3 -- Populate a cost, then re-run

Set cost on the offending rows before the API call. The most defensible source is the item's own purchase history with that supplier — the most recent non-zero PO line unit price:

SELECT TOP 1 pl.unit_price, ph.order_date, ph.po_no
FROM po_hdr ph WITH (NOLOCK)
JOIN po_line pl WITH (NOLOCK) ON pl.po_no = ph.po_no
WHERE pl.inv_mast_uid = ? AND ph.supplier_id = ? AND pl.unit_price > 0
ORDER BY ph.order_date DESC;

Items with no PO history have no value to derive and need an operator-chosen fallback — that decision belongs to whoever owns the item data, not to the integration.

With the costs populated, re-running the identical transaction succeeds. Verified on 28 of 28 items across two suppliers, with no manual UI work. Retries before the data fix failed every time.

When you cannot fix the data

Use the Interactive API and answer the popup with cb_1 ("Yes, Proceed Anyway") — see Item window popups. Lead with the data fix: it is faster, it is bulk-safe, and it leaves the item correct for desktop users too.

Response Validation

Important: The Transaction API returns HTTP 200 even for failed transactions. Always check the Summary and Messages sections of the response body -- never rely on the HTTP status code alone to determine success or failure. (Credit: Neil Timmerman)

Fragment -- shows only the check. Full runnable version: Create Order.

response = httpx.post(
    f"{ui_server_url}/api/v2/transaction",
    headers=headers,
    json=payload,
    verify=False,
)
# HTTP 200 does NOT mean the transaction succeeded
response.raise_for_status()
result = response.json()

# Always check the Summary
succeeded = result["Summary"]["Succeeded"]
failed = result["Summary"]["Failed"]

if failed > 0:
    print(f"Transaction failed ({failed} failures)")
    for msg in result.get("Messages", []):
        print(f"  Error: {msg}")
else:
    print(f"Transaction succeeded ({succeeded} records)")
var response = await httpClient.PostAsync(
    $"{uiServerUrl}/api/v2/transaction", content);
// HTTP 200 does NOT mean the transaction succeeded
response.EnsureSuccessStatusCode();

using var result = JsonDocument.Parse(await response.Content.ReadAsStringAsync());

// Always check the Summary
var summary = result.RootElement.GetProperty("Summary");
var succeeded = summary.GetProperty("Succeeded").GetInt32();
var failed = summary.GetProperty("Failed").GetInt32();

if (failed > 0)
{
    Console.WriteLine($"Transaction failed ({failed} failures)");
    if (result.RootElement.TryGetProperty("Messages", out var messages) &&
        messages.ValueKind == JsonValueKind.Array)
    {
        foreach (var msg in messages.EnumerateArray())
            Console.WriteLine($"  Error: {msg}");
    }
}
else
{
    Console.WriteLine($"Transaction succeeded ({succeeded} records)");
}

Code Examples

See the examples/python/transaction/ (Python) and examples/csharp/Transaction/ (C#) directories for working examples:

Script Description
01_list_services.py List all available services
02_get_definition.py Get service schema/template
03_create_single.py Create a single record
04_create_bulk.py Create multiple records
05_update_existing.py Update existing records
06_async_operations.py Use async endpoints
test_session_pool.py Session pool diagnostic

Known Issues

Session Pool Contamination

The Transaction API uses a session pool on the server. When a transaction fails mid-process (e.g., due to validation errors), the session may be left in a "dirty" state with dialogs still open. Subsequent requests using that pooled session may fail with errors like:

Workarounds:

  1. Use the async endpoint - Creates dedicated session per request
  2. Implement retry logic - Retry failed requests after a delay
  3. Add jitter - Random delays between rapid requests
  4. Restart middleware - Clears the session pool (last resort)

See Session Pool Troubleshooting for detailed analysis.


Best Practices

  1. Get definition first - Fetch the service definition to understand required fields
  2. Use display values - Set UseCodeValues: false for clarity
  3. Check Summary - Always check Summary.Succeeded and Summary.Failed
  4. Handle failures gracefully - Messages array contains error details
  5. Consider async for bulk - Use async endpoint for large batches
  6. Add delays between requests - Prevents session pool issues
  7. Validate locally first - Check required fields before sending

Common Errors

Error Cause Solution
400 Bad Request Malformed request Check JSON structure
401 Unauthorized Invalid/expired token Refresh token
202 Accepted Async request queued (not an error) Poll with GET /async?id= for status
"Required field missing" Missing required field Check definition for required fields
"Unexpected response window" Session pool dirty Retry or use async endpoint
"Invalid value" Wrong dropdown value Use UseCodeValues: false with display values
Service fails on /transaction Service requires commands endpoint Use /api/v2/commands instead (see Commands Endpoint)