On this page
User Defined Tables (UDT) Service 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.
Added April 2026 — Discovered and tested by Felipe Maurer. Additional contributions from David Sokoloski (initial P21 help docs reference), Brad Vandenbogaerde (database tables, SaaS hostname fix), John Kennedy (SQL keyword issue, working Python script), and Jon Christie (response format quirk).
Overview
Source: Official P21 Help Documentation (Zendesk) + community working code + actual API testing (April 2026).
P21 provides a UDT Service API at /udtservice/api/udtdata/ for writing to User Defined Tables (UDTs). UDTs are custom tables created through P21's User Defined Table Maintenance window, prefixed with udt_ in the database (the window also creates a matching udv_ view).
The UDT Service API handles write operations (insert, update, delete). For reading UDT data, use the OData API — UDT tables are queryable via Data Services like any other P21 table.
On 2026.1 and later there is a second, differently-shaped endpoint for high-volume loads: the Bulk Data API (/udtservice/api/bulkupload/{table}), which bulk-inserts from an uploaded CSV file instead of a JSON body.
Creating a UDT is a UI-only operation. There is no API path to define a new UDT — it is not among the Transaction API's services, and no corresponding Interactive API window could be opened. Use P21's User Defined Table Maintenance window. A newly created UDT is not visible to OData until the schema is refreshed (SOA Admin → Refresh OData API service, see Prerequisites); the Bulk Data API, by contrast, sees it immediately.
Dropping one needs a refresh too — in the other direction. After a UDT is dropped, OData keeps it in the schema and queries fail with
404 "Invalid object name 'dbo.{udt}'."(a SQL error surfaced as a 404) until the schema is refreshed again. Note the difference from a table that was never exposed, which 404s with an empty body — theInvalid object namewording specifically means "OData still expects this table, but the database no longer has it."
Key Characteristics
- Write-only — Three endpoints for insert, update, and delete
- Stateless — No session management required
- Column-based payloads — Data is sent as column name/value pairs
- Condition-based targeting — Updates and deletes identify rows with a
{"rowuid": N}condition, where N is the row's primary-key value (see Update) - Shared auth — Uses the same Bearer token as all other P21 APIs
When to Use
- Inserting custom data into UDT tables from external systems
- Updating or deleting UDT records programmatically
- B2B integrations that write to custom P21 tables
- Automating data entry for custom workflows built on UDTs
Limitations
- Write only — No read/query endpoints; use OData for reads
- No endpoint-based schema discovery — no service endpoint lists tables or columns, but the catalog is readable over OData:
master_udt_definition(one row per UDT:udt_table_name,udt_view_name) andmaster_udt_definition_column(per-column name,datatype_cd,length,precision/scale,nullable_flag,df_value). Verified on 2026.1. - SQL keyword filtering — Values containing SQL keywords (e.g., "drop", "insert") may be rejected even in legitimate data
- Primary-key targeting only — the one condition the service honors is
{"rowuid": N}, the row'sudt_{tablename}_uidvalue. A{"name": ..., "value": ...}condition on any column,row_uidincluded, is a 400 on update and deletes nothing on delete — see the warning under Update - Bulk upload is insert-only — the Bulk Data API has no update or delete counterpart
Base URL
https://{hostname}/udtservice/api/udtdata/
Example: https://play.example.com/udtservice/api/udtdata/
SaaS environments: The hostname may require
-apiin the FQDN (e.g.,play-api.example.cominstead ofplay.example.com). Credit: Brad Vandenbogaerde.
Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/udtservice/api/udtdata/insertudtdata |
Insert one or more rows |
PUT |
/udtservice/api/udtdata/updateudtdata |
Update rows by condition |
DELETE |
/udtservice/api/udtdata/deleteudtdata |
Delete rows by condition |
POST |
/udtservice/api/bulkupload/{table} |
2026.1+ — bulk-insert rows from a CSV file (details) |
Authentication
The UDT Service API uses the same Bearer token authentication as all other P21 APIs. Both consumer key and user/password authentication work.
See Authentication for token generation.
Required Headers
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: application/json
Content-Type: application/json
Payload Structure
All three endpoints use a common JSON structure with table, rows, columns, and conditions fields.
Core Structure
{
"table": "udt_table_name",
"rows": [
{
"columns": [
{"name": "column_name", "value": "column_value"},
{"name": "another_column", "value": "another_value"}
],
"conditions": []
}
]
}
| Field | Type | Description |
|---|---|---|
table |
string | UDT table name (e.g., udt_custom_orders) |
rows |
array | Array of row objects to process |
rows[].columns |
array | Column name/value pairs for data |
rows[].conditions |
array | Row selectors for update: {"rowuid": N}, the row's primary-key value. Delete takes conditions at the top level instead |
Insert vs Update vs Delete
| Operation | columns |
conditions |
|---|---|---|
| Insert | Required (data to write) | Empty array [] |
| Update | Required (new values) | Required, inside each row: [{"rowuid": N}] |
| Delete | Not used | Required, at the top level: [{"rowuid": N}] |
Insert
Insert one or more rows into a UDT table. All non-nullable columns must be included in the payload. Columns not included will be set to NULL.
Request
POST /udtservice/api/udtdata/insertudtdata
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
Accept: application/json
Payload
{
"table": "udt_custom_orders",
"rows": [
{
"columns": [
{"name": "order_ref", "value": "ORD-2026-001"},
{"name": "customer_name", "value": "ACME Supply Company"},
{"name": "order_total", "value": "1250.00"},
{"name": "status", "value": "pending"}
],
"conditions": []
}
]
}
Multi-Row Insert
Insert multiple rows in a single request by adding objects to the rows array:
{
"table": "udt_custom_orders",
"rows": [
{
"columns": [
{"name": "order_ref", "value": "ORD-2026-001"},
{"name": "customer_name", "value": "ACME Supply Company"},
{"name": "order_total", "value": "1250.00"},
{"name": "status", "value": "pending"}
],
"conditions": []
},
{
"columns": [
{"name": "order_ref", "value": "ORD-2026-002"},
{"name": "customer_name", "value": "ACME Manufacturing"},
{"name": "order_total", "value": "875.50"},
{"name": "status", "value": "pending"}
],
"conditions": []
}
]
}
Example
"""Insert a row into a UDT table via the UDT Service API."""
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 = "udt_custom_orders" # UDT table name
# ---------------------------------------------------------------------------
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",
}
payload = {
"table": TABLE,
"rows": [
{
"columns": [
{"name": "order_ref", "value": "ORD-2026-001"},
{"name": "customer_name", "value": "ACME Supply Company"},
{"name": "order_total", "value": "1250.00"},
{"name": "status", "value": "pending"},
],
"conditions": [],
}
],
}
resp = client.post(
f"{BASE_URL}/udtservice/api/udtdata/insertudtdata",
json=payload,
headers=headers,
)
result = resp.json()
if result["errorNo"] == 0:
print(f"Insert: {result['errorMessage']}")
else:
print(f"Error {result['errorNo']}: {result['errorMessage']}")
# Read-back — errorNo == 0 does not prove the row landed; confirm via OData.
check = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": "order_ref eq 'ORD-2026-001'"},
headers=headers,
)
check.raise_for_status()
rows = check.json().get("value", [])
print(f"Read-back: {len(rows)} row(s) found for order_ref=ORD-2026-001")
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 = "udt_custom_orders"; // UDT table name
// ---------------------------------------------------------------------------
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);
// Insert a row
var payload = new
{
table = Table,
rows = new object[]
{
new
{
columns = new object[]
{
new { name = "order_ref", value = "ORD-2026-001" },
new { name = "customer_name", value = "ACME Supply Company" },
new { name = "order_total", value = "1250.00" },
new { name = "status", value = "pending" },
},
conditions = Array.Empty<object>(),
},
},
};
var resp = await client.PostAsync(
$"{BaseUrl}/udtservice/api/udtdata/insertudtdata",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
var result = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
if (result.GetProperty("errorNo").GetInt32() == 0)
{
Console.WriteLine($"Insert: {result.GetProperty("errorMessage").GetString()}");
}
else
{
Console.WriteLine(
$"Error {result.GetProperty("errorNo").GetInt32()}: {result.GetProperty("errorMessage").GetString()}");
}
// Read-back — errorNo == 0 does not prove the row landed; confirm via OData.
var checkResp = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter=order_ref eq 'ORD-2026-001'");
checkResp.EnsureSuccessStatusCode();
var checkResult = JsonDocument.Parse(await checkResp.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine(
$"Read-back: {checkResult.GetProperty("value").GetArrayLength()} row(s) found for order_ref=ORD-2026-001");
// --- 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;
}
Update
Update existing rows in a UDT table. Each row object carries the new values in columns and the target row in conditions, as {"rowuid": N}.
⚠️ Target the row with
{"rowuid": N}, not with aname/valuepair.Nis the row's primary-key value. On a UDT created in P21's User Defined Table Maintenance window that key isudt_{tablename}_uid(for exampleudt_custom_orders_uid), and the table has norow_uidcolumn. A{"name": "row_uid", "value": "12345"}condition does not reach those rows:
Call Body Result Update rows[].conditions: [{"name": "row_uid", "value": "5"}]400 {"error":["Invalid Row Uid!"]}Update rows[].conditions: [{"name": "udt_{tablename}_uid", "value": "5"}]400 Invalid Row Uid!Update rows[].conditions: [{"rowuid": 5}]200 [1] row updated ...; the new value reads backUpdate rows[].conditions: [{"rowuid": "6"}]200 [1] row updated ...; a string value worksUpdate rows[].conditions: [{"RowUid": 7}]200 [1] row updated ...; the key name is not case-sensitiveUpdate two row objects, each with its own rowuid200 [2] rows updated ...; each row gets its own valuesUpdate one row object, conditions: [{"rowuid": 5}, {"rowuid": 6}]200 [2] rows updated ...; both rows get the same valuesUpdate conditions: [{"name": "{column}", "value": "nomatch", "rowuid": 5}]200 [1] row updated ...; thename/valuepair is ignoredUpdate rows[].conditions: [{"rowuid": 999999}](no such row)200 [0] rows updated ... successfully!(Verified on play 26.1.5950.0, 2026-10-29, on a UDT created there, with every result read back over OData on rows added for the test.)
The service selects rows by primary key only. A condition is not a WHERE clause on a column: the service reads
rowuidand ignoresnameandvalue. To change rows by some other column's value, read their primary keys over OData first, then send onerowuidper row.A miss still says "successfully".
errorNois0whether or not a row matched; read the[N]count inerrorMessageand treat[0]as a failure.Find the primary-key column before you start.
master_udt_definitionover OData gives the table name; the key isudt_{tablename}_uid, which you can confirm withGET /odataservice/odata/table/{udt}?$top=1.GET /odataservice/odata/table/{udt}?$select=row_uidreturns 404 "Could not find a property named 'row_uid'" on these tables. Whetherrowuidmaps to a literalrow_uidcolumn on a table that has one could not be tested: no such UDT was available. See Breaking Changes entry 7.
Request
PUT /udtservice/api/udtdata/updateudtdata
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
Accept: application/json
Payload
{
"table": "udt_custom_orders",
"rows": [
{
"columns": [
{"name": "status", "value": "completed"},
{"name": "order_total", "value": "1300.00"}
],
"conditions": [
{"rowuid": 12345}
]
}
]
}
Example
ROW_UIDis the row's primary-key value, read from theudt_{tablename}_uidcolumn over OData (see Reading UDT Data). The read-back filters on that column, so setUID_COLUMNto match your table.
"""Update one UDT row, selected by its primary key, via the UDT Service API."""
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 = "udt_custom_orders" # UDT table name
UID_COLUMN = "udt_custom_orders_uid" # the table's primary key: udt_{tablename}_uid
ROW_UID = 12345 # that key's value for the row to update
# ---------------------------------------------------------------------------
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",
}
payload = {
"table": TABLE,
"rows": [
{
"columns": [
{"name": "status", "value": "completed"},
{"name": "order_total", "value": "1300.00"},
],
# rowuid takes the primary-key value; a name/value pair is ignored.
"conditions": [
{"rowuid": ROW_UID},
],
}
],
}
resp = client.put(
f"{BASE_URL}/udtservice/api/udtdata/updateudtdata",
json=payload,
headers=headers,
)
result = resp.json()
# Read the [N] count: errorNo is 0 even when no row matched.
if resp.status_code == 200 and result.get("errorNo") == 0:
print(f"Update: {result['errorMessage']}")
else:
print(f"Error (HTTP {resp.status_code}): {result}")
# Read-back — confirm the new values actually landed via OData.
check = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": f"{UID_COLUMN} eq {ROW_UID}"},
headers=headers,
)
check.raise_for_status()
rows = check.json().get("value", [])
if rows:
print(f"Read-back: status={rows[0].get('status')}, order_total={rows[0].get('order_total')}")
else:
print(f"Read-back: no row with {UID_COLUMN} = {ROW_UID}")
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 = "udt_custom_orders"; // UDT table name
const string UidColumn = "udt_custom_orders_uid"; // the table's primary key: udt_{tablename}_uid
const int RowUid = 12345; // that key's value for the row to update
// ---------------------------------------------------------------------------
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 payload = new
{
table = Table,
rows = new object[]
{
new
{
columns = new object[]
{
new { name = "status", value = "completed" },
new { name = "order_total", value = "1300.00" },
},
// rowuid takes the primary-key value; a name/value pair is ignored.
conditions = new object[]
{
new { rowuid = RowUid },
},
},
},
};
var resp = await client.PutAsync(
$"{BaseUrl}/udtservice/api/udtdata/updateudtdata",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
var body = await resp.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(body).RootElement;
// Read the [N] count: errorNo is 0 even when no row matched.
if (resp.IsSuccessStatusCode && result.TryGetProperty("errorNo", out var errorNo) && errorNo.GetInt32() == 0)
{
Console.WriteLine($"Update: {result.GetProperty("errorMessage").GetString()}");
}
else
{
Console.WriteLine($"Error (HTTP {(int)resp.StatusCode}): {body}");
}
// Read-back — confirm the new values actually landed via OData.
var checkResp = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter={UidColumn} eq {RowUid}");
checkResp.EnsureSuccessStatusCode();
var checkResult = JsonDocument.Parse(await checkResp.Content.ReadAsStringAsync()).RootElement;
var checkRows = checkResult.GetProperty("value");
if (checkRows.GetArrayLength() > 0)
{
var row = checkRows[0];
Console.WriteLine(
$"Read-back: status={row.GetProperty("status").GetString()}, order_total={row.GetProperty("order_total").GetString()}");
}
else
{
Console.WriteLine($"Read-back: no row with {UidColumn} = {RowUid}");
}
// --- 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;
}
Note: To get a row's primary-key value, query the UDT table via OData first (see Reading UDT Data).
Delete
Delete rows from a UDT table. Send conditions at the top level of the payload, one {"rowuid": N} per row, where N is the row's primary-key value.
⚠️ Delete reports success even when it deletes nothing. Check the
[N]row count inerrorMessage, nevererrorNoalone. What each delete body returns (the first two are HTTP 200 and delete nothing; the last two work):
Body Result {"table": "...", "conditions": [{"name": "row_uid", "value": "9"}]}200 [0] rows deleted ... successfully!; the row is still there{"table": "...", "conditions": [{"rowuid": 999999}]}(no such row)200 [0] rows deleted ... successfully!{"table": "...", "rows": [{"columns": [], "conditions": [{"rowuid": 9}]}]}400 {"error":["Conditions cannot be blank or none!"]}; delete does not readrows[]{"table": "...", "conditions": [{"rowuid": 9}]}200 [1] row deleted ...; the row is gone on read-back{"table": "...", "conditions": [{"rowuid": 7}, {"rowuid": 8}]}200 [2] rows deleted ...; both rows are gone(Verified on play 26.1.5950.0, 2026-10-29, on a UDT created there, with each result read back over OData on rows added for the test.) As with update, the service selects rows by primary key only, so read the keys over OData before a delete by any other column. See the warning under Update.
Request
DELETE /udtservice/api/udtdata/deleteudtdata
Authorization: Bearer <ACCESS_TOKEN>
Content-Type: application/json
Accept: application/json
Payload
{
"table": "udt_custom_orders",
"conditions": [
{"rowuid": 12345}
]
}
Example
"""Delete one UDT row, selected by its primary key, via the UDT Service API."""
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 = "udt_custom_orders" # UDT table name
UID_COLUMN = "udt_custom_orders_uid" # the table's primary key: udt_{tablename}_uid
ROW_UID = 12345 # that key's value for the row to delete
# ---------------------------------------------------------------------------
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",
}
# conditions is TOP-LEVEL for delete, and rowuid takes the primary-key value.
payload = {
"table": TABLE,
"conditions": [
{"rowuid": ROW_UID},
],
}
resp = client.request(
"DELETE",
f"{BASE_URL}/udtservice/api/udtdata/deleteudtdata",
json=payload,
headers=headers,
)
result = resp.json()
# Check the row count in errorMessage — errorNo == 0 lies when 0 rows were deleted.
if resp.status_code == 200 and result.get("errorNo") == 0:
print(f"Delete: {result['errorMessage']}")
else:
print(f"Error (HTTP {resp.status_code}): {result}")
# Read-back — confirm the row is actually gone via OData.
check = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": f"{UID_COLUMN} eq {ROW_UID}"},
headers=headers,
)
check.raise_for_status()
rows = check.json().get("value", [])
print(f"Read-back: {len(rows)} row(s) remain with {UID_COLUMN} = {ROW_UID}")
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 = "udt_custom_orders"; // UDT table name
const string UidColumn = "udt_custom_orders_uid"; // the table's primary key: udt_{tablename}_uid
const int RowUid = 12345; // that key's value for the row to delete
// ---------------------------------------------------------------------------
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);
// conditions is TOP-LEVEL for delete, and rowuid takes the primary-key value.
var payload = new
{
table = Table,
conditions = new object[]
{
new { rowuid = RowUid },
},
};
var request = new HttpRequestMessage(
HttpMethod.Delete, $"{BaseUrl}/udtservice/api/udtdata/deleteudtdata")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
};
var resp = await client.SendAsync(request);
var body = await resp.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(body).RootElement;
// Check the row count in errorMessage — errorNo == 0 lies when 0 rows were deleted.
if (resp.IsSuccessStatusCode && result.TryGetProperty("errorNo", out var errorNo) && errorNo.GetInt32() == 0)
{
Console.WriteLine($"Delete: {result.GetProperty("errorMessage").GetString()}");
}
else
{
Console.WriteLine($"Error (HTTP {(int)resp.StatusCode}): {body}");
}
// Read-back — confirm the row is actually gone via OData.
var checkResp = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter={UidColumn} eq {RowUid}");
checkResp.EnsureSuccessStatusCode();
var checkResult = JsonDocument.Parse(await checkResp.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine(
$"Read-back: {checkResult.GetProperty("value").GetArrayLength()} row(s) remain with {UidColumn} = {RowUid}");
// --- 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;
}
Note: The
DELETEHTTP method with a JSON body is non-standard. Some HTTP clients require usingrequest()orSendAsync()with an explicitHttpRequestMessageto include a body on DELETE requests.
Bulk Data API (2026.1+)
Source: Live verification against a 2026.1 tenant (July 2026). Epicor's Prophet 21 Release 2026.1 Release Guide announces this as a core enhancement — "Bulk Data API for User-Defined Tables – A new API enables high-volume data uploads into user-defined tables" — but names no endpoint, and the in-middleware SDK reference (
/docs/p21sdk) does not document it. Everything below was established by probing the live service and confirming every result with an OData read-back.
POST /udtservice/api/bulkupload/{table} bulk-inserts rows into a UDT from an uploaded CSV file. It is a different shape from the JSON udtdata endpoints above: a multipart/form-data file upload, not a JSON body.
POST /udtservice/api/bulkupload/udt_custom_orders HTTP/1.1
Authorization: Bearer <ACCESS_TOKEN>
Accept: application/json
Content-Type: multipart/form-data; boundary=----X
------X
Content-Disposition: form-data; name="file"; filename="orders.csv"
Content-Type: text/csv
order_code,description,qty,order_date
A1,First order,1.5,2026-01-15
------X--
Success returns HTTP 200:
{"isSuccessful": true, "message": "Data uploaded successfully to udt_custom_orders."}
Contract
| Aspect | Verified behavior |
|---|---|
| Method | POST only — GET/PUT/OPTIONS return 405 with Allow: POST |
| URL segment | The table name, not an action — /bulkupload/udt_custom_orders |
| Body | multipart/form-data. Any other content type returns 415 with an empty body |
| Form field name | Must be file — any other name returns a 400 validation error naming file as required |
| File format | Comma-delimited CSV with a header row. Tab-delimited is rejected |
| Header names | Must match the UDT columns exactly — case-sensitive, no surrounding whitespace |
| Column subset | Allowed — omitted columns are written as NULL |
| Column order | Irrelevant — mapped by header name |
| Quoting | Standard CSV quoting works (embedded commas and quotes round-trip) |
| Filename / extension / content-type | Ignored — .txt, no extension, and application/octet-stream all upload fine |
| Atomicity | All-or-nothing — one bad row rejects the whole file and inserts nothing |
| Duplicates | Insert-only, no upsert — uploading the same key twice creates two rows |
| Volume | 1,000 rows in a single call verified (no cap established) |
| Update / delete | No counterpart — /bulkupdate and /bulkdelete are 404 |
| Target table | Must be a registered UDT. Ordinary P21 tables (supplier, inv_mast, po_hdr) return "Incompatible table for bulk insert: {table}" |
Gotchas
⚠️ A CSV without a header row reports success and inserts nothing. The service treats the first line as the header, finds no data rows beneath it, and returns HTTP 200
{"isSuccessful": true}having written zero rows. A migration or nightly job that omits the header logs a clean success for every run while loading nothing. Always verify the row count with an OData read-back — the response body cannot tell you how many rows landed (it reports no count at all).⚠️ Values are silently rounded to the column's scale. Into a
decimal(2,1)column,1.66stores as1.7,1.64as1.6, and1.99as2.0— HTTP 200, no warning. Scale overflow is silent; only precision overflow errors (99.9intodecimal(2,1)returns 400). Match your file's precision to the column definition rather than relying on the API to flag it.
NULL can only be expressed by omitting the column. A blank value (A1,) and the literal text NULL both return 400 ("The given value '' ... cannot be converted to type decimal") — even when the column is nullable. Consequence: one file cannot mix rows that have a value with rows that don't for the same column; split them into separate uploads by column-presence.
Rows are not attributed to the API user. The auto-generated audit columns default to suser_sname() / GETDATE(), so created_by records the middleware's SQL login (e.g. Admin), not the authenticated caller. The file can set created_by explicitly and the supplied value is stored as-is — these columns are writable, so don't treat them as a trustworthy audit trail. The identity PK (udt_{name}_uid) is the exception: a value supplied in the file is silently ignored and the server-assigned identity wins.
Errors identify the offending column, not the row. Failures return a bare JSON string with the column position and name — e.g. "The given value 'abc' of type String from the data source cannot be converted to type decimal for Column 3 [qty]." or "The given ColumnName 'not_a_column' does not match up with any column in data source.". There is no row number, so on a large file you get no direct pointer to which record failed.
Example
"""Bulk-insert rows into a UDT from an in-memory CSV, then verify with a read-back."""
import csv
import io
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 = "udt_custom_orders" # UDT table name
# ---------------------------------------------------------------------------
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 bulk_upload(client: httpx.Client, table: str, rows: list[dict]) -> dict:
"""Bulk-insert rows into a UDT from an in-memory CSV.
The header row is mandatory — without it the service returns success and
inserts nothing. Column names are case-sensitive and must match the UDT.
"""
if not rows:
raise ValueError("no rows to upload")
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=list(rows[0]))
writer.writeheader() # REQUIRED — omitting it silently inserts 0 rows
writer.writerows(rows)
response = client.post(
f"{BASE_URL}/udtservice/api/bulkupload/{table}",
files={"file": ("upload.csv", buf.getvalue().encode(), "text/csv")},
timeout=300,
)
response.raise_for_status()
return response.json()
def row_count(client: httpx.Client, table: str) -> int:
"""Read-back — the upload response carries no row count."""
response = client.get(
f"{BASE_URL}/odataservice/odata/table/{table}",
params={"$count": "true", "$top": "0"},
timeout=120,
)
response.raise_for_status()
return response.json().get("@odata.count", 0)
with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
token = get_token(client)
client.headers = httpx.Headers(
{"Authorization": f"Bearer {token}", "Accept": "application/json"}
)
# Usage — count before and after, because "isSuccessful" does not mean "inserted"
before = row_count(client, TABLE)
result = bulk_upload(client, TABLE, [
{"order_code": "A1", "description": "First order", "qty": "1.5"},
{"order_code": "A2", "description": "Second order", "qty": "2.5"},
])
after = row_count(client, TABLE)
print(f"{result['message']} — rows inserted: {after - before}")
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 = "udt_custom_orders"; // UDT table name
// ---------------------------------------------------------------------------
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(5) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = await GetTokenAsync(client);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var rows = new List<Dictionary<string, string>>
{
new() { ["order_code"] = "A1", ["description"] = "First order", ["qty"] = "1.5" },
new() { ["order_code"] = "A2", ["description"] = "Second order", ["qty"] = "2.5" },
};
// Usage — count before and after, because "isSuccessful" does not mean "inserted"
var before = await RowCountAsync(client, Table);
var message = await BulkUploadAsync(client, Table, rows);
var after = await RowCountAsync(client, Table);
Console.WriteLine($"{message} — rows inserted: {after - before}");
// --- 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");
}
// Bulk-inserts rows into a UDT from an in-memory CSV, built by hand (no CSV package —
// this repo's docs examples run with zero package installs). The header row is
// mandatory — without it the service returns success and inserts nothing. Column
// names are case-sensitive and must match the UDT.
static async Task<string> BulkUploadAsync(
HttpClient client, string table, IReadOnlyList<Dictionary<string, string>> rows)
{
if (rows.Count == 0) throw new ArgumentException("no rows to upload", nameof(rows));
var columns = rows[0].Keys.ToList();
var csv = new StringBuilder();
csv.AppendLine(string.Join(",", columns)); // REQUIRED — omitting it silently inserts 0 rows
foreach (var row in rows)
csv.AppendLine(string.Join(",", columns.Select(c => row[c])));
using var content = new MultipartFormDataContent();
var file = new ByteArrayContent(Encoding.UTF8.GetBytes(csv.ToString()));
file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
// The form field MUST be named "file" — any other name is rejected.
content.Add(file, "file", "upload.csv");
var response = await client.PostAsync($"{BaseUrl}/udtservice/api/bulkupload/{table}", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
// Read-back — the upload response carries no row count.
static async Task<int> RowCountAsync(HttpClient client, string table)
{
var response = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/{table}?$count=true&$top=0");
response.EnsureSuccessStatusCode();
var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
return json.TryGetProperty("@odata.count", out var count) ? count.GetInt32() : 0;
}
// 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;
}
Verification scope: established against one purpose-built UDT (
char,decimal, anddatecolumns) on a 2026.1 test tenant, with every claim confirmed by an OData read-back. Behavior against other column types (e.g.bit,text) and files larger than 1,000 rows is untested. Because 2026.1 is the first release to ship this endpoint and no 25.2 tenant remained available, the "new in 2026.1" attribution rests on Epicor's release guide rather than an A/B test.
Reading UDT Data (OData)
Source: Official P21 Help Documentation + community working code.
The UDT Service API does not provide read endpoints. To query UDT data, use the OData API with the udt_ table prefix via Data Services.
Prerequisites
UDT tables must be exposed through the Data Services API. If a UDT table is not visible:
- Open SOA Admin (
https://{hostname}/docs/admin.aspx) - Navigate to Administration > Refresh OData API service
- Verify the table appears in the OData metadata
Querying UDTs via OData
GET /odataservice/odata/table/udt_custom_orders?$filter=status eq 'pending'
Authorization: Bearer <ACCESS_TOKEN>
Accept: application/json
"""Query UDT data via OData — the UDT Service API has no read endpoints of its own."""
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 = "udt_custom_orders" # UDT table name
UID_COLUMN = "udt_custom_orders_uid" # the table's primary key: udt_{tablename}_uid
# ---------------------------------------------------------------------------
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
}
resp = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": "status eq 'pending'"},
headers=headers,
)
resp.raise_for_status()
data = resp.json()
for row in data.get("value", []):
print(f"Order: {row['order_ref']}, Total: {row['order_total']}")
# The primary key is the rowuid value for update and delete.
print(f" {UID_COLUMN}: {row[UID_COLUMN]}")
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 = "udt_custom_orders"; // UDT table name
const string UidColumn = "udt_custom_orders_uid"; // the table's primary key: udt_{tablename}_uid
// ---------------------------------------------------------------------------
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 UDT data via OData
var resp = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter=status eq 'pending'");
resp.EnsureSuccessStatusCode();
var data = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
foreach (var row in data.GetProperty("value").EnumerateArray())
{
Console.WriteLine($"Order: {row.GetProperty("order_ref")}, Total: {row.GetProperty("order_total")}");
// The primary key is the rowuid value for update and delete.
Console.WriteLine($" {UidColumn}: {row.GetProperty(UidColumn)}");
}
// --- 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;
}
Tip: The
udt_{tablename}_uidcolumn returned by OData is the primary key. Its value is what update and delete take as{"rowuid": N}.
Response Format
All three endpoints return the same response structure.
Success Response
{
"id": 0,
"errorNo": 0,
"errorMessage": "[1] row inserted in [udt_custom_orders] table successfully..!",
"documentNo": null,
"sqlErrorNumber": 0,
"sqlErrorSeverity": 0,
"sqlErrorState": 0,
"sqlObjectName": null,
"sqlErrorLineNo": 0,
"sqlErrorMessage": null
}
Error Response (Table Not Found)
{
"id": 0,
"errorNo": 4001,
"errorMessage": "Table udt_nonexistent not available!",
"documentNo": null,
"sqlErrorNumber": 0,
"sqlErrorSeverity": 0,
"sqlErrorState": 0,
"sqlObjectName": null,
"sqlErrorLineNo": 0,
"sqlErrorMessage": null
}
Response Fields
| Field | Type | Description |
|---|---|---|
id |
int | Always 0 |
errorNo |
int | 0 = success, non-zero = error |
errorMessage |
string | Result message (success AND error messages use this field) |
documentNo |
string/null | Document number if applicable |
sqlErrorNumber |
int | SQL Server error number (0 if no SQL error) |
sqlErrorSeverity |
int | SQL Server error severity |
sqlErrorState |
int | SQL Server error state |
sqlObjectName |
string/null | SQL object that caused the error |
sqlErrorLineNo |
int | SQL line number of error |
sqlErrorMessage |
string/null | Raw SQL error message |
Checking for Success
Always check errorNo, not errorMessage. Success messages are returned in the errorMessage field, which is counterintuitive.
"""Check errorNo (not errorMessage) to tell a UDT Service API success from a failure."""
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 = "udt_custom_orders" # UDT table name
# ---------------------------------------------------------------------------
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",
}
payload = {
"table": TABLE,
"rows": [
{
"columns": [
{"name": "order_ref", "value": "ORD-2026-002"},
{"name": "customer_name", "value": "ACME Manufacturing"},
{"name": "order_total", "value": "875.50"},
{"name": "status", "value": "pending"},
],
"conditions": [],
}
],
}
resp = client.post(
f"{BASE_URL}/udtservice/api/udtdata/insertudtdata",
json=payload,
headers=headers,
)
result = resp.json()
if result["errorNo"] == 0:
# Success — errorMessage contains the success description
print(f"OK: {result['errorMessage']}")
else:
# Error — errorNo is non-zero
print(f"Failed (code {result['errorNo']}): {result['errorMessage']}")
if result.get("sqlErrorMessage"):
print(f"SQL Error: {result['sqlErrorMessage']}")
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 = "udt_custom_orders"; // UDT table name
// ---------------------------------------------------------------------------
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 payload = new
{
table = Table,
rows = new object[]
{
new
{
columns = new object[]
{
new { name = "order_ref", value = "ORD-2026-002" },
new { name = "customer_name", value = "ACME Manufacturing" },
new { name = "order_total", value = "875.50" },
new { name = "status", value = "pending" },
},
conditions = Array.Empty<object>(),
},
},
};
var resp = await client.PostAsync(
$"{BaseUrl}/udtservice/api/udtdata/insertudtdata",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
var result = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
if (result.GetProperty("errorNo").GetInt32() == 0)
{
// Success — errorMessage contains the success description
Console.WriteLine($"OK: {result.GetProperty("errorMessage").GetString()}");
}
else
{
// Error — errorNo is non-zero
Console.WriteLine(
$"Failed (code {result.GetProperty("errorNo").GetInt32()}): {result.GetProperty("errorMessage").GetString()}");
if (result.TryGetProperty("sqlErrorMessage", out var sqlErr) && sqlErr.ValueKind == JsonValueKind.String)
{
Console.WriteLine($"SQL Error: {sqlErr.GetString()}");
}
}
// --- 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;
}
Known Issues
Source: Community-reported issues, verified via actual API testing (April 2026). Tested on P21 version 25.2. Applies to all
/udtservice/api/udtdata/endpoints.
1. Success Messages in errorMessage Field
| Discovery | April 2026 (community-reported) |
| Affected versions | All known versions with UDT support |
| Tested endpoints | insertudtdata, updateudtdata, deleteudtdata |
| Workaround | Check errorNo == 0 for success, not the field name |
The API returns success messages in the errorMessage field (e.g., "[1] row inserted in [udt_custom_orders] table successfully..!"). Always check errorNo == 0 for success, not the field name. Credit: Jon Christie; also reported by Bryce Byker in this P21WWUG topic.
2. SQL Keyword False Positives
| Discovery | April 2026 (community-reported) |
| Affected versions | All known versions with UDT support |
| Tested endpoints | insertudtdata, updateudtdata |
| Workaround | No known workaround — abbreviate or encode values |
Values containing SQL keywords like "drop", "insert", "delete", "update", or "select" may be blocked by the API's SQL injection filter, even when they appear in legitimate data. For example, a product description containing "drop tube" or "insert fitting" could be rejected. Credit: John Kennedy.
Quote characters are part of the same filter, and the behavior is version-dependent. The protection is reported to be a simplistic string replacement rather than real parameterization, which also catches quote characters (' and ") inside otherwise ordinary values. The everyday casualty is dimensional data: 6" hose, 1/2" fitting, 3' section — feet-and-inches notation is exactly the punctuation the filter objects to. A community session in 2026 reported that Epicor has since relaxed this logic to accept more characters, so which characters your version rejects is not something to take on faith from any document, including this one: if your UDT values can contain quotes, insert one row carrying the real punctuation on your own version and read it back before building on it. Testing published on the community forum was done on ~25.1 and may not describe current behavior. (Community session, Felipe Maurer, 2026.)
3. Update/Delete Only Accept UIDs as Conditions
| Discovery | April 2026 (community-reported) |
| Affected versions | All known versions with UDT support |
| Tested endpoints | updateudtdata, deleteudtdata |
| Workaround | Query via OData first to obtain each row's primary key, then send {"rowuid": N} |
Update and delete select rows by primary key only. The service reads the rowuid value in each condition and ignores name and value, so a condition on any other column cannot select rows: on update it returns 400 Invalid Row Uid!, and on delete it deletes nothing. Query via OData first to obtain the udt_{tablename}_uid value of each target row. See the warning under Update (verified on play 26.1.5950.0, 2026-10-29).
4. All Non-Nullable Columns Required on Insert
| Discovery | April 2026 (from official documentation) |
| Affected versions | All known versions with UDT support |
| Tested endpoints | insertudtdata |
| Workaround | Include all non-nullable columns in the payload |
When inserting, every column that is defined as non-nullable in the UDT must be included in the columns array. Columns that are omitted from the payload will be set to NULL, which will fail if the column has a NOT NULL constraint.
5. Epicor Documentation JSON Typos
| Discovery | April 2026 (community-reported) |
| Affected versions | Official documentation as of April 2026 |
| Workaround | Validate JSON before sending |
The official Epicor documentation examples for this API contain JSON syntax errors (extra trailing commas, mismatched brackets). If copying from the official docs, validate your JSON before sending. Credit: Felipe Maurer.
6. SaaS Hostname Difference
| Discovery | April 2026 (community-reported, Epicor support confirmed) |
| Affected versions | SaaS-hosted environments |
| Tested endpoints | insertudtdata |
| Workaround | Add -api to the FQDN hostname |
SaaS-hosted P21 environments may require -api in the FQDN hostname for the UDT Service endpoint. For example:
- On-premise:
https://play.example.com/udtservice/api/udtdata/ - SaaS:
https://play-api.example.com/udtservice/api/udtdata/
If you receive connection errors or 404s in a SaaS environment, check whether the -api hostname variant is required. Credit: Brad Vandenbogaerde.
Complete Workflow Example
A typical UDT workflow: insert a record, query it via OData, update it, then delete it.
"""Insert a UDT row, look it up via OData, update it, then delete 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
TABLE = "udt_custom_orders" # UDT table name
UID_COLUMN = "udt_custom_orders_uid" # the table's primary key: udt_{tablename}_uid
# ---------------------------------------------------------------------------
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",
}
# 1. INSERT a row
insert_payload = {
"table": TABLE,
"rows": [
{
"columns": [
{"name": "order_ref", "value": "ORD-2026-100"},
{"name": "customer_name", "value": "ACME Supply Company"},
{"name": "order_total", "value": "500.00"},
{"name": "status", "value": "draft"},
],
"conditions": [],
}
],
}
resp = client.post(
f"{BASE_URL}/udtservice/api/udtdata/insertudtdata",
json=insert_payload,
headers=headers,
)
result = resp.json()
print(f"Insert: errorNo={result['errorNo']}, {result['errorMessage']}")
# 2. READ via OData to get the row's primary key
resp = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": "order_ref eq 'ORD-2026-100'"},
headers=headers,
)
resp.raise_for_status()
rows = resp.json().get("value", [])
if not rows:
print("Row not found via OData")
else:
row_uid = rows[0][UID_COLUMN]
print(f"Found {UID_COLUMN}: {row_uid}")
# 3. UPDATE the row
update_payload = {
"table": TABLE,
"rows": [
{
"columns": [
{"name": "status", "value": "confirmed"},
{"name": "order_total", "value": "525.00"},
],
# rowuid takes the primary-key value; a name/value pair is ignored.
"conditions": [
{"rowuid": row_uid},
],
}
],
}
resp = client.put(
f"{BASE_URL}/udtservice/api/udtdata/updateudtdata",
json=update_payload,
headers=headers,
)
print(f"Update: HTTP {resp.status_code}, {resp.json().get('errorMessage', resp.text)}")
check = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": f"{UID_COLUMN} eq {row_uid}"},
headers=headers,
)
check.raise_for_status()
updated = check.json().get("value", [])
print(f"Read-back after update: status={updated[0].get('status') if updated else None}")
# 4. DELETE the row — conditions is TOP-LEVEL for delete, not nested in rows[].
delete_payload = {
"table": TABLE,
"conditions": [
{"rowuid": row_uid},
],
}
resp = client.request(
"DELETE",
f"{BASE_URL}/udtservice/api/udtdata/deleteudtdata",
json=delete_payload,
headers=headers,
)
print(f"Delete: HTTP {resp.status_code}, {resp.json().get('errorMessage', resp.text)}")
check = client.get(
f"{BASE_URL}/odataservice/odata/table/{TABLE}",
params={"$filter": f"{UID_COLUMN} eq {row_uid}"},
headers=headers,
)
check.raise_for_status()
print(f"Read-back after delete: {len(check.json().get('value', []))} row(s) remain")
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 = "udt_custom_orders"; // UDT table name
const string UidColumn = "udt_custom_orders_uid"; // the table's primary key: udt_{tablename}_uid
// ---------------------------------------------------------------------------
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. INSERT a row
var insertPayload = new
{
table = Table,
rows = new object[]
{
new
{
columns = new object[]
{
new { name = "order_ref", value = "ORD-2026-100" },
new { name = "customer_name", value = "ACME Supply Company" },
new { name = "order_total", value = "500.00" },
new { name = "status", value = "draft" },
},
conditions = Array.Empty<object>(),
},
},
};
var resp = await client.PostAsync(
$"{BaseUrl}/udtservice/api/udtdata/insertudtdata",
new StringContent(JsonSerializer.Serialize(insertPayload), Encoding.UTF8, "application/json"));
var result = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine(
$"Insert: errorNo={result.GetProperty("errorNo").GetInt32()}, {result.GetProperty("errorMessage").GetString()}");
// 2. READ via OData to get the row's primary key
resp = await client.GetAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter=order_ref eq 'ORD-2026-100'");
resp.EnsureSuccessStatusCode();
var data = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
var rows = data.GetProperty("value");
if (rows.GetArrayLength() == 0)
{
Console.WriteLine("Row not found via OData");
}
else
{
var rowUid = rows[0].GetProperty(UidColumn).GetInt32();
Console.WriteLine($"Found {UidColumn}: {rowUid}");
// 3. UPDATE the row
var updatePayload = new
{
table = Table,
rows = new object[]
{
new
{
columns = new object[]
{
new { name = "status", value = "confirmed" },
new { name = "order_total", value = "525.00" },
},
// rowuid takes the primary-key value; a name/value pair is ignored.
conditions = new object[]
{
new { rowuid = rowUid },
},
},
},
};
resp = await client.PutAsync(
$"{BaseUrl}/udtservice/api/udtdata/updateudtdata",
new StringContent(JsonSerializer.Serialize(updatePayload), Encoding.UTF8, "application/json"));
Console.WriteLine($"Update: HTTP {(int)resp.StatusCode}, {await resp.Content.ReadAsStringAsync()}");
var updated = JsonDocument.Parse(await client.GetStringAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter={UidColumn} eq {rowUid}")).RootElement.GetProperty("value");
Console.WriteLine(
$"Read-back after update: status={(updated.GetArrayLength() > 0 ? updated[0].GetProperty("status").GetString() : null)}");
// 4. DELETE the row — conditions is TOP-LEVEL for delete, not nested in rows[].
var deletePayload = new
{
table = Table,
conditions = new object[]
{
new { rowuid = rowUid },
},
};
var deleteRequest = new HttpRequestMessage(
HttpMethod.Delete, $"{BaseUrl}/udtservice/api/udtdata/deleteudtdata")
{
Content = new StringContent(JsonSerializer.Serialize(deletePayload), Encoding.UTF8, "application/json"),
};
resp = await client.SendAsync(deleteRequest);
Console.WriteLine($"Delete: HTTP {(int)resp.StatusCode}, {await resp.Content.ReadAsStringAsync()}");
var remaining = JsonDocument.Parse(await client.GetStringAsync(
$"{BaseUrl}/odataservice/odata/table/{Table}?$filter={UidColumn} eq {rowUid}")).RootElement.GetProperty("value");
Console.WriteLine($"Read-back after delete: {remaining.GetArrayLength()} row(s) remain");
}
// --- 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;
}
Related
- Authentication — Token generation
- API Selection Guide — Which API to use when
- OData API — Read UDT data via Data Services
- Error Handling — Common P21 error patterns