On this page
- ⚠ P21 Breaking-Change Alerts
- 2026-09-14 — v1.25.0
- 2026-09-12 — v1.24.0
- 2026-09-12 — v1.23.0
- 2026-09-12 — v1.22.0
- 2026-09-12 — v1.21.0
- 2026-09-12 — v1.20.0
- 2026-09-12 — v1.19.0
- 2026-09-11 — v1.18.0
- 2026-09-11 — v1.17.0
- 2026-09-11 — v1.16.0
- 2026-09-11 — v1.15.0
- 2026-09-11 — v1.14.0
- 2026-09-11 — v1.13.0
- 2026-08-30 — v1.12.2
- 2026-08-30 — v1.12.1
- 2026-08-30 — v1.12.0
- 2026-08-29 — v1.11.0
- 2026-08-29 — v1.10.0
- 2026-08-26 — v1.9.0
- 2026-08-25 — v1.8.11
- 2026-08-25 — v1.8.10
- 2026-08-25 — v1.8.9
- 2026-08-20 — v1.8.8
- 2026-08-20 — v1.8.7
- 2026-08-20 — v1.8.6
- 2026-08-20 — v1.8.5
- 2026-08-20 — v1.8.4
- 2026-08-20 — v1.8.3
- 2026-08-20 — v1.8.2
- 2026-08-20 — v1.8.1
- 2026-08-20 — v1.8.0
- 2026-08-20 — v1.7.0
- 2026-08-20 — v1.6.1
- 2026-08-19 — v1.6.0
- 2026-08-19 — v1.5.1
- 2026-08-11 — v1.5.0
- 2026-07-21 — v1.4.0
- 2026-07-14 — v1.3.0
- 2026-07-10 — v1.2.0
- 2026-07-10 — v1.1.1
- 2026-07-10 — v1.1.0
- 2026-07-10 — v1.0.0
- 2026-07-06
- 2026-06-15
- 2026-05-22
- 2026-04-16
- 2026-04-10
- 2026-04-04
- 2026-03-06
- 2026-02-25
- 2026-02-17
- 2026-02-16
- 2026-02-13
- 2026-02-12
- 2026-02-11
- 2026-02-09
- 2026-01-20
- 2026-01-19
- 2026-01-02
- 2025-12-27
- 2025-12-26
- 2025-12-25 — Initial Release
- Contributors
- Related
Changelog
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.
All notable changes to this documentation project are listed below, grouped by date. This project uses Conventional Commits.
⚠ P21 Breaking-Change Alerts
Standing alerts for P21 platform version changes that break or silently corrupt API integrations — maintained in P21 Breaking Changes by Version. Check before any upgrade.
- 2026.1 —
SessionId→Id;TabNameno longer accepted on/v2/tab; four data-integrity hazards (nonexistent record loads asStatus: 2+ empty window; batched/v2/changeis sequential and fail-fast — the list applies in order and stops at the first rejection, so position decides what survives; UDT update/delete can't target rows in a 2026.1-created UDT — delete reports[0] rows deleted ... successfully!and does nothing;IgnoreDisabled: truereports success on writes that write nothing); and a badDatawindowNamedestroys the next request on that window with an empty 500. Found on 2026.1.5873.1 vs 2025.2.5855.0; re-verified on production 26.1.5894.1 (July 2026), re-run in full on 26.1.5940.0 (August 2026) and re-run again unchanged on 26.1.5950.0 (September 2026). → details- 2026.1 — FIXED in 26.1.5940.0, still fixed on 26.1.5950.0 — the empty HTTP 500 unless
Acceptincludesapplication/jsonand the ghost session it left behind no longer reproduce. Keep sendingAccept: application/json: without it you now get a 200 carrying XML, which breaks JSON parsers just as thoroughly. An empty 500 on a current build means entry 9, not the header. → resolved entries- 25.2 —
DatawindowNamerequired in Interactive change requests (3-param form stops working). → details
2026-09-14 — v1.25.0
Two findings surfaced while building an unrelated downstream service (a typed P21 gateway) hit real tenant behavior for the first time — an unverified guess this repo had already flagged turned out wrong, and a genuine API-design gap in the Item service had no documented workaround.
- fix: The OData
addresstable's mailing columns keep themail_prefix —mail_address1,mail_city,mail_postal_code, etc., not the unprefixed guess (address1,city,postal_code) a downstream consumer had shipped with an explicit "unverified" flag on it. Confirmed againstdefinitions/Address.json'sDbColumnNamemapping and a live$select(the unprefixed form 404s:Could not find a property named 'address1' on type 'dbo.address'). Cross-referenced against docs/05's Address Fields table, which already had the same columns correctly under their PascalCase Entity API names (MailAddress1) — the gap was OData-specific — @mrwuss - docs: Creating a brand-new item through the raw
Itemservice alone doesn't work — documented as an Item Service Gotcha. The Units of Measure tab is disabled until the item record exists, but the item header's own save validation requires a default sales/purchase unit already set on it — a circular dependency no field ordering orIgnoreDisabledresolves, because the Transaction API has no wizard session to sequence the item window's cascading defaults.POST /api/inventory/parts(the Inventory REST API, a separate endpoint family) takes locations/suppliers/units in one flat payload and resolves the sequencing server-side — already the documented "When to Use" guidance in doc 11, now cross-linked from doc 03 so someone starting from the Transaction API side doesn't have to rediscover it the hard way — @mrwuss - docs:
$metadataalso answers "what are this table's real columns" — extended the existing discovery example to show it, not just "is this table exposed." Theaddressmistake above happened because$metadatawas checked for table existence but never for column names before guessing them. The real path ismetadata["dbo"]["address"](anEntityTypekeyed by schema then table name,dbofor base tables — notns, which only holds the queryable table list); every key besides$Kind/$Keyis a real column. Caught mid-edit: a first draft of this same note guessed the wrong key path (ns.address/ a$Propertiesarray that doesn't exist) instead of checking the real JSON response — corrected before it shipped, which is itself the argument for the rule — @mrwuss - docs:
POST /api/inventory/parts's incomplete-payload errors, mapped. No field-level 422 and no/newtemplate, but theErrorMessageon a 500 does name the actual missing prerequisite — verified by omitting each required block from Minimum Create Payload in turn. The trap:Locations+Supplierswith noLocationSupplierslink gives the identical message to having noSuppliersat all, so a present-but-unlinkedSuppliersblock is easy to misdiagnose as ignored. Every failed attempt confirmed to leave nothing behind (GETimmediately after →404) — @mrwuss
2026-09-12 — v1.24.0
Workstream C tier 1 of the full-coverage program: all eleven business-object REST families from the discovery sweep, each explored via the official SDK contracts before a single live call was made, all verified against 26.1.5950.0.
- feat: Eleven new families documented in doc 15 —
filehandler,environment/systems,inventory/inventorymovement,inventory/externalcounts,inventory/partscan,accounting/customerformtemplates,service/serviceorders,sales/opportunities,sales/consignmentusageorders,accounting/exchangerates,inventory/serialnumberextdinfo. Six have a fully verified success path; three are blocked purely by the play tenant's own empty configuration (no CRM lookups, no consignment contract, one currency) and are documented as exactly that, not as API defects; two have a verified refusal path with an unresolved success precondition, the same honest shape as entry v1.22.0's tag-adjustment finding — @mrwuss - feat:
filehandleris a real network file share, not a sandbox. Full upload/detail/download/delete round trip verified against the middleware's own configured storage path. Treat this family as filesystem access with the same caution consumer keys already get — the blast radius is a filesystem here, not just data. - fix:
accounting/customerformtemplates'sPUTis not a blind upsert — caught by re-running the demo script, not by the first successful call. The original verification sent a nullCustomerFormTemplateUidonce, got a clean create, and this page briefly describedPUTas upserting. Running the identical call again disproved it:500 "The customer_form_template data already exists". Null means create-only; update needs the record's real uid, which this family's own REST surface has no keyed GET to retrieve — read it over OData instead. The corrected demo script does this automatically — @mrwuss - feat:
service/serviceordersisn't a distinct record type — the finding is what the family actually is. Its fields are an ordinary sales order header, andGETresolves against anyoe_hdr.order_no, service-flagged or not — verified against orders already used elsewhere in this repo's own examples.PUTon the same order was refused with"Unable to find Order Header using Order No", immediately after theGETthat read it; the read and write paths disagree about the same record and the mechanism is unresolved. - feat:
inventory/inventorymovementfully verified, with four distinct real validation errors along the way (moveAvailable/moveAllocationsareY/Nstrings, not booleans;toBinmust be a real stock association, not just a bin that exists; same-bin moves are refused; a real bin can be put-locked) and a false-success hazard found by re-running the demo a second time:200 "success"does not mean a nonzero quantity moved —TransactionDetail.QuantityMoved: "0"is possible from a bin the route itself created a moment earlier, while the item's established primary bin gets a real, hard check instead. - feat:
inventory/externalcountscreate fully verified, with a nested-payload trap:ItemIdmust be repeated on the bin sub-record, not just the line — omitting it fails with"Bin Detail Record... missing/unmatched", which does not name the actual missing field one level down. Confirmed the create does not touchinv_loc.qty_on_hand— it stages, it does not adjust. - feat:
inventory/partscanandaccounting/customerformtemplates' create/update split, fully verified; the latter has no POST at all, confirmed a 405 rather than assumed from/help. - docs: Three families blocked purely by tenant configuration, documented with the exact precondition each needs.
sales/opportunitiescreate fails becauseopportunity_status/stage/type/stepare all empty on this tenant — confirmed by querying each over OData, not assumed from the error text alone.sales/consignmentusageordersneeds a real consignment contract id that no OData object on this tenant could source.accounting/exchangeratesneeds a second currency; the tenant has exactly one (currency_hdrconfirms it directly). All three demo scripts check the precondition before attempting a create, so a reader gets a diagnosis instead of a confusing 500. - docs:
inventory/serialnumberextdinfo: a missing field corrupts the error message, not just the request. OmittingInvMastItemId(which looks server-derived) leaves the backend's own error template unfilled —"item 1"and a literal"%s"instead of the real item id and line number — even when the actual precondition failure is unrelated. Sending it doesn't fix the outcome against a genuinely real serial number, but it makes every failure this route can produce legible. - feat: All eleven families get a runnable demo, Python and C#, plus 16 new Postman requests (the "05. Other REST Families" folder is now 26 requests). Every write is dry-run by default; every one that could succeed on this tenant was actually run, not just written.
2026-09-12 — v1.23.0
Runnable demos for everything v1.22.0 verified, plus a repo-wide C# authentication bug the demo work exposed.
- feat: Three new write recipes, Python and C#, dry-run by default.
gl_post.py/GlPost.cs(posts a balanced entry, finds an open period automatically),purchase_order_create.py/PurchaseOrderCreate.cs(sync or--async-create),inventory_wms_adjustment.py/InventoryWmsAdjustment.cs(signed-delta stock adjustment, auto-resolves the item's primary bin). Every one read back what it wrote and left the tenant in its starting state — GBY at location 40 begins and ends each run atqty_on_hand: 0. The Postman collection's 05. Other REST Families folder grew from 10 to 15 requests to match, chained through two new collection variables (GL_TRANSACTION_NUMBER,CREATED_PO_NO); ran clean end-to-end vianewman, 15/15, 0 failures — @mrwuss - fix: A repeat-and-forget bug in
P21Auth.GetTokenV2Asyncsilently authenticated every C# example with whichever consumer key happened to be in.env, whenever one was present at all — even though its Python sibling, which this file's own header claims to mirror, only uses a consumer key when a caller explicitly asks for one. Found by accident: a new example that queried OData failed with a generic401 "You are not authorized to access API.", which read like a permissions problem until the same failure reproduced on the pre-existing, previously-shippedOData/example project, unrelated to anything written today. The actual cause: a consumer key scoped to a specific table list (aud: /odata:po_hdr,po_line,inv_mast,...) was sitting in.envfor an unrelated reason, and every C# example's defaultGetTokenAsync(client, config)call picked it up automatically. REST-family routes (/api/{family}/...) aren't gated by that scope and kept working, masking the problem on five other examples in the same session; only OData calls against a table outside the list failed. Fixed to prefer username/password whenever both are set, matching Python's actual default — confirmed against the previously-brokenOData/example (now returns real supplier rows) and spot-checkedEntity/andTransaction/for regressions, both clean — @mrwuss - docs: The same generic "not authorized" error has two unrelated causes, and the text alone can't tell you which. Doc 00 documented it only as a missing Application Security/Dataservice Permission grant on a User Credential login, and separately claimed a consumer key "bypasses these requirements entirely" — true for the user-permission half of the picture, but a consumer key produces the identical error text when a call falls outside its own scope, which is a different failure with a different fix (widen the key's scope, not touch user permissions). Decode the token's
audclaim to tell them apart — @mrwuss - docs:
examples/csharp/README.md's environment table now states the credential precedence explicitly, with the bug above as the cautionary example of what silently trusting the old precedence cost.
2026-09-12 — v1.22.0
Workstream B of the full-coverage program: exercise the five write paths doc 15 had only described. All five got a live run on 26.1.5950.0; four now have a verified success path, one has a verified refusal path and an open success case.
- feat:
accounting/glPOST, fully verified. Takes the same shape the GET returns — an array of lines, posted together as one entry. The server assignsTransactionNumber, setsApproved: trueand aSourceTypeCdautomatically, and enforces the balance itself: a single unbalanced line was refused with"Records do not balance"fromGlManager.UpdateAllRecords, not a generic CRUD error — @mrwuss - feat:
purchasing/purchaseorderscreate, update and async create, fully verified. Two real traps found along the way: an emptyBuyerIdfails with an escaped<ImportReturn>XML error naming both "Invalid buyer ID" and an unrelated-looking "Sales/Production/PO Intersection" complaint — the second is cascade noise from the first, not a separate requirement, and both vanish with a realBuyerId. AndUnitPricesent on create is silently ignored, stored as0.00regardless of what was sent, confirmed over OData —PriceEdit: "N"routes pricing through lookup instead of the caller.POLinesis genuinely populated in the POST/PUT responses (unlike the always-null GET) — three different shapes across three verbs: populated on POST,nullon GET, empty list on PUT. Async create observed:Status: 3running →Status: 2complete, withMessagescarrying the new PO number as a plain string — @mrwuss - feat: Two of the four
createWms*adjustment routes, fully verified.createWmsAdjustmentneeds two undocumented preconditions — an activereasonrecord (a historical inactive one, even one still displayed on old records, fails withThis Adjustment Reason record could not be retrieved) and abinCdon any bin-tracked location (Bin is required., naming neither the item nor location). With both met,unitQuantityconfirmed as a signed delta by posting1twice from a 0 on-hand and watching it reach 2.createWmsAdjustmentWithCostdoes honor a suppliedunitCostexactly — the opposite choice from the PO family'sUnitPrice, and there was no way to know that without testing both — @mrwuss - docs: The two
createWmsTagAdjustment*routes: refusal verified, success not reproduced. Both refuse cleanly and identically ("{item} is not a tagged item") on two different items, including one withinv_mast.use_tags_flag = 'Y'— that flag is confirmed not sufficient on its own. Notag-named OData object exists to inspect real tag records, so the actual precondition (a live physical tag from receiving/putaway, or something else) is recorded as unresolved rather than guessed at — @mrwuss - fix:
GET /odataservice/odata/table/binand/view/binboth 404, on every surface and casing. Found while locating a bin code for the adjustment tests above. Returns a raw IIS "File or directory not found" HTML page, not the normal OData JSON 404 — the object is real (bin.$Keyis in$metadata, andinv_loc.primary_bin/bin_udboth resolve fine), so this reads as a routing collision with IIS's own reservedbin/assemblies-folder name rather than a genuinely missing object — @mrwuss
2026-09-12 — v1.21.0
Doc 15 gets what the repo's other API docs already have: runnable examples in both languages, a payload, and a Postman folder — and building them surfaced a real counting error in the doc itself.
- fix: The UDF count in doc 15 was wrong — 96, not 128. The original figure was computed as
152 − 24, which only holds if every table's join key is a single column. Two of the 23 (ship_to_ud,oe_line_ud) have composite two-column join keys, so the plumbing deduction is 34 columns, not 23. Re-verified live while building the runnable demo script: 23 (surrogate keys) + 34 (join-key columns) + 95 (nullable) + 1 (thecustomer_ud.autoorder_flagexception) = 152, exactly. Caught by testing the worked example against the live tenant rather than trusting the number that shipped with it — exactly the failure mode dual-verification exists to catch — @mrwuss - feat: Runnable examples for all five doc 15 families, in both languages.
udf_inventory,gl_journal_entry,task_crud,purchase_order_read,inventory_adjustment_read— Python underexamples/python/rest/, C# under a newRest/project inP21Examples.sln. The CRM task example is a full create → read → update round trip, dry-run by default like the recipes cookbook; the other four are read-only. All five verified live against 26.1.5950.0 in both languages independently (tasks17056and17057, both closedCompleted: Y) — @mrwuss - feat: A Postman folder for the same five families, 05. Other REST Families. Ten requests including the full CRM task chain, with a
TASK_NOcollection variable carrying the created key from Create through Get and Update. Run end-to-end vianewmanagainst play: 10/10 requests, 0 failures (task17058) — @mrwuss - feat: A payload file for the CRM task create,
sales-task-create.json— the first payload in the library for a REST family rather than the Transaction API. -
fix:
validate_payload.pyno longer misreads a REST-family payload as a malformed Transaction API one. The new payload above previously produced 2 false errors and 24 false warnings (missing "Transactions" array, 24×unknown top-level property) because the validator assumed every payload was Transaction-shaped. It now recognizes the flat REST-family object shape (noName/Transactions, noServiceName/TransactionStates) and checks only structural sanity for it, with a note explaining there is no committed field schema for these families the waydefinitions/provides for Transaction API services — @mrwuss -
fix:
dotnet buildon the full C# solution was broken, unrelated to anything above — three unescaped"inside a verbatim string literal inexamples/csharp/Interactive/ResponseWindows.cs(("&Yes"/"&No"/"Cancel")) prematurely closed the string, cascading into 55 parse errors reported dozens of lines later in an unrelated block. Found only because building the newRestproject meant running a full solution build for the first time this session. Fixed by doubling the embedded quotes (""&Yes""), the correct escape for a verbatim string. Full solution now builds clean, 0 warnings — @mrwuss
2026-09-12 — v1.20.0
Five families the tenant has always hosted, now documented — starting with the hazard they share.
- feat: New doc: Other REST Endpoint Families.
extensibility/userdefinedfields,accounting/gl,sales/tasks,purchasing/purchaseordersandinventory/inventoryadjustments— all five surfaced by reading the middleware's family list rather than guessing, all verified against 26.1.5950.0. Routes, response shapes, and an explicit per-route note saying which paths were exercised and which are transcribed from/help— @mrwuss - fix: The bare collection GET is an unbounded full-table dump, and
$topis silently ignored.GET /{family}/takes no filter, no paging and no row limit:sales/tasksreturned 28,332,703 bytes / 17,050 records,inventory/inventoryadjustments63 MB, andaccounting/glandpurchasing/purchaseordersnever returned inside 180 seconds. Adding$top=2produced a byte-identical 28,332,703-byte response — neither honored nor rejected, the same silent-success shape as OData'sinoperator.page/pageSize,limitandtopbehaved the same. Filter on OData, then fetch by key — @mrwuss - feat: Every user-defined field in the system, with types.
extensibility/userdefinedfieldsenumerates UDFs flat or grouped by table with the*_ud→ base-table mapping — 152 fields across 23 tables on the test tenant, 28 KB, no paging hazard. Documented with the layout convention that separates plumbing from custom fields (ColumnOrder: 1is always{table}_uid, 23/23; the NOT NULL columns after it are the join key, composite onship_to_udandoe_line_ud; the nullable ones are the real UDFs — 128 of the 152), and with the one field that breaks the rule,customer_ud.autoorder_flag.{tableId}is the*_udname, case-insensitive; the base-table name is the natural guess and 404s — @mrwuss - feat: A whole GL journal entry in one call.
GET /api/accounting/gl/{transactionNumber}returns every distribution line of the transaction — transaction 3 came back as two lines summing to 0.00.JournalIdnames the subledger andSourcecarries that subledger's key, soIA/1000002walks straight to inventory adjustment 1000002 on another family documented here. An unknown transaction number returns200 [], not a 404 — @mrwuss - feat: CRM task CRUD, verified end to end.
/new→ POST → GET → PUT againstsales/tasks, with the trap that cost the first attempt: the create fails"Customer ID is required"and there is noCustomerIdfield — the customer goes inLinkId. The same error also says "CRUD Update error: Update failed" on what was a create, because the middleware routes both through one verb, so "Update" in an error is not evidence you hit the wrong route. No delete route exists; close a task withCompleted: "Y"— @mrwuss - fix: Child collections are always
null, andUserDefinedFieldsis always{}.POLines,POSales,POHdrNotes,POLineNotesandLinesare published in the contracts and populated on none of them. Not a parameter you are missing —includeLines,expand,$expand,fullandincludeChildrenall returned a byte-identical 742-byte header.UserDefinedFieldsis{}even on records that have UD data: PO 584441 carriesreceived_flag: "Y"inpo_hdr_udand the API returns an empty object. Read children and UDF values over OData — @mrwuss
2026-09-12 — v1.19.0
The endpoint that lists the endpoints — and the probe that was answering the wrong question.
- fix: A
/ping404 does not mean the family is absent. This page carried a family sweep built by callingGET {base}/{family}/pingagainst a guessed list, split into "answering" and "not on this tenant". Re-run against the middleware's own published list on 26.1.5950.0, every single family in the right-hand column is published and answers/helpwith HTTP 200 —sales/invoices,chat,ecommerce,eh,environment/systems,localization,printing,inventory/rental,inventory/inventorymovement,integrationProcedures,.configuration,pathguideandhelpall 404 on/pingand 200 on/help;documentandlogistics/roadnet500 on/ping;cardstorage405s.pingis an ordinary route a family either implements or doesn't, not a health check the middleware answers on every family's behalf. Probe with/help.sales/invoicesmakes the cost concrete: the tenant hosted it the whole time and the sweep recorded it as missing — @mrwuss - feat:
apiref.aspxtakes a bearer token, so the family list is scriptable. It looks like a browser-only ASPX page and was documented as needing the Access to SOA Admin Page setting; the ordinaryAuthorization: Bearerheader authenticates it too. Unauthenticated it 302s to/docs/logon.aspxand serves the login form with HTTP 200, so a client that follows redirects and checks only the status code parses a login page and concludes the tenant hosts nothing — assert on content. The page header also prints the middleware version, SQL data source and database name, which makes it a fast check of which environment you are pointed at — @mrwuss - feat: The full family list, read off the middleware instead of guessed. 48 families on the 26.1.5950.0 tenant, grouped and each linked to its
/help— including ten the old sweep never named (custom/v2/HostFacade,epayments-legacy,epicorhelpservice,folderbrowser,pathguideasync,rest/logistics/drivers,security/token,ui/UIServerRouter,ui/router/v1,help) — @mrwuss - docs: The other two catalogs, alongside the first.
apiref.aspxlists REST/SOAP families only.GET {uiserver}/api/v2/servicesreturns the 301 Transaction API services, and the OData service documents at/odataservice/odata/table/and/view/return 3,407 tables and 3,758 views — names only, the cheap half of$metadatawhen the question is just "does this object exist here" — @mrwuss
2026-09-11 — v1.18.0
Production moved to 26.1.5950.0. The registry is build-indexed, so it was re-run rather than assumed.
- fix: The 2026.1 registry is re-verified on 26.1.5950.0, and it is unchanged. Entries 3–9 all still reproduce, entries 1 and 2 are still fixed, no entry changed mechanism and no new entry was found — a maintenance build that touched none of this. Each entry now carries its own 5950.0 stamp, and the page header names the latest build verified so a reader can tell at a glance whether the page has been run against their own. Re-run live: the
Id/SessionIdrename, the four-rowTabName/PageNamematrix, the nonexistent-record load against a real-PO control, the order-dependent/v2/changebatch ([external_po_no, company_id]leavesZZ_HDRapplied; the reverse order applies nothing), theDatawindowNameburn (3/3, with theColumn is disabledcontrol staying clean), theDatawindowNameomitted/""/nullequivalence, the/v2/datasubset, the six-rowAcceptmatrix, the four 404ing version paths, the token-scoped session delete, andIgnoreDisabledonOrder'sLINE_NOTE.line_note— @mrwuss - fix: A successful window load does not always return
Messages: []. Entry 5 offered the empty message list as half the discriminator between a real record and a nonexistent one. The 5950.0 control load disproved it: a perfectly good PO came backStatus: 1carrying a mandatory item note as{"Type": 1}, and any window that surfaces item notes, DynaChange alerts or mandatory-note prompts can do the same. Read the messageType, not the list length — the not-found message isType: 2. A client gating on "Messagesis non-empty" rejects real records on note-carrying windows, which fails worse than the hazard the entry warns about — @mrwuss - docs: The column named in an
IgnoreDisabledrefusal is not stable. Entry 8's unflaggedOrderrefusal is documented asColumn is disabled: note; the 5950.0 re-run gotColumn is disabled: note_idfrom the same element, because the named column is just the first disabled one the transaction reaches and that moves with the payload. Same refusal, same false success under the flag. Match onColumn is disabled:, not on the column — @mrwuss - docs: A P21 system
udt_*table cannot stand in for a user-created one. Entry 7's delete half has now gone two re-runs without a test tenant that has a user-created UDT. Pointing delete at the system table that shares the prefix returns400 {"errorNo": 4001, "errorMessage": "Invalid UDT table"}— the service tells them apart — so the silent no-op stays carried forward on its July 2026 verification rather than quietly restated. The update half'sInvalid Row Uid!and the nested-conditionstrap reproduce verbatim — @mrwuss - docs: The
Statusenum 400 carries a second, misleading error. The body listscontent: ["The content field is required."]alongside the realTransactions[0].Statusconversion error — an artifact of the whole body failing to bind. A client that logs the firsterrorsentry reports a missing request body for what is a bad enum value — @mrwuss - fix: An unregistered consumer key is an HTTP 500, not a 401. This page documented the OAuth-standard
401 invalid_clientshape. On 26.1.5950.0 a key the tenant does not know returns HTTP 500 withErrorMessage: "Unable to generate client token."andErrorType: P21.Business.Common.TokenException— the actual diagnosis (NotFoundException ... No resources found for query string "Consumer Key: {guid}") sits inInnerException, so the top-level text tells you nothing. Verified against two hosts with an unregistered key and an all-zeros GUID, with and withoutusername. The 401 shape is kept and marked as not reproduced rather than deleted. Also flagged: the error body echoes the key's GUID, so a failed token call writes a live skeleton-key credential into your logs — @mrwuss - docs: A tenant refresh moves the consumer key with it. Consumer keys are registered per tenant, and a test environment restored from production comes back carrying production's registrations: the test tenant's own key stops working on both hosts and production's key starts working on both. Re-check which key each host accepts after any refresh — and note the safety consequence, that a key opening both environments no longer distinguishes them, so the base URL becomes the only thing keeping a test run off production — @mrwuss
- docs: Build stamps swept across docs 00, 03, 04, 06 and 14 — the
serverinfoversion table gains its 5950.0 row (Monitoring/*populated, matching 5940.0 and unlike 5930.1), theStatus: "Existing"rows now read "5940.0 and later" rather than naming one build, andGET /sessionsis noted as scoped to the user while the delete is scoped to the token — @mrwuss
2026-09-11 — v1.17.0
- feat: New recipe: Age Open AR on Each Invoice's Own Terms. The first receivables page in this cookbook: page
invoice_hdrover OData, compute the balance client-side, and bucket every open item against its ownnet_due_daterather than the customer's current terms. Both programs were run side by side against a production tenant of ~6,200 open invoices and produce identical figures; the C# builds clean onnet8.0. It doubles as the worked case for two constraints documented in v1.16.0 — why the obvioustotal_amount gt amount_paidfilter cannot be sent, and why the customer master's terms are the wrong clock. Gotchas cover credit memos ageing as negative rows,paid_in_full_flagnot meaning "has a balance", theEdm.String/Edm.Decimalsplit betweeninvoice_hdr.customer_idandcustomer.customer_idthat silently blanks a client-side join, and stable-$orderbypaging — @mrwuss
2026-09-11 — v1.16.0
A $filter cannot compare two columns, and on string columns it does not tell you so.
- fix: The right-hand side of a
$filteris always a literal. Naming a second column on the right does not compare the two — the service takes the name as a literal and converts it to the left column's type. On typed columns that 404s (total_amount gt amount_paid→Failed to convert parameter value from a String to a Decimal.;order_date lt invoice_date→ the same forDateTime), and arithmetic across columns fails identically. On string columns it returns HTTP 200 and the wrong answer:bill2_name eq ship2_namecame back@odata.count: 0against a tenant where the same predicate matches 739,355 rows in SQL, because it was evaluated asbill2_name eq 'ship2_name'. Nothing in the response separates "no rows match" from "your filter did not mean what you wrote" — the same silent-success shape asinbeing accepted and ignored. Filter server-side on literals and on the flags P21 already maintains, and compare columns in your own code — @mrwuss - feat:
customer.terms_idis the default for the next document, not the terms on an existing one. The terms an invoice was billed on live on the invoice (invoice_hdr.terms_id,net_due_date), and one account can carry several sets across its open items. Ageing against the master value silently describes a document that does not exist: on a production tenant, an account whose master readNet 180held two invoices 142 and 149 days past due that had been billedNet 30before the terms changed — comfortably inside terms by the master, the oldest receivables on the account by their own due dates — @mrwuss - docs: AR tables added to Common Tables —
invoice_hdr/invoice_line(credit memos are ordinary rows with a negativetotal_amount),ar_receipts/ar_receipts_detail(where the cash application and its date live),terms/customer_terms, andcredit_status, which decodescustomer.credit_statusand carries the order-entry action each code triggers — @mrwuss
2026-09-11 — v1.15.0
The salesrep on an order is not on the order, and two schema columns mean something other than what they are called.
- feat: Order Service — Reassigning the Salesrep. There is no salesrep column on
oe_hdr— the rep lives in theoe_hdr_salesrepgrid, exposed by theOrderservice asTP_SALESREPS.tp_salesreps(Type: List, keyed onsalesrep_id, confirmed against the service definition). Add-then-retire in one transaction, and this grid has a realdelete_flag, unlikecustomer_salesrep, which needsrow_status_flag: "Delete".TABPAGE_1.orderonly loads the document. Have the incoming rep inherit the outgoing row'sprimary_salesrepandcommission_splitrather than hardcoding100, or a split-commission order quietly becomes a single-rep order. Verified across 66 successful writes — @mrwuss - fix: Failure detail is in the top-level
Messages, not on the transaction.Results.Transactions[0]returnsStatus: "Failed"with its ownMessagesset to null; the reason is in the sibling top-level array. Reading the wrong one makes the API look like it failed for no stated reason — @mrwuss - feat:
oe_hdr.completed = 'T'is what blocks the write, andcompletedis not a boolean. A document in an in-progress editing state raises "may currently be edited by USER", which the stateless API auto-answersNo. Production distribution:Y639,247 ·N184,408 ·T308. Pre-screen a batch withWHERE completed <> 'T'—process_in_progress_lockis the wrong table and was empty (0 rows) while the prompt was firing. These locks are usually abandoned rather than live: theTrows span every year from 2011 to 2026, only 31 of 308 in the current year, so "wait for the user to finish" is generally wrong and retrying never clears it — @mrwuss - feat: Quotes are
oe_hdr.projected_order = 'Y'.quote_typeis NULL on every row and there is noquote_flag. Over three months of production orders,projected_order = 'Y'→ 0 of 5,469 ever invoiced;'N'→ 66.8% invoiced. A live-document query that doesn't exclude'Y'mixes a third of the book in as quotes, and a quote query filtered onquote_typereturns nothing and looks like the site doesn't use them — @mrwuss
2026-09-11 — v1.14.0
The OData service is two surfaces, not one, and the empty-bodied 404 that fact produces is easy to read as "not exposed".
- feat: The table/view split, and the 404 it produces.
/odataservice/odata/table/and/odataservice/odata/view/partition the database exactly byTABLE_TYPE, with no overlap: measured on a production tenant,table/$metadatacarries 3,409 entity types against 3,409BASE TABLErows in the SQL catalogue, andview/$metadatacarries 3,759 against 3,759VIEWrows. Ask for a view on thetablepath and you get an empty-bodied 404 — indistinguishable from a name that doesn't exist, and from the 404 a bad$selectcolumn produces. Don't infer the surface from the name:class_expansion_viewis a base table and lives ontable. Also recorded: P21's*_udtables and site-custom base tables are ordinarytableobjects and fully readable, and object names are case-insensitive — @mrwuss - fix: This corrects #158, which this project filed. That issue reported "only BASE TABLES are exposed — every VIEW returns 404" and proposed removing the view surface from the docs, on the strength of
p21_view_*objects 404ing. They 404 on thetablepath only; every one of them answers onview. Had it been applied as filed it would have deleted a working surface and the 25 Enterprise/Global Search views documented in v1.11.0, which areview-surface objects and are confirmed reachable. Verified on both a production and a play tenant — @mrwuss - feat: The OData allow-list is baked into the token, and these tokens never expire. A consumer key's named-table grants ride inside the JWT
audclaim as/api;/p21sdk;/odata:po_hdr,po_line,…, enforced per object — out-of-scope returns 401 with"You are not authorized to access API", which is a real permissions signal and worth contrasting with the empty 404 above. The operational trap: the allow-list is fixed at issue time and consumer-key tokens runExpiresIn: 630720000(~20 years,exp: 2147483647), so a client holding a cached token keeps enforcing the old list and a scope change in SOA Admin appears to do nothing. Password-grant tokens expire in 86400s and pick changes up on their own — @mrwuss
2026-09-11 — v1.13.0
w_message dialogs are controllable, not just auto-answered — this documentation said otherwise in three places and in both response-window example programs.
- fix:
w_messageis drivable underResponseWindowHandlingEnabled: true.GET /window?id={id}returns itsDefinition.Name: "w_message",Definition.Title, and emptyDatawindows/TabPageList;GET /tools?windowId={id}lists real buttons (cb_1/cb_2/cb_3— Yes/No/Cancel);POST /toolsanswers it — the same mechanism as every other response window type. It has no fields, soTabName: nulldoesn't apply, but "no form" isn't "no control." Corrected the "no dedicated answer endpoint" note, theTabName: nullnote, and the Response Window Types table, which listed it as "Cannot be inspected" / "Default-answered" without qualifying that asResponseWindowHandlingEnabled: false-only behavior — @yeshayak, verified @mrwuss - feat: New worked example: "Save changes before closing?". Stage a PO header note, close the window instead of saving it, and the
w_messageblocks. Answeringcb_1(Yes) saves the note first;cb_2(No) discards it — confirmed in both directions via a fresh window reload against the same PO, on 26.1.5930.1 and again on 26.1.5950.0 — @yeshayak, verified @mrwuss - fix: Two nesting traps recorded alongside the example, because both re-derive the old wrong conclusion.
GET /windowreturns{"Definition": {…}, "Data": []}—Name,Title,DatawindowsandTabPageListall sit insideDefinition, so a client reading top-levelTitlegetsnulland calls the popup anonymous.GET /toolsidentifies each button asToolName, notName; readNameand every button comes backnull, which is indistinguishable from "this window exposes no usable tools" — precisely the reading that produced the original "w_message cannot be answered" claim — @mrwuss - fix: What
ResponseWindowHandlingEnabled: falsedoes to this sequence is a 400, not a silent default. The entry first said the identical sequence returnedStatus: 2with nowindowopened. Re-running it on 5950.0, it never reaches thew_message: step 1'scb_addfails first with HTTP 400 naming the window it refused to open —"Unexpected response window: Notepad Entry Window. Window class: w_notepad_response_lite". Underfalse, a response window that is not a plain message box is a hard error carrying its class name rather than an auto-answer. The auto-answer behaviour and itsMessagesecho remain documented for message boxes reached by other routes, where they were observed — @mrwuss - fix: The correction reached the example programs too.
05_response_windows.pyandResponseWindows.csboth carried the old claim as a "REMAINING LIMITATION", and the C# program printed"w_message dialogs cannot be answered via the API"from a live branch taken whenever the tool list came back empty. Both now describe the real behaviour, mapcb_Nby each tool'sTextrather than by position, and point the empty-tool-list branch at theToolNametrap that most often causes it — @mrwuss
2026-08-30 — v1.12.2
- docs: Alex Westemeier is now credited as an original author, not only per section. Roughly thirty section-level credits already named him, which is accurate but leaves a reader with no sense of the scale: docs 03, 04 and 12 now carry an Original source line under the disclaimer naming what in each document began as his process work — upsert semantics and
Keys,IgnoreDisabled, report-service discovery, the Item window's nested location edits, the buy-side build → receive → vouch cycle, the response-window and popup mechanics, the in-window wizard, theui/fullsurface, and the production order lifecycle end to end. The README gains an Acknowledgments section saying the same thing in one place, alongside Felipe Maurer's contributions through P21WWUG and community sessions, and his row in the contributors table below now reflects the actual scope. Re-verification here does not transfer authorship of a discovery — @mrwuss
2026-08-30 — v1.12.1
Two corrections to how this repo describes itself, both prompted by questions the docs could not answer from their own text.
-
docs: Provenance and attribution is now written down. A large share of doc 03 originates in P21WWUG forum topics and community conference sessions, and doc 05's taxonomy correction came straight from the forum — but the rule governing that material lived only in the habit of whoever wrote each section. It is now explicit: a community claim is a lead, not a fact, until it is verified here; state the build you verified on; say so inline when you could not verify, and why; credit both the finder and the verifier. The citation shapes already in use are given as templates, along with the two link rules — cite public sources even when they need a membership to read, and when a source is a private or internal repository credit the person, not the repository. The community is now listed as a fourth content source in the README and
CLAUDE.md, where only the SDK, working code and live testing appeared before — @mrwuss -
docs: The OData version claim now rests on two tenants, not one. A production and a test tenant both return
OData-Version: 4.0on$metadataand on ordinary data calls, with"$Version": "4.0"in the JSON CSDL — so v4 is not an environment-specific setting, and a v3 label anywhere is a stale string rather than a different service. Re-confirmed on the way:/odataservice/odata/$metadata404s on both, and only the collection path/odataservice/odata/table/$metadataanswers — @mrwuss
2026-08-30 — v1.12.0
A fourth window-driving surface, three findings verified on top of it, and a payload this repo has been shipping broken. Everything below was re-run live on 26.1.5940.0 before it was written down; patterns and first verification from Alex Westemeier.
-
feat: The ui/full surface — the Angular web client does not use the service registry. It opens windows by menu class name (
m_*) over its own REST routes on the UI server, with the same bearer token and no/api/prefix ({ui}/ui/full/v1/window/; with the prefix, 404). That reaches windows the Transaction and Interactive APIs cannot open at all. Verified end-to-end by creating a supplier group through Supplier Group Maintenance — a window whoseframe_menu.service_nameis NULL — and confirming both rows over OData. Documented with the endpoint table, theSuccess/Stateenvelope (there is noStatus1/2/3 here), Python and C# examples, and the gotchas: the open body must be a raw JSON string, a refused call returns HTTP 200 withSuccess: falseand no messages at all,GET .../window/toolsis 405, and a fabricated menu class is indistinguishable from a real window you cannot reach. Closes #151 — Alex Westemeier, verified @mrwuss -
fix: "NULL
service_namemeans no API surface" was too strong, in 04 § Window→Service Discovery, 02 and 08. It means no Transaction or Interactive surface. Readservice_nametogether withnew_ui_enabled/angular_enabled: NULL and web-disabled is what leaves a window unreachable. The doc's two standing examples survive the correction —m_zipcodemaintenanceandm_postalcodegroupmaintenancewere probed onui/fulland 400 exactly like a menu name that does not exist — but Supplier Group Maintenance is the counter-case, andframe_menu.class_nameis now in the discovery query as the second route out. Also documented: inCannot open window because is not available..., a blank window name means theServiceNamenever resolved in the registry, while an echoed name means it resolved and was refused — @mrwuss -
feat: Production order notes — the
ProductionOrderwindow writes header notes through the same Notepad Entry popup as the Order and PurchaseOrder windows (prod_order_hdr_note_tab&&cb_addnote/cb_editnote, popup datawindow_dw_hdr). They land innotewithnote_type_cd 2758anddocument_uid = prod_order_number, classless, attributed to whichever account holds the token. Verified end to end. Two traps recorded with it: withoutResponseWindowHandlingEnabled: truethe tool call fails outright with HTTP 400Unexpected response window: Notepad Entry Window, and the window carries four note grids, not one (header, process, process PO line, route). Closes #152 — Alex Westemeier, verified @mrwuss -
fix:
IgnoreDisabledentry 8 now names four services, and finally shows both outcomes side by side. TheProductionOrdernote grid reproduces the signature exactly:Column is disabled: notewithout the flag;Succeeded: 1,Status: "Passed", no messages and nothing written with it, the echoed transaction reduced to the header element alone. Minutes later on the same build, the same flag genuinely inserted a location supplier row on theItemwindow. Same tenant, same build, opposite outcomes, identical-looking responses — which is the entry's whole point, now demonstrable rather than asserted. Closes #153 — @mrwuss -
fix: The primary-bin payload this repo ships has been failing. It repeats
location_idinside theTABPAGE_18.inv_loc_detailEdits as well as itsKeys;location_idis a disabled column on that form, so the transaction dies withGeneral Exception: Column is disabled: location_idand writes nothing. Corrected in 03 § Item Service, the set-primary-bin-supplier recipe, both payload files and both language recipes — five copies, the propagation pattern this repo keeps tripping over.IgnoreDisabled: truealso makes the error go away and is explicitly called out as the wrong fix. Closes #154 — @mrwuss -
feat: Adding a location supplier row — the silent no-op documented since v1.8 named its fix in prose ("add the location supplier row first") without ever showing it. The payload keys on both
location_idandsupplier_idand needsIgnoreDisabled: true, because both columns are disabled on that grid; the single-key control fails loudly and writes nothing. P21 creates the item-levelinventory_supplierrecord in the same call. New payload files, a recipe section, and a note thatinventory_supplier_x_locmay not be OData-exposed — read it back throughPOST /transaction/get— Alex Westemeier, verified @mrwuss -
feat: Enabling bin tracking —
track_binswas a field name in a table and nothing more. It is a checkbox ("ON"/"OFF"), and enabling it assigns a primary bin when the item has none —NEEDS_BINhere,0on another tenant, so the value is environment-specific and the assignment is not."OFF"reverts and clears that bin again. Other-charge and subtotal items are refused outright, a different location's row can block the save, and items with stock on hand want a decision rather than a batch. New payload files; the Item window's location element map now includesTABPAGE_23.tp_23_dw_23(location-levelpurchase_class_id) — Alex Westemeier, verified @mrwuss
2026-08-29 — v1.11.0
Three undocumented surfaces from @yeshayak, verified against a live 26.1.5930.1 tenant and re-verified here on 26.1.5940.0 before merge. Two of the findings turn out to be build-dependent, which is recorded rather than smoothed over.
-
feat: Batch Pricing —
POST /api/inventory/parts/prices(and the identical/api/inventory/v2/parts/prices) is commonly mistaken for XML-only. It isn't — both JSON and XML bodies work, re-verified on 5940.0. The "XML only" reputation comes from a WCF quirk: the service picks its (de)serializer fromContent-Typerather than by inspecting the body, so a JSON body sent withContent-Type: application/xmlreturns a generic "Request Error" HTML page that gives no hint the fix is a header. A second, indistinguishable trap was found while verifying: the XML body must be a bare<ArrayOfItemPriceInfo>with no namespace, and adding a plausible DataContract namespace produces the same generic 400 page — two different mistakes, one useless error, which is most of the reason for the reputation. Also documented:companyId/customerId/salesLocIdare required and validated one at a time (surfacing as HTTP 500, not 400), andshipToId/orderDate/jobNoare optional and now verified — note the query string omits theoptionalprefix the WCF method parameters carry. The tenant's ownparts/help/operations/GetItemPricespage is the authoritative schema — @yeshayak, verified @mrwuss -
feat: Enterprise/Global Search Views — 25 views under a
p21_view_es_*prefix, discovered via the tenant's full OData$metadataand not listed anywhere else in this documentation. Count and split confirmed independently on 5940.0: 18 denormalized search-source views (es_customer,es_item,es_sales_order, …) ending in a consistentunique_id/max_date_last_modifiedpair useful for incremental sync, and 7 index-configuration views (es_index_hdr,es_index_field,es_index_priority_*) describing the search feature rather than carrying business data. Not part of the curated 118-view/data/erp/views/v1surface. Thep21_view_es_ship_toexample makes the case for them — one row (63 columns) already joins customer, tax group, freight/carrier, terms, branch, salesrep and class codes that would otherwise take 6+ reads and client-side joins — @yeshayak, verified @mrwuss -
feat: There is a version endpoint after all —
GET {uiserver}/ui/common/v1/serverinfo, undocumented, same bearer auth, XML by default and JSON withAccept: application/json. This corrects doc 14, which said flatly that no version endpoint exists and pushed readers to the session-create response.serverinfois the better probe precisely for this page's purpose: it needs no session. A tenant refusing session-create cannot tell you its build the other way — which is exactly when you need to know which breaking-change entries apply. Session-create is now documented as the fallback — @yeshayak, reconciled @mrwuss -
fix:
Monitoring/shortversionis not the field to read, and the reason is build-dependent. Reported as returning"0.0"on a direct API call against 26.1.5930.1 while the web app's own traffic showed a real value. On 26.1.5940.0 it is populated correctly ("26.1"/"26.1.5940.0"), so the entry is now a per-build table rather than a flat "not populated" claim. The advice survives either way and is what matters:Version/Application Versionwas correct on both builds — note the literal space in the key. Also recorded from the re-verification:Monitoring/cloudroleinstancereads...prophet21play.productionon a play tenant, because it names the Azure role instance and not the business environment — do not pattern-match it to decide whether you are pointed at live data. A C# compile error in the original example (arecorddeclared before a local function, invalid under top-level statements) was fixed by the contributor — @yeshayak, corrected @mrwuss -
chore:
.gitignorenow excludes local Prettier config, so a contributor's format-on-save cannot silently reformatdocs/*.mdinto a style the repo's tables and emphasis don't survive — @yeshayak
2026-08-29 — v1.10.0
Four open issues closed. The v1.9.0 findings finally swept through the pages people land on, the definitions/ folder made to agree with itself, the cookbook's coverage claim made true, and a direct-ship date hazard documented on both the read and write sides.
-
fix: v1.9.0's answers now reach the pages people land on. #146 verified the 5940.0 behavior and wrote it into docs 14 and 03; everything else kept teaching the pre-5940.0 answers — the same write-once-don't-propagate pattern as #140 and #142. Swept: 72 example-header comments (
# 2026.1 returns an empty 500 without this→ without this you get XML, not JSON), theStatus: "Existing"claim in docs 01 and 06, two recipes and five example programs, and theCLAUDE.mdrow that contradicted the row above it. Left alone deliberately: "empty 500" is still the current, correct signature for an unknown service name, unmappedActioncodes, and unavailable report generation — a blanket replace would have corrupted those, so the sweep was per-occurrence — @mrwuss -
feat: 06 § Empty HTTP 500 on an Interactive Call rewritten as a two-row decision, and entry 9 finally appears in doc 06 at all. The symptom-lookup page was the worst place for this to be stale: it sent anyone debugging an empty 500 on a current build after the
Acceptheader, which is fixed, instead of the previous request'sDatawindowName, which is not. The two are separable on sight — entry 1 fires on session-create every time, entry 9 fires once, right after a 400 you already received. The missing-Acceptcase gets its own section as what it now is: an XML 200 that raisesJSONDecodeError/JsonExceptionin the parser while the access log shows success. Doc 06's batched-/v2/changeentry and doc 04's upgrade blurb sharpened to sequential fail-fast — @mrwuss -
fix: The Postman collection sent no
Acceptheader on any of its 22 requests — while doc 14 claimed every example in the repo already did. On 5940.0 that means the collection's own Get Token test script (pm.response.json()) breaks on the XML 200 it now receives. All 22 requests now carryAccept: application/jsonwith a description saying why, the 8 raw-body requests carryContent-Type, and the token script checksContent-Typeand fails loudly rather than silently setting a blankTOKENthat breaks every later request — @mrwuss -
fix:
definitions/now agrees with itself, and all 36 files come from one build. The folder,_manifest.jsonandDOCUMENTED_SERVICESdisagreed three ways:Addresswas on disk but in neither the fetch list nor the manifest, five more files were unreachable by a default fetch, and the manifest'sfetched_date(2026-08-11) predated files the README said came from later builds. AddedAddress,ItemDefaults,PickZone,PutawayZone,PurchaseOrderReceipt,RMA,SalesPriceBook,ShipToand the threePurchasePricingPageSupplier*variants to the fetch list — closing the "documented in docs/ but no definition" gap for the five services #148 named — then re-fetched all 36 from 26.1.5940.0 in one pass. Disk, manifest and fetch list are now identical sets; the README lists them by area, and warns that definitions are build-specific — which columns a window disables shifts between builds, as entry 6's retired repro demonstrates — @mrwuss -
feat: update-supplier-contact is no longer invisible. The page existed, was linked from doc 03, and had no INDEX row, no repo files and no payloads. Now it has all four: an INDEX row next to the salesrep-email task it pairs with,
update_supplier_contact.py,UpdateSupplierContact.cswith a Recipes menu entry, and a validator-verified JSON/XML pair. All 15 recipes now have Python + C# repo files. The read-back path was confirmed live on 5940.0 —address.id == supplier_id, contact fields onTABPAGE_3.tp_3_dw_3— @mrwuss -
fix: The cookbook's coverage claims are now true.
docs/recipes/README.mdsaid each recipe "also links repo files"; it now says most do and points at the list, matching what the Python recipes README already admitted. Payload library gainsupdate-order-lines(on disk but missing from the table), plus new validator-verified pairs forcreate-customer,create-requisition-poandupdate-supplier-contact— all 30 payload files validate clean. Two duplicate INDEX rows collapsed, andcheck_anchors.pyadded toscripts/README.md, where CLAUDE.md already assumed it was listed — @mrwuss -
feat: The re-fetch surfaced a schema change nobody was looking for:
TimeEntry's labor grid gained a second key.TP_LABORRECORDING.prod_order_line_comp_laborkeys onprod_order_number+line_numberon 26.1.5940.0; the previous definition in this library keyed onprod_order_numberalone. Under the one-key shape, labor rows for different lines of the same production order collapse into one, last value wins, silently, withSucceeded: 1— the row-collapse trap firing on a schema drift rather than a payload mistake.line_numberis notRequired, so sending it is safe on either shape. Noted in doc 12 and the record-labor-time recipe. Two other deltas were benign:Itemgained an integration tabpage,Addressonly reordered — @mrwuss -
feat:
po_line.supplier_ship_dateis not a supplier promise on direct-ship POs. Confirming a direct ship writes the confirmation's ship date onto every line on it, including quantity that has not shipped — so on a partial confirmation the promise for the open balance is destroyed, and P21 has nowhere to record a reforecast. Verified across 687,879 receipts, all history, zero exceptions:inventory_receipts_hdr.shipment_dateis populated on 100% ofpo_type='D'receipts and 0% of every other type. Real partially-received lines carry asupplier_ship_datein the past against hundreds of open pieces whiledate_due— untouched by receiving on every PO type — still shows the real expectation. Documented on the read side in 02 with the query to find affected lines, and on the write side in 04 § DirectShipConfirmation — @mrwuss -
fix: The "hidden field" reading of
DirectShipConfirmation.shipment_datewas wrong, and the real story is simpler. It was reported asRequired: truewith an emptyLabel, inferred to be a field P21 fills in and never shows. On 26.1.5940.0 the definition readsLabel: "Supplier Ship Date"— it is a labeled, user-facing field that/defaultspre-fills with today's date. So the overwrite is not a hidden stamp; it is one visible date for the whole confirmation, defaulted to today, written down to every line. The write path remains unreproduced — everyPOST /transactionagainst the service returns200withTransactions: []andSummary {0,0,0}— and that is stated plainly rather than papered over: the date behavior rests on the field binding and the receipt data, not on a live write. Also recorded: the checkbox fields publishValidValues: ["ON","OFF"]and default to"OFF", notY/N— @mrwuss
2026-08-26 — v1.9.0
The 2026.1 registry re-run end to end against a newer middleware build. Two entries retire, one gets a sharper mechanism, one is new — and the empty 500 everyone learned to recognize now means something else.
-
feat: The 2026.1 registry is now build-indexed, because it has to be. Play moved to 26.1.5940.0, past the 26.1.5910.3 the page was last verified on, and the answers changed. Every entry was re-run. Entries 3–8 still reproduce; entries 1 and 2 do not. The overview table now separates still live from resolved, each entry carries the builds it applies to, and Reading the middleware version is promoted from a footnote to a prerequisite — which entries apply to you now depends on a number you have to go and read — @mrwuss
-
fix: Entry 1 — the empty-500
Acceptdefect — is fixed in 26.1.5940.0, and the mitigation still stands. EveryAcceptvariant now returns HTTP 200. But withoutapplication/jsonyou get a DataContract XML<Session>body, which is the 2025.2 fallback restored — and it breaks every client written against this API just as reliably as the 500 did:httpxraisesJSONDecodeError, .NETSystem.Text.JsonraisesJsonException, both confirmed. So the failure moved from an empty 500 you can see in the status code to a 200 that blows up one frame deeper in the parser — arguably harder to attribute, since a 200 in the log looks like the call worked. Kept on the page rather than deleted: it still bites anyone upgrading from an affected build, and it is the symptom they will search for. Entry 2 is downstream of it and is no longer reachable; its token-scoping rule is not part of the defect and is re-verified as current behavior — @mrwuss -
feat: Entry 9 — a bad
DatawindowNameeats the next request on that window. A400 "Unable to find datawindow named X"is loud and applies nothing, which the page already said. What it does not say is that the following call —/v2/change,/v2/dataor/v2/tabalike — returns an empty-body HTTP 500 and does nothing. Exactly one request, deterministic across 4/4 runs, window healthy again afterward. AColumn is disabled400 does not do this, which isolates it to this error. This now owns the empty-500 signature that entry 1 made famous, and the two are easy to separate: entry 1 fires on session-create every time, this one fires once, right after a 400 you already received. A client that retries the failed change but not the one after it silently drops a field — @mrwuss -
fix: Entry 6 is sequential and fail-fast, not merely "non-atomic". The list is applied in order and stops at the first rejection: fields before the bad one are applied and stay applied, fields after it are never attempted. So the same two-field batch gives opposite results depending only on the order you wrote it in —
[good, disabled]leaves the good field committed to the buffer,[disabled, good]leaves nothing. The old framing was right about the consequence and vague about the cause, which left open whether survivors were arbitrary. They are not. Also retires the entry 5894.1 repro: it usedTABPAGE_18.extended_info.extended_desc, which on 5940.0 is no longer a disabled column and writes cleanly — nothing about entry 6 changed, the field did. Re-established withcompany_id, and the general lesson recorded: which columns a window disables shifts between builds, so a repro pinned to a field name outlives its field — @mrwuss -
fix:
Status: "Existing"is an HTTP 400 now, not a 500 — and"New"is the only string the enum takes.P21.Transactions.Model.V2.TransactionStatushas exactly one member. Enumerated on 5940.0 by posting each candidate with an emptyDataElementslist, so nothing could be written:"New"accepted case-insensitively;"Existing","Update","Delete","Modified","Insert","Upsert","None"and a dozen more all rejected. On older builds the same payload produced aNullReferenceException500 atToInternalBeSpecification— a server-fault shape for what was always a bad request, which sent more than one integration hunting a middleware bug. Also recorded: the binder accepts any integer, because .NET enum binding does not range-check, so an out-of-range value binds silently instead of erroring. Send the string. Four stale "returns HTTP 500" claims corrected across doc 03 — @mrwuss -
docs: P21 supports both .NET and .NET Framework for business rules, for now — the middleware's own migration to .NET 10 in 2026.1 did not drag rule code with it. New section separating the three runtimes that get conflated: Epicor's middleware (.NET 10, not your choice), DynaChange rules (both, for now), and your API client (anything that speaks HTTP; this repo targets
net8.0+). The dual support is a transition state rather than a commitment, so new rules are better written against modern .NET and existing Framework rules are migration debt with an unannounced due date. Cross-linked from the consumer-key warning in 00, where "the rule is arbitrary .NET code" was doing quiet work — the caveat applies to both runtimes — @mrwuss -
docs: Entries 3, 4, 5, 7 and 8 re-verified on 5940.0 and stamped as such. The entry 4 four-row
TabName/PageNamematrix reproduces exactly, blank tab name in the error text included. Entry 5 re-run against a same-session control that loads a real record, so theStatus: 1/Status: 2split is shown rather than asserted. Entry 8 re-run onOrderLINE_NOTE.line_notewith the note read back unchanged either side, and the response's dropped DataElement now recorded — the echo returns only the header, so nothing marks the omission. Entry 7 is honestly partial: therow_uidprobe, theInvalid Row Uid!update and the nested-conditionstrap all reproduce, but the delete no-op could not be re-tested — the tenant carries no user-created UDT — and that half is the dangerous one, so it is flagged rather than quietly restated — @mrwuss -
docs: Two adjacent claims re-checked while connected. OData is v4 re-confirmed (
$Version: 4.0,OData-Version: 4.0), and the no-server-driven-paging note gets the number that makes it land: an unbounded$selectreturned 806,503 rows in a single response — no cap, no truncation, no continuation link. Always send$top. And the 401 redirect hazard from v1.8.10 re-verified on .NET 9.0.19 and .NET 10.0.11 — identical on both, because strippingAuthorizationacross a redirect is deliberateHttpClientbehavior, not a bug awaiting a fix. Do not wait for a newer runtime; fix the URL — @mrwuss
2026-08-25 — v1.8.11
Two claims checked mechanically rather than believed, and the 401 from v1.8.10 given an entry under the error people will actually search for.
-
feat: 06 §
401 Authorization header was not present or 'Bearer' was missing— the v1.8.10 failure now has its own catalog entry, because the message is what you search for and it points at the wrong thing: the token is fine and the credentials are fine, the header simply never arrived. Three causes in likelihood order, led by the redirect, plus the one-line diagnostic that separates them — re-issue with automatic redirects disabled; a307instead of the401means the URL is the bug, not the credentials — @mrwuss -
fix: The repo said C# examples target
net9.0. They don't need it. Extracted all 80 complete C# page programs fromdocs/anddocs/recipes/and built every one in a scratchnet8.0console project: 80 built, 0 failed.net9.0in README, CONTRIBUTING and the recipes README also contradictedexamples/csharp/README.md, which tells you to install the .NET 8 SDK and whose seven projects all targetnet8.0— so the repo was telling a reader who followed its own setup instructions that the examples needed a newer runtime. Nownet8.0or later, in all three plus the eight recipe pages whose Prerequisites bullet named the runtime — which is where a reader actually decides what to install. Historical changelog entries keep their original wording — @mrwuss -
docs: CONTRIBUTING's build check now says what "complete" excludes. Blocks the prose labels a structural sketch — elided bodies, a usage trailer calling methods the sketch never defines — are illustrations and are not expected to compile; 04 § Async Context Manager is the example, and it already links its runnable counterpart. Recorded so the next person running the check doesn't "fix" a sketch into disagreeing with its Python twin — @mrwuss
-
docs: The router/redirect hazard is now routed from CLAUDE.md and the task index alongside the other verified hazards, rather than living only in the two pages that describe it — @mrwuss
2026-08-25 — v1.8.10
Every C# example in the repo failed to authenticate, and the doc note that would have explained it said the opposite.
-
fix: The C# examples could not authenticate — 401 on every program.
P21Auth.GetUiServerUrlAsyncrequested the router as/api/ui/router/v1?urlType=external, without the trailing slash. The server answers that with a 307 to the trailing-slash form, and .NET'sHttpClientstrips theAuthorizationheader when it follows a redirect — so the second request arrived unauthenticated and came back401 {"Description":"Authorization header was not present or 'Bearer' was missing."}. The token was never the problem; it was obtained successfully one call earlier. Fixed by requesting the trailing-slash URL, which avoids the redirect outright — @mrwuss -
fix:
P21Config.LoadDotEnv()never found the repo.env. It walked up fromAppContext.BaseDirectoryfor a fixed 6 iterations, but an example runs from<project>/bin/<config>/<tfm>/— already three levels below its own project folder — putting the repository root 7 levels up. Every C# example therefore died with "P21_BASE_URL environment variable is required" unless you exported the variables by hand. It now walks to the filesystem root — @mrwuss -
fix: 00 § UI Server URL said C# was unaffected by this. The old note read "C#
HttpClientfollows GET redirects by default and is unaffected" — half right and wholly misleading: it follows the redirect, and that is exactly when it drops the header. Replaced with a per-client table of what actually happens on the slashless URL, the 401 body it produces, and why following the redirect is not an equivalent fix to not causing one. .NET dropsAuthorizationon any auto-redirect — same-origin included, and whether the header sits onDefaultRequestHeadersor on the individual request — @mrwuss -
chore: Trailing slash everywhere, so nothing re-learns this. The five Python call sites, the remaining
docs/00/03/04snippets, and the Postman collection's Get UI Server URL request now all send/api/ui/router/v1/?urlType=external. The Python paths worked before — httpx keeps the header on a same-origin redirect — but they were paying for a pointless round trip and modelling the form that breaks in another language — @mrwuss
Verified on 26.1: token → router → OData → Transaction all green through P21Client.CreateAsync(), and dotnet run --project Recipes now reaches a recipe's payload with no environment variables exported.
2026-08-25 — v1.8.9
A grid everyone had written off as undeletable, and the field that deletes it.
-
feat: Customer Service — Removing a Salesrep Grid Row —
CUSTOMERSALESREP.customersalesrephas nodelete_flag, and sending one returnsInvalid column name: delete_flag. That reads as "this grid has no delete", which is wrong:row_status_flag(ValidValues: ["Active", "Delete"]) is the mechanism, and it has been in the element all along. Send the label — the field is typedLongand the column storescode_p21704/700, but"700"and"704"are both rejected withInvalid row_status_flag valueunderUseCodeValues: false— @mrwuss -
fix: The reassign-salesrep recipe was teaching the workaround. It said the outgoing rep must be demoted to
OFF/0%and that "both rows stay". The Customer payload, both language examples, the gotchas and the read-back now delete the row instead. Anyone who followed the old recipe has a live 0% rep row per reassignment — cosmetic, but it is why unfiltered rep reports show retired reps — @mrwuss -
docs: Three constraints that only show up when you try it. (1) You cannot delete the only primary —
This salesrep is set up as the primary salesrep for this record. You cannot delete it., the same guard that blocks demoting the last primary. (2) Row order inside the element decides the outcome: promote-then-delete passes, delete-then-promote fails with that message, same two rows. (3)row_status_flag: "Active"on a deleted row revives it (700 → 704) — @mrwuss -
docs: The delete is soft, and
/transaction/getstill returns the row. The row stays incustomer_salesrepat700with its old commission intact — 12,060 rows at 700 against 20,201 at 704 on one 26.1 instance, so an unfiltered read overstates live reps by more than half again. OData needsrow_status_flag eq 704; the/transaction/getread-back needs the same filter, which is the sharper trap — that call is our own recommended read-after-write check, so verifying a successful delete shows the row still sitting there — @mrwuss -
docs: The finding is now routed everywhere it would be looked for, not just where it was found. 06 § Common Transaction Errors gains
Invalid column name: {name}(and why a missing field is not always a missing capability) andInvalid {field} value: {value}(label vscode_p21code); 06 § Incompatible Operand Types is a new OData entry for the quoted-numeric-key 404; 02 § Active Record Filter picks upcustomer_salesrepalongsideprice_pageand notes that soft-deleted rows keep their old values. Six new rows across the task index and the quick-symptom table — @mrwuss -
docs: Repo files for the recipe.
examples/python/recipes/reassign_salesrep.pyandexamples/csharp/Recipes/ReassignSalesrep.cs(menu entry 14) — the recipe had page programs but no repo files, unlike every other recipe. Both build both payloads, both are validator-clean, both are dry-run/EXECUTE-gated, and both read the grids back with the soft-deleted rows labelled. The definitions README now leads with readValidValues, not just the field names, which is the transferable half of this finding — @mrwuss -
docs: Read
ValidValuesbefore concluding a grid can't delete.delete_flagis the common pattern, not the universal one. The general rule and the one-liner that prints a grid's fields with their valid values. The siblingTABPAGE_SALESREP.tabpage_salesrepon ShipTo proves the asymmetry: same concept, and it does havedelete_flag(Char, lenient —"ON"and"Y"both land as'Y') — @mrwuss -
fix: The recipe's OData read-back filters were quoting a numeric key.
customer_salesrep.customer_idandship_to_salesrep.ship_to_idareEdm.Decimal, socustomer_id eq '100198'returns 404 —Found operand types 'Edm.Decimal' and 'Edm.String'— and the complete example's verification loop would have thrown on the last line. Filters are now unquoted in both language examples and in the Verify block, with a gotcha explaining why the 404 doesn't mean what it looks like — @mrwuss
(From issue #140, independently re-verified end to end on 26.1: definition, every write case, the primary guard, row ordering, the revive path, both read surfaces, and the code_p21 mapping. "Inactive" (705) is rejected by this grid — ValidValues is authoritative about the labels, not just the codes.)
2026-08-20 — v1.8.8
A controlled A/B turned the most common error in the API into something quite different from what it looks like.
- feat:
Column is disabledCan Mean "Disabled For You" — the error is not always a property of the field. Identical request sequence, same tenant, same moment, two consumer-key tokens differing only in theusername: a CSR login setdisposition = 'D'(Status: 1); a service account was refused withColumn is disabled: disposition. Both could open the window, load the order and set every other field on the line. Nothing in the response says why.
Role-scoped DynaChange rules, Application Security and window permissions all surface as this one undifferentiated message. The practical consequences are the point: a payload that works in development can fail in production because the service account there has a different role — and it will look like a schema problem; Required: true in the definition tells you nothing, because disabled-ness is evaluated per session; and the fix is a P21 configuration change, not a payload change. Test with the account that will actually run the integration — @mrwuss
-
fix: v1.8.7's
dispositionfinding was incomplete. It said the field is settable only at line entry, which is true but not the whole story — it is user-gated on top of that. The wizard section now carries the A/B result and the consequence for automation: a service account may be able to trigger the wizard and still be unable to create the line that feeds it — @mrwuss -
docs: One permission does not unlock a workflow. Granting a service account a Buyer ID cleared the
To create a PO, you must have a valid Buyer ID.gate onc_create_po(Status: 2→Status: 1, verified) — and that same account was still refuseddisposition. Two gates, two different mechanisms, one workflow. Recorded so nobody grants one setting and assumes the path is open — @mrwuss
2026-08-20 — v1.8.7
The direct-ship wizard, driven end-to-end here rather than taken on report.
- feat: Driving an In-Window Wizard upgraded from "reported" to verified. Order 1519097 created with a
Dline, re-opened withc_create_po = 'ON', save → the wizard,cb_next→ PO 998303 on page 2,cb_finish. Confirmed in the database:po_hdrpo_type='D',approved='Y', plus anoe_line_porow linking order line ↔ PO line withconnection_type='P'. Matches the reported shape field for field — @mrwuss, flow originally verified by Alex Westemeier - feat:
dispositionis settable only at line entry — new table showing all three attempts:/transaction→Column is disabled; interactive change on an existing line →Column is disabled; interactive change on the line as it is entered →Status: 1. So an existing order line cannot be converted to direct ship through either API, and there is no stateless path at all — @mrwuss - docs: The popup tool list also contains the parent window's ribbon.
GET /v2/tools?windowId={popupId}returnsQuick.Save,inquiry.*,help.*and tab-scoped tools alongside the popup's own buttons — search it for the specific names rather than reading the first few entries and concluding you have the wrong window. Also records that saving a new order raises a differentcb_1/cb_2/cb_3rule dialog, which is precisely why the two-phase sequence matters — @mrwuss - docs: Buyer-ID prerequisite now shows both sides —
Status: 2with the error when the user has none,Status: 1when they do. Every other step works right up until the save, so it's worth checking first — @mrwuss
2026-08-20 — v1.8.6
Closes the cross-check. The wizard limitation now has a worked escape hatch.
- feat: Driving an In-Window Wizard — we have always said the Transaction API cannot drive wizards and pointed at the Interactive API without ever showing it. The Order window's PO/RFQ generation wizard (direct-ship PO linked to a sales order) is now documented end to end: the two-phase sequence, identifying the wizard by its buttons before answering, and the result (
po_hdrpo_type='D'+ anoe_line_polink row). Thedocs/03limitation row now links to it instead of stopping at "use the Interactive API" — @mrwuss, flow verified by Alex Westemeier - docs: Two prerequisites verified here, and both save wasted time.
c_create_po = 'ON'returnsStatus: 2/ "To create a PO, you must have a valid Buyer ID." when the API user has none — a P21 configuration change, not an API problem. Anddispositionis a disabled column:Column is disabled: dispositionthrough/transactionand through an interactive change on an existing line, so a direct-ship line cannot be started statelessly at all; it must be set as the line is entered in the window — @mrwuss - docs: ⚠️ The wizard commits at
cb_next, beforecb_finish— a session that dies mid-wizard leaves a real, linked PO behind. An abandoned wizard is not a no-op. Cross-linked to WhatFailedactually guarantees as the same class of hazard. Also records thatsupplier_idon the wizard page is disabled through the API — it defaults to the item's primary supplier and cannot be overridden, so if vendor choice matters this flow cannot make it — @mrwuss
The wizard itself was not re-driven here: our API user has no Buyer ID and granting one is an operator decision. Everything reachable without that change was verified; the rest is credited and marked as such.
2026-08-20 — v1.8.5
The AP buy-side cycle, verified end-to-end on our own tenant — PO 998302 → receipt 5706613 → voucher 1775027, each step SQL-confirmed.
- feat: PurchaseOrder Service — one stateless POST, no session. Field mappings confirmed by read-back:
unit_price_display→po_line.unit_price(notunit_price; the field is labelled "PO Price"),buyer_id→po_hdr.requested_by(an employee number, not a login).division_idandcompany_idare both markedRequired: trueand both were omitted — they default, anddivision_idmirroredsupplier_id.po_typeisn't settable here (ours defaulted to'B') — @mrwuss - feat: PurchaseOrderReceipt Service — the Transaction API can receive a PO, which was an open question in the source notes. Header only:
po_no+receive_all='Y', plusexternal_reference_nofor tagging. Works when every line's item has a usable primary bin; when one doesn't, the Bin tab is contextual to the selected grid row, so flat bin rows land on the wrong line — the same context rule as order lines — and you must drive the window interactively. Records the two diagnostic signatures (bin-sum mismatch; put-locked bin'0'), theufc_inv_loc_primary_bincolumn that tells you which path you're on, and that direct-ship POs can't be received here at all — @mrwuss, bin analysis by Alex Westemeier - feat: ConvertPOToVoucher Service — the line list is keyed
["receipt_number", "line_number", "po_no"], matching theKeyFieldstriple already in our Keys table. Send five header fields and nothing else:company_id,branch_id,period,year_for_periodare all markedRequired: trueand all disabled — @mrwuss - fix: Probing a field in isolation gives false negatives on disabled-ness — a correction to v1.8.4, which reported that the
company_idrefusal "did not reproduce." It does. 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:company_idonConvertPOToVoucherwith no valid PO fails on "No receipts have been selected" and looks accepted, while the same field in a complete payload returnsColumn is disabled: company_id. Both observed on the same tenant an hour apart. Test disabled-ness in context, never in isolation — now stated in WhatRequiredactually means — @mrwuss - docs: INDEX rows for creating, receiving and vouching a PO — @mrwuss
- chore:
scripts/check_anchors.py— validates every internal anchor link against the generated HTML. Added because the same failure bit twice in one day: an em dash in a heading collapses to a single hyphen in the generated id, so## Foo — Barbecomes#foo-bar, and a hand-written#foo--barrenders fine on GitHub while 404-ing on the published page. Run it aftergenerate_html.py; it exits non-zero and names the offenders — @mrwuss
2026-08-20 — v1.8.4
Investigating the cross-check findings turned two reported claims into precise, verified rules — and answered a question left open in v1.8.0.
- feat: What
Failedactually guarantees — the repo says "checkSummary" on every write page without saying what a failure count promises. Now measured, three scopes with read-backs: within one Transaction it is atomic (a valid line edit paired with a disabled column rolled back both times, same element and later element); across Transactions in one POST it is not ({"Failed": 1, "Succeeded": 1}and transaction 1's write persisted). The trap is the second:Failed ≥ 1does not mean nothing happened, and a client that retries the whole POST re-applies everything that already worked. Third scope — downstream documents a service generates — is outside both, which is where the reportedDirectShipConfirmationcase sits — @mrwuss, from a report by Alex Westemeier
Recorded honestly: the blanket "a transaction can commit while reporting Failed" did not reproduce on ordinary Order writes. It is specific to services that cascade into other documents, and to Interactive wizards that commit at intermediate steps. That distinction is the useful part.
-
feat: What
Requiredactually means — the definition'sRequiredflag is not a contract and is wrong in both directions. The cleanest proof is our own most-used service:Ordermarkscompany_idasRequired: true, andcompany_idis the disabled column we have always told readers never to send.JobContractPricing.contract_nois the reverse — marked optional, actually mandatory. 571 of 7,539 fields (7.6%) across the committed definitions carry the flag, so this is not a rare mislabel. Guidance: derive the minimum payload empirically, because padding to satisfyRequiredhitsColumn is disabled, and a Transaction is atomic — one padded field kills the whole write — @mrwuss -
feat:
basicsis computed, not curated — 03 § Endpoints. v1.8.0 documented that the endpoint omits fields you need and includes fields you can't write; this explains why. For every element it returns exactlyKeyFields∪Required— verified across 220 elements in four services, zero mismatches. It inherits theRequiredflag's errors wholesale: it listscompany_idonOrderbecause the definition marks it required, and omitscustomer_id/source_loc_id/ship_to_idbecause the definition doesn't. Practical upshot:basicstells you nothing the definition doesn't, so generate it offline fromdefinitions/*.json— its value is the ready-to-fill shape, not the field selection — @mrwuss -
docs: INDEX rows for both new sections and an 06 entry for "
Failed: 1— did anything land?" — @mrwuss
2026-08-20 — v1.8.3
Cross-check against Alex Westemeier's verified process work — 17 new commits since the July pass. This entry ships the one finding that breaks something published earlier today; the rest are filed as #130–#133.
- fix: The
Orderservice refuses RMAs — use theRMAservice. An order withoe_hdr.rma_flag = 'Y'fails to load at all:You cannot retrieve an RMA from the Order Entry/Front Counter window.TheRMAservice is the same window for the return side, publishing an identicalTABPAGE_1.orderform keyed onorder_nowith the same fields — swap the service name and the payload works unchanged. Verified on our own tenant: the same RMA order refused byOrder, loaded byRMA.
This shipped immediately rather than waiting because the update-order-lines recipe went out earlier today telling readers to key TABPAGE_1.order and sweep open orders — exactly the bulk pattern that hits RMAs. Adds an RMA Service reference entry, a gotcha in the recipe and in Order Service Gotchas, and an INDEX row for the error text. Detect with oe_hdr.rma_flag and route per order rather than discovering it as a failure — found by Alex Westemeier while reassigning takers at scale; verified and documented by @mrwuss
2026-08-20 — v1.8.2
Completes the 26.2 help sweep — every one of the 1,251 articles scored for API relevance, 17 candidates triaged, two findings worth keeping.
- docs: ⚠ A DynaChange business rule can read a consumer key's value — 00 § Method 2: Consumer Key. A rule can be assigned a Rule Consumer Key so its code can authenticate to the P21 APIs; Epicor's own wording is that the rule programmer then "has access to the consumer key name and value in code in the RuleState class (
RuleState.ConsumerKey,RuleState.ConsumerName)", alongsideSession.MiddlewareUrl. Read with the impersonation warning already in that section, the consequence is concrete:Allow creation of business rulesis admin-equivalent wherever a key is attached to a rule, because rule code is arbitrary .NET and can exfiltrate the key. Working as designed — documented as a caveat about who you grant it to — @mrwuss - docs: The UDT
row_uidproblem is a naming mismatch, not a missing identifier — 14 § entry 7. P21's generated UDT maintenance windows expose a searchable Row ID and use it to recall, edit and delete rows — the exact three operations the service cannot perform. It sits over theudt_{tablename}_uidprimary key while the service accepts only a column named literallyrow_uid. The window and the service disagree about the name of the same thing, which is why this reads as a defect rather than a design choice. Also records the mass update path in those windows as the no-API bulk option. Cross-linked from 13 — @mrwuss - docs: Sweep closed out. DynaChange documentation never addresses how rules interact with API writes — our documentation of auto-answered prompts and
Column is disabledis original work with no vendor counterpart, which is worth knowing before anyone goes looking for it again.Business Rules for DevelopersandRule Class APIdescribe a .NET DLL extension surface (Visual Studio, compiled rule classes), not a REST API. Recorded inscraped/site-map.mdso the next pass starts from what's left, not from zero — @mrwuss
2026-08-20 — v1.8.1
Help-site re-scrape for 26.2 and one correction it produced.
- fix:
Allow access to the API system pool— Epicor's own docs contradict each other, and v1.8.0 took one side. The Application Security reference calls it "a technical setting... only changed under instruction from Epicor support"; the Prism documentation lists "Application Security – Enable API System Pool" as a required prerequisite for approving sales orders in Prism. 00 § Application Security settings now records both rather than repeating the "don't touch" line as settled — @mrwuss - chore:
scraped/site-map.mdrebuilt for 2026.2 — new host (p21help262), all-new category IDs, 45 categories / 305 sections / 1,251 articles. Records the two genuinely new categories (DynaChange Designer, Prism), theOPTIONAL - WMSrename, and that auth is required for everything (403 on article HTML, 401 on the API) — @mrwuss - chore: Scraper hygiene —
scrape_full_site.pyrepointed at 26.2. The other seven scripts are one-off 25.2 jobs with hardcoded40628…article IDs and were deliberately left on the 25.2 host: a new host with dead IDs only looks usable. The distinction is written down in the site map — @mrwuss - docs: Two categories triaged so the next pass doesn't repeat the work. Prism is not an API surface (1 of 13 articles mentions API, and only the prerequisite above). Middleware is 10 articles of install/config with no API-usage content — the API documentation lives on the middleware itself at
/docs/, not in the help site. DynaChange Designer (22 articles) is flagged as the highest-value remaining target — @mrwuss - docs:
updated_atis useless as a change signal on this site — all 1,251 articles carry the same date because 26.2 was republished wholesale. Finding real changes since 25.2 means diffing article text against the local corpus; noted in the site map so nobody trusts the timestamps — @mrwuss
2026-08-20 — v1.8.0
Findings from two authoritative sources we had never systematically mined: the Epicor SDK and API Reference hosted on our own middleware, and the P21 26.2 help site. Everything marked verified was tested against a 26.1 tenant; vendor-documented items that could not be exercised say so.
- fix: The OData API is v4, not v3 — corrected in 02 and 01. Proven live: the service returns
OData-Version: 4.0,@odata.context, and a JSON CSDL$metadatawith"$Version": "4.0". Consequences now stated:substringofis gone (usecontains), the metadata format is v4, and a v3 client library will not talk to this service. Adds Epicor's explicit capability matrix — most usefully the negatives: no server-driven paging, no$expand— @mrwuss - feat: The other OData surface —
/data/erp/views/v1— a second OData endpoint the repo had never mentioned, verified live: 118 curatedp21_view_*views, v3 on the wire, and the thing that makes it worth knowing — native single-row key addressing,p21_view_oe_hdr('1013938')and compound keys likep21_view_customer(company_id='1', customer_id=100915M)(note theM=Edm.Decimal). Includes a which-one-you-want table and its documented limits (curated views, no joins, UDFs not queryable) — @mrwuss - feat: Commands Endpoint § Request Shape — we listed which 11 services require
/api/v2/commandsbut never how to call it. Now documented: theRequests[]shape and theActioncodes verified by running each value (0open,1close,2change,5select row,6save,9run tool;3returns 204 and4returnsStatus: 2with arguments unmapped;7/8/10+ return an empty 500) — @mrwuss - fix: The notes story was incomplete —
/commandsis the sanctioned path for standalone notepads. v1.7.0 concluded notes were Interactive-only. That holds for the/transactionendpoint and for order notes, but not generally: verified end-to-end on 26.1, a six-step/commandspayload wrote an item note — satisfying the mandatory drag-and-drop area selector viaAction 5+Action 9 cb_select— returnedsavesucceeded, and the row was confirmed in thenotetable. 03 § Limitations now carries a three-surface comparison table instead of a blanket claim, and the drag-and-drop limitation row names both escape hatches — @mrwuss - fix: The "
purchasing/*404s" claim was a guess, and wrong — 05 § Discovering what your tenant actually exposes.purchasing/purchaseordersanswers 200; so doaccounting/gl,extensibility/userdefinedfields,inventory/inventoryadjustments,sales/tasks,service/serviceordersand more. Replaces guessed wildcards with a 38-family ping sweep (22 answering) and, more importantly, with the method: the middleware's ownapiref.aspxlists every family your tenant exposes, each with a/helppage. Also records thatinventory/v2/partsreturns a byte-identical response to v1, and that a405(as oncardstorage) means a real family and a wrong probe verb — @mrwuss - feat: Callbacks instead of polling —
/api/v2/transaction/async/callbackwas a one-line row in the endpoints table with no payload anywhere in the repo. Now documented: theContent+Callbackenvelope (URL, method, content type, custom headers for authenticating into your own receiver), that P21 posts back theAsyncRequestitself, and thatCallbackResult— which looks like a dead always-null field on plain/async— is how you learn whether your endpoint accepted the call. Also records the documented-vs-observed conflict onStatus(SDK saysActive/Complete/Failed; 26.1 returns integers with no failed state) — @mrwuss - feat: Application Security settings that affect API access — from the 26.2 help.
Access to SOA Admin Pageis the one that costs time: a correct P21 password bouncing off the middleware logon page is this setting, not a bad credential. AlsoAllow access to the API system pool(do not touch — context for 07) andAllow creation of business rules. Plus Attributing writes to a real user:Allow overriding audit trail userlets a caller log the real end user rather than the service account — shipped, but scoped to Epicor's own companion apps, so our "writes land against the service account" limitation is narrower than we stated — @mrwuss - docs: Transaction API special scenarios from the SDK — the Task
target_date-before-start_datestub is now explained (both dates default to today, so start validates against an unset target) with the fullForm.formfield list; the credit-card scenario gains theTP_CCTRANSACTIONRESPONSEandTP_REMITTANCESfield names and the note that the payment elements go after every other element;UseCodeValuesnow cites Epicor's own recommendation — @mrwuss
2026-08-20 — v1.7.0
Continuation of the live-testing session, plus a fold-in of every open repo issue (all maintainer-verified production findings). Everything marked verified below carries a DB or read-back confirmation.
- feat: Recipe: Modify an Existing Sales Order — edit a line's quantity in place and add a new line, one stateless POST. Built on the verified
user_line_nohandle mechanics: header element sends onlyorder_no(re-sending create-time header fields fails onColumn is disabled: customer_id), the keyed items list upserts per row, mixed update+insert in a single POST verified live (handle010updated 3→4,030inserted,020untouched). Runnable Python + C# examples and validator-verified JSON/XML payloads included — @mrwuss - feat: Standalone Notepad Windows — the drag-and-drop area picker that closes
ItemNotepad/CustomerNotepad/SupplierNotepadto the Transaction API is an ordinary set of button tools in the Interactive window:cb_select/cb_selectall/cb_deselect/cb_deselectall. Item note written end-to-end (header fields →TABPAGE_17→cb_selectall→ save) and confirmed in thenotetable. Every P21 notes surface now has a documented working path — @mrwuss - feat: Elements with no declared
KeyFieldsnever fold — Keys § What the definition already tells you. Verified onOrder→TABPAGE_RELEASE.tabpage_release: two differing rows withKeys: []landed as two rows, and a re-sent identical row was treated as a new row (rejected by business validation, not folded). The collapse trap lives only on keyed elements; on the keyless third the trap inverts — nothing deduplicates, and retries append duplicates — @mrwuss - fix: Async Operations rewritten from a live round-trip — on 26.1 the submit returns HTTP 200 (not the previously documented 202) with a status wrapper; wrapper
Statusis queue state, not outcome (3running,2completed — for failures too); the real outcome is a double-encoded JSON envelope inside theMessagesstring (json.loadsit, then readSummary/innerMessages). Verified with a valid and an invalid transaction side by side — @mrwuss - docs: ⚠ Consumer keys are skeleton keys — 00 § Method 2: Consumer Key now leads with the impersonation warning: the key authenticates by itself and the
usernameheader is unauthenticated context, so a key holder can impersonate any user including admins — and an admin impersonation can mint its own admin account, surviving key revocation. Keys stay in trusted environments; vendors get username/password service accounts instead — @mrwuss - docs: Salesrep Service — name/email edits via
contact_idkey; email iscontacts_email_addresson TABPAGE_2;login_idis untouched by a rename; retirement isdelete_flag. Closes #109 — @mrwuss - docs: Inventory REST location-append gotchas at scale — from a 138-item × 2-location production run:
LocationSuppliersappend sets the location primary supplier in the same PUT; GL is required when the location has noinventory_defaultsrow; kit items rejectBuy: "Y"; OP/OQ needs a positive order quantity; transient 500s retry clean;PrimaryBinandReplenishmentLocationare writable; re-PUTs are idempotent. Closes #110 — @mrwuss - docs: ItemDefaults Service — creates the per-company/location defaults record new
inv_locrows inherit; the sales/purchase pricing units are required ('Sales Pricing Unit' is a required column); creating the defaults row first is the fix for fresh-location GL failures. Closes #111 — @mrwuss - docs: PutawayZone / PickZone Services — single-form
bin_zoneservices; the zone type is set by which service you call (1406/1407); fresh-location sequence PutawayZone → PickZone → BinLocation verified end-to-end; RESTPrimaryBindocumented as the lighter primary-bin path. Cross-linked from the create-bins and set-primary-bin-supplier recipes. Closes #112 — @mrwuss - fix(site): table columns size by content again — cells now use
overflow-wrap: break-wordinstead ofanywhere, so a column is never squeezed below its longest token (part numbers stay on one line) and a genuinely-too-wide table scrolls inside its wrapper instead of squishing — @mrwuss - docs: recipes index rows added for
update-order-linesand the previously missingreassign-salesrep; INDEX rows for every new section — @mrwuss
2026-08-20 — v1.6.1
Follow-the-thread session: each v1.6.0 fact was pushed one step further against the live 26.1 tenant, and every result below is DB-confirmed.
- feat: Sales Order Notepad Writes — the door the notes dead-end points to is now verified open on the Order window itself: header note (
oe_hdr_notepad) and line note (oe_line_notepad, attached to the row selected viaPUT /v2/row) both written end-to-end with the same Notepad Entry popup mechanics as the PO recipe. Order-window differences documented: tool names are namespaced (hdr_note&&cb_addnote, not barecb_add), the tool list is tab-scoped and accumulates as tabs are visited (a missing tool usually means a missing tab-select), mandatory customer notes surface as loadMessageswithout blocking, and line notes never appear in/transaction/get'sLINE_NOTEelement — read notes back through OData (oe_hdr_notepad/oe_line_notepad) — @mrwuss - feat: Design for updates: assign your own line handles —
user_line_nois caller-assignable at create time. An order created with the same item on handles010/020(no collapse, nounit_quantitykey needed) was then updated by handle, in place, no phantom lines. Turns the stable-key advice from a debugging move into a design rule for integrations that create-then-maintain lines — @mrwuss - docs:
/transaction/gethas no server-side subsetting — the envelope'sQuery/FieldMap/TransactionSplitMethodfields are echo-only (byte-identical response when sent populated), element-list fields are ignored, and keying aTransactionStateon aListelement fails the read. You always get the whole window; filter client-side. Recorded so nobody chases those fields again — @mrwuss - docs:
basicsanswers for report services too — and at their size it is the cheapest criteria probe going:m_pickticketsreturns a ready-to-fill criteria skeleton in 601 bytes againstdefinition's 15.8 KB. Noted in the discovery-endpoints comparison as the API-side twin of the SQL Help trick. The definition-probe discovery method re-verified on 26.1 (m_picktickets/m_reprintpurchaseorders200; absent names 500 on all three endpoints alike) — @mrwuss
2026-08-19 — v1.6.0
Documented from a recorded community conference session on intermediate P21 API development (Felipe Maurer, 2026), then live-tested against a 26.1 tenant. The session covered ground this repo had used correctly in specific recipes without ever explaining generally — most importantly Keys. The testing confirmed the session's central claims exactly, corrected two, and settled two the session left open. Claims that were not re-tested say so inline and give the reason.
- feat: New Keys — Row Identity and the Collapse Trap — the Transaction API's least-documented behavior and its most silent failure. Rows in a
Listelement that the service considers the same row are folded into one, last-value-wins, withSucceeded: 1and no warning: two order lines for the same item at quantities 5 and 10 produce one line at quantity 10.Keysis theGROUP BYthat splits them — name a column whose value actually differs (hereunit_quantity). Covers choosing a key (it must be a real column on the element; the field that differs, not the one that repeats; timestamps as a last resort; compound keys fine; surplus keys are accepted silently, so a wrong key set looks exactly like a right one), why over-keying breaks updates (a too-specific key stops matching, so an intended update upserts into a new line), and a debugging loop for the symptom that always looks like an API bug: the write succeeded and the data is wrong. Corroborated mechanically from the committed definitions —Order'sTP_ITEMS.itemsdeclaresKeyFields: ["oe_order_item_id"], which is precisely why the collapse lands on the item ID, andJobContractPricing'sJOBPRICELINE.jobpricelinedeclares["item_id", "line_no"], which is the same rule behind the already-verified contract-line guidance to addline_nowhen one item appears on several lines. 214 of 335Listelements acrossdefinitions/declare key fields; the section shows how to read them per service.
Verified live (26.1, 2026-08-19) — each step a real Order write with a /transaction/get read-back: two rows of one item at 5 and 10 with Keys: [] collapse to one line at quantity 10 (Succeeded: 1, no messages); Keys: ["unit_quantity"] splits them into two; two different items need no Keys at all; an update keyed on unit_quantity appends a third line instead of editing the second; the same update keyed on user_line_no edits in place. Corrected: "surplus keys are accepted silently" holds only for a key that is real and sent — a key naming a column absent from that row's Edits fails the transaction with General Exception: Sequence contains no matching element, and one naming a nonexistent column fails with Invalid column name: {name}. Both are hard failures, so only a real, sent, non-discriminating key is silent — community session, verified and documented by @mrwuss
- feat: New Reading One Record — POST /transaction/get — the endpoint appears in a dozen examples in doc 03 but had no section of its own. It is a POST despite the name, it returns one record as a complete TransactionSet across every tab, and because the response is shaped like a request it doubles as a clone template: read a record, change the key, post it back (a configured user copied to a new hire, a customer modeled on an existing one). Adds the /transaction/get-vs-OData table — one record whole and pre-joined versus many records one table at a time — and the clone caveats: disabled/auto-generated fields come back in the response and IgnoreDisabled is not a reliable way to write them, popups and stale references stop the replay, read the clone back. Verified live (26.1, 2026-08-19): one Order read returns 102 elements across every tab, and multi-record retrieval works — N TransactionStates return N Transactions (new subsection Reading several records in one call), at a few hundred KB of JSON per record. Both clone caveats reproduced on a verbatim replay: cross-field validation rejected it (The Expedite Date must be on or before the Required Date), then disabled display columns did (Column is disabled: customer_name, then company_id). Filtering the read-back through the basics field list cloned it cleanly in one pass — community session, verified and documented by @mrwuss
- feat: The interleaving rule stated generally — The general rule: repeat the element pair, don't batch it. The API replays a payload as an operator would work the window, so a child element attaches to whichever parent row is current at that point in the sequence: item A → detail A → item B → detail B, never A → B → detail A → detail B, which silently applies both details to the last row. An element may repeat as many times as needed. Doc 03 previously stated this only as one-off sequences for lot items and contract break lines; those are instances of this rule. Verified live (26.1, 2026-08-19) on a two-line order with TP_EXTDINFO.extd_info: the batched form returns Succeeded: 1 with no messages and puts both descriptions on line 2, last one winning, leaving line 1's extended_desc null — community session, verified and documented by @mrwuss
- feat: GET /api/v2/basics/{name} added to the endpoints table, with a note on which of the three discovery endpoints answers which question: definition for the full schema and dropdown valid values (this is where site-specific values such as carrier_id come from), defaults for defaults and a fillable template, basics for an abbreviated field list — carrying the session's warning that basics can omit fields you need and include fields you cannot write.
Verified live (26.1, 2026-08-19) — the endpoint is real and both halves of the warning hold. basics returns a Status: "New" TransactionSet skeleton with Keys prefilled from each element's KeyFields and IgnoreIfEmpty: true on every edit, so you fill in values and POST it. On Order all three endpoints return the same 102 elements, but basics carries 103 fields against definition/defaults' 1,266. Its header list omits customer_id, source_loc_id and ship_to_id — all needed to create an order, and omitting ship_to_id fails the save with the unattributed This column is required. — while including company_id, which is refused with Column is disabled — community session, verified and documented by @mrwuss
- feat: Lowercase item_id creates an item the UI cannot open. The desktop client folds typed item IDs to uppercase, so this is unreachable from the application — but the Transaction API applies no such conversion, accepts the lowercase ID, and creates the item; opening it in Item Maintenance / Item Master Inquiry then crashes the client (the browser, in the web client). Normalize item_id yourself before every create. Independently confirmed from production experience and deliberately not re-tested — the item it creates cannot be cleaned up through the UI. Added to Item Service Gotchas — community session, documented by @mrwuss
- feat: Async has no cancel path — Async Operations now records that the immediate 202 acknowledges queueing only, not validation or success (a transaction the synchronous endpoint would reject returns a normal request ID), that the outcome and its business-rule text live only in the status GET, and that once queued, nothing stops the work: a loop that submits 50,000 wrong requests runs all 50,000, each firing the same DynaChange rules, alerts and event rules. The real hazard is the loss of backpressure — a payload bug that a synchronous run surfaces on record one surfaces here after the batch has landed. Probed live (26.1, 2026-08-19): every cancel-shaped route 404s, and DELETE /api/v2/transaction/async returns 405 — the route exists, for POST only — community session, verified and documented by @mrwuss
- feat: Report windows are still windows — added to PDF Report Generation: the API user needs access to the report window; DynaChange data-change rules apply (sites cap date ranges so nobody runs a report wide open, and pdfreport hits those same caps, so a payload that works for one user can fail for another); and a wide-open run has been reported executing three days before exhausting server memory, where cancelling the HTTP request does not stop the server-side generation. Also adds a third report-name discovery path to sit beside the window_x_menu query — right-click any field in the report window → SQL Help, which names the m_* window directly — community session, documented by @mrwuss
- docs: "The Transaction API can't do notes" is right for Order, but not for the usual reason — the session attributes it to the Add Line Note wizard. The Order definition does publish note elements as ordinary List DataElements — LINE_NOTE.line_note and HDR_NOTE.hdr_note (both keyed note_id, with note/topic/notepad_class_desc) plus TP_ITEMNOTES.tp_itemnotes (keyed note_uid) — so the wizard is incidental. Tested live (26.1, 2026-08-19): they are all disabled. Every column of both note elements is refused one at a time (Column is disabled: topic, then notepad_class_desc, then note); TP_ITEMNOTES refuses a step earlier with Tab page is disabled and cannot be selected. And IgnoreDisabled: true makes it worse — the same LINE_NOTE write returns Succeeded: 1 and the note is still empty on read-back, a third service exhibiting Breaking Changes entry 8. The standalone ItemNotepad/CustomerNotepad/SupplierNotepad services close the same way: their mandatory area selector is a drag-and-drop control — omit it and the save fails with You must select at least one area where this note will display., send it as a Selected Areas row and it fails with Column is disabled: area — the drag-and-drop limitation with a paper trail. Notes are Interactive API territory; use the verified notepad path. Recorded in Limitations — @mrwuss
- docs: Transaction API limitations extended — wizards launched from another window (the PO wizard and assembly decoder from Order Entry; credit-card entry, which is an embedded merchant-gateway form rather than a P21 window, so the documented TAPI path takes a token generated elsewhere), drag-and-drop windows, and mandatory notes, which strand a transaction on the notes tab and can be turned into prompts by a user setting on the API user's profile — with the caution that mandatory notes usually exist for a reason — community session, documented by @mrwuss
- docs: UDT quote characters belong to the SQL-injection filter, and the behavior is version-dependent — SQL Keyword False Positives now records that the protection is reported to be a string replacement rather than parameterization, so ' and " inside ordinary values are caught too. The everyday casualty is dimensional data (6" hose, 1/2" fitting, 3' section). Epicor is reported to have relaxed the logic since, and the community forum testing was done on ~25.1 — so the entry tells you to insert one row with the real punctuation on your version and read it back rather than trusting any document, this one included. Not testable here — the test tenant has no user-created UDT, and creating one is UI-only — community session, documented by @mrwuss
- docs: Breaking Changes entry 8 extended past
JobContractPricing— theIgnoreDisabled: truesilent no-op reproduces onOrderLINE_NOTE.line_note(refused loudly without the flag,Succeeded: 1and nothing written with it). Three unrelated services now show the same false success, which marks it as platform behavior rather than aJobContractPricingquirk; the mitigation is broadened from one service to any write that uses the flag — @mrwuss
2026-08-19 — v1.5.1
Community contribution: the /api/sales/orders family creates orders as well as reading them.
- feat:
POST /api/sales/orders/creates a sales order — documented from a community contribution tested on 25.2, with the two shape rules that decide whether the call works at all: the trailing slash is required (without it the request is not routed to the API — and on a POST you cannot lean on a redirect-following client the way the read-side 307 lets you), andLinesis an object, not an array — the lines go inLines.list, which the/newtemplate shows without explaining. Adds the header and line field tables, an example body, a complete runnable Python/C# program, and a REST-vs-Transaction comparison for choosing between the two order-creation paths. The section states plainly what the contribution does not settle — the success response shape and where the order number comes back, which fields are strictly required versus optional, whether the inline ship-to fields are required alongsideShipToIdor only a fallback, the validation-error status codes, and whether the/newtemplate's other collections (Notes,Salesreps, …) use the samelistnesting — so nobody mistakes one working payload for a specification. Doc 05's "writes are untested" note, the selection guide and the task index are updated accordingly. → 05 § Creating an Order — Rob Landham (@roblandham), documented by @mrwuss — Fixes #108
2026-08-11 — v1.5.0
Cleared issues #103 and #105–#107, converted every API-calling code example in the docs into a complete runnable program, and re-verified a batch of long-standing claims against a live tenant. Verification build: 26.1.5910.3. The checks turned up several errors in our own documentation; those are listed first.
First pass — claims checked while resolving the issues
- fix: The OData
inoperator is accepted and silently ignored.$filter=id in (2,3,4)returns HTTP 200 and every row in the table — verified against a 50,077-row view, which returned all 50,077. A genuine syntax error 404s loudly, so this fails in the one way you cannot notice. The Avoiding N+1 section previously recommended IN clauses while its example still issued one query per id; it now uses anorchain, batched, and the operator tables carry the warning — @mrwuss - fix: An Interactive session can only be deleted by the token that created it. Same-token
DELETE /api/ui/interactive/sessionsreturns 200 and clears it. A session belonging to a previous token — a crashed process, a worker that re-authenticated, a retry path that fetched a fresh token first — cannot be deleted at all: no body,?id=,?sessionId=,{"Id"}and{"SessionId"}are all refused with400 {"ErrorMessage":"Invalid session"}, and onlySessionCleanupExpirationreaps it. Doc 14 previously promisedDELETE"clears it instantly" without qualification. Full attempt matrix in End Session — @mrwuss - fix:
IgnoreDisabled: truefails silently on disabled columns, not just theVALUEStab.corp_address_idis refused loudly (Column is disabled: corp_address_id) without the flag; with it the same payload returnsSucceeded: 1and the value is unchanged. The flag therefore has two outcomes that are indistinguishable in the response — real unlock, or swallowed refusal. Entry 8 generalized accordingly — @mrwuss - fix:
GET /v2/windowresponse shape corrected. It returns{Data, Definition};Definition.Datawindowsis a map keyed by datawindow name (andFieldslikewise), whileTabPageListis an array. The doc's helper iteratedDatawindowsas a list. OpeningPurchaseOrderreturned 2 datawindows out of the dozens the service defines, so absence here proves nothing — see Get Window State — @mrwuss - fix:
po_line_notesdoes not exist — the PO line notepad table ispo_line_notepad(definitions/PurchaseOrder.json,tp_21_dw_21). Anyone querying the documented name found nothing — @mrwuss - docs:
corp_address_idbeing read-only after save was previously attributed to "production reports"; now verified, with the exact refusal — @mrwuss
Second verification pass — every remaining unproven claim chased
- fix:
sales_price_pageis not an OData table — it 404s. The table isprice_page. Doc 08's OData examples and doc 01's hybrid example both used the wrong name — @mrwuss - fix: The active-record filter was wrong for most tables.
row_status_flagdoes not exist oncustomer,supplierorinv_mast— those usedelete_flag eq 'N'. The doc's own example (supplier_id eq 10050 and row_status_flag eq 704) returns404 Could not find a property named 'row_status_flag' on type 'dbo.supplier'. Where the column does exist the codes are now confirmed fromcode_p21: 704 Active, 705 Inactive, 700 Delete — and soft-deleted rows dominate (297 of 300 sampledprice_pagerows were700), so the filter matters more than it looks — @mrwuss - docs:
GET /v2/windowreturns only the ACTIVE tab page's datawindows, and each tab selection replaces the last — proven by walkingSalesPricePagetab by tab (form→values→costs→price_page_po_cost_calc). Previously described vaguely as "a varying subset"; a datawindow's absence proves only that its tab isn't active — @mrwuss - docs: Interactive and Transaction use different datawindow names for the same tab —
form/values/costsinteractively versusd_dw_price_page_main/d_dw_price_page_values/d_dw_price_page_costin the Transaction definition. Both correct, not interchangeable. Tab page names are shared. This resolves a long-standing contradiction between doc 08's prose anddefinitions/SalesPricePage.json— @mrwuss - docs: Dropdown display values come from the Transaction definition, not the window. Interactive field metadata carries only
Name,Label,DataType,Enabled— noValidValues.GET /api/v2/definition/SalesPricePagelists them, and confirms the four values doc 08 documents (Supplier / Product Group,Source,Supplier List Price,Multiplier) plus the full accepted sets for all four code fields — @mrwuss - fix: PDF report generation rewritten from a production implementation. An earlier draft of this entry reported that
POST /api/v2/process/pdfreportlooked broken, because every attempt on the Play tenant returned an empty HTTP 500. That reading was wrong: the endpoint runs at production volume — one integration emails supplier POs all day, logging 154 successes against 3 empty 500s and one dropped connection in a single afternoon, with every affected PO succeeding moments later. New guidance in An empty 5xx has several causes: the same empty 5xx covers a transient engine fault (most common — retry, 3 attempts with 0.5 s × attempt backoff), criteria that match nothing such as a wrongcompany_id, or a record/environment that cannot print; parse theErrorType/ErrorMessageenvelope before checking the status code, since it can arrive on a 200 as readily as a 5xx; check the record exists first so "not found" is your own error rather than an anonymous 5xx; and treat success as per-document (ResponseStatus.StatusCode == "Success"and non-emptyDocumentData, checked for every element, not just the first). Both recipe examples now retry and parse envelope-first.m_reprintpicktickets'sUseCodeValuesrequirement remains unconfirmed and is stated as such — @mrwuss - docs: A 26.1 session failure is not a reason to downgrade to V1 auth. The empty-500-without-
Acceptbreak lands on session-create, so it reads as "our V2 tokens stopped working on 26.1" — a production integration reached that conclusion and shipped a legacy-/api/security/tokenfallback before a controlled same-token test isolated the header. V2 tokens open sessions normally onceAccept: application/jsonis present (confirmed here on 26.1.5910.3). Downgrading costs credentials-in-headers and per-operator attribution, since the legacy endpoint has no consumer key and every write lands on the service account. Two-request diagnostic added to Authentication and entry 1 — @mrwuss - docs: The company column has two names.
po_hdr,po_lineandinvoice_hdrusecompany_no;oe_hdr,customer,inv_locandcontactsusecompany_id;supplier,inv_mastandaddresscarry neither. Same value, different column — and guessing wrong returns the unknown-column 404, not an empty result. New Company Scoping section, which also records that API criteria names are a separate namespace from column names (m_reprintpurchaseorderstakescompany_idwhilepo_hdrstorescompany_no) — @mrwuss - docs: The UDT delete
conditionsplacement could not be re-verified — the test tenant has no UDTs and creating one is UI-only. Both documented shapes now carry that status explicitly rather than implying a fresh test — @mrwuss
Audited against two production integrations
Read the P21 integration code of two systems that run these APIs daily and compared every behavioural claim they encode against this documentation.
- feat: New Field Length Limits — the service definition exposes
DataTypebut no length, so there is no API route to field widths (verified 26.1.5910.3). Documents the SQL to read a column's width, measured end-to-end limits (po_hdr.external_po_no40 — 40 persists, 41 is refused;inventory_supplier.supplier_part_no40;po_line_notepad.topic30), the fact that an over-length value fails at the/changecall rather than at save, and the two traps that make a length sweep lie: a write that reports success against a nonexistent record, and a record that refuses to save at every length. Includes the cautionary case of a limit that was published as 32 for years because it was interpolated between three data points with the middle never tested — @mrwuss - fix:
TabNameon/v2/tabis ignored, not rejected. Entry 4 said 26.1 "no longer accepts" it, which is true ofTabNamealone but misleads anyone sending both keys for cross-version support. Verified on 26.1.5910.3:PageNamealone switches,TabNamealone 400s, both together switch, and when the two disagreePageNamewins — proof thatTabNamecontributes nothing. Also records that the failure message names a blank tab (Tab with name or display text of does not exist.) because the server is reporting thePageNameit never received — it reads as a wrong tab name when the real problem is the wrong key — @mrwuss - fix: The
/v2/toolsclick key must beToolName. Posting the button underName— the spelling most other P21 payloads use — is accepted and does nothing:Status: 2with no message, so the tool never runs and the popup you were dismissing stays open — @mrwuss - fix: A blocked save's popup id is only in
Events. The top-levelResponseWindowIdexists in the response and is empty on real 26.1 responses; a production integration had every blocked save fail before switching to thewindowopenedevent. Documented as a negative so nobody reaches for the obvious field — @mrwuss - fix: Doc 04's rule-callback example still told readers that which items trip "Item Issues Detected" "differs between environments" — the framing this release replaced with a deterministic root cause. It now points at the root cause and data fix and keeps the interactive path for cases that cannot be data-fixed — @mrwuss
- docs: Entry 6's non-active-tab silent drop re-tested a second time and still does not reproduce — on 26.1.5910.3, the exact field from the original 5873.1 report (
supplier_ship_dateon a non-active tab) applied normally withStatus: 1. The client that filed the original report nonetheless still writes tab-by-tab, and the entry now recommends that shape as cheap insurance rather than as a workaround for a demonstrable bug — @mrwuss
Repo conventions and indexes
- docs: The runnable-example convention is now written down where contributors will find it — CONTRIBUTING § Code Examples. It states the rule (paste, edit the
EDIT THESEblock, run), the zero-install constraint (httpx;net9.0+ System.Text.Json, no NuGet, noMicrosoft.Extensions.*), that the HTML generator takes tab labels from the fence language so the fence follows the marker directly, that the UI-server helper belongs only in Transaction/Interactive examples, that every write ends in a read-back, and that illustrative fragments stay fragments with a pointer. Previously this lived only in the working notes for the conversion, so nobody could follow it — @mrwuss - fix(site): The site no longer scrolls sideways — anywhere, at any width. Wide tables used to break out of their section; the first attempt put them in a horizontal scroll container, which only made the overflow tidy. This replaces that with tables that actually fit: a cell breaks inside a long identifier only when the token genuinely cannot fit (
overflow-wrap: anywhere, which — unlikebreak-word— lowers a cell's min-content width, and that is what lets the table shrink). The same sweep turned up three further causes of page-level horizontal scroll, none of them tables:.contentis a flex item that lackedmin-width: 0, so it refused to shrink and sidebar-plus-content overflowed every viewport between roughly 900px and 1200px; the mobile breakpoint fired at 900px, too late to rescue that band, and now fires at 1100px; and the fixed Print / Save as PDF button, pinned to the right edge, widened the very layout it was measured against on phones — it now flows inline below 600px. Long tokens in plain prose break too, not just those in code spans. Verified across 320 combinations — all 32 generated pages at ten viewport widths from 320px to 1440px — with zero page scroll and zero table overflow. The four widest tables were also narrowed at the source: theShippingtracking-column comparison became a list (one cell held six service names, which is what forced the width), and three pricing tables each shed a redundant column into prose — @mrwuss - docs: Root README now leads with what the examples actually are, rather than "working examples"; definitions README lists the five services added this release and their 26.1.5910.3 fetch build, and groups the five pricing services so the break-field naming split is discoverable from the schema library — @mrwuss
Runnable examples
- feat: Every API-calling example in the docs is now a complete program. Paste it into a file, edit the constants in its
EDIT THESEblock, run it — no repo clone, no.env, no helper imported from another page. Python needs onlyhttpx; C# targetsnet9.0with System.Text.Json and no NuGet packages, sodotnet new console+ paste +dotnet runworks. Every write example ends with a read-back that prints what actually landed, because in this API HTTP 200 routinely lies. Examples that exist to illustrate a rule rather than run — payload shapes, field-order demonstrations, error samples — are deliberately left as fragments, each pointing at its nearest complete program. Verified mechanically: every Python block parses, every complete C# program compiles, and both preambles plus a sample of converted programs were run against a live tenant — @mrwuss
Issue work
- fix: JobContractPricing break tiers were documented under the wrong field name. Doc 03 stated that the first tier is
calculation_valuewith no suffix, and both the Python and C# create examples sent it that way. The field iscalculation_value1— confirmed indefinitions/JobContractPricing.json(FieldDefinitionsand the payloadTemplate) and against a liveGET /api/v2/definition. Same correction forother_cost→other_cost1. There is no unsuffixed form, and this service has no per-tieruom(that isSalesPricePage) — @mrwuss - fix:
VALUES.valueswrites are refused on 26.1, andIgnoreDisabledhides it. New VALUES Writes Are Refused on 26.1 and breaking-change entry 8. All three write paths — updating an existing line, inserting a new line onto an existing contract, and creating a contract outright — fail atomically withGeneral Exception: Tab page is disabled and cannot be selected. AddingIgnoreDisabled: trueflips the response toSucceeded: 1/Status: "Passed"and writes nothing, dropping the affected DataElements from the echo so the omission is invisible. A control run isolates it: the identical create with theVALUESelement deleted succeeds and creates both contract and Source-priced line, so contract/line creation is fine — it is this element that is refused. The doc-03 create example is kept but marked, andCLAUDE.md'sIgnoreDisablednote now carries the boundary. Regression vs long-standing is unproven — no earlier build was available — @mrwuss - fix:
contract_nois required to create a job contract. The header field table said "auto-assigned if blank"; a create without it fails withRequired value missing for Contract No (for Job/Contract Hdr) on row 1.The definition's ownRequiredflag isfalse, so the definition alone would not catch this. A bare header is also rejected — creation requires a fully specified line, including a validuomfor that item — @mrwuss - feat: New recipe reassign-salesrep (Transaction
Customer+ShipTo) — reassigning a rep writes to two asymmetric grids:CUSTOMERSALESREP.customersalesrephas nodelete_flag(demote the old row, promote the new), while ShipTo'sTABPAGE_SALESREP.tabpage_salesrepdoes (add the new row, delete-flag the old). Also documentsKeys-as-strings, the disabledcustomer_idon a default ship-to, that you writeON/OFFbut read backY/N(both spellings are accepted on write — verified), and that demoting the only primary fails withPrimary salesrep is required., so demote and promote must travel in one transaction. Closes #103 — @mrwuss - feat: New Transaction-API section Shipping Service — Carrier Tracking Number —
Shippingis the only one of 299 services that writesoe_pick_ticket.tracking_no. After invoicing the write is refused at record selection (the error is attributed topick_ticket_no, nottracking_no), so no payload edit gets past it;c_tracking_noon theOrder/FrontCounter/RMA/ServiceOrdergrids is a computed, disabled display column. Workarounds and their limits are documented, andcompany.edit_tracking_number_flagis recorded as an open question — flipping it did not help, but the session pool reads company settings at session creation and the test environment could not be restarted. Closes #106 — @mrwuss - feat: Purchase-side pricing services documented for the first time — Purchase-Side Pricing Services covers
PurchasePricingPageSupplier/...Item/...DiscGrpplus the header/link services, and Transaction API Alternative documentsSalesPricePageas a Transaction service (FORM.formkeyed onprice_page_uid— simpler than the Interactive field-order dance for updates). New Cross-Service Break-Field Names table: the same concept iscalculation_value{n}on sales pages and contracts butvalue{n}on purchase pages, whose DB column is nonethelessCalculation_Value{n}— three conventions, no cross-reference until now. Also adds the previously undocumented per-tieruom{n}on the sales-page VALUES tab. Closes #107 — @mrwuss - fix: "Item Issues Detected" root-caused. Doc 03 said which items trip the popup "differs per environment" and to fall back to the Interactive API. It is deterministic: a site-configured DynaChange rule with
apply_during_save_flag = 'Y'fires on every Item-window save, and on the system under test the trigger is aninventory_supplierrow with bothcostandlist_priceat zero — it is the zero rows that matter, not the primary supplier row, and it blocks all Item-service writes. New Root Cause and Data Fix gives thebusiness_rule→business_rule_data_elementjoin to identify your own site's rule and the SQL to find and fix the rows; 28 of 28 blocked items cleared with no manual UI work. Closes #105 — @mrwuss - docs: New Interactive Is Not an Escape Hatch in the selection guide — both APIs drive the same windows and enforce the same rules, so a disabled column or window-level gate blocks both. The genuine exception is response-window dialogs, which Interactive can answer and Transaction cannot; that is a difference in dialog handling, not a way past a lock — @mrwuss
- chore: Fetched five service definitions (
PurchasePricingPageSupplier,PurchasePricingPageSupplierItem,PurchasePricingPageSupplierDiscGrp,SalesPriceBook,ShipTo). Fixed two dead cross-page anchors that used GitHub's hyphen rule instead of the generator's, and added agent worktrees/task transcripts to.gitignore— @mrwuss
2026-07-21 — v1.4.0
Cleared the open findings backlog (issues #98–#102): five live-verified 26.1 discoveries written into the docs, with four new service definitions fetched from the 26.1 play tenant (build 26.1.5894.1). All new cross-links validated against the generator's heading ids.
- feat: New recipe create-customer (Transaction
Customer) — minimal working create, plus the two non-obvious required fields:salesrep_idis hard-required and its failure surfaces as the misleading "Salesrep ID is required for a new ship to.", anddefault_branchis required but not supplied by the defaults template. End-to-end scripts in Python and C#. Closes #99 — @mrwuss - feat: New recipe create-requisition-po (Transaction
RequisitionPurchaseOrder) — requisition POs are created via a type-specific service, not by settingpo_hdr_po_type(a disabled column onPurchaseOrder). Documents the vendor-vs-supplier id distinction, the misleading line-level supplier error whose fix is on the header, the requisition-item precondition (inv_loc.requisition='Y'), and the verified result letterpo_hdr.po_type = 'R'. Added to the Common Services table and a new Purchase Order Types section with the po_type letter table. End-to-end scripts in Python and C#. Closes #101 — @mrwuss - feat: New Transaction-API section GL Dimensions in the API — GL dimensions attach at voucher/invoice/JE time, not on POs:
ConvertPOToVoucherandVoucherByItemcarry the dimension fields and theTP_TRANS_X_GL_DIMENSIONgrid, whilepo_hdr/po_linehave no dimension columns (pre-tagging a PO needs UD fields). NotesVendorInvoice500s on/definition. Closes #102 — @mrwuss - docs: Interactive Open Window now warns that opening by
Name/Titlecan return HTTP 400 "not available or user does not have permission" even where the same window opens byServiceName—ServiceNameis the only reliable identifier. Added Window-to-Service Discovery documentingframe_menu.service_nameas the window→service map (NULL = no API surface). Closes #98 — @mrwuss - docs: New OData section Undeployed / Unlicensed Windows — the postal-code-group family (
postal_code_group_hdr/_detail,salesrep_postalcode,ideal_locations_by_zip) is OData-readable but dead storage when the module is undeployed: no Transaction/Interactive surface, and Customer-create never consults it (no zip→salesrep cascade — verified by seeding then creating). Readable ≠ live. Closes #100 — @mrwuss - chore: Fetched and committed four service definitions from 26.1 (
Salesrep,RequisitionPurchaseOrder,ConvertPOToVoucher,VoucherByItem); added them to the fetch script's documented set and the definitions README. Fixedfetch_definitions.pyto merge_manifest.jsonon a partial--servicesrun instead of overwriting it (a targeted fetch previously erased the record of every other definition), and documentedP21_SCRUB_TERMSin.env.example— @mrwuss
2026-07-14 — v1.3.0
Production moved to 2026.1, so every 2026.1 claim in these docs was re-tested against a live 2026.1 tenant (build 26.1.5894.1) rather than the 5873.1 test build they were written from. Epicor's Prophet 21 Release 2026.1 Release Guide was also mined for API-relevant content — it yielded exactly one new API surface, which is now documented.
- feat: New Bulk Data API section (doc 13) — 2026.1's "Bulk Data API for User-Defined Tables" announced in the release guide, which names no endpoint (and the in-middleware SDK reference at
/docs/p21sdkdoesn't document it either). Found and fully characterized by probing a live 2026.1 tenant:POST /udtservice/api/bulkupload/{table},multipart/form-datawith the form fieldfile, comma-delimited CSV with a mandatory header row, header names matched exactly and case-sensitively, column subsets allowed, all-or-nothing per file, insert-only (no upsert; no/bulkupdateor/bulkdelete), 1,000 rows/call verified. Two hazards, both read-back confirmed: a headerless CSV returns{"isSuccessful": true}and inserts zero rows, and values are silently rounded to the column scale (1.66→1.7intodecimal(2,1); only precision overflow errors). Also:NULLis expressible only by omitting the column (blank and literalNULLboth 400, even on nullable columns), andcreated_byrecords the middleware's SQL login rather than the API user while remaining writable from the file. Documents UDT catalog discovery over OData (master_udt_definition/master_udt_definition_column) and that creating a UDT is UI-only — @mrwuss - fix: Breaking Changes § 2026.1 re-verified on production 26.1.5894.1 — entries 1–5 all still reproduce, with three corrections. The
Acceptrule is "application/jsonmust be present", not "*/*breaks" (application/json, */*returns 200;application/xmlandtext/htmlfail — full matrix added). A ghost session is cleared immediately byDELETE /sessions— the previous "wait outSessionCleanupExpiration(~6 min)" advice was unnecessary — and the ghost masks the header experiment you'd run to diagnose it. The nonexistent-record load (#5) does carry a diagnostic the original report missed:Messages: [{"Text": "Enter a valid ID or leave ID blank.", "Type": 2}]— @mrwuss - fix: Breaking Changes entry #6 rewritten — the reported mechanism ("a batch containing a non-active-tab field returns
Status: 1while silently dropping it") did not reproduce on 26.1.5894.1 across eight configurations (batched/single,DatawindowNamesupplied/omitted, tab active/inactive); the field applied every time. What does reproduce is that batched/v2/changeis non-atomic: one rejected field returns an HTTP 400 envelope with noStatuswhile the other fields in the same batch are already applied (read-back confirmed) — same partial-application outcome, different route. Entry now documents the verified behavior with the original claim recorded as a correction; the one-field-per-call mitigation is unchanged. 5873.1 is no longer available to distinguish "fixed later" from "misattributed" — @mrwuss - docs: New 2026.1 observations in doc 14 — the middleware version has no endpoint but rides the session-create response (
Properties[0].Properties.fullversion), the only reliable way to confirm your build;GET /v2/datareturns only a varying subset of a window's datawindows (a datawindow's absence proves nothing — it is not a reliable field-level read-back); a nonexistentDatawindowNamefails loudly with HTTP 400 rather than silently; andDatawindowNameis optional for header fields on 26.1 though still required on 25.2, so keep sending it — @mrwuss - docs: 2026.1 release-guide notes — Epicor attributes the release's platform work to ".NET Platform Modernization: migration to .NET 10 and a re-architected middleware home page", consistent with (though not confirmed by Epicor as the cause of) the content-negotiation and contract changes above; 2026.1 is the last release to support SQL Server 2016 (2026.2 requires 2019+); Crystal Reports reaches SAP end-of-support 31 Dec 2028, with Report Studio and Epicor Forms Service as the forward path — @mrwuss
- docs: The UDT
row_uidhazard promoted into the Breaking Changes registry as 2026.1 entry 7, the standing alerts header, the Error Handling guide (a new UDT Service Errors section keyed on the literal strings you'd search for —"Invalid Row Uid!","[0] rows deleted ... successfully!","Conditions cannot be blank or none!","Invalid UDT table"/"Incompatible table for bulk insert"), and the task index. Doc 14's overview was softened to match what we can actually prove: this entry has no prior-version comparison (no pre-2026.1 tenant remained), so rather than let the page keep implying every entry was A/B'd, the registry now states that entries lacking a prior-version check say so — and entry 7 says so — @mrwuss - feat(site): Per-section "Copy BBCode" buttons on the Changelog and Breaking Changes — for quoting a release, a version's findings, or a single entry on the P21 forum. Hovering any heading (h2/h3/h4) reveals the button; it copies that section only, stopping cleanly at the next heading of equal or higher rank, so an entry copies alone while its parent version copies with every entry nested under it. Every link is rewritten to an absolute URL so it still resolves once pasted — including same-page anchors, and resolved against the page's canonical published URL rather than
document.baseURI(which would bake in afile://path when the page is opened locally). Output is BBCode targeted at a forum that accepts no[list]/[*],[quote]or[code]: lists become•bullet lines (nested ones indented), tables one line per row (cells joined with|, header bolded), code blocks and inline code plain text with line breaks preserved, and blockquotes inlined — the ⚠ callouts already lead with bold, so they still read as warnings. Verified across all 45 sections on both pages: no forbidden tags, no relative URLs, no unbalanced tags — @mrwuss - fix(site): 23 broken anchor links repaired across the docs and recipes — every heading containing an em-dash (e.g. "Upsert Semantics — Keyed Rows…", "No Joins — Chain Queries by UID", "Cost Model — Know This…") was linked with a double hyphen while the generator emits a single one, so the links silently landed at the top of the page instead of the section. Eight of them were in
INDEX.mditself — the routing layer whose entire job is jumping to the right section — plus five recipe pages and four cross-doc references. Found by validating every link against the generated HTML ids rather than a re-implemented slug rule; all 277 anchor links acrossdocs/**/*.mdnow resolve — @mrwuss - docs: Dropping a UDT also needs an OData schema refresh — until it runs, queries against the dropped table return
404 "Invalid object name 'dbo.{udt}'.", distinct from the empty-bodied 404 of a table that was never exposed; the wording tells you which situation you're in — @mrwuss - fix: UDT update and delete cannot reach data in a 2026.1-created UDT (doc 13) — both endpoints are hard-wired to a literal
row_uidcolumn, but 2026.1's User Defined Table Maintenance names the primary keyudt_{tablename}_uidand creates norow_uid. Update then returns400 {"error":["Invalid Row Uid!"]}for every condition (the real PK name, any other column, any casing, string or int), and delete returns HTTP 200{"errorNo": 0, "errorMessage": "[0] rows deleted ... successfully!"}while deleting nothing — only the[0]count betrays the no-op. Also found: delete readsconditionsfrom the payload's top level, not nested inrows[]as documented (the nested form 400s with "Conditions cannot be blank or none!"); both forms are now shown. Found while clearing the bulk-API probe rows — the deletion silently "succeeded" four times before a read-back showed the count unchanged. Guidance added: verifyrow_uidexists ($select=row_uid) before relying on these endpoints, and check the row count inerrorMessagerather thanerrorNo— @mrwuss - fix: Propagated the corrected 2026.1 behavior to every page that still taught the superseded version — Error Handling (the
Acceptrule,DELETE-to-clear-the-ghost, the ghost masking the header experiment you'd run to diagnose it, plus a new entry for the partially-applied batch after a 400), Authentication, and Interactive API — which now shows both session-create response shapes (25.2SessionId+Statusvs 2026.1Id+Properties) instead of only the 25.2 one — @mrwuss - docs: Interactive API — documented that P21 exposes no version endpoint and the middleware build rides the session-create response (
Properties[0].Properties.fullversion), the only reliable way to confirm which build you're on before trusting version-specific behavior — @mrwuss - docs: OData —
$metadatais on the collection path (/odataservice/odata/table/$metadata; the service-root form 404s), returns JSON CSDL rather than XML EDMX (~4 MB, ~3,400 tables), with a snippet for listing exposed tables fromns.container— the fastest way to settle "is this table actually exposed?" before debugging a 404. Also: an unknown$selectcolumn returns 404, not 400, which is easily misread as a missing table or permission (a wrong column name is the likelier cause) — @mrwuss - docs: Entity API terminology corroborated by Epicor's in-middleware SDK reference (
/docs/p21sdk), whose first-party catalog lists exactly four APIs — Transaction ("previously known as the v2 API", in Epicor's own words), Entity, Interactive, Data Services — split by capability rather than URL prefix. The UDT Service and its new Bulk Data API appear in none of them, so the SDK catalog is not exhaustive either — @mrwuss
2026-07-10 — v1.2.0
- feat: New P21 Breaking Changes by Version registry (doc 14) — a first-class page cataloging middleware changes that break or silently corrupt integrations, checked before upgrades. Launches with 2026.1 (verified 2026.1.5873.1 vs 2025.2.5855.0 during upgrade validation; reported to Epicor): Interactive endpoints return an empty HTTP 500 without
Accept: application/json(httpx/.NETAccept: */*defaults fail) with a ghost-session side effect (409 "Session already exists" until cleanup → alternating 500/409); session-create response renamedSessionId→Id;/v2/tabbindsPageNameonly; and two silent-false-success hazards — nonexistent record loads returnStatus: 2with an empty window (wasStatus: 0), and multi-field/v2/changesilently drops non-active-tab fields while returningStatus: 1— each with its verified mitigation (existence pre-read; one-field-per-change-per-active-tab + read-back). The 25.2DatawindowNamechange is consolidated into the same registry. Standing Breaking-Change Alerts header added to the top of this changelog; cross-references added in 00/04/06/INDEX/CLAUDE.md — Fixes #96 — @mrwuss
2026-07-10 — v1.1.1
End-to-end alignment audit: nine area agents checked every tracked file against the session's live-verified findings; ~118 findings triaged and fixed in one pass (PR #94). Highlights:
- fix: Interactive API client code in the batch-patterns guide taught calls that fail —
?windowId=on/v2/window//v2/data(only/v2/toolstakes it), eventDataaccessed as a dict (it is a key/value list),resp["Tools"]on a bare-array response — corrected in both languages; five legacy Python interactive scripts carried the same parameter bug — Fixes #93 — @mrwuss - fix: SalesPricePage code tables corrected against live
code_p21reads (220=Source, 221=Price, 227=Value; earlier published values were misassigned), and that doc's C# tabs rewritten from a nonexistent endpoint surface to the real v2 API — Fixes #93 — @mrwuss - fix: Response-window examples in both languages modernized from the pre-2026 "dialogs cannot be answered" framing to the verified
GET/POST /v2/toolsanswer flow (live-tested; onlyw_messageboxes remain auto-answered), which also surfaced and fixed a latent VALUES-datawindow name bug — Fixes #93 — @mrwuss - fix: Both languages' "update existing" examples built payloads that would INSERT (no record identification) — corrected with the key field included and an explicit unverified-for-this-service caveat; all
/api/dataaccess/v1OData URLs replaced (live-verified 404;/odataservice/odatais the working route) — Fixes #93 — @mrwuss - feat: Every write example now dry-runs by default (
--execute/ typingEXECUTE) and verifies with a read-back where practical; shared clients harden the router call (redirects + XML fallback) and requireDatawindowName;Valueis a string in every payload-building path — Fixes #93 — @mrwuss - docs: Selection guide now covers the Inventory REST and UDT Service APIs and routes
/api/sales/orderscorrectly; error guide gains theStatus: "Existing"and report-service traps; changelog contributors table refreshed — Fixes #93 — @mrwuss
2026-07-10 — v1.1.0
Example-layout reorg: the repo now serves all four consumption styles symmetrically — Python, C#, JSON, XML.
- refactor: Python examples moved from
scripts/toexamples/python/(clean git renames) for symmetry withexamples/csharp/;scripts/now holds repo tooling only (generate_html.py,fetch_definitions.py,validate_payload.py). All path references updated across docs, recipe pages, README, CLAUDE.md, C# header comments, and.gitignore; newexamples/python/README.mdmirrors the C# one — Fixes #87 — @mrwuss - feat: New
examples/payloads/library — 11 JSON + 9 XML standalone, copy-ready request bodies for the documented tasks, generated from one source of truth (the XML can never drift out of DataContract element order) and every file machine-verified withscripts/validate_payload.py. Report (pdfreport) payloads ship JSON-only pending XML verification of that endpoint. Recipe pages link their payload files alongside the end-to-end code files — Fixes #87 — @mrwuss - feat:
validate_payload.pynow recognizesPOST /api/v2/transaction/getrequest bodies (ServiceName/TransactionStatesshape with object-styleKeys) — @mrwuss
2026-07-10 — v1.0.0
First tagged release. This wave cross-checked the docs against a community process playbook — every disputed claim was live-verified against a 25.2 test tenant — and restructured the repo for progressive disclosure: task routing, a schema library, a recipes cookbook, end-to-end example files, and payload-correctness tooling. Verified findings credit: Alex Westemeier.
Corrections (our docs were wrong):
- fix: Report-service discovery —
GET /api/v2/services?type=reportreturns an empty list, not the report services; them_*services are hidden from/api/v2/servicesentirely (exactly 299 transaction objects) but remain fully callable viadefinition/defaults/pdfreport. Documented the definition-probe andwindow_x_menumenu-leaf discovery paths. Also:GET /v2/tools?id=returns HTTP 400 (not 500), and the router endpoint without a trailing slash can respond 307 (breaks non-redirect-following clients) — all verified live — Fixes #54 — Alex Westemeier, @mrwuss - fix: API Selection Guide no longer steers all updates to the Interactive API —
Status: "New"+ keyed rows is a verified Transaction API update path and an upsert (inserts when the key doesn't match; 81 lines added in one verified run). Related JobContractPricing corrections: commission costs ARE writable withIgnoreDisabled: true(previously documented as Interactive-only);end_date >= todayheader validation; one-transaction-per-POST rule for line inserts (header optimistic-concurrency collisions + duplicateline_no);pricing_methodmust precedeprice(silent $0 line). NewIgnoreDisabledsection: unlocks disabled columns AND disabled sub-tabs (contract BINS), top-level placement only (silently ignored inside a Transaction) — Fixes #56 — Alex Westemeier, @mrwuss - fix: Reconciled the April 2026 "form-type response windows are dismiss-only" limitation with the July 2026
TabName: nulleditable-response-window finding — the earlier tests addressed popup fields withTabName: "FORM"; retry withTabName: nullbefore concluding a popup is dismiss-only — Fixes #68 — @mrwuss - fix(site):
docs/INDEX.mdrendered toINDEX.html, which on case-insensitive filesystems is the same file as the landing pageindex.html— the Task Index HTML was silently clobbered every build and its sidebar links 404'd on the published site. The generator now emits it astask-index.html— Fixes #84 — @mrwuss - Not adopted after testing: the community claim that
company_idis a disabled column on the JobContractPricing FORM did not reproduce (header saves pass with it included) — the documented #44 update path stands.
New verified content:
- docs: PDF report generation expansion — the wrong-endpoint trap (
/api/v2/transactionacceptsm_*payloads, returnsSucceeded, emits nothing), per-serviceUseCodeValuesdifferences (m_pickticketsrequirestrue+ code values;falsereturns HTTP 500), a workedm_pickticketsexample (creates the pick-ticket record at the requested location AND returns the PDF;printed='Y'prerequisite), and print flags on/transactionreturning PDFs atResults.Transactions[].Documents[]with the make-location limitation — Fixes #58 — Alex Westemeier, @mrwuss - docs: Order service —
source_loc_ideffectively required (tax-jurisdiction error),requested_datemust followorder_date, DynaChange prompts auto-answered with the default silently kill lines; new Interactive API "Sales Order Entry with Assembly Lines" flow (assembly promptcb_1, date-cascadew_response_commonon new orders,takerdefaults to the API user, quickmode bypasses the assembly prompt) — Fixes #60 — Alex Westemeier, @mrwuss - docs: Item service nested-element recipes — primary bin (Form→List→Form) and primary supplier (Form→List→List) with the write-flag vs read-field distinction and the silent no-op when the supplier lacks a location-level row; "Item Issues Detected" rule-callback answering (
cb_1, retrieve-time popups); the detail-formselect_rowtrap — Fixes #62 — Alex Westemeier, @mrwuss - docs: BinLocation bulk bin creation — three-field keyed create, mandatory top-level
IgnoreDisabled, codes-not-uids,ON/OFF↔Y/Nflag conversion, clone-a-twin practice,p21_view_binread-back — Fixes #64 — Alex Westemeier, @mrwuss - docs: Production Order Lifecycle (end-to-end) — stock netting on sales-order auto-create, make-location pick-ticket limitation, labor-before-print timing, the shell-confirm trap (a bare Transaction API confirm flips status/
qty_confirmedbutqty_applied=0and moves no stock — confirm interactively), completion mechanics (separatebin_cd/unit_quantitycalls, per-componentnew_costoverride, status codes 702/1962/1268), Quick Time Entry strict field order and open-period requirement,Shipping(ship + invoice in one save),InventoryAdjustment, and the cost model (receipt vs moving-average COGS, pooling) — Fixes #66 — Alex Westemeier, @mrwuss - docs: OData — explicit no-
@odata.nextLinknote, "No Joins — Chain Queries by UID" pattern withp21_view_*guidance, base-host-not-ui_server note; Interactive — key fields commit the cursor (later fields in the same change call silently ignored), integer-string numerics, the verified BINS unlock recipe for existing contracts (load byjob_no) — Fixes #68 — Alex Westemeier, @mrwuss - docs:
UseCodeValues↔code_p21mapping (labels fromcode_p21language_id 9; DB/OData return the integercode_no) with verified enum maps; definition-endpoint HTTP 500 "Window <> is not available" documented as environment availability, not permissions (238/299 fetchable on the test tenant) — Fixes #70 — Alex Westemeier, @mrwuss - docs: Payload Anatomy — type-annotated TransactionSet skeleton and a mistakes→symptoms table (
Keysas string, misplacedIgnoreDisabled, quoted booleans, object-for-array nesting, non-stringValues, wrong property case, cascade-breaking field order); XML Payloads — full content negotiation verified on every/api/v2endpoint (all four Content-Type/Accept combinations), the mandatory DataContract namespace, alphabetical element order (top-level violation → HTTP 500; nested → silently dropped element → NullReference failure),Keysarrays namespace,TransactionStateRequestroot for/transaction/get, and the fetch-the-Template-as-XML practice — Fixes #82 — @mrwuss
Structure & tooling:
- feat: Task Index routing layer —
docs/INDEX.mdmaps ~80 tasks to exact section anchors so readers (and AI agents) load only what a task needs; navigation convention documented — Fixes #72 — @mrwuss - feat: Service-definition schema library —
definitions/holds sanitized full-field definition JSON (every DataElement, field, key, type, label + payload template) for all 21 documented services;scripts/fetch_definitions.pyrefreshes and sanitizes (drops environment-specificufc_*fields, redacts lookup-backedValidValuesthat carry live instance data, scrub-term gate) — Fixes #74 — @mrwuss - feat: Recipes cookbook —
docs/recipes/with 10 self-contained task pages (complete payload, full runnable Python + C# example, verified gotchas, verify read-back) plus a conventions README; INDEX routes tasks to recipes first — Fixes #76 — @mrwuss - feat: End-to-end example files —
examples/python/recipes/(dry-run by default,--executegates writes) and theexamples/csharp/Recipes/solution project (menu runner,EXECUTE-gated writes); every recipe page links its files — Fixes #79 — @mrwuss - feat(site): Recipes published on the HTML site — subfolder conversion with depth-aware sidebars, a Recipes nav section and landing-page grid, and repo-file links (
definitions/, example files) rewritten to GitHub; landing page gains the Task Index card and the previously missing Production & Labor and UDT cards — Fixes #77 — @mrwuss - feat: Offline payload validator —
scripts/validate_payload.pychecks JSON and XML payload files against the shape rules anddefinitions/schemas (exact paths to each problem, did-you-mean suggestions, XML namespace + element-order enforcement, verified field-order rules);--self-testincluded — Fixes #82 — @mrwuss
2026-07-06
- docs: Correct Entity API taxonomy — Epicor's "Entity API" is an umbrella term for two APIs: the REST API (the
/api/entity/,/api/inventory/, and/api/sales/endpoint families) and the eCommerce API (Entity SOAP API); URL segments are arbitrary and don't define API boundaries, so the repo's/api/entity/-only framing and "Inventory is a separate API" language were wrong. Removed the incorrect warning that category URLs "do not work":/api/sales/ordersexists and responds (verified July 2026: ping 200,/newreturns a full order template, GET by order number returns ~70 fields,/approveroute present); other category families (sales/customers,purchasing/*,ar/*, …) 404 on the tested tenant. Adds a Terminology section and an "Other REST Endpoint Families" section — Felipe Maurer (P21WWUG, taxonomy correction, 25.1 middleware evidence, forum topic), verified and documented by @mrwuss — Fixes #53 - docs: Add PurchaseOrder notepad writes documentation (Interactive API) — header notes (
po_hdr_notepad, PO Notes tabTABPAGE_7, datawindowtp_7_dw_7, toolscb_add/cb_edit) vs line notes (po_line_notes,TABPAGE_21/tp_21_dw_21after selecting a row intp_17_dw_17, toolscb_add_line/cb_edit_line), full popup walkthrough (w_notepad_response_lite:_dw_hdr/_dw_areas/_dw_select,TabName: null,cb_select_all→cb_ok), and the silent-misfile warning: both tools are labelled "Add Note" butcb_add_linefiles the note against the currently-selected line with HTTP 200/savesucceededand no error. RequiresResponseWindowHandlingEnabled: true— withfalsethe add tool returns HTTP 400 "Unexpected response window". Both recipes and the misfile scenario verified end-to-end on a live test tenant (July 2026, read-back confirmed) — Fixes #49 — @mrwuss - docs: Document
GET /api/v2/definition/{Service}as the authoritative schema map — response shapeTransactionDefinition.DataElementDefinitions[]withName,DatawindowName,Type,KeyFields, andFieldDefinitions[](Name,DbColumnName,DataType,Required); added as window discovery technique #7 in the Interactive API guide. Warning boxes in both guides:TABPAGE_Nnames are not sequential with the visible tab order (PurchaseOrder carries 37 tab pages, many hidden — the grid that looks like the second tab isTABPAGE_17), so match on the datawindow name (tp_N_dw_N/d_...) or read the window'sTabPageList; live testing on two servers showed the Interactive window'sTABPAGE_Nnames matching the Transaction definition 1:1 — Fixes #50 — @mrwuss - docs: Add "Verifying Writes" section to Interactive API guide — a save can return
Status: 1withsavesucceededfor the primary datawindow while a child-grid change never persists (verified: a correctly-persisted note and a silently-misfiled one produce identical save responses); status semantics vary across P21 versions, and the save response never includes inserted child keys (note_idappears in the parent grid once the notepad popup commits, but only a read-back proves persistence). Recommendation: read the record back viaPOST /api/v2/transaction/get(or OData) before treating the write as done — verified live: the read-back recovered the server-generatednote_idand located a misfiled note; cross-linked from the Transaction API guide — Fixes #51 — @mrwuss
2026-06-15
- docs: Document Inventory REST API
ItemDesccharacter-set and whitespace behavior — on a 26.1 tenant the API enforces no symbol restriction (all printable ASCII" ' \& < > # / \ | , ; : . ( ) [ ] { } * + = % $ @ ! ? ~ ^ _ -and Unicode such asé,½,°round-trip intact via GET → PUT → GET); only the 40-char limit (41+ **silently discarded** on PUT, HTTP 200) and trailing-whitespace trim apply. **Version/pipeline caveat:** 25.x tenants and downstream consumers (reporting, label printing, EDI) may reject characters the 26.1 REST API accepts — the double-quote"` is a known offender — so strip risky symbols from descriptions and part numbers before writing if targeting 25.x or feeding reporting. Includes a round-trip probe snippet. Verified against Prophet21Play (26.1) — Fixes #47 — @mrwuss
2026-05-22
- fix: Correct JobContractPricing update guidance — Transaction API does update existing
job_price_linerows when called withStatus: "New"and the FORM key fields (company_id,contract_no,job_no,end_date) inEdits(notKeys); previous docs incorrectly deflected readers to the Interactive API. Adds Updating an Existing Contract subsection with verified payload shape, notes thatpricing_methodSource → Price conversion works in the same call, and inlines a/api/v2/transaction/getretrieval example. Empirically verified by 173 successful price updates against contractJOB-1001on a production tenant (HTTP 200, OData re-read confirmed). TheStatus: "Existing"500 caveat remains as an "unused — use New instead" note, no longer a write ban — Fixes #44 — @mrwuss via PR #45
2026-04-16
- docs: Add UDT Service API documentation (
docs/13-UDT-Service-API.md) — complete CRUD documentation for/udtservice/api/udtdata/endpoints (insert, update, delete), OData read patterns, response format quirks, SQL keyword false positives, SaaS hostname differences, Python and C# examples — Felipe Maurer (discovery and testing), David Sokoloski (P21 help docs reference), Brad Vandenbogaerde (database tables, SaaS hostname fix), John Kennedy (SQL keyword issue), Jon Christie (response format quirk), @mrwuss - docs: Add Inventory REST API pricing endpoints — two verified V2 pricing URL patterns, URL encoding requirements for special characters, forward slash (
/) encoding confirmed broken (returns 404), verified pricing response structure with availability data — Felipe Maurer, John Kennedy, @mrwuss - docs: Add Interactive API response window handling for tabless windows —
TabName: nullpattern for changing fields on popup dialogs, common response window buttons — Jon Christie — @mrwuss - docs: Add window discovery techniques section — GetState, GetTools, GetData, result event inspection, P21 SQL Information dialog, browser DevTools — @mrwuss
- docs: Add V1 REST endpoint reference table — internal SDK endpoints for debugging and network trace analysis — @mrwuss
- docs: Expand 25.2 DatawindowName breaking change — add ConvertPOToVoucher (Jeff Patterson, Josiah Shollenberger), Order Entry (Neil Timmerman), Clippership Auto Shipping (Josh Owen), Doc Links (Jaime Nelson) to affected windows list; bug confirmed through 25.2.5776.1, acknowledged by Epicor as development bug; add PO Receiving Group fix example — David Sokoloski (first discovered 4-param workaround), Jeff Patterson (confirmed fix) — @mrwuss
- docs: Add PDF Report Generation section to Transaction API —
/api/v2/process/pdfreportendpoint for generating base64-encoded PDF documents (purchase orders, pick tickets), verified servicesm_reprintpurchaseordersandm_reprintpicktickets, Python and C# examples — Jeff Poss (endpoint discovery), @mrwuss - docs: Add Stored Procedure Executor section to Transaction API —
m_storedprocedureexecutorservice for loading SP definitions via Transaction API, UID lookup workflow,argument_listparameter discovery, database tables (stored_procedure_def,spe_parameter_info,spe_procedure_info) — Felipe Maurer, Kevin Landry, Brad Vandenbogaerde, @mrwuss - docs: Add DynaChange and Popup Handling section to Transaction API — DynaChange enforcement in TAPI workflows, popup suppression pattern for API user profiles, Visual Rule limitations with response/callback attributes, "Column is disabled" root causes, HTTP 200 response validation gotcha — Felipe Maurer, Brad Vandenbogaerde, Justin Cassidy, Neil Timmerman, @mrwuss
2026-04-10
- docs: Correct Inventory REST API — existing
inv_locfields CAN be updated via GET → modify → PUT (previously documented as append-only), add ItemDesc 40-char limit, POST 307 redirect, location soft-delete viaDelete: "Y", PurchaseDiscountGroup/SalesDiscountGroup fields, minimum create payload — Fixes #31 — @mrwuss - docs: Add JobContractPricing service documentation — full-service structure (25 DataElements), multi-line break interleaving pattern, 15-tier break structure, non-break vs break line patterns, Status "Existing" NullReferenceException (platform-wide bug), commission cost column limitations — Fixes #32 — @mrwuss
- docs: Add Assembly service documentation — full-service structure (15 DataElements), component_type valid values (hose-specific), copy_item_id field, item-must-exist-first validation, Part + Assembly creation workflow, Status "Existing" bug — Fixes #33 — @mrwuss
- docs: Add Interactive API operational patterns — tab unlock sequences (JobContractPricing example), add_row Status=2 creates row despite failure status, response window type taxonomy (button-only vs form+button vs message box), UOM auto-population best practice, timeout recommendations — Fixes #34 — @mrwuss
- docs: Expand authentication documentation — token TTL and reuse patterns, multi-API token reuse, TokenManager class examples (Python + C#)
2026-04-04
- docs: Expand consumer key authentication documentation — verified consumer key + username works for Interactive API sessions, added SOA Admin configuration fields, JWT token claims, API-specific behavior table, scope behavior, Python and C# code examples — @mrwuss
- feat: Add
consumer_usernameto P21Config andload_config()for consumer key auth support - chore: Scrub all personal and company-specific data from repository history via git-filter-repo
2026-03-06
- feat: Add Production & Labor API documentation — TimeEntry service for recording labor hours against production orders, ProductionOrder service with full field definitions (54 header fields, assembly lines, components, labor entries, completions, routing), Labor/LaborProcess services for labor code maintenance, 24 production-related Transaction API services discovered, 13 Interactive API windows verified working — @mrwuss
- feat: Add Python example scripts for production/labor — service discovery, TimeEntry definition, labor hour recording, ProductionOrder definition (
examples/python/production/) - feat: Add C# example code for production/labor — mirrors Python examples (
examples/csharp/Production/) - docs: Update API Selection Guide with production/labor use cases and decision table entries
- docs: Update Transaction API with production & labor services table
- docs: Update Interactive API with production & labor windows table
2026-02-25
-
feat: Add C# code examples alongside Python across all documentation — tabbed code blocks (Python/C#) in generated HTML with global language sync and localStorage persistence, 23 C# console app examples mirroring every Python script, shared C# client library (
examples/csharp/Common/) with auth, config, and HttpClient wrapper — @mrwuss -
fix: Correct Interactive API
ResultStatusenum mapping —None=0, Success=1, Failure=2, Blocked=3(was incorrectly0=Failure, 2=Blocked, 3=Dialog), verified against source (ResultWrapper.cs) and live API — PR #26 - docs: Update Data Structures Reference to show integer status types with numeric values
- docs: Document
/toolsendpoint workaround for non-message-box response windows —GET /toolsdiscovers buttons,POST /toolsclicks them (verified onw_inventory_scan_lookup) - docs: Update Event Data format documentation — confirmed KV-list format
[{"Key": "...", "Value": "..."}] - fix: Document P21 25.2 breaking change —
DatawindowNamenow required in Interactive API change requests (3-parameterChangeDatamethod no longer works, must use 4-parameter form). Affects Item, PO Receiving Group, Delivery List, Group Pick Ticket, and likely other windows. Updated all code examples and batch processing patterns. Source: community forum reports. - fix: Rewrite
06_complex_workflow.pyfrom v1 to v2 — endpoints, payload format (ListnotChangeRequests),DatawindowNamecasing, integer status checks, v2 save format - fix: Fix
get_opened_window_id()in reusable client to handle KV-list Event Data format[{"Key": "windowid", "Value": "..."}] - fix: Fix Error Handling doc — Blocked status is integer
3(not string), Event Data uses KV-list format
2026-02-17
- docs: Add standalone Inventory REST API documentation — moved from Entity API doc, added verified PUT/POST behavior, multi-company inventory workflow (GET → Append → PUT), error examples, automation patterns — Sibin Francis (@sibinfrancisaj) via PR #25, verified and restructured by @mrwuss
- fix: Update inv_loc write access known issue — PUT can append new inv_loc records via Inventory REST API (partially resolved)
2026-02-16
- feat: Add Postman Collection for all P21 APIs — pre-configured requests for Auth, OData, Transaction, Interactive, and Entity APIs with auto-capture test scripts — NextTWis (@NextTWis) via PR #24
- fix: Correct API endpoints in Postman collection — verified all URLs against live server, fixed Entity/Interactive/Transaction paths and payloads — @mrwuss
2026-02-13
- docs: Add Inventory REST API documentation to Entity API guide — verified endpoints, caveats, extended properties — Sibin Francis (@sibinfrancisaj) via PR #23
- fix: Disable Jekyll rendering and add root URL redirect to HTML docs — @mrwuss
2026-02-12
- feat: Add reusable P21 API client (
examples/python/common/client.py) with sync/async support, namespace helpers for all 4 APIs, and auto token refresh — Claude Jones (@RadAJones) via PR #16 - fix: Address CodeRabbit + live API testing feedback on client — duplicate parser removal, query param fixes (
?id=vs?windowId=), entity address guards, response window forwarding — @mrwuss - docs: Add XML token responses section, query parameter testing results, and cross-reference updates across Auth, Interactive, Entity, and Error Handling docs — @mrwuss
- docs: Fix Entity API address limitations, add SOAP/mobile endpoints and error codes — @mrwuss
- feat: Add sidebar navigation with page index and on-page table of contents to all HTML docs — @mrwuss
- docs: Expand Transaction API with commands endpoint, async limits, and special scenarios — @mrwuss
- docs: Expand Interactive API with session params, data structures, and missing endpoints — @mrwuss
- docs: Fix OData pagination guidance — page size defaults and performance — @mrwuss
2026-02-11
- docs: Add complete field listings for all Entity API templates — @mrwuss
- docs: Rewrite Entity API docs based on verified live testing — confirmed working, composite keys, address limitations — @mrwuss
- docs: Add OData Dataservice Permissions prerequisites and fix OData version (v3, not v4) — @mrwuss
2026-02-09
- docs: Add production learnings from 700+ bulk Interactive API operations — session batching, error recovery, page expiration patterns — @mrwuss via PR #11
2026-01-20
- docs: Add Interactive API v1 vs v2 differences — endpoint comparison, migration guide — @mrwuss via PR #9
- chore: Cleanup repo — reorganize HTML to
docs/html/, fix broken paths, restore Known Issues — @mrwuss
2026-01-19
- docs: Add working endpoint for responding to P21 dialogs — @mrwuss
2026-01-02
- docs: Add row selection bug workaround and
inv_locexample for Interactive API — @mrwuss - docs: Add Interactive API v1 vs v2 differences — @mrwuss
2025-12-27
- docs: Add lessons learned from Cube Writer project — session pool contamination patterns — @mrwuss via PR #8
2025-12-26
- docs: Add disclaimer to all documentation pages — @mrwuss
- docs: Add SalesPricePage dropdown codes reference — @mrwuss via PR #1
2025-12-25 — Initial Release
- feat: Initial project setup with full documentation for all 4 P21 APIs — @mrwuss
- docs: Authentication — token endpoints (V1/V2), credentials vs consumer keys, API scopes, token refresh
- docs: API Selection Guide — decision flowchart and comparison table
- docs: OData API — query syntax, filtering, pagination, example scripts
- docs: Transaction API — service discovery, bulk operations, async patterns, example scripts
- docs: Interactive API — session management, window operations, response handling, example scripts
- docs: Entity API — CRUD operations on customers, vendors, contacts, addresses
- docs: Error Handling — HTTP status codes, API-specific errors, Python patterns
- feat: GitHub Pages support with card-based landing page
- feat: HTML generation script with print/PDF support
- feat: Community contribution templates (CONTRIBUTING.md, issue templates)
Contributors
| Contributor | GitHub | Contributions |
|---|---|---|
| @mrwuss | @mrwuss | Project creator, all documentation, HTML generation, maintenance |
| Claude Jones | @RadAJones | Reusable P21 API client with sync/async support (PR #16) |
| Sibin Francis | @sibinfrancisaj | Inventory REST API documentation (PR #23) |
| NextTWis | @NextTWis | Postman Collection for P21 API verification (PR #24) |
| Rob Landham | @roblandham | POST /api/sales/orders/ order creation — payload shape, required trailing slash, Lines.list nesting (issue #108) |
| Yeshaya Kohn | @yeshayak | Batch Pricing — JSON and XML bodies, the Content-Type trap behind its "XML only" reputation, and the bare <ArrayOfItemPriceInfo> namespace trap; the 25 Enterprise/Global Search views; the serverinfo version endpoint, which corrected doc 14's "no version endpoint exists"; the build-dependence of Monitoring/shortversion (PR #150). w_message dialogs are drivable via GET/POST /tools — this documentation had called them unanswerable in three places and in both response-window example programs since January (PR #155) |
| Alex Westemeier | @AWestemeier | Original author of much of the verified material in docs 03, 04 and 12, and of the recipes cookbook and schema-library patterns — report-service discovery, upsert semantics and Keys, IgnoreDisabled, Item nested location edits, the production order lifecycle, the buy-side build → receive → vouch cycle, the in-window wizard, and the ui/full surface |
| Jeff Poss | PDF Report Generation endpoint discovery | |
| Felipe Maurer | Entity API taxonomy correction, UDT Service API discovery and testing, Inventory pricing endpoints, Stored Procedure Executor UID discovery, DynaChange enforcement in TAPI, Keys/row-collapse and /transaction/get session material (v1.6.0) |
|
| Kevin Landry | Stored Procedure Executor execution via Interactive API | |
| Brad Vandenbogaerde | UDT database tables and SaaS hostname fix, SP Executor database tables, Visual Rule response/callback TAPI limitation | |
| Justin Cassidy | DynaChange as root cause for "Column is disabled" errors | |
| Neil Timmerman | TAPI HTTP 200 response validation gotcha | |
| John Kennedy | UDT Service SQL keyword issue, Inventory pricing URL-encoding testing (forward-slash limitation) | |
| Jon Christie | UDT response format quirk, TabName: null pattern for tabless response windows |
|
| David Sokoloski | UDT P21 help docs reference, first discovered the 25.2 DatawindowName 4-parameter workaround | |
| Jeff Patterson | Confirmed the 25.2 DatawindowName fix, PO Receiving Group and ConvertPOToVoucher reports |