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

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.

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

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 ignoredColumn 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
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 OrderTABPAGE_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
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
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)

Report Services

Service P21 Window Purpose
m_reprintpurchaseorders PO Reprint Purchase order PDF reprints (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 undocumented. Verified/inferred mapping from the definition's display list plus live data:

Letter Display name Notes
B Regular Backorder
S Regular Stock replenishment (both B and S display "Regular")
P Special
D Direct Ship
N Non-Stock
R Requisition verified via RequisitionPurchaseOrder create + DB read-back
X Process PO
Q Vendor RFQ

Only R (Requisition) is verified end-to-end; the rest are inferred from the display-name order and live data — treat them as strong hints, not confirmed. Environment: 26.1.5894.1 (play), July 2026.


Response Format

Success Response

{
    "Messages": ["Transaction 1:: "],
    "Results": {
        "Name": "Order",
        "Transactions": [
            {
                "DataElements": [
                    {
                        "Name": "TABPAGE_1.order",
                        "Rows": [{
                            "Edits": [
                                {"Name": "order_no", "Value": "1013938"}
                            ]
                        }]
                    }
                ],
                "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. A failure anywhere rolls the whole Transaction back — including edits that already validated. 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 writes are atomic as the table above shows, so this is specific to services that cascade.)

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.


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 RefusedColumn 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.

What makes a column disabled for a user: DynaChange data-change rules scoped to a role, Application Security settings, and window-level permissions — the same machinery documented under DynaChange and Popup Handling and Application Security settings. The API surfaces all of it as one undifferentiated message.

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 and on Order's LINE_NOTE.line_note — three unrelated surfaces, marking 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://your-server.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.)

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.p21server.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.p21server.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.p21server.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.p21server.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):

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_salesrepsType: 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

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.

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:

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.p21server.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 = "31"                             # unique per header; survives renewals
END_DATE = "2030-01-01"                   # must be >= today -- validated every save
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},
                            {"Name": "end_date",    "Value": END_DATE},
                        ],
                        "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.p21server.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 = "31";                    // unique per header; survives renewals
const string EndDate = "2030-01-01";          // must be >= today -- validated every save
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 },
                                new { Name = "end_date", Value = EndDate },
                            },
                            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. - end_date must be >= today. The header is validated on every save; a past date is rejected with "end date must be equal to or greater than today". This means you cannot edit lines on an expired contract without also moving its end_date forward (a real side effect) -- for expired contracts, use the Interactive API instead. - 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": "31"},
                    {"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.p21server.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.p21server.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.

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.p21server.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.p21server.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

Item Service Gotchas

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

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.

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

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_nonot 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.

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.p21server.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.p21server.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;
}

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 998302, 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": "22026"},
        {"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

PurchaseOrderReceipt Service — Receiving a PO

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

{ "Name": "TABPAGE_1.tp_1_dw_po", "Type": "Form", "Keys": [],
  "Rows": [{ "Edits": [
    {"Name": "po_no",       "Value": "998302"},
    {"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 1775027 against receipt 5706613, 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.)

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 — 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 FailedInvalid row_status_flag value: 700
"704" false FailedInvalid row_status_flag value: 704
"Inactive" false FailedInvalid 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 FailedYou 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.)

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

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. Create the defaults row first and both problems disappear.

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

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": "Y"}
                ]
            }],
            "Type": 0
        }],
        "Status": 0
    }],
    "UseCodeValues": false
}

Constants that apply to every report payload: Status and Type are numeric 0 (not the "New" record-edit shape) 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}.

UseCodeValues requirements vary per report service. m_reprintpurchaseorders works with UseCodeValues: false (as above), but m_picktickets 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. When a report errors on seemingly-correct criteria, retry with UseCodeValues: true and the code values from the service's definition (ValidValues).

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 three 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.
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, 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

"""Generate a PO reprint PDF and save it to disk."""
import base64
import os
import re

import httpx

# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com"   # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False                        # True once you trust the cert chain
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("/")


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

    # Generate PO reprint 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": "Y"},
                    ]
                }],
                "Type": 0,
            }],
            "Status": 0,
        }],
        "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.p21server.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);

// Generate PO reprint 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 = "Y" },
                            }
                        }
                    },
                    Type = 0
                }
            },
            Status = 0
        }
    },
    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('/');
}

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

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": 0,
      "DataElements": [
        {
          "Keys": [],
          "Type": 0,
          "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"). Side effect: the pick-ticket row now exists in P21 at that location and can be confirmed/completed like any other.

For any other report, swap Name and the criteria Edits (field names from GET /api/v2/definition/{name}); the endpoint, Status/Type: 0, 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).

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.


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.p21server.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.p21server.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 Yesverified Yesverified

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

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

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

// Always check the Summary
var succeeded = (int)result["Summary"]!["Succeeded"]!;
var failed = (int)result["Summary"]!["Failed"]!;

if (failed > 0)
{
    Console.WriteLine($"Transaction failed ({failed} failures)");
    var messages = result["Messages"] as JArray;
    if (messages != null)
    {
        foreach (var msg in messages)
            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)