On this page
- Overview
- Endpoints
- Authentication
- Query Parameters
- Filter Expressions
- Common Patterns
- Data Type Formatting
- Response Format
- Common Tables
- Enterprise/Global Search Views (p21_view_es_*)
- Undeployed / Unlicensed Windows: Readable Tables, No API Surface
- Examples
- Code Examples
- Best Practices
- Common Errors
- Page Size Guidance
- Performance Tips
- The other OData surface: /data/erp/views/v1
- OData Schema Refresh
- Related
OData API
Disclaimer: This is unofficial, community-created documentation for Epicor Prophet 21 APIs. It is not affiliated with, endorsed by, or supported by Epicor Software Corporation. All product names, trademarks, and registered trademarks are property of their respective owners. Use at your own risk.
Overview
The OData API provides read-only access to P21 data using the OData v4 protocol. It's the fastest way to query P21 tables and views.
Key Characteristics
- Read-only - Cannot create, update, or delete data
- Standard protocol - OData v4 (see Protocol version — earlier versions of this doc said v3)
- Direct access - Query any P21 table or view
- Efficient - Supports filtering, field selection (no server-driven paging — see What the service supports)
- No session - Simple request/response model
There are two OData surfaces on a P21 server. This document covers
/odataservice/odata/— the current one, which reaches every exposed table and view. A second, older surface at/data/erp/views/v1exposes a curated set of ~118 views with single-row key addressing. See The other OData surface.
Protocol version
Verified against a 26.1 tenant (August 2026) and re-verified on 26.1.5940.0 — the service answers as OData 4.0, and it answers the same on a production and a test tenant (both checked, August 2026), so this is not an environment-specific setting:
GET {base}/odataservice/odata/table/
OData-Version: 4.0
Content-Type: application/json; odata.metadata=minimal; odata.streaming=true
{"@odata.context": "{base}/odataservice/odata/table/$metadata", "value": [...]}
GET {base}/odataservice/odata/table/$metadata
{"$Version": "4.0", "$EntityContainer": "ns.container", ...}
@odata.context and a JSON CSDL $metadata are v4 constructs; v3 used odata.metadata at the document root and an XML metadata document. Epicor's own SDK reference guide agrees: "The Data Services API is based on the OData v4 standard."
This matters in practice because v4 removed substringof (use contains) and changed the metadata format — a v3 client library will not talk to this service correctly.
What the service supports
Epicor publishes an explicit capability list for this implementation. The negatives are the useful part:
| Feature | Supported |
|---|---|
$filter — eq ne gt ge lt le |
Yes |
$filter — and or not |
Yes |
$filter — startswith endswith contains |
Yes |
$select $orderby $top $skip $count |
Yes |
| Server-driven paging | No — page yourself with $top + $skip (see Page Size Guidance) |
substringof |
No — removed in OData 4.0; use contains |
$expand (navigation properties) |
Not exposed — there are no relationships to expand; join client-side |
in operator |
Accepted and silently ignored — returns every row; use an or chain (see Logical Operators) |
$apply (aggregate, groupby) |
Accepted and silently ignored — returns plain rows; aggregate client-side |
POST {collection}/$query |
Yes — the query options go in a text/plain body (see Long filters and POST /$query) |
(Source: Prophet 21 SDK, Data Services reference guide, served from your own middleware at {middleware}/docs/p21sdk/index.html#/data/reference-guide. The in, $apply and $query rows are from testing on play 26.1.5950.0, 2026-10-29: $apply=aggregate($count as n), $apply=groupby((company_id)) and a groupby with an aggregate each returned HTTP 200 and ordinary oe_hdr rows.)
Endpoints
| Endpoint | Purpose |
|---|---|
/odataservice/odata/table/{tablename} |
Query a database base table |
/odataservice/odata/view/{viewname} |
Query a database view |
/odataservice/odata/table/$metadata |
Schema document — every exposed base table and column |
/odataservice/odata/view/$metadata |
Schema document — every exposed view and column |
tableandvieware two separate surfaces, and an object appears on exactly one of them. Ask for a view on thetablepath, or a base table on theviewpath, and you get an empty-bodied 404 — the same response as a name that does not exist at all. See The table/view split.
Base URL Example
https://play.example.com/odataservice/odata/table/supplier
Base host, not ui_server: OData runs on the P21 base host — unlike the Transaction and Interactive APIs, it does not use the UI server URL returned by the router endpoint. Don't prefix OData paths with the ui_server.
Discovering What's Exposed ($metadata)
$metadata hangs off the collection path, not the service root: /odataservice/odata/table/$metadata works, while /odataservice/odata/$metadata returns 404. (The correct path is the one echoed in every response's @odata.context.)
The document is JSON CSDL, not the XML EDMX you may expect, and it is large — roughly 4 MB and ~3,400 tables on a stock tenant. Exposed tables are the keys of ns.container:
"""Check what tables are exposed via OData $metadata."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
response = client.get(
f"{BASE_URL}/odataservice/odata/table/$metadata",
headers=headers,
timeout=300, # ~4 MB payload; the shared client's 120s default is tight
)
response.raise_for_status()
metadata = response.json()
tables = [name for name in metadata["ns"]["container"] if not name.startswith("$")]
print(f"{len(tables)} tables exposed") # e.g. 3394
print("po_hdr exposed?", "po_hdr" in tables)
This is the quickest way to answer "is this table actually exposed to Data Services?" before debugging a 404 — and to find the real name of a table you're guessing at.
The same document also answers "what are this table's real column names" — check it before guessing a $select, not after a 404. Each table is a JSON CSDL EntityType, keyed by table name under its DB schema (dbo for base tables — not under ns, which only holds ns.container's table list, no column detail). Every other key on the EntityType object is a column name ($Kind/$Key are the only non-column keys; a property with no $Type at all defaults to Edm.String):
# Continuing from the client/metadata above; needs Accept: application/json — this endpoint
# answers in XML EDMX by default (see the token endpoint's own default-XML behavior, docs/00).
entity_type = metadata["dbo"]["address"] # schema first, then table name
columns = [k for k in entity_type if not k.startswith("$")]
print(columns) # e.g. ['id', 'name', 'mail_address1', 'mail_address2', 'mail_city', ...]
This is the address table's actual answer: the mailing columns are mail_address1/mail_city/mail_postal_code/mail_country (not address1/city), which cost a 404 to discover the hard way once — see Common Tables for the full verified list. Checking $metadata first avoids that round trip entirely; it is the authoritative schema, not a cache of it. Verified live against a real dbo.address EntityType, 26.1, September 2026 — including catching a first draft of this exact note that guessed the wrong key path (ns.address / $Properties) before checking the real response.
Newly created tables need a manual refresh. A table added after the service started (e.g. a new UDT) is absent from
$metadataand 404s on query until SOA Admin → Refresh OData API service is run. See Schema Refresh.
The table/view split, and the 404 it produces
The two collection paths partition the database exactly, by INFORMATION_SCHEMA.TABLES.TABLE_TYPE. Measured on a production tenant (September 2026) and matched against the SQL catalogue on the same database:
table/$metadata |
view/$metadata |
SQL catalogue | |
|---|---|---|---|
| Entity types exposed | 3,409 | 3,759 | 3,409 BASE TABLE, 3,759 VIEW |
| Objects on both surfaces | - | - | 0 overlap |
| Payload size | ~4.1 MB | ~6.3 MB | — |
Every base table is on table, every view is on view, and nothing is on both. The practical consequences:
- Asking the wrong path returns an empty-bodied 404, identical to the response for a name that doesn't exist.
GET /odataservice/odata/table/p21_view_oe_hdr→ 404;GET /odataservice/odata/view/p21_view_oe_hdr→ 200.GET /odataservice/odata/view/oe_hdr→ 404; ontableit returns 252 columns. A 404 here means wrong surface, wrong column name, or wrong object — it is not a permissions signal. - Don't infer the surface from the name.
class_expansion_viewis aBASE TABLEdespite the suffix, so it lives ontableand 404s onview. The object'sTABLE_TYPEdecides, never its name. - Check the matching
$metadatabefore concluding something is unexposed. A view missing fromtable/$metadataproves nothing — all 3,759 of them are missing from it. - User-defined tables are fully readable, and this is not obvious from the table list: P21's own
*_udtables (oe_hdr_ud,oe_line_ud,inv_mast_ud,customer_ud,supplier_ud,location_ud,po_line_ud) and site-custom base tables alike are ordinary base tables on thetablesurface. - Object names are case-insensitive.
Custom_Alerts,custom_alertsandCUSTOM_ALERTSall resolve.
The 25 Enterprise/Global Search views documented below are on the view surface, which is why they do not appear in table/$metadata.
One object name is unreachable regardless of surface: bin
GET /odataservice/odata/table/bin and GET /odataservice/odata/view/bin both 404 — with a raw IIS "404 - File or directory not found" HTML page, not the normal OData JSON 404 body every other missing-object case in this section produces. Verified live on 26.1.5950.0, every casing tried (bin, Bin), both surfaces, with and without a $select/$filter.
The table itself is real — bin.$Key appears in table/$metadata, and its data is reachable indirectly through inv_loc.primary_bin and the bin_ud extension table (which resolves fine; only the bare name bin fails). The most likely explanation is a routing collision: bin is also IIS's own reserved name for a website's compiled-assemblies folder, and something in the URL rewrite chain in front of the OData service appears to intercept a path segment named exactly bin before it reaches the OData handler — which would explain why the failure looks like a static-file 404 rather than an application-level one. Not confirmed against IIS configuration directly; recorded as an observed, reproducible symptom.
Read bin data through a join instead. inv_loc.primary_bin and inv_loc.location_id cover the common case (an item's primary bin at a location); for other bin attributes, bin_ud (its user-defined-field extension table) is reachable normally and may carry what you need.
One table is withheld from every token: users
The users table (P21 user accounts) cannot be read through OData by any token. The two authentication methods fail in different shapes, and one of them looks like success:
| Token | table/users?$top=1 |
table/users/$count |
|---|---|---|
| Consumer key, with or without a username | 401 You are not authorized to access API. Please contact administrator to get access. |
401 |
| Password grant | 200 with {"value":[]} |
404 Value cannot be null |
What the tests ruled out:
- Scope. The consumer key's decoded
audclaim listedusersas an exact entry in its OData allow-list, and the token was minted immediately before the calls. Every other table in the same list returned 200 with the same token. - Columns. Under a consumer key,
$select=id,namereturns the same 401, so the block is on the table, not on sensitive columns. - Filters. Under a password grant,
$filteron the caller's ownidstill returns an emptyvalue. A control read oftable/customer?$top=1with the same token returns a real row, and the table is not empty in SQL.
A P21 system administrator confirmed that withholding users from consumer keys is intended behavior: adding users to a key's scope has no effect by design. The password-grant result shows the restriction covers that method too. Epicor has not published a list of tables withheld this way.
Treat an empty value on users as "withheld", not "no rows". A client that checks only the status code reports a password-grant read as a success that found nothing. There is no supported OData workaround. Whether a custom view that selects from users is also withheld is untested.
Credit: @mrwuss — verified on production and play 26.1, September 2026, with both authentication methods; a second integrator reported the same consumer-key 401 on another play middleware host and on live (#164). An earlier reading that a password grant could read
userscame from a status-code-only check and is withdrawn.
Read-after-write consistency
Every write recipe in this repo verifies a Transaction API write by reading it back over OData (or /transaction/get) immediately afterward — no delay, no retry. That pattern is now a tested fact, not just an accident of every example happening to work: 125 back-to-back single-field writes (inv_mast.item_desc via the Item service), each followed instantly by an OData table: read-back of the same field, returned zero stale reads — 75 sequential plus 50 more across 5 concurrently-writing items (26.1, September 2026). The OData table: surface reflects a Transaction API write with no observable lag, at least at this scale.
What this does not establish, so do not over-read it:
- Only the
table:surface was tested.inv_mast/Itemhas no matchingview:object to compare against ($metadataconfirms noitem/inv_mast/v_itemview exists — Discovering What's Exposed); whether aview:object (which may be backed by its own query/cache layer) lags is unconfirmed. - This was light load (5 concurrent writers, single tenant), not a stress test. Heavier concurrent write volume, a busier tenant, or a different middleware deployment could behave differently.
- This is a snapshot of one build. Re-verify before relying on it as a hard invariant if the tenant's P21 or middleware version changes — see the 2026.1 breaking-changes registry for how much this platform's behavior moves between builds.
Within those bounds: a downstream integration treating an immediate post-write OData mismatch as a hard data-integrity alarm (no retry, no grace period) is not chasing a phantom on this surface — verified table: reads have not lagged a Transaction write in any attempt made here.
Authentication
Include the Bearer token in the Authorization header:
GET /odataservice/odata/table/supplier HTTP/1.1
Host: play.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json
See Authentication for token generation.
Prerequisites (User Credential Auth Only)
If you authenticate with User Credentials (username/password), a valid token alone is not enough. The P21 user must also have two permissions configured in the P21 Desktop Client:
- User Maintenance → Application Security → "Allow OData API Service" = Yes
- Role Maintenance → Dataservice Permission → Allow the specific tables/views being queried
Without these, you'll get a "You are not authorized to access API" error even with a valid token.
Consumer Key authentication skips these requirements - access is controlled by the key's API scope instead. See Authentication - P21 Permissions for full setup details and screenshots.
Query Parameters
All OData query parameters are prefixed with $.
$select - Choose Fields
Return only specific fields:
/odata/table/supplier?$select=supplier_id,supplier_name
⚠️ An unknown
$selectcolumn returns 404, not 400. Naming a column that doesn't exist fails the whole request with an empty-bodied 404 — indistinguishable at a glance from "table not exposed" or "no permission". If a table you know exists suddenly 404s, drop the$selectand retry before chasing Data Service permissions; a misremembered column name (e.g.supplier_idonpo_hdr, which actually hasvendor_id) is the more common cause. Verified on 2026.1.
$filter - Filter Results
Filter records based on conditions:
/odata/table/supplier?$filter=supplier_id eq 10050
Dates are bare yyyy-MM-dd literals, and now() is not supported — see Non-Expired Records.
$orderby - Sort Results
Sort by one or more fields:
/odata/table/supplier?$orderby=supplier_name asc
$top - Limit Results
Return only N records:
/odata/table/supplier?$top=10
$skip - Pagination
Skip N records (combine with $top for paging):
/odata/table/supplier?$skip=20&$top=10
No
@odata.nextLink: P21's OData responses do not include a continuation link. Paging is entirely client-driven with$skip/$top— loop until a page comes back with fewer rows than$top(or track@odata.count). See the Pagination Helper.The service will not save you from yourself here. Re-confirmed on 26.1.5940.0: a
$selectwith no$topagainstp21_view_oe_hdrreturned 806,503 rows in a single response — no truncation, no continuation link, no server-side cap. An unbounded query is not a page-one query; it is the whole table, materialized in your process. Always send$top.
$count - Get Count
Include total count in response:
/odata/table/supplier?$count=true
Filter Expressions
Comparison Operators
| Operator | Meaning | Example |
|---|---|---|
eq |
Equal | $filter=supplier_id eq 10050 |
ne |
Not equal | $filter=status ne 'Inactive' |
gt |
Greater than | $filter=amount gt 100 |
ge |
Greater or equal | $filter=date ge 2025-01-01 |
lt |
Less than | $filter=quantity lt 50 |
le |
Less or equal | $filter=price le 99.99 |
Logical Operators
| Operator | Example |
|---|---|
and |
$filter=supplier_id eq 10050 and row_status_flag eq 704 |
or |
$filter=status eq 'A' or status eq 'B' |
not |
$filter=not endswith(name,'Inc') |
⚠️
inis accepted and silently ignored.$filter=id in (2,3,4)returns HTTP 200 with every row in the table — the filter is discarded without a warning, unlike a genuine syntax error, which 404s. Match multiple values with anorchain instead, and keep it inside the length limits. Verified on 26.1.5910.3 and again on 26.1.5950.0 — see Avoiding N+1 Query Patterns.
String Functions
| Function | Example |
|---|---|
startswith |
$filter=startswith(supplier_name,'ABC') |
endswith |
$filter=endswith(supplier_name,'Inc') |
contains |
$filter=contains(description,'filter') |
Null Checks
$filter=expiration_date eq null
$filter=notes ne null
The right-hand side is always a literal — you cannot compare two columns
Every comparison in $filter is column operator literal. Naming a second column on the right does not compare the two; the service takes the name as a literal value and tries to convert it to the left column's type. On a typed column that fails loudly. On a string column it succeeds and quietly returns the wrong answer.
| Filter | Result |
|---|---|
total_amount gt 0 |
200 — literal, works as expected |
total_amount gt amount_paid |
404 Failed to convert parameter value from a String to a Decimal. |
order_date lt invoice_date |
404 Failed to convert parameter value from a String to a DateTime. |
total_amount sub amount_paid gt 0 |
404 — same conversion error; arithmetic across two columns is not a way around it |
bill2_name eq ship2_name |
200 — and zero rows |
That last row is the hazard. The same predicate in SQL matches 739,355 rows on the tenant it was measured against; over OData it returns HTTP 200, @odata.count: 0, and an empty value array, because it was evaluated as bill2_name eq 'ship2_name'. Nothing in the response distinguishes "no rows match" from "your filter did not mean what you wrote" — this is the same class of silent-success trap as in being accepted and ignored.
Do the comparison client-side. Filter server-side on what the service can evaluate — literals, and flag columns that already encode the answer — then compare columns in your own code:
# wrong: silently or loudly fails
$filter=total_amount gt amount_paid
# right: filter on the flag P21 maintains for exactly this question,
# then compute the balance yourself
$filter=paid_in_full_flag eq 'N' and invoice_type eq 'IN'
Where a derived comparison has to happen in the database — an aging report, a reconciliation — that is a reason to reach for SQL or a purpose-built view rather than OData.
Long filters and POST /$query
A long or chain runs into two separate limits. Both return HTTP 404, the status this service also uses for a missing table or a bad column name, so read the body before you debug the query: the first limit answers with an IIS HTML page, the second with a one-line message.
| Limit | Value | What you get past it |
|---|---|---|
Query string on a GET |
8,192 characters, URL-encoded | HTTP 404 with an IIS HTML page, "404 - File or directory not found." At about 16 KB, HTTP 400 "The size of the request headers is too long", then HTTP 414. |
Terms in one flat or chain |
799 | HTTP 404, Recursion depth exceeded allowed limit. The limit holds for GET and POST alike. |
The first limit binds first on a GET: 220 terms of the form order_no eq 'NNNNNNN' (seven-digit order numbers) fit in 8,167 characters, and 221 do not. The count depends on how long each term is, so measure the encoded query string, not the term count.
POST {collection}/$query removes the query-string limit. Send the same options you would put after the ? as the request body, with Content-Type: text/plain. The body can be plain or URL-encoded. application/json and application/x-www-form-urlencoded bodies return HTTP 500.
POST /odataservice/odata/table/oe_line/$query
Content-Type: text/plain
Accept: application/json
$select=order_no,line_no&$orderby=order_no,line_no&$top=10000&$skip=0&$filter=order_no eq '500100' or order_no eq '500101'
It works on both the table and view surfaces, and $select, $orderby, $top, $skip and $count behave as they do on a GET.
The term limit still applies. Keep each flat chain under 800 terms, or group the terms in parentheses: (a or b or ... ) or (c or d or ...) with 50 terms in each group returned HTTP 200 and 1,969 oe_hdr rows for 2,000 consecutive order numbers in one call.
(Verified on play 26.1.5950.0, 2026-10-29, against oe_hdr, oe_line and p21_view_oe_line. Both limits are server configuration, so a different tenant can have different values.)
For a worked example that batches order numbers through $query, see Order lines carry no header status.
Common Patterns
Active Record Filter
P21 tracks record status two different ways, and using the wrong one 404s the whole request. Check which column the table actually has before filtering.
Tables with row_status_flag (e.g. price_page, customer_salesrep). The values are code_p21 codes — confirmed against those tables on 26.1:
row_status_flag |
Meaning |
|---|---|
704 |
Active |
705 |
Inactive |
700 |
Delete (soft-deleted) |
$filter=row_status_flag eq 704
This filter matters more than it looks: soft-deleted rows are not removed, so they accumulate. On the tenants tested, 297 of 300 sampled price_page rows were 700 (deleted), and customer_salesrep held 12,060 rows at 700 against 20,201 at 704 — an unfiltered query returns dead data, in the price_page case overwhelmingly so.
A deleted row also keeps its old values (customer_salesrep rows at 700 still carry their last commission_percentage), so an unfiltered read isn't merely noisy — it reports retired records with live-looking data. The write side of the same flag is in 03 § Removing a Salesrep Grid Row: the Transaction API sets it with the label (Delete), not the code.
Tables with delete_flag — customer, supplier, inv_mast and many others have no row_status_flag at all. They use a 'Y'/'N' char column:
$filter=delete_flag eq 'N'
⚠️ Filtering on the wrong column fails loudly — and misleadingly.
supplier?$filter=row_status_flag eq 704returns 404 withCould not find a property named 'row_status_flag' on type 'dbo.supplier'. A bare 404 from this API is easy to misread as "table not exposed" or "no permission", so check the column first:GET /odataservice/odata/table/{name}?$top=1and look at the keys.Quoting a numeric key fails the same way.
customer_salesrep?$filter=customer_id eq '100198'also returns 404 —A binary operator with incompatible types was detected. Found operand types 'Edm.Decimal' and 'Edm.String'. Many*_idcolumns (customer_salesrep.customer_id,ship_to_salesrep.ship_to_id) areEdm.Decimaleven though the value looks like an identifier string, so send it bare:customer_id eq 100198. The same$top=1probe answers this too — a quoted value in the response means a string column. Note the asymmetry with the Transaction API, where everyValueyou write is a string regardless of column type.
Company Scoping — company_id vs company_no
The company code is the same value everywhere ("ACME"), but the column that holds it has two different names, and a third group of tables doesn't carry it at all. Verified on 26.1.5910.3:
| Column | Tables (verified) |
|---|---|
company_id |
oe_hdr, customer, inv_loc, contacts |
company_no |
po_hdr, po_line, invoice_hdr |
| (neither) | supplier, inv_mast, address — these records are not company-scoped |
Purchasing and invoicing use company_no; order entry, customers and inventory locations use company_id. There is no way to infer which from the table's subject matter, and guessing wrong produces the 404 described above rather than an empty result — po_hdr?$select=po_no,company_id fails outright.
The API's field names are not the column names. The
m_reprintpurchaseordersreport takes its criteria field ascompany_id, even though the underlyingpo_hdrcolumn iscompany_no. Window/service field names and database column names are separate namespaces throughout P21 — take criteria names fromGET /api/v2/definition/{service}and column names from the table itself, and don't translate between them.
Non-Expired Records
To filter out expired records, compare expiration_date against a date value:
$filter=expiration_date ge 2025-01-01
Warning: The now() function is not supported in P21 OData. Using it will return a 404 error:
# DOES NOT WORK - returns 404
$filter=expiration_date ge now()
# CORRECT - use explicit date
$filter=expiration_date ge 2025-12-28
For date-relative queries, calculate the date in your application code:
Full runnable version: Code Examples — the same
params/filterpattern shown below is used in a complete, authenticated query in the Filtered Query example.
from datetime import date, timedelta
tomorrow = (date.today() + timedelta(days=1)).isoformat()
filter_expr = f"expiration_date ge {tomorrow}"
var tomorrow = DateTime.Today.AddDays(1).ToString("yyyy-MM-dd");
var filterExpr = $"expiration_date ge {tomorrow}";
No Joins — Chain Queries by UID
P21 OData has no joins: each request hits a single table or view. To traverse relationships, chain queries on the _uid key columns — every child table carries its parent's uid. Example, walking a job contract down to its bins and item IDs:
job_price_hdr $filter=contract_no eq 'JOB-1001' → job_price_hdr_uid
job_price_line $filter=job_price_hdr_uid eq {uid} → job_price_line_uid, inv_mast_uid
job_price_bin $filter=job_price_line_uid eq {line_uid} → min_qty / max_qty / reorder_qty ...
inv_mast $filter=inv_mast_uid eq {im_uid} → item_id
Two ways to avoid long chains:
- Check for a pre-joined view first. Many
p21_view_*views already join the common paths (e.g.p21_view_bin) — query/odataservice/odata/view/{viewname}instead of chaining tables. - Batch the lookups — collect the uids from one query and fetch the next level with an
or-combined filter rather than one call per row (see Avoiding N+1 Query Patterns).
Order lines carry no header status: read the headers first
completed, approved and projected_order (the quote flag) exist only on oe_hdr. Neither oe_line nor the p21_view_oe_line view has them; the lines carry their own complete, cancel_flag and delete_flag, which describe the line, not the order. To work on the lines of open orders, filter the headers, then fetch the lines for those order numbers in batches. Filtering the lines by order-number range or by line complete alone pulls in lines from completed orders and quotes.
This program totals extended_price across the lines of open orders. It sends both queries through POST /$query, so one request carries 500 order numbers, and it parses the prices as decimals (see Decimals arrive as JSON numbers):
"""Total the open order lines: read the headers, then their lines through POST /$query."""
import json
import re
from decimal import Decimal
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
COMPANY_ID = "ACME" # oe_hdr.company_id to total
MAX_ORDERS = 2000 # stop after this many open orders
BATCH = 500 # order numbers per request; a flat or-chain fails at 800 terms
PAGE = 10000 # rows per page
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def query(client: httpx.Client, headers: dict, path: str, options: str) -> list:
"""POST the query options as text/plain; parse decimals as Decimal, not float."""
r = client.post(
f"{BASE_URL}/odataservice/odata/{path}/$query",
content=options,
headers={**headers, "Content-Type": "text/plain"}, # JSON or form bodies return 500
)
r.raise_for_status()
return json.loads(r.text, parse_float=Decimal)["value"]
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
}
# 1. The status flags live on oe_hdr only; oe_line has none of them.
header_filter = (
f"company_id eq '{COMPANY_ID}' and completed eq 'N' and projected_order eq 'N' "
"and cancel_flag eq 'N' and delete_flag eq 'N'"
)
order_nos: list[str] = []
while len(order_nos) < MAX_ORDERS:
page = query(client, headers, "table/oe_hdr",
f"$select=order_no&$orderby=order_no&$top={PAGE}"
f"&$skip={len(order_nos)}&$filter={header_filter}")
order_nos += [row["order_no"] for row in page]
if len(page) < PAGE:
break
order_nos = order_nos[:MAX_ORDERS]
# 2. The lines for those orders, BATCH order numbers per request.
total = Decimal("0")
line_count = 0
for i in range(0, len(order_nos), BATCH):
chain = " or ".join(f"order_no eq '{n}'" for n in order_nos[i:i + BATCH])
skip = 0
while True:
rows = query(client, headers, "table/oe_line",
f"$select=order_no,line_no,extended_price&$orderby=order_no,line_no"
f"&$top={PAGE}&$skip={skip}"
f"&$filter=({chain}) and cancel_flag eq 'N' and delete_flag eq 'N'")
total += sum((row["extended_price"] for row in rows), Decimal("0"))
line_count += len(rows)
skip += len(rows)
if len(rows) < PAGE:
break
print(f"{len(order_nos)} open orders, {line_count} lines, extended price {total}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string CompanyId = "ACME"; // oe_hdr.company_id to total
const int MaxOrders = 2000; // stop after this many open orders
const int Batch = 500; // order numbers per request; a flat or-chain fails at 800 terms
const int Page = 10000; // rows per page
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// 1. The status flags live on oe_hdr only; oe_line has none of them.
var headerFilter = $"company_id eq '{CompanyId}' and completed eq 'N' and projected_order eq 'N' " +
"and cancel_flag eq 'N' and delete_flag eq 'N'";
var orderNos = new List<string>();
while (orderNos.Count < MaxOrders)
{
var page = await QueryAsync(client, "table/oe_hdr",
$"$select=order_no&$orderby=order_no&$top={Page}&$skip={orderNos.Count}&$filter={headerFilter}");
orderNos.AddRange(page.Select(row => row.GetProperty("order_no").GetString()!));
if (page.Count < Page) break;
}
orderNos = orderNos.Take(MaxOrders).ToList();
// 2. The lines for those orders, Batch order numbers per request.
var total = 0m;
var lineCount = 0;
for (var i = 0; i < orderNos.Count; i += Batch)
{
var chain = string.Join(" or ", orderNos.Skip(i).Take(Batch).Select(n => $"order_no eq '{n}'"));
var skip = 0;
while (true)
{
var rows = await QueryAsync(client, "table/oe_line",
$"$select=order_no,line_no,extended_price&$orderby=order_no,line_no&$top={Page}&$skip={skip}" +
$"&$filter=({chain}) and cancel_flag eq 'N' and delete_flag eq 'N'");
// GetDecimal reads the JSON number text exactly; GetDouble would round it.
total += rows.Sum(row => row.GetProperty("extended_price").GetDecimal());
lineCount += rows.Count;
skip += rows.Count;
if (rows.Count < Page) break;
}
}
Console.WriteLine($"{orderNos.Count} open orders, {lineCount} lines, extended price {total}");
// --- helpers ---------------------------------------------------------------
// POST the query options as text/plain; JSON or form bodies return 500.
static async Task<List<JsonElement>> QueryAsync(HttpClient client, string path, string options)
{
var response = await client.PostAsync(
$"{BaseUrl}/odataservice/odata/{path}/$query",
new StringContent(options, Encoding.UTF8, "text/plain"));
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("value").EnumerateArray().Select(e => e.Clone()).ToList();
}
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
(Verified on play 26.1.5950.0, 2026-10-29: both programs returned 1,200 open orders, 10,320 lines and the same total in three line requests. The same lines fetched with GET in batches of 50 order numbers took 24 requests and returned the same total.)
Columns that don't mean what their name says
A column name is a claim about intent, and P21 has a few where the stored value stops matching the intent once a downstream process touches the row. These are read hazards specifically: the value is present, well-typed and plausible, so nothing in the response tells you it is no longer the thing you asked for.
customer.terms_id is the default for the next document, not the terms on an existing one
customer.terms_id is the master-file default applied when a document is created. The terms a given invoice was actually billed on live on that invoice: invoice_hdr.terms_id, with the resulting date in invoice_hdr.net_due_date. The two disagree whenever an account's terms have changed, and an account can carry several sets of terms across its open invoices at once.
This is a read hazard because the master value is the easier one to join to and looks authoritative. Age an invoice against the customer's current terms and the arithmetic silently describes a document that does not exist. A worked case from a production tenant: an account whose master record read Net 180 had two invoices sitting 142 and 149 days past due — both billed Net 30, months before the terms changed. Judged against the master's 180 days they looked comfortably inside terms; judged against their own net_due_date they were the oldest receivables on the account.
Age every invoice on its own net_due_date. Reach for customer.terms_id only when you want to know what the next document will be billed on. The same applies to terms_desc, which is denormalized onto invoice_hdr — the invoice's copy is the historical truth, and terms.terms_id → terms.net_days resolves the master's current meaning.
oe_hdr.completed is not a boolean — 'T' is a real third value
Y and N are what you expect. T marks a document left in an in-progress editing state, and it is rare enough to miss in a sample: company-wide on a production tenant (September 2026), Y 639,247 · N 184,408 · T 308. It matters for two reasons. A filter written as = 'N' and one written as <> 'Y' disagree on every T row, so the choice is never cosmetic. And a T row refuses Transaction API writes — the record-lock prompt fires and the stateless API answers it No — which makes WHERE completed <> 'T' the pre-screen for any batch write against orders. See Order Service — Reassigning the Salesrep.
Do not reach for process_in_progress_lock to find these: it was empty (0 rows) on the same tenant while the prompt was firing, and does not back this behaviour.
Quotes are oe_hdr.projected_order = 'Y' — quote_type is empty and there is no quote_flag
Looking for the obvious column gets you nothing: quote_type is NULL on every row in the table, and no quote_flag column exists. The flag that actually separates a quote from a live order is projected_order. Measured over three months of orders on production: projected_order = 'Y' → 0 of 5,469 ever invoiced (0.0%); projected_order = 'N' → 7,266 of 10,878 invoiced (66.8%, the rest still open or cancelled).
The practical consequence runs in both directions. A query for live documents that does not exclude 'Y' silently mixes quotes into the order book — 5,469 of 16,347 documents in that window. A query for quotes that filters on quote_type returns nothing at all and looks like the site does not use quotes.
po_line.supplier_ship_date is last-shipment-observed on direct-ship POs, not a supplier promise
On a direct-ship PO (po_hdr.po_type = 'D'), confirming the shipment writes the confirmation's ship date down onto po_line.supplier_ship_date for every line on that confirmation — including quantity that has not shipped. Read the column afterwards and you get the date of the last confirmation, not the date the supplier promised.
On a partial confirmation, the promise for the open balance is destroyed. There is nowhere in the PO line to record a reforecast for the unshipped quantity, so the original promise is simply gone:
po_no |
line | ordered | received | supplier_ship_date |
date_due |
|---|---|---|---|---|---|
| 500105 | 1 | 200 | 147 | 2026-06-01 | 2026-07-24 |
| 500106 | 1 | 600 | 360 | 2026-05-22 | 2026-06-09 |
| 500107 | 1 | 1200 | 480 | 2026-06-02 | 2026-06-16 |
Each of these has hundreds of pieces still open against a supplier_ship_date in the past that describes a shipment covering a fraction of the line.
It is confined to direct ship, and the split is total. Verified against a 26.1 tenant (August 2026) across 687,879 receipts, all history, zero exceptions in either direction:
po_hdr.po_type |
receipts | inventory_receipts_hdr.shipment_date populated |
|---|---|---|
D — direct ship |
233,070 | 100% |
B / N / P / S / X |
454,809 | 0% |
shipment_date is only ever set by the direct-ship confirmation, and only the direct-ship confirmation pushes it down to the line. On every other PO type, supplier_ship_date is untouched by receiving and remains a usable promise.
What to use instead. date_due (labeled Expected Date) is not written by receiving on any PO type, and it survives the confirmation — in every row above it still carries a later, different date. Use date_due for the live line-level expectation. For on-time-delivery analysis, either exclude po_type = 'D' lines that have a receipt against them, or capture supplier_ship_date into your own store before the first confirmation, because P21 does not keep the prior value.
GET {base}/odataservice/odata/table/po_line
?$select=po_no,line_no,supplier_ship_date,date_due,qty_ordered,qty_received
&$filter=qty_received gt 0 and qty_received lt qty_ordered
Join to po_hdr.po_type to tell which rows are affected — the line carries no flag of its own, which is the whole trap.
The write itself is documented on the service that performs it: Interactive API § Direct-ship confirmation.
oe_line_po holds stale links: join on both keys and filter delete_flag
po_line has no sales-order column. The link from a PO line to the sales-order line it was bought for lives in oe_line_po: order_number, line_number, po_no, po_line_number, quantity_on_po, connection_type, delete_flag. (On a direct-ship PO, po_hdr.sales_order_number also names the order.)
The table keeps stale rows. Some rows carry a po_no that matches a current PO but point at unrelated sales orders years older than it. Match on po_no alone and those old orders attach themselves to today's PO. To get only real links:
- Match
(po_no, po_line_number)topo_line.(po_no, line_no). - Match
(order_number, line_number)tooe_line.(order_no, line_no). - Drop rows with
delete_flag = 'Y'. Treat a NULLdelete_flagas not deleted.
GET {base}/odataservice/odata/table/oe_line_po
?$select=order_number,line_number,po_no,po_line_number,quantity_on_po,delete_flag
&$filter=po_no eq {po_no} and po_line_number eq {line_no}
Write each literal to match the column's type in $metadata — a quoted numeric key 404s (details). Then keep the rows whose order_number + line_number pair exists in oe_line and whose delete_flag is not Y.
A deleted PO leaves its sales-order lines open with nothing on order. Deleting the PO sets delete_flag = 'Y' on its oe_line_po rows, but the sales-order lines it was bought for stay open. A query that filters delete_flag correctly shows those lines with no PO, which is the true state: the demand is uncovered and needs a new PO. See 03 § Purchase Order Types for the flags on the deleted PO itself.
oe_line_po also returns every historical link for a PO line, not the receipt layer that is on the shelf now. Tracing current stock to the order it was bought for needs lot or receipt matching on top of this join.
Credit: @mrwuss — verified against production 26.1 reads, October 2026 (#184).
Kit and assembly lines: parent cost ignores rebates, and component lines carry COGS too
Two traps make kit (assembly) margin wrong when you read oe_line or invoice_line naively.
1. The parent's commission_cost is the pre-rebate rollup. A kit parent line rolls up its components' sales_cost (the pre-rebate average) into commission_cost. A rebate price page that lowers a component's cost does not reach the parent's commission_cost, so margin computed from it shows rebated kits as losses.
Parent assembly |
commission_cost |
other_cost / COGS |
|---|---|---|
B (kit parent) |
Pre-rebate rollup | Rebated rollup |
P (production-order line) |
Pre-rebate rollup on the invoice | Pre-rebate rollup on the invoice — the invoice posts both at the pre-rebate value |
For rebated kits, read the after-rebate cost from the parent's other_cost on assembly = 'B' lines, or from the components; do not use commission_cost. The contract-side fix is in rebate-assembly-program.
2. Summing every invoice line double-counts kit cost. Component lines carry their own COGS on the invoice, in addition to the parent's rolled-up cost. invoice_line.invoice_line_uid_parent ≠ 0 marks a component line. Count cost from the parent lines or from the components, never both:
GET {base}/odataservice/odata/table/invoice_line
?$select=invoice_no,line_no,invoice_line_uid_parent,commission_cost,other_cost,cogs_amount
&$filter=invoice_no eq {invoice_no} and invoice_line_uid_parent eq 0
As with oe_line_po above, write {invoice_no} to match the column's type in $metadata.
Credit: @mrwuss — verified against production data 26.1, June 2026, re-checked October 2026 (#183).
inv_adj_line.cost is 0 on lines valued at moving average
An inventory adjustment writes inv_adj_hdr / inv_adj_line and posts inv_tran rows with trans_type = 'IA'. On lines that P21 values at moving-average cost when the adjustment posts, inv_adj_line.cost stays 0. The cost that reached the books is inv_tran.unit_cost_amt on the IA row. Value adjustments from inv_tran, not from the adjustment lines.
Two more traps when you total adjustments:
- Net both directions per item. A filter on negative quantity counts one half of every lost-then-found pair and overstates loss.
- Check the reason codes for automated transfers. Some sites book automatic location-to-location replenishment as paired
IArows under an adjustment reason. Those pairs net to zero and are not losses; exclude that reason from a loss view. (Real transfers aretrans_type = 'TR'.)
The reason text is in reason, joined on reason.id = inv_adj_hdr.reason_id and reason.company_no = inv_adj_hdr.company_id — company_no on reason, not company_id (see Company Scoping).
User-defined dropdown labels are in extensibility_dropdown_value, not on the row
A user-defined dropdown column (on a *_ud table) stores the data value, not the label users see. The labels live in extensibility_dropdown_value, keyed by table_name and field_name, mapping data_value to display_value:
GET {base}/odataservice/odata/table/extensibility_dropdown_value
?$select=table_name,field_name,data_value,display_value
&$filter=table_name eq 'oe_hdr_ud' and field_name eq '{ud_column}'
They are not in code_p21, which holds P21's own enums.
Column names that trip queries
The obvious guess is wrong for these, and a wrong name in $select or $filter fails with a 404 (Could not find a property named ...) rather than returning an empty column:
| Table | Use | Not |
|---|---|---|
prod_order_line_component |
qty_used (also qty_needed, qty_requested, qty_allocated) |
qty_completed, qty_required |
prod_order_line_component |
join on prod_order_number |
prod_order_id |
prod_order_hdr |
source_location_id · complete · cancel |
location_id, completed, complete_date |
prod_order_line |
completed |
— |
invoice_line |
line_no |
line_number |
prod_order_line_comp_labor |
service_technician_uid (the worker) |
created_by (who keyed the entry) — see 12 § Reading Labor Actuals |
Credit: @mrwuss — the adjustment, dropdown-label and column-name findings verified against production 26.1 reads, June to October 2026 (#185).
Data Type Formatting
| Type | Format | Example |
|---|---|---|
| String | Single quotes | 'Active' |
| Number | No quotes | 10050 |
| Decimal | No quotes | 99.99 |
| Date | ISO format | 2025-01-01 |
| DateTime | ISO format | 2025-01-01T00:00:00.000Z |
| Boolean | No quotes | true or false |
| GUID | No quotes | 5BC2E4CE-0C0A-4394-A066-29B5835424DA |
Decimals arrive as JSON numbers
Edm.Decimal columns come back as JSON numbers written at the column's full scale: "unit_price":235.000000000, "extended_price":940.0000. The wire value is exact. Precision is lost in the client, when a JSON parser turns the number into a binary float. Summing 10,320 oe_line.extended_price values as Python floats gave a total ending in .020000003; the same values parsed as decimals gave one ending in .0200.
Many key-looking columns are Edm.Decimal too, written without a fraction: oe_hdr.customer_id arrives as 100198, and oe_line.line_no as 1. The *_uid columns are Edm.Int32.
Parse money and quantities as decimals:
import json
from decimal import Decimal
rows = json.loads(response.text, parse_float=Decimal)["value"] # not response.json()
var price = row.GetProperty("extended_price").GetDecimal(); // GetDouble() rounds
For a client that cannot change its parser, send Accept: application/json;IEEE754Compatible=true. The service then writes every Edm.Decimal value as a JSON string ("extended_price":"940.0000", "customer_id":"100198", "line_no":"1") and leaves Edm.Int32 values as numbers. (Verified on play 26.1.5950.0, 2026-10-29.)
String Escaping
Single quotes in values must be escaped by doubling:
$filter=supplier_name eq 'O''Brien Supply'
Item IDs with Special Characters
P21 item IDs commonly contain characters that need URL encoding in OData filters: /, +, #, &, and spaces. The single-quote doubling rule still applies within the OData filter expression, but these special characters also need URL encoding in the query string.
Python pattern for safe OData filter construction:
"""Build an OData filter for an item ID with special characters, then query it."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
ITEM_ID = "1/2-FITTING" # item ID containing characters that need escaping
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def safe_item_filter(item_id: str) -> str:
"""Build a safe OData filter for item IDs with special characters.
Handles:
- Single quotes (doubled within OData expression)
- URL-unsafe characters (/, +, #, &, spaces) via percent-encoding
Args:
item_id: Raw item ID (e.g., "1/2-FITTING", "ITEM+SIZE#3")
Returns:
URL-encoded filter expression
"""
# First, escape single quotes for OData
escaped = item_id.replace("'", "''")
# Build the filter expression
filter_expr = f"item_id eq '{escaped}'"
return filter_expr
# Examples of item IDs that need escaping:
# "1/2-FITTING" -> item_id eq '1/2-FITTING' (/ needs URL encoding)
# "ITEM+SIZE" -> item_id eq 'ITEM+SIZE' (+ needs URL encoding)
# "PART #3" -> item_id eq 'PART #3' (# and space need URL encoding)
# "O'RING-204" -> item_id eq 'O''RING-204' (quote doubled)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
# httpx encodes the filter value in the query string automatically
params = {"$filter": safe_item_filter(ITEM_ID)}
response = client.get(
f"{BASE_URL}/odataservice/odata/table/inv_mast",
params=params,
headers=headers,
)
response.raise_for_status()
data = response.json()
print(f"{len(data['value'])} matching rows for {ITEM_ID!r}")
for row in data["value"]:
print(row.get("item_id"), row.get("item_desc"))
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "1/2-FITTING"; // item ID containing characters that need escaping
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Examples of item IDs that need escaping:
// "1/2-FITTING" -> item_id eq '1/2-FITTING' (/ needs URL encoding)
// "ITEM+SIZE" -> item_id eq 'ITEM+SIZE' (+ needs URL encoding)
// "PART #3" -> item_id eq 'PART #3' (# and space need URL encoding)
// "O'RING-204" -> item_id eq 'O''RING-204' (quote doubled)
// Using with HttpClient (URL encoding via Uri.EscapeDataString):
var filter = Uri.EscapeDataString(SafeItemFilter(ItemId));
var url = $"{BaseUrl}/odataservice/odata/table/inv_mast?$filter={filter}";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
// HttpClient sends the properly encoded query string
var json = await response.Content.ReadAsStringAsync();
var data = JsonDocument.Parse(json).RootElement;
var rows = data.GetProperty("value");
Console.WriteLine($"{rows.GetArrayLength()} matching rows for '{ItemId}'");
foreach (var row in rows.EnumerateArray())
{
var itemId = row.TryGetProperty("item_id", out var idProp) ? idProp.ToString() : "";
var itemDesc = row.TryGetProperty("item_desc", out var descProp) ? descProp.ToString() : "";
Console.WriteLine($"{itemId} {itemDesc}");
}
// --- helpers ---------------------------------------------------------------
/// <summary>
/// Build a safe OData filter for item IDs with special characters.
/// Doubles single quotes for OData; URL encoding is handled by HttpClient.
/// </summary>
static string SafeItemFilter(string itemId)
{
// Escape single quotes for OData
var escaped = itemId.Replace("'", "''");
// Build the filter expression
return $"item_id eq '{escaped}'";
}
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Tip: When using
httpx(Python) with theparamsdict orUri.EscapeDataString(C#), URL encoding is handled automatically. The main concern is correctly doubling single quotes within the OData filter expression itself.
Response Format
Success Response
{
"@odata.context": "https://play.example.com/odataservice/odata/$metadata#supplier",
"value": [
{
"supplier_id": 10050,
"supplier_name": "ACME Supply Company",
"row_status_flag": 704,
...
},
...
]
}
With Count
{
"@odata.context": "...",
"@odata.count": 1547,
"value": [...]
}
Error Response
{
"error": {
"code": "400",
"message": "Invalid filter expression"
}
}
Common Tables
| Table | Description |
|---|---|
supplier |
Supplier master data |
customer |
Customer records |
inv_mast |
Inventory master |
price_page |
Price page definitions |
price_book |
Price book records |
price_library |
Price library definitions |
product_group |
Product groups |
invoice_hdr / invoice_line |
AR invoices and their lines. invoice_type 'IN'; credit memos are ordinary rows with a negative total_amount |
ar_receipts / ar_receipts_detail |
Cash receipts and their per-invoice application. ar_receipts_detail.invoice_no joins to invoice_hdr; ar_receipts.date_received is when the money landed |
terms / customer_terms |
Terms definitions (net_days, discount_days, discount_pct) and per-customer assignments |
credit_status |
Decodes customer.credit_status (GOOD, ACA, WATCH, TRC, PRC, COD, TBD) and carries the order-entry action each one triggers |
address |
Mailing/physical address, one row per id (shared key space with customer_id/supplier_id/etc. — join on id) |
addresscolumns keep theirmail_/phys_prefix on OData —mail_address1,mail_address2,mail_city,mail_state,mail_postal_code,mail_countryfor the mailing address;phys_address1,phys_address2,phys_city,phys_state,phys_postal_code,phys_countryfor the physical one. A$selectwithout the prefix (address1,city,postal_code, …) 404s:Could not find a property named 'address1' on type 'dbo.address'.Verified live, 26.1 (September 2026); confirmed againstdefinitions/Address.json'sDbColumnNamemapping (address.mail_address1, etc.). The Entity API's Address Fields table documents the same columns under their PascalCase names (MailAddress1,PhysAddress1) if you're on that surface instead.
Enterprise/Global Search Views (p21_view_es_*)
Added August 2026 — discovered and verified live against a 26.1.5930.1 tenant.
25 views share a p21_view_es_* prefix and back P21's in-app global search feature (the es reads as "enterprise search"; not an officially documented expansion, inferred from the schema below). They are not part of the curated 118-view /data/erp/views/v1 surface — query them the normal way, at /odataservice/odata/view/{viewname}, and the same $top is mandatory guidance applies.
They split cleanly into two groups by what their columns look like.
Search-source views (18)
Denormalized, heavily-joined views of a single business entity — one row per record, with lookups (class codes, descriptions, salesrep names, etc.) already resolved. All 18 share two trailing fields useful for incremental sync: unique_id (a per-view natural key, often composite — e.g. p21_view_es_item's is {item_id}--{supplier_id}, since the view fans an item out to one row per supplier) and max_date_last_modified (the latest modification timestamp across every joined table, so $filter=max_date_last_modified gt {timestamp} catches changes anywhere in the join, not just on the primary table).
| View | Source entity | Fields |
|---|---|---|
p21_view_es_customer |
Customer | 58 |
p21_view_es_contacts |
Contact | 87 |
p21_view_es_vendor |
Vendor | 75 |
p21_view_es_item |
Item | 15 |
p21_view_es_item_supplier |
Item × Supplier | 57 |
p21_view_es_sales_order |
Sales order header | 115 |
p21_view_es_sales_order_line |
Sales order line | 157 |
p21_view_es_quote |
Quote | 89 |
p21_view_es_invoice_hdr |
Invoice header | 137 |
p21_view_es_invoice_line |
Invoice line | 139 |
p21_view_es_vendor_invoice |
Vendor invoice | 49 |
p21_view_es_ship_to |
Ship-to | 63 |
p21_view_es_pick_ticket_hdr |
Pick ticket header | 21 |
p21_view_es_pick_ticket_line |
Pick ticket line | 16 |
p21_view_es_production_order |
Production order | 85 |
p21_view_es_production_order_component |
Production order component | 61 |
p21_view_es_service_order |
Service order | 93 |
p21_view_es_work_in_process |
WIP stage | 45 |
Index configuration views (7)
These carry no business data — they configure the search feature itself. Every one of them returned "value": [] on the tenant this was verified against (no custom indexes had been configured), but the schema is always present and the relationships below are legible from the foreign-key-shaped column names:
| View | Purpose |
|---|---|
p21_view_es_index_hdr |
One row per named search index — index_name plus the source view it indexes (view_name), display panel layout (line1_panel…line3_extended), and refresh schedule (refresh_rate_seconds, last_sync_date) |
p21_view_es_index_field |
One row per indexed column (FK es_index_hdr_uid) — searchable_flag / display_in_search_flag / filter_in_search_flag per field, plus data_type |
p21_view_es_index_priority_hdr |
A named priority/ranking profile |
p21_view_es_index_priority_field |
Per-profile override of an index field's searchable/display/filter flags (FKs to both the profile and the field) |
p21_view_es_index_priority_ranking |
Result ordering — assigns a rank to an (profile, index) pair |
p21_view_es_index_priority_role |
Links a priority profile to a role (role_uid) |
p21_view_es_index_priority_user |
Links a priority profile to a user (users_uid) |
Inferred, not confirmed: the hdr→field and priority-hdr→field/ranking/role/user relationships above are read off the column names and
$metadatatypes (Edm.Int32FK-shaped columns), not off live data — every*_priority_*view was empty on the verification tenant, so the actual row shape and what "priority" changes about search ranking were not directly observed.
Example: querying p21_view_es_item
Verified live, 26.1.5930.1:
GET /odataservice/odata/view/p21_view_es_item?$top=1
Authorization: Bearer {token}
Accept: application/json
{
"@odata.context": "{base}/odataservice/odata/view/$metadata#p21_view_es_item",
"value": [
{
"item_id": "WIDGET-001",
"item_desc": "Standard Widget Assembly",
"extended_desc": "Standard Widget Assembly - full description text",
"supplier_part_no": "",
"upc_code": null,
"catalog_name": null,
"contract_number": null,
"ean_code": null,
"purchase_discount_group": "Default",
"discount_group_description": "Default",
"alternate_code": null,
"alternate_code_desc": null,
"supplier_id": 10143,
"unique_id": "WIDGET-001--10143",
"max_date_last_modified": "2026-07-24T11:22:14.747-04:00"
}
]
}
Some rows on the verification tenant had a stray leading space on
item_id(e.g." EX21-NI"). It isn't consistent padding — row lengths on the same column varied freely (7 to 19 characters) with no fixed width, so this reads as pre-existing data-entry noise in that specific database rather than a behavior of the view or column type. Not included as a documented gotcha; if you see it on your tenant, treat it as a data-quality issue, not an API contract.
Example: querying p21_view_es_ship_to — the case for these views
p21_view_es_item above is deliberately the simplest of the 18 (15 fields). p21_view_es_ship_to (63 fields) is a better demonstration of what these views are for: one row already joins ship-to, customer, tax group, freight/carrier, payment terms, branch, salesrep, and both customer- and ship-to-level class codes — a query that would otherwise mean separately reading ship_to, customer, tax_group, freight_code, terms, company, and the class-code tables and joining them client-side (OData has no server-side joins).
Verified live, 26.1.5930.1:
GET /odataservice/odata/view/p21_view_es_ship_to?$top=1
Authorization: Bearer {token}
Accept: application/json
{
"@odata.context": "{base}/odataservice/odata/view/$metadata#p21_view_es_ship_to",
"value": [
{
"ship_to_id": 1,
"ship_to_name": "Address 1",
"customer_id": 100199,
"customer_name": "ACME Plumbing Supply",
"phys_address1": null,
"phys_address2": null,
"phys_city": null,
"phys_state": null,
"phys_postal_code": "08701",
"company_id": "ACME",
"company_name": "ACME Distribution Inc.",
"default_branch": "01",
"tax_group_id": "NJ UEZ TAX",
"terms_id": "10",
"default_carrier_id": null,
"delivery_instructions": null,
"freight_code_uid": 2,
"tax_group_description": "NJ UEZ Sales Tax",
"freight_cd": "IN/OUT",
"freight_desc": "Customer Pays Incoming and Outgoing Freight",
"branch_description": "Main Branch",
"carrier_name": null,
"terms_desc": "COD",
"cust_class_1id": null,
"cust_class_1desc": null,
"cust_class_2id": "NOT INSU",
"cust_class_2desc": "Not Insured",
"ship_to_class1_id": null,
"ship_to_class1_desc": null,
"date_created": "2019-03-27T15:12:13.623-04:00",
"date_last_modified": "2021-04-26T13:09:15.507-04:00",
"created_by": "apiuser",
"last_maintained_by": "apiuser",
"corp_address_id": 100199,
"corp_address_name": "ACME Plumbing Supply",
"salesrep_id": "1002",
"salesrep_name": "Jane Smith",
"freight_charge_id": null,
"unique_id": "ACME-1-1002",
"max_date_last_modified": "2026-08-19T15:00:00-04:00"
}
]
}
Trimmed to the fields that make the point; the live response carries all 63 (mailing address, all five customer- and ship-to-level class code pairs,
route_description,shipping_route_uid, etc.). Noteunique_id's shape here is{company_id}-{ship_to_id}-{salesrep_id}— a different composite pattern thanp21_view_es_item's{item_id}--{supplier_id}(double dash).unique_idcomposition is per-view; don't assume one view's key shape for another.
Undeployed / Unlicensed Windows: Readable Tables, No API Surface
OData exposes the schema, not the deployment state. A table can be fully readable over OData while the feature that populates it is undeployed or unlicensed — its maintenance window is classic-desktop-only and it has no Transaction/Interactive API surface. The rows you read are then dead storage: nothing writes them and no business logic consults them.
The clearest verified example (26.1.5894.1, play, July 2026) is the native zip → salesrep/territory family. The schema is present and OData-readable, but on an install where the module is undeployed the tables are empty and inert:
| Table | Contents | OData |
|---|---|---|
postal_code_group_hdr |
zip-group id/desc per company, primary_salesrep_id, territory_uid, default_sales_location_id, source-location ids |
readable |
postal_code_group_detail |
from_postal_code/to_postal_code ranges per group |
readable |
postal_code_group_location_priority |
ranked location list per group | readable |
salesrep_postalcode |
salesrep_id + start_postal_code/end_postal_code (simpler, likely older) |
readable |
ideal_locations_by_zip |
location-sourcing-by-zip (paired with the "Import Ideal Locations" menu item) | readable |
Why there's no write path:
- Window: "Postal Code Group Maintenance" (
w_postal_code_group_maint), AR › Maintenance — classic desktop only (frame_menu:new_ui_enabled='N',angular_enabled='N',service_nameNULL). A NULLservice_namerules out the Transaction and Interactive APIs; the'N'web flags rule out theui/fullsurface as well, and that combination is what leaves the window with no API surface at all — confirmed by anui/fullopen ofm_postalcodegroupmaintenance, which 400s exactly like a menu name that does not exist (see Window→Service Discovery). - Transaction API: no service — absent from
/api/v2/services; plausible hidden names all 500 on/definition. - Interactive API: by-Name open returns 400 "not available or user does not have permission" — the undeployed-window signal, not a grantable permission.
Behavioral consequence — verified: seeding matching rows in both salesrep_postalcode and postal_code_group_hdr+detail (a zip mapped to a deliberately wrong-region rep) and then creating a customer with that zip in the mailing and physical address does not default salesrep_id. The create fails "Salesrep ID is required for a new ship to." until the rep is supplied explicitly — Customer create never consults these tables when the module is undeployed. So: readable ≠ live. Treat OData-visibility of a feature table as evidence the schema exists, not that the feature is active on your install.
Examples
Basic Query
Get all suppliers:
GET /odataservice/odata/table/supplier
Filtered Query
Get active price pages for a supplier:
GET /odataservice/odata/table/price_page
?$filter=supplier_id eq 10050 and row_status_flag eq 704
&$select=price_page_uid,description,effective_date,expiration_date
&$orderby=description
Pagination
Get page 3 (10 records per page):
GET /odataservice/odata/table/supplier
?$skip=20
&$top=10
&$count=true
Complex Filter
Products starting with 'FILTER' and price over $10:
GET /odataservice/odata/table/inv_mast
?$filter=startswith(item_id,'FILTER') and list_price gt 10
&$select=item_id,item_desc,list_price
Code Examples
Basic Query
"""Query suppliers with $top and $select."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
# Query suppliers
response = client.get(
f"{BASE_URL}/odataservice/odata/table/supplier",
params={"$top": 10, "$select": "supplier_id,supplier_name"},
headers=headers,
)
response.raise_for_status()
data = response.json()
for supplier in data["value"]:
print(f"{supplier['supplier_id']}: {supplier['supplier_name']}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Query suppliers
var select = Uri.EscapeDataString("supplier_id,supplier_name");
var url = $"{BaseUrl}/odataservice/odata/table/supplier?$top=10&$select={select}";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var data = JsonDocument.Parse(json).RootElement;
foreach (var supplier in data.GetProperty("value").EnumerateArray())
{
var supplierId = supplier.GetProperty("supplier_id");
var supplierName = supplier.GetProperty("supplier_name");
Console.WriteLine($"{supplierId}: {supplierName}");
}
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Filtered Query
"""Query active price pages for a supplier."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
SUPPLIER_ID = 10050 # supplier to look up price pages for
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
# Get price pages for supplier
params = {
"$filter": f"supplier_id eq {SUPPLIER_ID} and row_status_flag eq 704",
"$select": "price_page_uid,description,calculation_value1",
"$orderby": "description",
}
response = client.get(
f"{BASE_URL}/odataservice/odata/table/price_page",
params=params,
headers=headers,
)
response.raise_for_status()
data = response.json()
for page in data["value"]:
print(page["price_page_uid"], page["description"], page["calculation_value1"])
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const int SupplierId = 10050; // supplier to look up price pages for
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Get price pages for supplier
var filter = Uri.EscapeDataString($"supplier_id eq {SupplierId} and row_status_flag eq 704");
var select = Uri.EscapeDataString("price_page_uid,description,calculation_value1");
var orderby = Uri.EscapeDataString("description");
var url = $"{BaseUrl}/odataservice/odata/table/price_page?$filter={filter}&$select={select}&$orderby={orderby}";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var data = JsonDocument.Parse(json).RootElement;
foreach (var page in data.GetProperty("value").EnumerateArray())
{
var uid = page.GetProperty("price_page_uid");
var description = page.GetProperty("description");
var calcValue = page.GetProperty("calculation_value1");
Console.WriteLine($"{uid} {description} {calcValue}");
}
// --- helpers ---------------------------------------------------------------
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Pagination Helper
"""Fetch all records from a table via $skip/$top pagination."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
TABLE = "supplier" # table to page through
FILTER_EXPR = "row_status_flag eq 704" # optional; set to None for no filter
# ---------------------------------------------------------------------------
def get_token(client: httpx.Client) -> str:
"""v2 token endpoint — credentials go in the body, never in headers."""
r = client.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
)
r.raise_for_status()
try:
return r.json()["AccessToken"]
except (ValueError, KeyError): # some middleware answers in XML
match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
if not match:
raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
return match.group(1)
def get_all_records(client, headers, base_url, table, filter_expr=None, page_size=5000):
"""Fetch all records with automatic pagination.
Args:
client: An authenticated httpx.Client.
headers: Request headers, including the Authorization bearer token.
base_url: OData service base URL.
table: Table name to query.
filter_expr: Optional OData $filter expression.
page_size: Records per request. Larger values mean fewer HTTP
round-trips, which is usually the biggest performance factor.
Use 5,000-25,000 for bulk/preload scenarios. Use 50-200
when paginating for a UI. There is no documented server-side
maximum -- 25,000 has been verified in production.
"""
records = []
skip = 0
while True:
params = {"$top": page_size, "$skip": skip, "$count": "true"}
if filter_expr:
params["$filter"] = filter_expr
response = client.get(
f"{base_url}/table/{table}",
params=params,
headers=headers,
)
response.raise_for_status()
data = response.json()
records.extend(data["value"])
total = data.get("@odata.count", len(records))
if len(records) >= total:
break
skip += page_size
return records
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json", # without this you get XML, not JSON
"Content-Type": "application/json",
}
all_records = get_all_records(
client,
headers,
f"{BASE_URL}/odataservice/odata",
TABLE,
filter_expr=FILTER_EXPR,
page_size=200,
)
print(f"{len(all_records)} records fetched from {TABLE}")
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string Table = "supplier"; // table to page through
const string FilterExpr = "row_status_flag eq 704"; // optional; pass null for no filter
// ---------------------------------------------------------------------------
var handler = new HttpClientHandler
{
// Test tenants often present a self-signed cert. Delete this line in production.
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var records = await GetAllRecordsAsync(
client, $"{BaseUrl}/odataservice/odata", Table, FilterExpr, pageSize: 200);
Console.WriteLine($"{records.Count} records fetched from {Table}");
// --- helpers ---------------------------------------------------------------
/// <summary>
/// Fetch all records with automatic pagination.
/// </summary>
/// <param name="client">An authenticated HttpClient.</param>
/// <param name="baseUrl">OData service base URL.</param>
/// <param name="table">Table name to query.</param>
/// <param name="filterExpr">Optional OData $filter expression.</param>
/// <param name="pageSize">
/// Records per request. Larger values mean fewer HTTP round-trips,
/// which is usually the biggest performance factor.
/// Use 5,000-25,000 for bulk/preload scenarios. Use 50-200
/// when paginating for a UI. There is no documented server-side
/// maximum -- 25,000 has been verified in production.
/// </param>
static async Task<List<JsonElement>> GetAllRecordsAsync(
HttpClient client, string baseUrl, string table,
string? filterExpr = null, int pageSize = 5000)
{
var records = new List<JsonElement>();
var skip = 0;
while (true)
{
var query = $"$top={pageSize}&$skip={skip}&$count=true";
if (filterExpr != null)
query += $"&$filter={Uri.EscapeDataString(filterExpr)}";
var url = $"{baseUrl}/table/{table}?{query}";
var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var data = JsonDocument.Parse(json).RootElement;
foreach (var item in data.GetProperty("value").EnumerateArray())
records.Add(item.Clone());
var total = data.TryGetProperty("@odata.count", out var countProp)
? countProp.GetInt32()
: records.Count;
if (records.Count >= total)
break;
skip += pageSize;
}
return records;
}
// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
var response = await client.PostAsync(
$"{BaseUrl}/api/security/token/v2",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}
// Some middleware answers this endpoint in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
try
{
var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
if (!string.IsNullOrEmpty(value)) return value;
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
if (!match.Success)
throw new InvalidOperationException(
$"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
return match.Groups[1].Value;
}
Best Practices
- Always use $select - Only request fields you need
- Add $filter early - Filter server-side, not client-side
- Use $top for previews - Don't fetch all data unnecessarily
- Right-size your page size - Use large
$top(5,000-25,000) for bulk/preload fetches; use small$top(50-200) for UI pagination. Round-trip overhead dwarfs payload size — see Page Size Guidance - Escape strings properly - Double single quotes in values
- Handle null values - Check for null in filters and responses
Common Errors
| Error | Cause | Solution |
|---|---|---|
| 400 Bad Request | Invalid filter syntax | Check filter expression |
| 401 Unauthorized | Invalid/expired token | Refresh token |
| 401/403 "Not authorized" | Valid token but missing P21 permissions | Enable "Allow OData API Service" in User Maintenance and grant table access in Role Maintenance → Dataservice Permission. See Prerequisites |
| 404 Not Found | Table doesn't exist, or unsupported function | Verify table name; avoid now() |
| 500 Server Error | Query too complex | Simplify query |
now() Function Not Supported
The standard OData now() function returns 404 in P21. Use explicit date values instead:
Full runnable version: Code Examples — the Filtered Query example is a complete, authenticated program using the same
paramspattern.
# Calculate date in code
from datetime import date, timedelta
tomorrow = (date.today() + timedelta(days=1)).isoformat()
# Use in filter
params = {"$filter": f"expiration_date ge {tomorrow}"}
// Calculate date in code
var tomorrow = DateTime.Today.AddDays(1).ToString("yyyy-MM-dd");
// Use in filter
var filter = Uri.EscapeDataString($"expiration_date ge {tomorrow}");
var url = $"{odataUrl}/table/price_page?$filter={filter}";
Page Size Guidance
Each HTTP request carries overhead: TCP connection, authentication, query planning, and serialization. For large result sets, the number of round-trips is usually the biggest performance factor, not the response payload size.
Choosing a Page Size
| Scenario | Recommended $top |
Why |
|---|---|---|
| Preloading / caching / bulk export | 5,000 - 25,000 | Minimize HTTP round-trips; 1 request beats 22 |
| UI pagination (browsable tables) | 50 - 200 | Match what the user actually sees |
| Unbounded queries (unknown size) | 1,000 - 5,000 | Balance round-trips vs memory |
No documented server-side cap. The P21 SDK does not specify a maximum page size. The Web.config allows up to 100 MB responses. A
$top=25000request has been verified working in production. Test with your dataset, but don't assume 100 is the right default.
Real-World Impact
Fetching ~2,500 records with $select on a few columns:
| Page Size | Requests | Wall Time |
|---|---|---|
| 100 | 25 | ~16s |
| 5,000 | 1 | ~1.7s |
The 10x improvement comes entirely from eliminating round-trip overhead.
Performance Tips
- Minimize HTTP round-trips - This is the #1 factor. Use a large
$topwhen you need all the data instead of looping with small pages - Always use $select - Only request fields you need; smaller payloads transfer faster
- Filter server-side - Use
$filterinstead of fetching everything and filtering in code - Use views for pre-joined data when available
- For UI-driven pagination, match page size to display needs (50-200 rows)
Measured Performance
| Query Type | Records | Time |
|---|---|---|
| Simple table | 10 | ~100ms |
| Filtered query | 160 | ~115ms |
| Full table scan | 1000+ | ~500ms |
| Bulk fetch ($top=5000) | 2,500 | ~1.7s |
Avoiding N+1 Query Patterns
When working with related entities (e.g., pages → books → libraries), avoid fetching related data in a loop:
Full runnable version: Pagination Helper — fetch each related set with a single paged query instead of one call per row.
# BAD: N+1 queries - one query per page
for page in pages:
book = await odata.get_book_for_page(page['uid']) # N queries!
library = await odata.get_library_for_book(book['uid']) # N more!
// BAD: N+1 queries - one query per page
foreach (var page in pages)
{
var book = await odata.GetBookForPageAsync(page["uid"]!.ToString()); // N queries!
var library = await odata.GetLibraryForBookAsync(book["uid"]!.ToString()); // N more!
}
Solution 1: Batch queries
Collapse the N follow-up queries into one by joining the ids with or. This is the real fix for the N+1 above — the loop below is what it replaces.
The
inoperator is silently ignored — do not use itThe obvious way to batch is
$filter=price_page_uid in (2,3,4). It returns HTTP 200 and the entire table. Verified on 26.1.5910.3 (2026-08-11) against a view holding 50,077 rows:
Filter Result (none) 200 — 50,077 rows price_page_uid in (2,3,4)200 — 50,077 rows (filter dropped) price_page_uid eq 2 or price_page_uid eq 3200 — 2 rows (correct) price_page_uid zz 2(deliberate garbage)404 — Syntax error at position 17The service does validate filter syntax — garbage is rejected loudly.
inparses and is then discarded, so you get a successful-looking response, no warning, and every row in the table. On a large view that is a silent full-table scan feeding whatever you do next. Use anorchain.Keep the chain inside the length limits: a
GETquery string stops at 8,192 characters, and a flat chain stops at 799 terms. Batches of about 50 ids perGETstay well inside both;POST /$querytakes several hundred per call.
# Get all pages first -- one query
pages = await odata.query("price_page", filter_expr="supplier_id eq 10050")
page_uids = [p["price_page_uid"] for p in pages]
# Then ONE query per batch of ids instead of one per id.
# `in` is silently ignored on this service -- join with `or`.
BATCH = 50
links = []
for i in range(0, len(page_uids), BATCH):
chunk = page_uids[i:i + BATCH]
or_chain = " or ".join(f"price_page_uid eq {uid}" for uid in chunk)
links += await odata.query("price_page_x_book", filter_expr=or_chain)
// Get all pages first -- one query
var pages = await odata.QueryAsync("price_page", filterExpr: "supplier_id eq 10050");
var pageUids = pages.Select(p => p["price_page_uid"]!.ToString()).ToList();
// Then ONE query per batch of ids instead of one per id.
// `in` is silently ignored on this service -- join with `or`.
const int Batch = 50;
var links = new List<JsonElement>();
for (var i = 0; i < pageUids.Count; i += Batch)
{
var chunk = pageUids.Skip(i).Take(Batch);
var orChain = string.Join(" or ", chunk.Select(uid => $"price_page_uid eq {uid}"));
links.AddRange(await odata.QueryAsync("price_page_x_book", filterExpr: orChain));
}
Solution 2: Cache lookups
For repeated lookups (like library-to-book mapping), cache results:
Full runnable version: Code Examples — no full program in this doc wraps caching directly; combine the cache pattern below with the authenticated request/response handling shown in Basic Query.
class P21OData:
def __init__(self):
self._library_book_cache: dict[str, dict | None] = {}
async def get_book_for_library(self, library_id: str) -> dict | None:
# Return cached result if available
if library_id in self._library_book_cache:
return self._library_book_cache[library_id]
# Fetch and cache
result = await self._fetch_book_for_library(library_id)
self._library_book_cache[library_id] = result
return result
public class P21OData
{
private readonly Dictionary<string, JsonElement?> _libraryBookCache = new();
public async Task<JsonElement?> GetBookForLibraryAsync(string libraryId)
{
// Return cached result if available
if (_libraryBookCache.TryGetValue(libraryId, out var cached))
return cached;
// Fetch and cache
var result = await FetchBookForLibraryAsync(libraryId);
_libraryBookCache[libraryId] = result;
return result;
}
}
The other OData surface: /data/erp/views/v1
A P21 server exposes two OData endpoints. Everything above describes /odataservice/odata/. The middleware's API Reference page also lists a Data Services family at /data/erp/views/v1 — an older, narrower surface that is genuinely useful for one thing the current one does awkwardly: fetching a single record by key.
Verified on a 26.1 tenant, August 2026.
Which one you want
/odataservice/odata/{table,view} |
/data/erp/views/v1 |
|
|---|---|---|
| Protocol | OData v4 (@odata.context) |
OData v3 (odata.metadata) |
| Coverage | Every exposed table and view (thousands) | 118 curated views, all p21_view_* |
| Single row by key | Filter and take the first result | Native — view('key') |
| User Defined Fields | Queryable | Not queryable (documented limitation) |
| Writes / joins | Neither | Neither |
Both are read-only, both need the same token, and neither can join — you assemble related data client-side. A third Data Services root, /data/manifest/v1, does accept writes.
The SDK page for this endpoint claims v4, which appears to be copied from its sibling page. The wire format says v3 (
odata.metadataat the document root,#p21_view_oe_hdr/@Elementfor a single entity). Trust the wire.
Listing what's exposed
GET {base}/data/erp/views/v1
Returns the 118 available views. The set is inventory- and order-centric — p21_view_oe_hdr, p21_view_oe_line, p21_view_invoice_hdr, p21_view_po_hdr, p21_view_inv_mast, p21_view_inv_loc, p21_view_lot, p21_view_prod_order_hdr, p21_view_job_price_hdr, the allocation-finder views, and so on. If your view is on the list, this endpoint is often the shorter path; if not, use /odataservice/.
Fetching one record by key
This is the reason to know the endpoint exists:
GET {base}/data/erp/views/v1/p21_view_oe_hdr('1001234')
{"odata.metadata": "{base}/data/erp/views/v1/$metadata#p21_view_oe_hdr/@Element",
"order_no": "1001234", "customer_id": "100200", "order_date": "..."}
For a view with a compound key, name each part — and mind the OData type suffixes, which is where this bites:
GET {base}/data/erp/views/v1/p21_view_customer(company_id = '1', customer_id = 100915M)
The trailing M marks an Edm.Decimal. Omitting it, or quoting a numeric key, produces the same incompatible types family of errors documented under Data Type Formatting — the rules there apply to both surfaces.
Documented limitations
- Read-only — no create, update or delete.
- No joins — retrieve each set and join locally.
- Curated views only — the list above is all of it.
- User Defined Fields cannot be queried. (Stated by Epicor for this surface. Not re-tested against
/odataservice/, which does expose UDF columns on the tables that carry them.) $countis rejected.?$count=trueis HTTP 400, "The query parameter '$count' begins with a system-reserved '$' character but is not recognized", and/$countis HTTP 415. This is OData v3: send$inlinecount=allpagesand readodata.count(a string) from the response. (play 26.1.5950.0, 2026-10-29.)
(Source: middleware API Reference ({middleware}/docs/apiref.aspx) and the SDK Data Services v1 reference guide; view count and key addressing verified live.)
A Data Services root that writes: /data/manifest/v1
/data/manifest/v1/ (trailing slash; the bare path 307s) is a separate Data Services root for shipping-manifest data, the one Clippership uses. Its service document lists clippership_return_10004, transfer_tracking, transport_shipping_info and p21_view_* views over them. It speaks the same OData v3 as /data/erp/views/v1, but it is not read-only:
| Call | Result |
|---|---|
GET /data/manifest/v1/clippership_return_10004?$top=2 |
200, rows |
POST /data/manifest/v1/clippership_return_10004 with a JSON row |
201; the row reads back over /odataservice with last_maintained_by set to the token's user |
DELETE /data/manifest/v1/clippership_return_10004({clippership_return_uid}) |
204; the row is gone (a hard delete) |
A POST must carry every NOT NULL column, or it fails with HTTP 500 and the SQL message in innererror.internalexception ("Cannot insert the value NULL into column 'handling_charge_flag'"). The test row sent pick_ticket_no, tracking_no, package_weight, shipped_date, carrier_name, total_charge, processed_flag, delete_flag and handling_charge_flag.
This is a way to add more tracking numbers to a shipment than the Transaction API's shipping columns allow. It writes the table directly, with none of the window's validation, and the Clippership import decides what happens to a row. Test it on a test tenant first. (Verified on play 26.1.5950.0, 2026-10-29, with one row marked processed_flag Y and delete_flag Y, then deleted. Community lead: Eric Davis, P21WWUG topic.)
OData Schema Refresh
The OData service automatically picks up changes to existing table/view schemas (e.g., column type changes). However, when new tables or views are added to the database, the OData service must be manually refreshed:
- Log in to the SOA Middleware home page (
https://{hostname}/api/admin) — this requires the P21 user's Access to SOA Admin Page application-security setting to be Yes; see Authentication § Application Security settings that affect API access - Go to Administration from the menu
- Find the "Refresh OData API service" section
- Click "Refresh OData API service"

Note: Schema changes from P21 application upgrades are handled automatically. Manual refresh is only needed for ad-hoc database changes between upgrades.
Related
- Authentication
- API Selection Guide
- Error Handling
- Batch Processing Patterns - Caching and N+1 query patterns
- examples/python/odata/ - Working examples