Data Services API¶
The Data Services API is P21's OData v4 compliant query interface. It exposes P21 data through named database views, allowing you to filter, sort, project, and paginate results using standard OData query parameters. It is the best choice for read-heavy workloads: reporting, dashboards, data exports, and integration feeds that need to query P21 data efficiently.
Base URL: https://{your-server}/api/dataservices/ Authentication: Bearer Token Format: JSON (OData v4)
Overview¶
The Data Services API is built on OData v4, an OASIS standard for building RESTful APIs with queryable data. You do not need to know OData deeply to use it effectively — the core query parameters are straightforward and well-documented.
Key characteristics:
- Stateless — No session required. Include the Bearer token on every request.
- Read-focused — Optimized for querying, not writing. For writes, use the Entity API or Interactive API.
- View-based — Data is exposed through named P21 views (
p21_view_*), not raw tables. - JSON responses — All responses are JSON in OData envelope format.
- Queryable — Rich filtering, sorting, projection, and pagination through URL parameters.
Authentication¶
Include the Bearer token on every request:
For token acquisition details, see the Authentication guide.
OData Query Parameters¶
All query parameters are prefixed with $ and passed as URL query string parameters.
| Parameter | Purpose | Example |
|---|---|---|
$filter | Filter rows by field conditions | $filter=company_id eq '01' |
$select | Return only specified fields | $select=customer_id,customer_name,phone |
$orderby | Sort results | $orderby=customer_name asc |
$top | Limit number of rows returned | $top=50 |
$skip | Skip rows for pagination | $skip=100 |
$count | Include total row count in response | $count=true |
$format | Override response format | $format=json |
$expand | Include related entities | $expand=Addresses |
$filter Operators¶
| Operator | Description | Example |
|---|---|---|
eq | Equal | $filter=company_id eq '01' |
ne | Not equal | $filter=status ne 'I' |
gt | Greater than | $filter=order_date gt 2024-01-01 |
ge | Greater than or equal | $filter=credit_limit ge 10000 |
lt | Less than | $filter=balance lt 0 |
le | Less than or equal | $filter=days_overdue le 30 |
and | Logical AND | $filter=company_id eq '01' and status eq 'A' |
or | Logical OR | $filter=status eq 'A' or status eq 'P' |
not | Logical NOT | $filter=not (status eq 'I') |
contains | String contains | $filter=contains(customer_name,'Acme') |
startswith | String starts with | $filter=startswith(customer_id,'C') |
endswith | String ends with | $filter=endswith(customer_name,'Corp') |
$orderby Syntax¶
Example: Customer Query (JavaScript)¶
A complete working example that queries customers filtered by company, with field selection, sorting, and a row limit:
async function getCustomers(baseUrl, token, companyId) {
const params = new URLSearchParams({
'$filter': `company_id eq '${companyId}'`,
'$select': 'customer_id,customer_name,phone,date_last_modified',
'$orderby': 'customer_name asc',
'$top': '50',
'$format': 'json'
});
const response = await fetch(
`${baseUrl}/api/dataservices/p21_view_customer?${params}`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
return response.json();
}
// Usage
const result = await getCustomers(
'https://p21-prod.yourcompany.com',
'eyJhbGci...',
'01'
);
// OData envelope: result.value contains the array of customers
const customers = result.value;
console.log(`Retrieved ${customers.length} customers`);
customers.forEach(c => console.log(c.customer_id, c.customer_name));
Response shape:
{
"@odata.context": "https://{server}/api/dataservices/$metadata#p21_view_customer",
"@odata.count": 1250,
"value": [
{
"customer_id": "C0001",
"customer_name": "Acme Corp",
"phone": "555-0100",
"date_last_modified": "2024-01-10T14:32:00"
},
{
"customer_id": "C0002",
"customer_name": "Beta Industries",
"phone": "555-0200",
"date_last_modified": "2024-01-08T09:15:00"
}
]
}
The OData Envelope
Data Services responses always wrap results in an OData envelope. Your actual records are in response.value (JavaScript) or the value property of the deserialized object (C#). The @odata.count field (when requested with $count=true) contains the total number of matching records before pagination.
Example: Customer Query (C#)¶
Equivalent implementation using HttpClient with strongly-typed response classes:
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Web;
public class DataServicesClient
{
private readonly HttpClient _http;
private readonly string _baseUrl;
public DataServicesClient(string server, string bearerToken)
{
_baseUrl = $"https://{server}/api/dataservices";
_http = new HttpClient();
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", bearerToken);
_http.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<ODataResponse<T>> QueryAsync<T>(
string viewName,
string? filter = null,
string? select = null,
string? orderby = null,
int? top = null,
int? skip = null,
bool count = false)
{
var query = HttpUtility.ParseQueryString(string.Empty);
if (filter is not null) query["$filter"] = filter;
if (select is not null) query["$select"] = select;
if (orderby is not null) query["$orderby"] = orderby;
if (top is not null) query["$top"] = top.ToString();
if (skip is not null) query["$skip"] = skip.ToString();
if (count) query["$count"] = "true";
var url = $"{_baseUrl}/{viewName}?{query}";
var response = await _http.GetAsync(url);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<ODataResponse<T>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
}
}
// OData response envelope
public class ODataResponse<T>
{
[JsonPropertyName("@odata.count")]
public int? Count { get; set; }
[JsonPropertyName("value")]
public List<T> Value { get; set; } = new();
}
// Customer record DTO
public class CustomerRecord
{
[JsonPropertyName("customer_id")]
public string CustomerId { get; set; } = string.Empty;
[JsonPropertyName("customer_name")]
public string CustomerName { get; set; } = string.Empty;
[JsonPropertyName("phone")]
public string? Phone { get; set; }
[JsonPropertyName("date_last_modified")]
public DateTime? DateLastModified { get; set; }
}
Usage:
var ds = new DataServicesClient("p21-prod.yourcompany.com", bearerToken);
var result = await ds.QueryAsync<CustomerRecord>(
viewName: "p21_view_customer",
filter: "company_id eq '01' and status eq 'A'",
select: "customer_id,customer_name,phone,date_last_modified",
orderby: "customer_name asc",
top: 100,
count: true
);
Console.WriteLine($"Total matching customers: {result.Count}");
foreach (var customer in result.Value)
{
Console.WriteLine($"{customer.CustomerId}: {customer.CustomerName}");
}
Common P21 View Names¶
P21 exposes its data through named views. The following are the most commonly used views for integration and reporting work.
Customer and Sales¶
| View Name | Description |
|---|---|
p21_view_customer | Customer master records — IDs, names, credit, status |
p21_view_address | Customer address records (ship-to, bill-to) |
p21_view_oe_hdr | Sales order headers — order number, date, customer, status |
p21_view_oe_line | Sales order line items — items, quantities, pricing |
p21_view_oe_line_status | Line-level status and fulfillment detail |
p21_view_inv_mast | Invoice master records |
Inventory¶
| View Name | Description |
|---|---|
p21_view_inv_mast | Inventory master — item IDs, descriptions, classifications |
p21_view_inv_loc | Inventory location quantities — on-hand, committed, available |
p21_view_item_category | Item category and classification codes |
p21_view_item_uom | Item unit of measure conversions |
Purchasing¶
| View Name | Description |
|---|---|
p21_view_po_hdr | Purchase order headers — PO number, vendor, date, status |
p21_view_po_line | Purchase order line items |
p21_view_vendor | Vendor master records |
p21_view_vendor_contact | Vendor contact information |
General¶
| View Name | Description |
|---|---|
p21_view_company | Company master records |
p21_view_location | Warehouse/branch location master |
p21_view_carrier | Shipping carrier records |
p21_view_contact | Contact records |
Discovering Available Views
The full list of available views depends on your P21 version and configuration. Query the OData metadata endpoint to see all available views:
This returns the full OData service document including all entity sets and their field schemas.Pagination¶
For large result sets, use $top and $skip together to page through data. Always use $orderby with pagination to ensure consistent ordering across pages.
Basic Pagination Pattern¶
async function getAllCustomers(baseUrl, token, companyId) {
const pageSize = 200;
let skip = 0;
const allCustomers = [];
while (true) {
const params = new URLSearchParams({
'$filter': `company_id eq '${companyId}'`,
'$select': 'customer_id,customer_name',
'$orderby': 'customer_id asc', // Required for stable pagination
'$top': pageSize.toString(),
'$skip': skip.toString()
});
const response = await fetch(
`${baseUrl}/api/dataservices/p21_view_customer?${params}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const page = data.value;
allCustomers.push(...page);
if (page.length < pageSize) break; // Last page
skip += pageSize;
}
return allCustomers;
}
C# Pagination Pattern¶
public async Task<List<T>> GetAllAsync<T>(
DataServicesClient client,
string viewName,
string? filter = null,
string? select = null,
string orderby = "rowid asc",
int pageSize = 200)
{
var all = new List<T>();
int skip = 0;
while (true)
{
var page = await client.QueryAsync<T>(
viewName: viewName,
filter: filter,
select: select,
orderby: orderby,
top: pageSize,
skip: skip);
all.AddRange(page.Value);
if (page.Value.Count < pageSize) break;
skip += pageSize;
}
return all;
}
Always Use $orderby with Pagination
Without a stable sort order, the same record may appear on multiple pages or be skipped entirely as the database returns rows in non-deterministic order. Always specify $orderby when paginating — typically by primary key or a unique timestamp field.
Use $count for Progress Reporting
Add $count=true to your first request (or a separate count-only request with $top=0) to get the total number of matching records before paginating. This lets you report progress or estimate completion time for long-running imports.
Incremental / Changed Records Pattern¶
For keeping an external system in sync with P21, filter by the date_last_modified field (available on most views) to retrieve only records changed since the last sync:
async function getChangedCustomers(baseUrl, token, sinceDate) {
const isoDate = sinceDate.toISOString().split('T')[0]; // YYYY-MM-DD
const params = new URLSearchParams({
'$filter': `date_last_modified gt ${isoDate}`,
'$orderby': 'date_last_modified asc',
'$top': '500'
});
const response = await fetch(
`${baseUrl}/api/dataservices/p21_view_customer?${params}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
return (await response.json()).value;
}
// Usage: get everything changed in the last 24 hours
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const changed = await getChangedCustomers(baseUrl, token, yesterday);
Error Handling¶
| Status | Meaning | Action |
|---|---|---|
200 OK | Success | Parse response.value |
400 Bad Request | Invalid OData query | Check filter syntax; validate field names |
401 Unauthorized | Token missing or expired | Refresh or reacquire the Bearer token |
403 Forbidden | Insufficient permissions | Check P21 security role for the view |
404 Not Found | View does not exist | Verify the view name against $metadata |
500 Internal Server Error | Server-side error | Check P21 server logs |
Example error response:
{
"error": {
"code": "400",
"message": "An error has occurred.",
"innererror": {
"message": "Could not find a property named 'invalid_field' on type 'p21_view_customer'.",
"type": "Microsoft.OData.ODataException"
}
}
}
Performance Tips¶
Always Use $select
P21 views can have 50–100+ columns. Using $select to request only the fields you need significantly reduces response size and improves query performance. Never query a view without $select in production code.
Filter Early and Specifically
Use $filter to restrict the result set as much as possible. Filter on indexed fields (like company_id, customer_id, primary keys) when available. Avoid contains() on large text fields — it forces a full-scan.
Use Appropriate Page Sizes
Page sizes of 200–500 records work well for most use cases. Very large pages (1000+) increase memory pressure on the server and slow individual requests. Very small pages (10–20) create excessive HTTP round-trips.