On this page
- Overview
- Token Endpoints
- Method 1: User Credentials
- Method 2: Consumer Key
- API Scopes
- P21 Permissions (User Credential Auth)
- Application Security settings that affect API access
- Using the Token
- Token Lifetime and Reuse
- UI Server URL
- Server Info Endpoint (Version & Environment Detection)
- XML Token Responses
- Common Errors
- Best Practices
- Related
P21 API Authentication
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
All P21 APIs require authentication via Bearer tokens. This guide covers the two authentication methods:
- User Credentials - Username and password authentication
- Consumer Key - Pre-authenticated application key
Token Endpoints
V2 Endpoint (Recommended)
POST https://{hostname}/api/security/token/v2
The V2 endpoint accepts credentials in the request body.
V1 Endpoint (Deprecated-Security Risk)
POST https://{hostname}/api/security/token
Security Warning: The V1 endpoint transmits credentials in HTTP headers. Headers are routinely logged by reverse proxies, load balancers, WAFs, and middleware — meaning usernames and passwords can end up in access logs, error logs, and monitoring dashboards. Always use V2 for new integrations. V1 is documented here only for reference with legacy systems.
On 2026.1, don't let a session failure talk you back onto V1. Interactive session-create misbehaves when the request lacks
Accept: application/json— an empty HTTP 500 on builds up to 5910.3, an HTTP 200 carrying XML on 26.1.5940.0 and later. Either way it reads convincingly as "our V2 tokens stopped working on 26.1". It isn't a token problem: a V2 token opens sessions normally once the header is present (verified on 26.1.5910.3 and 26.1.5940.0). A production integration misdiagnosed this and fell back to V1 before a controlled same-token test found the header.Downgrading costs you twice — credentials in headers, and per-operator attribution, since the V1 endpoint has no consumer key and every write lands against the service account. Two requests tell them apart: same token to session-create, once with
Acceptand once without. See Breaking Changes § 2026.1 entry 1.
Method 1: User Credentials
Use when you have a P21 username and password. The user must be: - A valid, non-deleted Prophet 21 user - Associated with a Windows user, AAD user, or SQL user
V2 Request (Recommended)
Request:
POST /api/security/token/v2 HTTP/1.1
Host: play.p21server.com
Content-Type: application/json
Accept: application/json
{
"username": "api_user",
"password": "your_password"
}
Response:
{
"AccessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"RefreshToken": "dGhpcyBpcyBhIHNhbXBsZSByZWZyZXNoIHRva2Vu...",
"ExpiresInSeconds": 86400,
"TokenType": "Bearer"
}
V1 Request (Legacy — Not Recommended)
Do not use this pattern. Credentials are in headers — see security warning above. The raw request below is shown for reference only (e.g., to recognize it in legacy code), deliberately without helper code. New integrations must use the V2 endpoint.
Request:
POST /api/security/token HTTP/1.1
Host: play.p21server.com
username: api_user
password: your_password
Content-Type: application/json
Accept: application/json
Response: Same as V2.
Code Examples
"""Get a P21 token via the V2 endpoint and print what came back."""
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_token_v2(base_url: str, username: str, password: str) -> dict:
"""Get token using V2 endpoint (recommended)."""
response = httpx.post(
f"{base_url}/api/security/token/v2",
json={"username": username, "password": password},
headers={"Accept": "application/json"},
verify=VERIFY_SSL,
)
response.raise_for_status()
return response.json()
# No V1 helper is provided — the V1 endpoint puts credentials in
# HTTP headers, which get logged by proxies and middleware.
if __name__ == "__main__":
token_data = get_token_v2(BASE_URL, USERNAME, PASSWORD)
print("TokenType:", token_data.get("TokenType"))
print("ExpiresInSeconds:", token_data.get("ExpiresInSeconds"))
print("AccessToken (truncated):", token_data["AccessToken"][:20] + "...")
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
using var client = new HttpClient();
var tokenData = await GetTokenV2Async(client, BaseUrl, Username, Password);
Console.WriteLine($"TokenType: {tokenData.GetProperty("TokenType").GetString()}");
Console.WriteLine($"ExpiresInSeconds: {tokenData.GetProperty("ExpiresInSeconds").GetInt64()}");
var accessToken = tokenData.GetProperty("AccessToken").GetString() ?? "";
Console.WriteLine($"AccessToken (truncated): {accessToken[..Math.Min(20, accessToken.Length)]}...");
// No V1 helper is provided — the V1 endpoint puts credentials in
// HTTP headers, which get logged by proxies and middleware.
// --- helpers ---------------------------------------------------------------
/// <summary>Get token using V2 endpoint (recommended).</summary>
static async Task<JsonElement> GetTokenV2Async(
HttpClient client, string baseUrl, string username, string password)
{
var body = new { username, password };
var content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync(
$"{baseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
Method 2: Consumer Key
Use for service accounts and automated integrations. Consumer keys bypass P21 user permission checks (Application Security and Dataservice Permissions) — access is controlled by the consumer key's API scope instead.
⚠️ A consumer key is a skeleton key — treat it like a domain-admin credential.
The key authenticates by itself; the
usernameheader is unauthenticated user context, not a login. That means the holder of a key can impersonate any P21 user, including an admin — no password, no consent, no per-user grant. Impersonating an admin confers that admin's access, which includes creating a brand-new user account with admin rights: at that point revoking the key no longer revokes the access, because the bad actor now has their own credentials. A leaked key is therefore not "an integration credential" — it is effectively unlimited, self-escalating access to the ERP.Handle keys accordingly:
- Confine keys to environments you control — your own servers, your own vaults. Never embed one in anything that leaves your network (client apps, spreadsheets, emailed config).
- Never hand a consumer key to a vendor or external party. If a third party needs API access, insist on a username/password service account instead — its access is bounded by P21's own permission system, it can't impersonate anyone, and it can be disabled like any other user.
- Rotate any key that has ever been shared beyond its intended environment, and audit for user accounts you don't recognize when you do.
And the key's value is readable by anyone who can author a DynaChange business rule. A business rule can be assigned a Rule Consumer Key (Edit Business Rule → Configuration Options), which exists so rule code can authenticate to the P21 APIs without hardcoding credentials. Epicor's description of what the rule author then gets:
"the rule programmer has access to the consumer key name and value in code in the RuleState class (
RuleState.ConsumerKey,RuleState.ConsumerName)" — combined withSession.MiddlewareUrl(added in 23.2), "that provides the rule programmer with the needed information to be used for consumer key authentication to the P21 APIs."Read that alongside the impersonation point above and the consequence is concrete:
Allow creation of business rulesis an admin-equivalent permission on any system where a consumer key is attached to a rule. A rule author can read the key's value out ofRuleStateand use it anywhere — the rule is arbitrary .NET code. Grant that setting to the same short list of people you would trust with the key itself, and treat the Rule Consumer Key dropdown as a decision about who can see this key, not just what this rule can call."Arbitrary .NET code" currently means either runtime. P21 supports both .NET and .NET Framework for business rules as of 2026.1 — the middleware's own migration to .NET 10 did not force rule code onto modern .NET with it. That widens the surface rather than narrowing it: a rule targeting .NET Framework has the same access to
RuleState.ConsumerKey, so the caveat above applies to both. Treat "for now" as load-bearing — the dual support is a transition state, not a commitment, so a new rule is better written against modern .NET than against Framework.(Source: P21 26.2 help, OPTIONAL - DynaChange Rules - BRE > Managing Business Rules. The feature is working as designed — this is a caveat about who you hand it to, not a defect.)
Creating a Consumer Key
- Log in to SOA Admin Page (
https://{hostname}/api/admin) - Open the API Console tab
- Click Register Consumer Key
- Configure:
| Field | Description | Recommended |
|---|---|---|
| Consumer | Descriptive name (e.g., MY_SERVICE_APP) |
Use a name that identifies the integration |
| Consumer Type | Desktop, Mobile, Web, or Service |
Service for API integrations |
| SDK Access | Enable for P21 SDK access | Enable if using /p21sdk scope |
| Token Expire | Key validity duration | Never Expire for service accounts |
| API Scope | Semicolon-delimited paths (see Scopes) | /api for full access |
When the Key Is Not Registered on That Tenant
A consumer key is registered per tenant, and a key the tenant does not know fails as an HTTP 500, not a 401:
POST /api/security/token/v2 // {"ClientSecret": "{unknown-guid}", "GrantType": "client_credentials"}
HTTP 500
{"ErrorMessage": "Unable to generate client token.",
"ErrorType": "P21.Business.Common.TokenException",
"InnerException": "... NotFoundException: Your query did not yield any results. No resources found for query string \"Consumer Key: {unknown-guid}\" ..."}
ErrorMessage is generic — the diagnosis is in InnerException. Verified on 26.1.5950.0 (September 2026); full detail and the log-hygiene caveat in Error Handling § Token Endpoint Errors.
Expect this after a tenant refresh. A test environment restored from production comes back with production's consumer-key registrations, so the test tenant's old key stops working and production's key starts working there instead. Re-check which key each host accepts after any refresh rather than assuming the environments keep separate keys — and note that a key which now opens both environments no longer distinguishes them, so whatever stops a test run from pointing at production has to be the base URL, not the credential.
Basic Request (No Username)
Returns a token tied to the consumer key with no P21 user context. Sufficient for read-only operations (OData, Entity API, Inventory REST API).
POST /api/security/token/v2 HTTP/1.1
Host: play.p21server.com
Content-Type: application/json
Accept: application/json
{
"ClientSecret": "00000000-1111-2222-3333-444444444444",
"GrantType": "client_credentials"
}
Response:
{
"AccessToken": "eyJhbGciOiJIUzI1NiIs...",
"TokenType": "Bearer",
"UserName": "",
"ExpiresIn": 630720000,
"RefreshToken": "",
"Scope": "/api;/p21sdk",
"SessionId": "a1b2c3d4-...",
"ConsumerUid": "8",
"AppKey": null
}
Request with Username (Required for Interactive API)
Adding username to the request associates the token with a P21 user identity. This is required for Interactive API sessions and provides audit trail attribution for write operations.
Important: The
usernamemust be a real P21 user account — not the consumer name. For example, if your consumer is namedMY_SERVICE_APP, you still need a P21 user likesvc_apiorapi_userin the username field. The token endpoint accepts any string, but session creation will fail withError retrieving/validating userif the user doesn't exist in P21.
POST /api/security/token/v2 HTTP/1.1
Host: play.p21server.com
Content-Type: application/json
Accept: application/json
{
"ClientSecret": "00000000-1111-2222-3333-444444444444",
"GrantType": "client_credentials",
"username": "api_user"
}
Response:
{
"AccessToken": "eyJhbGciOiJIUzI1NiIs...",
"TokenType": "Bearer",
"UserName": "api_user",
"ExpiresIn": 630720000,
"RefreshToken": "",
"Scope": "/api;/p21sdk",
"SessionId": "e1f2a3b4-...",
"ConsumerUid": "8",
"AppKey": null
}
JWT Token Claims
Consumer key tokens contain these claims:
| Claim | Description | Example |
|---|---|---|
sub |
P21 username (if provided) or empty | "api_user" |
aud |
Scope from consumer config (not the request); an OData allow-list rides in it | "/api;/p21sdk;/odata:po_hdr,po_line,inv_mast,…" |
P21.ConsumerUid |
Consumer key identifier | "8" |
P21.SessionId |
Middleware session ID | "a1b2c3d4-..." |
iss |
Token issuer | "P21.Soa" |
exp |
Expiration timestamp | 2147483647 (Never Expire) |
Note: The
Scopein the token response (and theaudJWT claim) is determined by the consumer key's configuration in SOA Admin — not by anyScopefield in the request. Requesting a different scope is silently ignored.
The OData allow-list is baked into the token, and these tokens never expire
When a consumer key is granted named OData tables, the allow-list travels inside the token as a suffix on the aud claim:
/api;/p21sdk;/odata:po_hdr,po_line,inv_mast,oe_hdr,oe_hdr_ud,customer_ud,…
Enforcement is per object and behaves as you would expect: an in-scope table returns 200, and an out-of-scope one returns 401 —
You are not authorized to access API. Please contact administrator to get access.
— where the same request under a username/password token returns 200. That 401 is a genuine permissions signal, and worth contrasting with the empty-bodied 404 that OData returns for a wrong object, a wrong column, or the wrong collection path.
Re-mint the token after any scope change. The allow-list is fixed at issue time, and consumer key tokens are effectively permanent —
ExpiresIn: 630720000(~20 years) withexp: 2147483647. A client holding a cached token keeps enforcing the old allow-list indefinitely, so a scope change made in SOA Admin looks like it did nothing. Password-grant tokens expire in 86400s and pick the change up on their own within a day; consumer-key clients will not. If a newly granted table still 401s, get a fresh token before looking anywhere else.
API-Specific Behavior (Verified)
| API | Without Username | With Username |
|---|---|---|
| OData | Works — uses consumer key scope | Works — username ignored for data access |
| Entity | Works — returns data | Works — uses specified user |
| Inventory REST | Works — returns data | Works — uses specified user |
| Transaction | Works — uses P21 install user | Works — uses specified user for audit |
| Interactive | Fails — Error retrieving/validating user |
Works — user must be a real P21 account |
Important Caveats
- Token endpoint creates a middleware session — each call to
/api/security/token/v2creates a new middleware session, which may invalidate tokens from previous calls. Get one token and reuse it. - No password required — the consumer key replaces password authentication entirely. The username is only for P21 user context, not authentication — which is exactly why a key must never leave a trusted environment (see the warning at the top of this section: the holder can impersonate any user, including admins).
- Scope is locked — the
Scopefield in the request is ignored. The consumer key's configured scope in SOA Admin determines access. /apiscope is sufficient — the/uiscope is not required for Interactive API. The/apiscope covers all endpoints including UI server operations.
Code Examples
"""Get a P21 token via consumer key authentication and print what came back."""
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
CONSUMER_KEY = "00000000-1111-2222-3333-444444444444" # from SOA Admin
USERNAME = "apiuser" # "" to omit (required for Interactive API)
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_consumer_token(
base_url: str,
consumer_key: str,
username: str = "",
) -> dict:
"""Get token using consumer key authentication.
Args:
base_url: P21 server URL (e.g., "https://play.p21server.com")
consumer_key: Consumer key GUID from SOA Admin
username: Optional P21 username (required for Interactive API)
Returns:
Token response dict with AccessToken, Scope, etc.
"""
payload = {
"GrantType": "client_credentials",
"ClientSecret": consumer_key,
}
if username:
payload["username"] = username
response = httpx.post(
f"{base_url}/api/security/token/v2",
json=payload,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
verify=VERIFY_SSL,
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
token_data = get_consumer_token(BASE_URL, CONSUMER_KEY, USERNAME)
print("TokenType:", token_data.get("TokenType"))
print("ExpiresIn:", token_data.get("ExpiresIn"))
print("UserName:", token_data.get("UserName"))
print("AccessToken (truncated):", token_data["AccessToken"][:20] + "...")
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string ConsumerKey = "00000000-1111-2222-3333-444444444444"; // from SOA Admin
const string Username = "apiuser"; // "" to omit (required for Interactive API)
// ---------------------------------------------------------------------------
using var client = new HttpClient();
var tokenData = await GetConsumerTokenAsync(client, BaseUrl, ConsumerKey, Username);
Console.WriteLine($"TokenType: {tokenData.GetProperty("TokenType").GetString()}");
Console.WriteLine($"ExpiresIn: {tokenData.GetProperty("ExpiresIn").GetInt64()}");
Console.WriteLine($"UserName: {tokenData.GetProperty("UserName").GetString()}");
var accessToken = tokenData.GetProperty("AccessToken").GetString() ?? "";
Console.WriteLine($"AccessToken (truncated): {accessToken[..Math.Min(20, accessToken.Length)]}...");
// --- helpers ---------------------------------------------------------------
/// <summary>Get token using consumer key authentication.</summary>
/// <param name="client">HttpClient to use for the request</param>
/// <param name="baseUrl">P21 server URL</param>
/// <param name="consumerKey">Consumer key GUID from SOA Admin</param>
/// <param name="username">Optional P21 username (required for Interactive API)</param>
static async Task<JsonElement> GetConsumerTokenAsync(
HttpClient client, string baseUrl, string consumerKey, string username = "")
{
var payload = new Dictionary<string, string>
{
["GrantType"] = "client_credentials",
["ClientSecret"] = consumerKey
};
if (!string.IsNullOrEmpty(username))
payload["username"] = username;
var content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync(
$"{baseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
API Scopes
Consumer keys restrict access to specific endpoints. Scopes are configured in SOA Admin as semicolon-delimited paths with a leading slash.
URL Scopes
| Scope | Access |
|---|---|
/api |
All API endpoints (recommended for full access) |
/api;/p21sdk |
API + SDK access (auto-added when SDK Access is enabled) |
/api;/ui |
API + UI sessions |
/uiserver0 |
Interactive and Transaction APIs only |
/odata |
OData endpoints (must specify tables) |
/api/.configuration |
Configuration endpoints only |
Note: When SDK Access is enabled in SOA Admin,
/p21sdkis automatically appended to the scope. The/apiscope alone is sufficient for all API operations including Interactive API sessions — the/uiscope is not required.
OData Table Scopes
For OData access, specify allowed tables/views:
/odata:price_page,supplier,product_group
This restricts the token to only those tables.
P21 Permissions (User Credential Auth)
When authenticating with User Credentials (Method 1), generating a valid token is not sufficient for API access. The P21 user account must also have specific permissions enabled in the P21 Desktop Client. Without these, you'll receive a generic "You are not authorized to access API" error even with a valid token.
Note: Consumer Key authentication (Method 2) bypasses these requirements entirely. Access is controlled by the consumer key's API scope instead.
A consumer key produces the identical
"You are not authorized to access API. Please contact administrator to get access."error when the call falls outside its own scope — this is not exclusive to the User Credential path above. Verified live on 26.1.5950.0: a key scoped with/odata:po_hdr,po_line,inv_mast,...(an explicit table list — see OData Table Scopes below) answered every/api/{family}/...REST call normally, since those routes are not gated by the OData scope at all, but a plainGET /odataservice/odata/table/{table}against any table not in that list failed with the exact same generic message a missing Application Security grant produces. The two causes are indistinguishable from the error text alone — decode the bearer token'saudclaim (a semicolon/comma-delimited scope string, plain base64 in the JWT's second segment) to see which table list, if any, it actually carries before assuming the account lacks a permission it may never have needed.
Step 1: Application Security
Each user must be explicitly granted API access in User Maintenance.
- Open User Maintenance in the P21 Desktop Client
- Pull up the user's details
- Go to the Application Security tab
- Find "Allow OData API Service" and set it to Yes (default is No)

Step 2: Role-Level Dataservice Permissions
After enabling Application Security, the user's role must grant access to specific tables and views.
- Open Role Maintenance in the P21 Desktop Client
- Pull up the role assigned to the user
- Go to the Dataservice Permission tab
- Set each required table/view to Allow

Key details about the Dataservice Permission screen:
- Schema Type dropdown filters between Tables, Views, or Both
- Allow All / Allow All Views / Allow All Tables checkboxes grant blanket access
- Each table/view can be individually set to Allow or Deny
- The list includes all 6000+ tables and views in the P21 database
Permission Requirements by Auth Method
| Auth Method | Application Security | Dataservice Permission | Notes |
|---|---|---|---|
| User Credentials | Required | Required | Both must be configured |
| Consumer Key (no username) | Not needed | Not needed | Access controlled by API scope; sufficient for OData, Entity, Inventory REST |
| Consumer Key (with username) | Not needed | Not needed | User must exist in P21 but doesn't need OData/Dataservice permissions; required for Interactive API |
Troubleshooting
If you receive a 401 or 403 "not authorized" error with a valid token:
- Verify Allow OData API Service = Yes in User Maintenance → Application Security
- Verify the target table/view is set to Allow in Role Maintenance → Dataservice Permission
- Ensure you're checking the correct role (the one actually assigned to the user)
Application Security settings that affect API access
The two settings above are the ones that gate OData. The Application Security tab in User Maintenance carries several more that decide whether API work is possible at all — each is a per-user Yes/No, and a No produces a failure that looks like something else entirely.
| Setting | Why an API integrator cares |
|---|---|
| Access to SOA Admin Page | Controls who can log in to the Middleware Administration website — where you register consumer keys, refresh the OData schema, and reach the Service Explorer and API Reference. Epicor's wording: "it only accepts logins from users with this setting set to Yes. It rejects all others, even if they are otherwise valid." A correct P21 password bouncing off the middleware logon page is this setting, not a bad credential. |
| Allow OData API Service | Step 1 of P21 Permissions above. |
| Allow overriding audit trail user | Lets a calling application log the actual end user rather than the API service account on transactions sent through the API. See Attributing writes to a real user below. |
| Allow access to the API system pool | Epicor's own docs contradict each other here. The Application Security reference calls it "a technical setting... should only be changed under instruction from Epicor support", while the Prism documentation lists "User Maintenance > Application Security – Enable API System Pool" as a required prerequisite for approving sales orders in Prism. So it is not untouchable — some features require it — but it is not a setting to flip speculatively either. Ask Epicor which applies to you, and note the connection to session pool problems. |
| Allow creation of business rules | Gates authoring of DynaChange Rules — the mechanism behind auto-answered prompts, Column is disabled refusals and report date caps that surface as API failures. Also a credential-disclosure surface: a rule assigned a consumer key can read that key's value from RuleState.ConsumerKey. See the consumer key warning. |
(Source: P21 26.2 help, System Setup > Users > Setting Application Security Settings for a User.)
Attributing writes to a real user
API writes normally land against the authenticating account — last_maintained_by shows the service user, and the audit trail records it, which is why a shared integration account makes "who changed this?" unanswerable.
Allow overriding audit trail user exists to solve exactly that. Epicor's description:
"allows Epicor companions to log the actual user signed into the companion application, rather than the API server user when sending transactions to Prophet 21 via the API."
The supported applications named are Project Hub and Scheduling and Capacity Planning. So the capability is real and shipped, but scoped to Epicor's own companion apps — whether a third-party integration can supply the acting user the same way is untested here, and the mechanism (a header? a session property?) is not documented publicly.
If per-operator attribution matters to your integration, this is the setting to ask Epicor about rather than assuming the service-account limitation is absolute. Note the related trade-off already documented under V1 vs V2 token endpoints: consumer-key auth with a username gives you P21 user context on the write, which is the closest generally-available equivalent.
Using the Token
Accept: application/jsonis not optional on 2026.1. Every example in this documentation sends it. Omit it — including via theAccept: */*default of httpx and .NETHttpClient— and the Interactive API refuses to give you JSON: an empty HTTP 500 (plus a ghost session) on builds up to 5910.3, and an HTTP 200 carrying XML on 26.1.5940.0 and later, which raisesJSONDecodeError/JsonExceptionin the parser instead. The mitigation is the same on every build: set it in one shared header builder rather than per call site. See Breaking Changes entry 1.
Include the token in the Authorization header for all API requests:
GET /odataservice/odata/table/supplier HTTP/1.1
Host: play.p21server.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json
"""Build authorization headers from a token and use them to call OData."""
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_token_v2(base_url: str, username: str, password: str) -> dict:
"""Get token using V2 endpoint (recommended)."""
response = httpx.post(
f"{base_url}/api/security/token/v2",
json={"username": username, "password": password},
headers={"Accept": "application/json"},
verify=VERIFY_SSL,
)
response.raise_for_status()
return response.json()
def get_auth_headers(token: str) -> dict:
"""Build authorization headers for API requests."""
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
if __name__ == "__main__":
token_data = get_token_v2(BASE_URL, USERNAME, PASSWORD)
headers = get_auth_headers(token_data["AccessToken"])
response = httpx.get(
f"{BASE_URL}/odataservice/odata/table/supplier",
headers=headers,
verify=VERIFY_SSL,
)
response.raise_for_status()
print(response.json())
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
using var authClient = new HttpClient();
var tokenData = await GetTokenV2Async(authClient, BaseUrl, Username, Password);
var token = tokenData.GetProperty("AccessToken").GetString() ?? "";
using var client = CreateAuthorizedClient(token);
var response = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/supplier");
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
// --- helpers ---------------------------------------------------------------
static HttpClient CreateAuthorizedClient(string token)
{
var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
client.DefaultRequestHeaders.Add("Accept", "application/json");
return client;
}
/// <summary>Get token using V2 endpoint (recommended).</summary>
static async Task<JsonElement> GetTokenV2Async(
HttpClient client, string baseUrl, string username, string password)
{
var body = new { username, password };
var content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync(
$"{baseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
Token Lifetime and Reuse
Token TTL
The token response includes an expiry field indicating how long the token remains valid, in seconds. The field name varies by auth flow and middleware format: user credential JSON responses use ExpiresInSeconds, consumer key JSON responses use ExpiresIn, and XML responses also use ExpiresIn. Always check for both field names when parsing (see the TokenManager examples below).
| Auth Method | Typical TTL | Notes |
|---|---|---|
| User Credentials | 86,400 seconds (24 hours) | Configurable per server |
| Consumer Key | 630,720,000 seconds (20 years) | When set to "Never Expire" in SOA Admin |
Important: Always read the expiry field (
ExpiresInSecondsorExpiresIn) from the response rather than hardcoding a TTL value. The default varies by server configuration and P21 version.
Why Reuse Tokens
Authenticate once at the start of your application or script and reuse that token for all subsequent API calls.
- Middleware sessions — each call to
/api/security/token/v2creates a new middleware session. Excessive token requests waste server resources and may invalidate previous sessions. - Rate limiting — frequent authentication requests may trigger server-side throttling.
- Performance — token acquisition involves a network round-trip and credential validation. Reusing a cached token avoids this overhead on every API call.
Multi-API Reuse
A single token works across all P21 APIs. You do not need separate tokens per API.
| API | Same Token? |
|---|---|
| OData | Yes |
| Transaction | Yes |
| Interactive | Yes (requires username in token request) |
| Entity | Yes |
| Inventory REST | Yes |
Token Refresh
When a token expires or is about to expire, re-authenticate to get a new one. The token response may include a RefreshToken field, but most P21 integrations simply re-authenticate with credentials since the token endpoint is lightweight.
Recommended approach: Check the token's remaining lifetime before each request and re-authenticate when the token is within 5 minutes of expiry. This avoids mid-request failures from an expired token.
Token Manager Pattern
The following pattern caches the token, checks expiry with a 5-minute buffer before each request, and automatically re-authenticates when needed.
"""Cache a P21 token, auto-refresh before it expires, and reuse it across calls."""
import time
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
# ---------------------------------------------------------------------------
# Buffer in seconds — re-authenticate this far before actual expiry
TOKEN_REFRESH_BUFFER = 300 # 5 minutes
class TokenManager:
"""Manages P21 token lifecycle with automatic refresh.
Caches the token and re-authenticates when the token is
within TOKEN_REFRESH_BUFFER seconds of expiry.
"""
def __init__(self, base_url: str, username: str, password: str) -> None:
self.base_url = base_url
self.username = username
self.password = password
self._token_data: dict | None = None
self._token_acquired_at: float = 0.0
def _is_token_valid(self) -> bool:
"""Check if the cached token is still valid (with buffer)."""
if self._token_data is None:
return False
expires_raw = self._token_data.get(
"ExpiresInSeconds",
self._token_data.get("ExpiresIn", 0),
)
try:
expires_in = int(expires_raw)
except (TypeError, ValueError):
expires_in = 3600
elapsed = time.time() - self._token_acquired_at
return elapsed < (expires_in - TOKEN_REFRESH_BUFFER)
def _authenticate(self) -> None:
"""Request a new token from the P21 token endpoint."""
response = httpx.post(
f"{self.base_url}/api/security/token/v2",
json={"username": self.username, "password": self.password},
headers={"Accept": "application/json"},
)
response.raise_for_status()
self._token_data = response.json()
self._token_acquired_at = time.time()
def get_token(self) -> str:
"""Get a valid access token, refreshing if needed."""
if not self._is_token_valid():
self._authenticate()
return self._token_data["AccessToken"]
def get_headers(self) -> dict[str, str]:
"""Get authorization headers with a valid token."""
return {
"Authorization": f"Bearer {self.get_token()}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if __name__ == "__main__":
# Usage — authenticate once, reuse for all API calls
manager = TokenManager(
base_url=BASE_URL,
username=USERNAME,
password=PASSWORD,
)
# OData query
odata_resp = httpx.get(
f"{BASE_URL}/odataservice/odata/table/supplier",
headers=manager.get_headers(),
)
odata_resp.raise_for_status()
print("OData status:", odata_resp.status_code)
# Entity API query — same token, no re-authentication
entity_resp = httpx.get(
f"{BASE_URL}/api/entity/customers/",
headers=manager.get_headers(),
)
entity_resp.raise_for_status()
print("Entity API status:", entity_resp.status_code)
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
// Usage — one long-lived HttpClient, per-request auth (always fresh token)
var manager = new TokenManager(BaseUrl, Username, Password);
using var client = new HttpClient();
// OData query
var odataReq = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/odataservice/odata/table/supplier");
await manager.ApplyAuthAsync(odataReq);
var odataResp = await client.SendAsync(odataReq);
odataResp.EnsureSuccessStatusCode();
Console.WriteLine($"OData status: {(int)odataResp.StatusCode}");
// Entity API query — same token, no re-authentication
var entityReq = new HttpRequestMessage(
HttpMethod.Get, $"{BaseUrl}/api/entity/customers/");
await manager.ApplyAuthAsync(entityReq);
var entityResp = await client.SendAsync(entityReq);
entityResp.EnsureSuccessStatusCode();
Console.WriteLine($"Entity API status: {(int)entityResp.StatusCode}");
// --- helpers ---------------------------------------------------------------
/// <summary>
/// Manages P21 token lifecycle with automatic refresh.
/// Caches the token and re-authenticates when the token is
/// within RefreshBufferSeconds of expiry.
/// </summary>
class TokenManager
{
// Re-authenticate this far before actual expiry
private const int RefreshBufferSeconds = 300; // 5 minutes
private readonly string _baseUrl;
private readonly string _username;
private readonly string _password;
private JsonElement? _tokenData;
private DateTime _tokenAcquiredAt = DateTime.MinValue;
public TokenManager(string baseUrl, string username, string password)
{
_baseUrl = baseUrl;
_username = username;
_password = password;
}
private bool IsTokenValid()
{
if (_tokenData == null) return false;
var data = _tokenData.Value;
var expiresIn = data.TryGetProperty("ExpiresInSeconds", out var expSeconds) ? expSeconds.GetInt64()
: data.TryGetProperty("ExpiresIn", out var expires) ? expires.GetInt64()
: 0;
var elapsed = (DateTime.UtcNow - _tokenAcquiredAt).TotalSeconds;
return elapsed < (expiresIn - RefreshBufferSeconds);
}
private async Task AuthenticateAsync()
{
// Short-lived HttpClient is acceptable here — token refresh is infrequent
// (once per TTL, typically 24h+ for user credentials, 20y for consumer keys).
using var client = new HttpClient();
var body = new { username = _username, password = _password };
var content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync(
$"{_baseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
_tokenData = doc.RootElement.Clone();
_tokenAcquiredAt = DateTime.UtcNow;
}
/// <summary>Get a valid access token, refreshing if needed.</summary>
public async Task<string> GetTokenAsync()
{
if (!IsTokenValid())
await AuthenticateAsync();
return _tokenData!.Value.GetProperty("AccessToken").GetString()!;
}
/// <summary>Apply auth to a request, refreshing the token if needed.</summary>
public async Task ApplyAuthAsync(HttpRequestMessage request)
{
var token = await GetTokenAsync();
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
if (!request.Headers.Contains("Accept"))
request.Headers.Add("Accept", "application/json");
}
}
UI Server URL
The Interactive and Transaction APIs require the UI server URL, which is obtained after authentication:
GET /api/ui/router/v1/?urlType=external HTTP/1.1
Host: play.p21server.com
Authorization: Bearer {token}
Accept: application/json
Response:
{
"Url": "https://play.p21server.com/uiserver0"
}
⚠️ Send the trailing slash. It is not cosmetic, and following the redirect is not an equivalent fix.
GET /api/ui/router/v1?urlType=external(no trailing slash afterv1) answers 307 to the trailing-slash form. What happens next depends on your client, and neither outcome is what you want:
Client Behavior on the slashless URL httpxwithoutfollow_redirectsReturns the 307 and an HTML body — no JSON to parse httpxwithfollow_redirects=TrueWorks — httpx keeps Authorizationon a same-origin redirect.NET HttpClientFollows the redirect but strips the Authorizationheader, so the second request arrives unauthenticated → 401The .NET case is the one that misleads, because the redirect is followed and the failure surfaces one step later as an authentication error rather than a routing one:
text 401 {"Description":"Authorization header was not present or 'Bearer' was missing.", "Error":"invalid_request","Uri":""}The token is fine — it never left the client. .NET drops
Authorizationon any auto-redirect, same-origin included, and whether the header was set onDefaultRequestHeadersor on the individual request;AllowAutoRedirectonly changes whether you see the 307 or the 401. Requesting/api/ui/router/v1/?urlType=externalavoids the redirect entirely and is the portable answer for every client. Verified live on 25.2 (July 2026) and re-verified on 26.1 with the .NET behavior isolated (August 2026).XML responses: As with the token endpoint, some middleware returns XML for the router response even with
Accept: application/json. Parse JSON first and fall back to XML (see XML Token Responses).
"""Resolve the P21 UI server URL needed for Interactive/Transaction API calls."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_token_v2(base_url: str, username: str, password: str) -> dict:
"""Get token using V2 endpoint (recommended)."""
response = httpx.post(
f"{base_url}/api/security/token/v2",
json={"username": username, "password": password},
headers={"Accept": "application/json"},
verify=VERIFY_SSL,
)
response.raise_for_status()
return response.json()
def get_auth_headers(token: str) -> dict:
"""Build authorization headers for API requests."""
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json"
}
def get_ui_server_url(base_url: str, token: str) -> str:
"""Get UI server URL for Interactive/Transaction APIs."""
response = httpx.get(
f"{base_url}/api/ui/router/v1/?urlType=external", # trailing slash avoids a 307
headers=get_auth_headers(token),
follow_redirects=True,
verify=VERIFY_SSL,
)
response.raise_for_status()
# Try JSON first; some middleware returns XML (like the token endpoint)
try:
return response.json()["Url"].rstrip("/")
except (ValueError, KeyError):
match = re.search(r"<Url>([^<]+)</Url>", response.text)
if not match:
raise ValueError(
f"Could not parse router response: {response.text[:500]}")
return match.group(1).rstrip("/")
if __name__ == "__main__":
token_data = get_token_v2(BASE_URL, USERNAME, PASSWORD)
ui_server = get_ui_server_url(BASE_URL, token_data["AccessToken"])
print("UI server URL:", ui_server)
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
using var authClient = new HttpClient();
var tokenData = await GetTokenV2Async(authClient, BaseUrl, Username, Password);
var token = tokenData.GetProperty("AccessToken").GetString() ?? "";
var uiServer = await GetUiServerUrlAsync(BaseUrl, token);
Console.WriteLine($"UI server URL: {uiServer}");
// --- helpers ---------------------------------------------------------------
static HttpClient CreateAuthorizedClient(string token)
{
var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
client.DefaultRequestHeaders.Add("Accept", "application/json");
return client;
}
static async Task<string> GetUiServerUrlAsync(string baseUrl, string token)
{
using var client = CreateAuthorizedClient(token);
// Trailing slash required: without it the server 307s, and HttpClient
// drops the Authorization header when it follows a redirect -> 401.
var response = await client.GetAsync(
$"{baseUrl}/api/ui/router/v1/?urlType=external");
response.EnsureSuccessStatusCode();
// Some middleware returns XML here even when asked for JSON, so try JSON
// first and fall back to regex extraction of <Url> (same pattern as
// ParseTokenResponse below).
var payload = await response.Content.ReadAsStringAsync();
try
{
using var doc = JsonDocument.Parse(payload);
var url = doc.RootElement.GetProperty("Url").GetString();
if (!string.IsNullOrEmpty(url)) return url.TrimEnd('/');
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }
var match = System.Text.RegularExpressions.Regex.Match(payload, "<Url>([^<]+)</Url>");
if (!match.Success)
throw new InvalidOperationException(
$"Could not parse router response: {payload[..Math.Min(500, payload.Length)]}");
return match.Groups[1].Value.TrimEnd('/');
}
/// <summary>Get token using V2 endpoint (recommended).</summary>
static async Task<JsonElement> GetTokenV2Async(
HttpClient client, string baseUrl, string username, string password)
{
var body = new { username, password };
var content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync(
$"{baseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
Server Info Endpoint (Version & Environment Detection)
/uiserver0/ui/common/v1/serverinfo is an undocumented UI-server endpoint — not part of the published OData/REST/SDK surface — that reports the P21 server's version and whether it considers itself a production instance. Reverse-engineered from the P21 web client's own network traffic; same bearer-token auth as every other uiserver0/* call, so it needs the UI server URL resolved first.
GET /uiserver0/ui/common/v1/serverinfo HTTP/1.1
Host: play.p21server.com
Authorization: Bearer {token}
Accept: application/json
Like the token and router endpoints, it serves DataContract XML by default but honors Accept: application/json — unlike those two, the JSON body is a flat {"Result": {"Section/Key": value, ...}} map (about a dozen keys; Session/State, Monitoring/*, Version/*) rather than a typed object:
Response (trimmed to the keys that matter):
{
"Result": {
"Monitoring/fullversion": "26.1.5940.0",
"Monitoring/shortversion": "26.1",
"Monitoring/isproduction": null,
"Monitoring/cloudroleinstance": "3694.prophet21play.production",
"Version/Application Version": "26.1.5940.0",
"Version/Build Framework": ".NET Core"
}
}
Twelve keys in total on 26.1.5940.0 — the rest are Monitoring/* telemetry (accountid, cloudrolename, configurationid, telemetrykey, tracelevel) plus Session/State.
Monitoring/cloudroleinstancesays "production" on a play tenant (3694.prophet21play.production) — it names the Azure role instance, not the business environment. Do not pattern-match on it to decide whether you are pointed at live data; useMonitoring/isproduction, and read the caveat on it below.Gotcha —
Monitoring/shortversion/fullversionare not reliably populated, and it varies by build. These are the keys whose names suggest version detection, and they are the ones to not depend on:
Build Monitoring/shortversionMonitoring/fullversionVersion/Application Version26.1.5930.1 "0.0""0.0.0.0""26.1.5930.1"26.1.5940.0 "26.1""26.1.5940.0""26.1.5940.0"26.1.5950.0 "26.1""26.1.5950.0""26.1.5950.0"Same call shape every time — fresh token, no browser session. On 5930.1 the two
Monitoring/*fields came back as zeros while the web app's own session traffic showed real values in them; on 5940.0 and 5950.0 they are populated correctly. Whatever drives the difference, the lesson is the same:Version/Application Versionwas right on all three builds and is the field to read. Note the literal space in the key, and that it carries the full build number, not a bare major.minor — parse the first two dot-segments if you only need"26.1"-style gating.
Two keys are useful in practice:
| Key | Type | Notes |
|---|---|---|
Version/Application Version |
string | e.g. "26.1.5940.0" — full build number; take the first two segments for major.minor gating (e.g. ES views require 26.1+, see Breaking Changes). Prefer this over Monitoring/shortversion/fullversion, which returned zeros on 5930.1 and real values on 5940.0 — this key was correct on both |
Monitoring/isproduction |
boolean, null, or absent | observed null (not false) on a non-production tenant — treat anything other than literal true as "not confirmed production," never assume false |
Use case: call this once at login, alongside resolving the UI server URL, and cache the result for the session. It's cheaper than probing a version-gated view and catching the 404, and it lets the UI cross-check a play/live banner against what the server itself reports instead of trusting only a build-time config value.
"""Detect P21 server version/environment via the undocumented serverinfo endpoint."""
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def get_token_v2(base_url: str, username: str, password: str) -> dict:
"""Get token using V2 endpoint (recommended)."""
response = httpx.post(
f"{base_url}/api/security/token/v2",
json={"username": username, "password": password},
headers={"Accept": "application/json"},
verify=VERIFY_SSL,
)
response.raise_for_status()
return response.json()
def get_server_info(base_url: str, token: str) -> dict:
"""GET /uiserver0/ui/common/v1/serverinfo - version + production flag.
Monitoring/shortversion and Monitoring/fullversion are not always
populated -- came back "0.0" / "0.0.0.0" on a direct API call even
though the same fields carry real values in the P21 web app's own
session traffic. Version/Application Version is the safer field to
read.
"""
response = httpx.get(
f"{base_url}/uiserver0/ui/common/v1/serverinfo",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
verify=VERIFY_SSL,
)
response.raise_for_status()
result = response.json().get("Result", {})
full_version = result.get("Version/Application Version") # e.g. "26.1.5930.1"
return {
"version": full_version,
"short_version": ".".join(full_version.split(".")[:2]) if full_version else None,
"is_production": result.get("Monitoring/isproduction"), # None/null if not confirmed
}
if __name__ == "__main__":
token_data = get_token_v2(BASE_URL, USERNAME, PASSWORD)
info = get_server_info(BASE_URL, token_data["AccessToken"])
print("Server info:", info)
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
using var authClient = new HttpClient();
var tokenData = await GetTokenV2Async(authClient, BaseUrl, Username, Password);
var token = tokenData.GetProperty("AccessToken").GetString() ?? "";
var info = await GetServerInfoAsync(BaseUrl, token);
Console.WriteLine($"Version: {info.Version}, IsProduction: {info.IsProduction?.ToString() ?? "unknown"}");
// --- helpers ---------------------------------------------------------------
// Monitoring/shortversion and Monitoring/fullversion are not always
// populated -- came back "0.0" / "0.0.0.0" on a direct API call even
// though the same fields carry real values in the P21 web app's own
// session traffic. Version/Application Version is the safer field to read.
static async Task<ServerInfo> GetServerInfoAsync(string baseUrl, string token)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.GetAsync($"{baseUrl}/uiserver0/ui/common/v1/serverinfo");
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var result = doc.RootElement.GetProperty("Result");
string? version = result.TryGetProperty("Version/Application Version", out var v)
? v.GetString() : null;
bool? isProduction = result.TryGetProperty("Monitoring/isproduction", out var p)
&& p.ValueKind is JsonValueKind.True or JsonValueKind.False
? p.GetBoolean() : null;
return new ServerInfo(version, isProduction);
}
/// <summary>Get token using V2 endpoint (recommended).</summary>
static async Task<JsonElement> GetTokenV2Async(
HttpClient client, string baseUrl, string username, string password)
{
var body = new { username, password };
var content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync(
$"{baseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
record ServerInfo(string? Version, bool? IsProduction);
Verified live on 26.1.5930.1 (August 2026); the Result flattening and JSON content-negotiation behavior matches the token and router endpoints on the same host. The Monitoring/shortversion gotcha above was caught by that same live run — the endpoint's shape matched expectations, but the field originally documented for version detection came back "0.0" on this direct API call despite being populated in the source web-app traffic the endpoint was reverse-engineered from.
XML Token Responses
Some P21 middleware instances return XML instead of JSON for token endpoints, even when Accept: application/json is set. This typically occurs with certain middleware versions or configurations.
Example XML Response
<?xml version="1.0" encoding="utf-8"?>
<TokenResponse>
<AccessToken>eyJhbGciOiJSUzI1NiIs...</AccessToken>
<TokenType>Bearer</TokenType>
<ExpiresIn>86400</ExpiresIn>
<RefreshToken>dGhpcyBpcyBhIHNhbXBsZQ...</RefreshToken>
</TokenResponse>
Note: XML responses use
ExpiresIn, user credential JSON responses useExpiresInSeconds, and consumer key JSON responses useExpiresIn. All represent the token lifetime in seconds.
Handling Both Formats
"""Get a token response and parse it whether the middleware answers JSON or XML."""
import re
import httpx
# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.p21server.com" # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False # True once you trust the cert chain
# ---------------------------------------------------------------------------
def parse_token_response(response: httpx.Response) -> dict:
"""Parse token response, handling both JSON and XML formats."""
# Try JSON first
try:
data = response.json()
if isinstance(data, dict) and "AccessToken" in data:
return data
except (ValueError, KeyError):
pass
# Fall back to XML regex parsing
text = response.text
result = {}
for field in ("AccessToken", "TokenType", "ExpiresIn",
"ExpiresInSeconds", "RefreshToken"):
match = re.search(rf"<{field}>([^<]*)</{field}>", text)
if match and match.group(1):
result[field] = match.group(1)
if "AccessToken" not in result:
raise ValueError(f"Could not parse token from response: {text[:500]}")
return result
if __name__ == "__main__":
raw_response = httpx.post(
f"{BASE_URL}/api/security/token/v2",
json={"username": USERNAME, "password": PASSWORD},
headers={"Accept": "application/json"},
verify=VERIFY_SSL,
)
raw_response.raise_for_status()
token_data = parse_token_response(raw_response)
print("TokenType:", token_data.get("TokenType"))
print("AccessToken (truncated):", token_data["AccessToken"][:20] + "...")
using System.Text;
using System.Text.Json;
// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.p21server.com"; // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
// ---------------------------------------------------------------------------
using var client = new HttpClient();
var body = new { username = Username, password = Password };
var content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
client.DefaultRequestHeaders.Add("Accept", "application/json");
var response = await client.PostAsync($"{BaseUrl}/api/security/token/v2", content);
response.EnsureSuccessStatusCode();
var tokenData = ParseTokenResponse(response);
var tokenType = tokenData.TryGetProperty("TokenType", out var tokenTypeEl) ? tokenTypeEl.GetString() : null;
Console.WriteLine($"TokenType: {tokenType}");
var accessToken = tokenData.GetProperty("AccessToken").GetString() ?? "";
Console.WriteLine($"AccessToken (truncated): {accessToken[..Math.Min(20, accessToken.Length)]}...");
// --- helpers ---------------------------------------------------------------
static JsonElement ParseTokenResponse(HttpResponseMessage response)
{
var text = response.Content.ReadAsStringAsync().Result;
// Try JSON first
try
{
using var parsed = JsonDocument.Parse(text);
if (parsed.RootElement.TryGetProperty("AccessToken", out _))
return parsed.RootElement.Clone();
}
catch (JsonException) { }
// Fall back to XML regex parsing
var result = new Dictionary<string, string>();
var fields = new[]
{
"AccessToken", "TokenType", "ExpiresIn",
"ExpiresInSeconds", "RefreshToken"
};
foreach (var field in fields)
{
var match = System.Text.RegularExpressions.Regex.Match(
text, $@"<{field}>([^<]*)</{field}>");
if (match.Success && !string.IsNullOrEmpty(match.Groups[1].Value))
result[field] = match.Groups[1].Value;
}
if (!result.ContainsKey("AccessToken"))
throw new InvalidOperationException(
$"Could not parse token from response: {text[..Math.Min(text.Length, 500)]}");
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(result));
return doc.RootElement.Clone();
}
Common Errors
| HTTP Code | Cause | Solution |
|---|---|---|
| 401 | Invalid credentials | Check username/password |
| 401 | Expired token | Request new token |
| 401 | Invalid consumer key | Verify key in SOA Admin |
| 403 | Scope restriction | Check consumer key scope |
| 404 | Wrong endpoint | Use /api/security/token/v2 |
| 200 (XML body) | Middleware returning XML instead of JSON | Use dual-format parser (see XML Token Responses) |
Best Practices
- Use V2 endpoint for new integrations
- Store credentials securely — use environment variables, not code
- Handle token expiration — refresh 5 minutes before expiry to avoid failed requests (see Token Lifetime and Reuse)
- Use consumer keys for service accounts — no password rotation needed
- Include username when using consumer keys with Interactive or Transaction APIs — this provides audit trail attribution and is required for session creation
- Reuse tokens — each call to the token endpoint creates a new middleware session; get one token and reuse it across all P21 APIs rather than requesting new tokens per-request (see Token Lifetime and Reuse)
- Restrict scopes to minimum required access
- Disable SSL verification only in development (
verify=False) - Handle both JSON and XML token responses for maximum middleware compatibility
Related
- API Selection Guide
- Error Handling
- examples/python/common/auth.py - Authentication module
- examples/python/common/client.py - Reusable P21 API client with auto token refresh