Skip to content

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:

GET https://{server}/api/dataservices/p21_view_customer
Authorization: Bearer {your-token}

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

$orderby={field} {asc|desc}
$orderby={field1} asc,{field2} desc

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:

GET https://{server}/api/dataservices/$metadata
Authorization: Bearer {token}
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.

GET /p21_view_customer?$filter=company_id eq '01'&$top=0&$count=true

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.