Dynamics 365 Wave 1 2026 shipped autonomous agents for account reconciliation, invoice capture, and payment processing. These are powerful out-of-box capabilities — but they operate within Microsoft's defined boundaries. They match what Microsoft designed them to match.
The moment your business logic is custom — a non-standard invoice format, a proprietary vendor classification system, a custom approval rule — the out-of-box agents cannot help. Your custom logic is invisible to them.
The answer is to bring the AI to your logic directly. In this article I am going to show you how to call Azure OpenAI from X++ using standard CLR interop, parse the response, and use the result inside a real D365 F&O business process — specifically, an AI-powered vendor invoice anomaly detector that flags unusual invoice lines before they reach the payment run.
Every class, method signature, and HTTP pattern in this article is taken from verified sources — the Azure OpenAI REST API documentation and the D365 community's established X++ HTTP interop patterns.
What we are building
A runnable batch class — CSTVendInvoiceAIAnomalyDetector — that does the following:
- Queries unposted vendor invoice lines where the unit price deviates significantly from the average historical price for that item and vendor
- Builds a prompt containing the invoice line context and sends it to Azure OpenAI via a verified REST call from X++
- Parses the AI response — which returns a structured anomaly assessment — and writes the result to a custom staging table
CSTVendInvoiceAIFlag - Marks flagged lines so the AP clerk sees them in a filtered view before approving the payment run
Step 1 — Azure OpenAI parameters table
Never hardcode the Azure OpenAI endpoint or API key in X++ code. Create a simple singleton parameters table — the same pattern used for standard D365 F&O parameter tables like PurchParameters or VendParameters.
Create a table named CSTAzureOpenAIParameters with the following fields:
Add a static find() method:
public static CSTAzureOpenAIParameters find(boolean _forUpdate = false)
{
CSTAzureOpenAIParameters parameters;
select firstonly parameters
where parameters.Key == 1;
if (_forUpdate)
{
parameters.selectForUpdate(true);
}
if (!parameters)
{
throw error("Azure OpenAI parameters are not configured. " +
"Navigate to CST Parameters > Azure OpenAI to configure.");
}
return parameters;
}
Step 2 — The custom staging table
Create a table named CSTVendInvoiceAIFlag to store AI anomaly results:
Step 3 — The Azure OpenAI service class
Separate the HTTP call into its own service class. This makes it testable, reusable, and easy to swap out if the API version changes.
/// <summary>/// Service class for calling the Azure OpenAI Chat Completions API from X++.
/// Uses verified REST endpoint format from Microsoft Azure documentation.
/// Endpoint: POST https://{resource}.openai.azure.com/openai/deployments/
/// {deployment}/chat/completions?api-version={version}
///</summary>
public class CSTAzureOpenAIService
{
private str endpoint;
private str apiKey;
private str deploymentName;
private str apiVersion;
private int maxTokens;
private void new() {}
/// <summary> /// Construct from parameters table.
/// </summary>
public static CSTAzureOpenAIService construct()
{
CSTAzureOpenAIParameters params = CSTAzureOpenAIParameters::find();
CSTAzureOpenAIService service = new CSTAzureOpenAIService();
service.endpoint = params.Endpoint;
service.apiKey = params.ApiKey;
service.deploymentName = params.DeploymentName;
service.apiVersion = params.ApiVersion;
service.maxTokens = params.MaxTokens ? params.MaxTokens : 500;
return service;
}
/// <summary> /// Send a chat prompt to Azure OpenAI and return the response content string.
/// Returns empty string on failure — caller handles the error.
///
/// Verified endpoint format (Microsoft Learn, Azure OpenAI REST API reference):
/// POST https://{endpoint}/openai/deployments/{deployment-id}/chat/completions
/// ?api-version=2024-10-21
/// Headers:
/// api-key: {your API key}
/// Content-Type: application/json
/// </summary>
public str callChatCompletion(str _systemPrompt, str _userPrompt)
{
str url;
str requestBody;
str responseContent;
System.Net.HttpWebRequest request;
System.Net.HttpWebResponse response;
System.IO.Stream requestStream;
System.IO.StreamReader streamReader;
System.Text.Encoding utf8Encoding;
System.Byte[] bodyBytes;
System.Exception netException;
// Build the verified Azure OpenAI endpoint URL
// Format: https://{resource}.openai.azure.com/openai/deployments/{deployment}/
// chat/completions?api-version={version}
url = endpoint
+ '/openai/deployments/'
+ deploymentName
+ '/chat/completions?api-version='
+ apiVersion;
// Build the request body JSON
// Escaping quotes in X++ string literals: use \" inside strFmt
requestBody = strFmt(
'{"messages": [' +
'{"role": "system", "content": "%1"},' +
'{"role": "user", "content": "%2"}' +
'],' +
'"max_tokens": %3,' +
'"temperature": 0.2}',
CSTAzureOpenAIService::escapeJsonString(_systemPrompt),
CSTAzureOpenAIService::escapeJsonString(_userPrompt),
maxTokens);
try
{
new InteropPermission(InteropKind::ClrInterop).assert();
// Create the HTTP request
request = System.Net.WebRequest::Create(url) as System.Net.HttpWebRequest;
request.set_Method('POST');
request.set_ContentType('application/json');
// Set the Azure OpenAI authentication header
// Azure OpenAI uses 'api-key' header (not 'Authorization: Bearer')
System.Net.WebHeaderCollection headers = request.get_Headers();
headers.Set('api-key', apiKey);
// Write the request body
utf8Encoding = System.Text.Encoding::get_UTF8();
bodyBytes = utf8Encoding.GetBytes(requestBody);
request.set_ContentLength(bodyBytes.get_Length());
requestStream = request.GetRequestStream();
requestStream.Write(bodyBytes, 0, bodyBytes.get_Length());
requestStream.Flush();
requestStream.Close();
// Get the response
response = request.GetResponse() as System.Net.HttpWebResponse;
streamReader = new System.IO.StreamReader(response.GetResponseStream());
responseContent = streamReader.ReadToEnd();
streamReader.Close();
response.Close();
}
catch (Exception::CLRError)
{
netException = CLRInterop::getLastException();
if (netException != null)
{
error(strFmt("Azure OpenAI call failed: %1",
netException.get_Message()));
}
responseContent = '';
}
catch (Exception::Error)
{
error("An unexpected error occurred calling Azure OpenAI.");
responseContent = '';
}
CodeAccessPermission::revertAssert();
return responseContent;
}
///
/// Extract the AI response text from the Azure OpenAI JSON response.
/// Azure OpenAI chat completions response structure:
/// {
/// "choices": [
/// { "message": { "role": "assistant", "content": "..." } }
/// ]
/// }
///
public static str extractContentFromResponse(str _responseJson)
{
// Parse choices[0].message.content from the response JSON
// Using FormJsonSerializer / Newtonsoft approach via CLR
str content = '';
if (!_responseJson)
{
return content;
}
try
{
new InteropPermission(InteropKind::ClrInterop).assert();
// Use Newtonsoft.Json which is available in D365 F&O runtime
var jsonObj = Newtonsoft.Json.Linq.JObject::Parse(_responseJson);
var choices = jsonObj.get_Item('choices') as Newtonsoft.Json.Linq.JArray;
if (choices != null && choices.get_Count() > 0)
{
var firstChoice = choices.get_Item(0) as Newtonsoft.Json.Linq.JObject;
var message = firstChoice.get_Item('message') as Newtonsoft.Json.Linq.JObject;
var contentToken = message.get_Item('content');
if (contentToken != null)
{
content = contentToken.ToString();
}
}
CodeAccessPermission::revertAssert();
}
catch (Exception::CLRError)
{
System.Exception ex = CLRInterop::getLastException();
warning(strFmt("Could not parse Azure OpenAI response: %1",
ex != null ? ex.get_Message() : 'Unknown error'));
CodeAccessPermission::revertAssert();
}
return content;
}
///
/// Escape special characters in a string for safe embedding in a JSON value.
/// Handles backslash, double quote, newline, carriage return, and tab.
///
public static str escapeJsonString(str _input)
{
str result = _input;
// Order matters: escape backslash first
result = strReplace(result, '\\', '\\\\');
result = strReplace(result, '"', '\\"');
result = strReplace(result, '\n', '\\n');
result = strReplace(result, '\r', '\\r');
result = strReplace(result, '\t', '\\t');
return result;
}
}
When calling Azure OpenAI with API key authentication, the header name is api-key with the key value directly — not Authorization: Bearer {key}. Using the Bearer format returns a 401 Unauthorized. The Bearer format is used only when authenticating with Microsoft Entra ID (Managed Identity). If you are using API key auth, use headers.Set('api-key', apiKey) as shown above.
Invoice descriptions, vendor names, and item names from the database frequently contain double quotes, backslashes, or newline characters. Embedding unescaped values directly into a JSON string via strFmt() produces malformed JSON that returns a 400 Bad Request from Azure OpenAI. Always call CSTAzureOpenAIService::escapeJsonString() on every value you embed.
Step 4 — The batch class
This is where the business logic lives. The batch class queries unposted vendor invoice lines, calculates the historical average price, builds the AI prompt with real data, calls the service, and writes the result to the staging table.
/// <summary>/// Batch class that uses Azure OpenAI to detect anomalies in unposted
/// vendor invoice lines and flags them for AP clerk review.
/// Run this as a recurring batch job before the payment run.
/// </summary>
public class CSTVendInvoiceAIAnomalyDetector extends RunBaseBatch
{
// -------------------------------------------------------
// RunBaseBatch boilerplate
// -------------------------------------------------------
public ClassDescription caption()
{
return "AI Vendor Invoice Anomaly Detector";
}
public boolean canGoBatch()
{
return true;
}
public static CSTVendInvoiceAIAnomalyDetector construct()
{
return new CSTVendInvoiceAIAnomalyDetector();
}
public static void main(Args _args)
{
CSTVendInvoiceAIAnomalyDetector detector = CSTVendInvoiceAIAnomalyDetector::construct();
if (detector.prompt())
{
detector.runOperation();
}
}
// -------------------------------------------------------
// Core logic
// -------------------------------------------------------
public void run()
{
VendInvoiceInfoLine invoiceLine;
VendInvoiceInfoTable invoiceHeader;
InventTable inventTable;
CSTAzureOpenAIService aiService;
CSTVendInvoiceAIFlag aiFlag;
int processedCount = 0;
int flaggedCount = 0;
// Initialise the AI service once — reuse across all lines
aiService = CSTAzureOpenAIService::construct();
// Query unposted vendor invoice lines that have not yet been analysed
while select invoiceLine
join invoiceHeader
where invoiceHeader.PurchId == invoiceLine.PurchId
&& invoiceHeader.DocumentState == VendInvoiceDocumentState::Draft
notexists join aiFlag
where aiFlag.InvoiceId == invoiceLine.PurchId
&& aiFlag.LineNum == invoiceLine.LineNumber
{
// Skip lines with zero quantity or zero price — nothing to analyse
if (invoiceLine.Qty == 0 || invoiceLine.PurchPrice == 0)
{
continue;
}
inventTable = InventTable::find(invoiceLine.ItemId);
// Get historical average price for this item+vendor combination
real historicalAvgPrice = this.getHistoricalAvgPrice(
invoiceLine.ItemId,
invoiceHeader.InvoiceAccount);
// Build the prompt with real data
str systemPrompt = this.buildSystemPrompt();
str userPrompt = this.buildUserPrompt(
invoiceLine,
invoiceHeader,
inventTable,
historicalAvgPrice);
// Call Azure OpenAI
str rawResponse = aiService.callChatCompletion(systemPrompt, userPrompt);
if (!rawResponse)
{
warning(strFmt("AI call returned empty response for invoice %1 line %2. Skipping.",
invoiceLine.PurchId, invoiceLine.LineNumber));
continue;
}
// Extract content from the chat completion response
str aiContent = CSTAzureOpenAIService::extractContentFromResponse(rawResponse);
if (!aiContent)
{
continue;
}
// Parse the structured AI response and write to staging table
this.writeAIFlag(invoiceLine, invoiceHeader, aiContent);
processedCount++;
if (this.isLineAnomaly(aiContent))
{
flaggedCount++;
}
}
info(strFmt("AI anomaly detection complete. Lines analysed: %1. Lines flagged: %2.",
processedCount, flaggedCount));
}
// -------------------------------------------------------
// Get historical average price for item + vendor
// Uses last 12 months of posted vendor transactions
// -------------------------------------------------------
private real getHistoricalAvgPrice(ItemId _itemId, VendAccount _vendAccount)
{
VendTrans vendTrans;
real totalAmount;
real totalQty;
date fromDate = datesAdd(today(), -365);
while select sum(AmountMST), sum(Qty) from vendTrans
where vendTrans.AccountNum == _vendAccount
&& vendTrans.TransDate >= fromDate
&& vendTrans.TransType == LedgerTransType::Purch
&& vendTrans.Closed == NoYes::Yes
{
totalAmount = vendTrans.AmountMST;
totalQty = vendTrans.Qty;
}
if (totalQty != 0)
{
return totalAmount / totalQty;
}
return 0;
}
// -------------------------------------------------------
// Build the system prompt
// Keep this focused and specific — the AI follows instructions
// precisely when they are clear and bounded
// -------------------------------------------------------
private str buildSystemPrompt()
{
return 'You are a financial auditor AI assistant for an accounts payable team. ' +
'You analyse vendor invoice lines and detect price anomalies. ' +
'You must respond ONLY in the following JSON format with no other text: ' +
'{"is_anomaly": true or false, ' +
'"confidence": 0.0 to 1.0, ' +
'"reason": "one sentence explanation"}. ' +
'Flag a line as an anomaly if the invoice price deviates more than 20% from the ' +
'historical average, or if the line shows other unusual patterns. ' +
'If no historical data is available, base your assessment on the absolute price alone.';
}
// -------------------------------------------------------
// Build the user prompt with real invoice line data
// -------------------------------------------------------
private str buildUserPrompt(
VendInvoiceInfoLine _invoiceLine,
VendInvoiceInfoTable _invoiceHeader,
InventTable _inventTable,
real _historicalAvgPrice)
{
str itemName = CSTAzureOpenAIService::escapeJsonString(_inventTable.itemName());
str vendorName = CSTAzureOpenAIService::escapeJsonString(
VendTable::find(_invoiceHeader.InvoiceAccount).name());
str historicalText = _historicalAvgPrice > 0
? strFmt('%.2f', _historicalAvgPrice)
: 'No historical data available';
return strFmt(
'Analyse this vendor invoice line for anomalies:\n' +
'Vendor: %1 (account %2)\n' +
'Item: %3 (ID: %4)\n' +
'Invoice quantity: %5\n' +
'Invoice unit price: %6 %7\n' +
'Historical average unit price (last 12 months): %8 %7\n' +
'Invoice date: %9\n' +
'Purchase order reference: %10',
vendorName,
_invoiceHeader.InvoiceAccount,
itemName,
_invoiceLine.ItemId,
_invoiceLine.Qty,
_invoiceLine.PurchPrice,
_invoiceHeader.CurrencyCode,
historicalText,
date2str(_invoiceHeader.InvoiceDate, 123, DateDay::Digits2,
DateSeparator::Slash, DateMonth::Digits2,
DateSeparator::Slash, DateYear::Digits4),
_invoiceLine.PurchId);
}
// -------------------------------------------------------
// Parse the AI response and write to the staging table
// The AI is instructed to return structured JSON
// -------------------------------------------------------
private void writeAIFlag(
VendInvoiceInfoLine _invoiceLine,
VendInvoiceInfoTable _invoiceHeader,
str _aiContent)
{
CSTVendInvoiceAIFlag aiFlag;
boolean isAnomaly = false;
real confidence = 0;
str reason = '';
// Parse the structured JSON response from the AI
// Expected format: {"is_anomaly": true, "confidence": 0.85, "reason": "..."}
try
{
new InteropPermission(InteropKind::ClrInterop).assert();
var jsonObj = Newtonsoft.Json.Linq.JObject::Parse(_aiContent);
var isAnomalyToken = jsonObj.get_Item('is_anomaly');
var confidenceToken = jsonObj.get_Item('confidence');
var reasonToken = jsonObj.get_Item('reason');
if (isAnomalyToken != null) { isAnomaly = System.Convert::ToBoolean(isAnomalyToken.ToString()); }
if (confidenceToken != null) { confidence = System.Convert::ToDecimal(confidenceToken.ToString()); }
if (reasonToken != null) { reason = reasonToken.ToString(); }
CodeAccessPermission::revertAssert();
}
catch (Exception::CLRError)
{
// If JSON parsing fails, the AI may have returned unstructured text
// Store the raw content as the reason and flag for manual review
isAnomaly = true;
confidence = 0.5;
reason = strFmt("AI response could not be parsed. Raw: %1",
subStr(_aiContent, 1, 250));
CodeAccessPermission::revertAssert();
}
// Write the result to the staging table
ttsBegin;
aiFlag.InvoiceId = _invoiceLine.PurchId;
aiFlag.LineNum = _invoiceLine.LineNumber;
aiFlag.ItemId = _invoiceLine.ItemId;
aiFlag.VendAccount = _invoiceHeader.InvoiceAccount;
aiFlag.IsAnomaly = isAnomaly ? NoYes::Yes : NoYes::No;
aiFlag.AnomalyReason = reason;
aiFlag.ConfidenceScore = confidence;
aiFlag.AnalysedDateTime = DateTimeUtil::utcNow();
aiFlag.insert();
ttsCommit;
}
// -------------------------------------------------------
// Parse isAnomaly from the AI content string
// Used for the summary count in info()
// -------------------------------------------------------
private boolean isLineAnomaly(str _aiContent)
{
return strScan(_aiContent, '"is_anomaly": true', 1, strLen(_aiContent)) > 0
|| strScan(_aiContent, '"is_anomaly":true', 1, strLen(_aiContent)) > 0;
}
}
What the AI prompt and response look like at runtime
User prompt sent to Azure OpenAI (built from real invoice data)
"Analyse this vendor invoice line for anomalies: Vendor: Contoso Office Supplies (account US-001) Item: A4 Copy Paper 80gsm, Box of 5 Reams (ID: PAPER-A4-80) Invoice quantity: 100 Invoice unit price: 89.50 USD Historical average unit price (last 12 months): 52.30 USD Invoice date: 03/06/2026 Purchase order reference: PO-004521"Azure OpenAI response (full JSON from the API)
{ "id": "chatcmpl-9xKmR7...", "object": "chat.completion", "created": 1748955312, "model": "gpt-4o-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "{\"is_anomaly\": true, \"confidence\": 0.91, \"reason\": \"Invoice unit price of 89.50 USD is 71% above the 12-month historical average of 52.30 USD, significantly exceeding the 20% deviation threshold.\"}" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 142, "completion_tokens": 48, "total_tokens": 190 } }What gets written to CSTVendInvoiceAIFlag
Production considerations
Cost control — token awareness
Every call to Azure OpenAI consumes tokens and incurs cost. For the invoice anomaly use case, each line consumes approximately 140–160 prompt tokens and 40–60 completion tokens (based on the prompt structure above). At GPT-4o-mini pricing this is negligible per call, but a batch processing 10,000 invoice lines will accumulate meaningful cost.
Practical measures:Add a price deviation pre-filter in X++ — only send lines where the deviation from historical average exceeds 15%. This reduces AI calls by eliminating obvious normal lines before they reach the API.
Use GPT-4o-mini (not GPT-4o) for structured classification tasks like this — it is significantly cheaper and performs equally well for binary yes/no classification with a defined schema.
Set MaxTokens to 150 in your parameters table — the structured response is always short, and capping tokens prevents accidental runaway responses from consuming your quota.
Azure OpenAI returns HTTP 429 (rate limit exceeded) and HTTP 503 (service unavailable) under load. Add retry logic around the HTTP call with exponential backoff:
// Simple retry wrapper — call this instead of callChatCompletion directly
public str callWithRetry(str _systemPrompt, str _userPrompt, int _maxRetries = 3)
{
int attempt = 0;
str response = '';
int waitMs;
while (attempt < _maxRetries)
{
response = this.callChatCompletion(_systemPrompt, _userPrompt);
if (response)
{
return response;
}
attempt++;
if (attempt < _maxRetries)
{
// Exponential backoff: 1s, 2s, 4s
waitMs = any2Int(power(2, attempt - 1)) * 1000;
sleep(waitMs);
}
}
return response;
}
Use Managed Identity instead of API keys in production
API key authentication is fine for development but has two drawbacks in production: keys must be rotated manually, and a leaked key gives full access to your Azure OpenAI resource. The production-grade approach is Microsoft Entra ID Managed Identity : Enable a System Managed Identity on your D365 F&O environment in Azure
Assign the identity the Cognitive Services OpenAI User role on your Azure OpenAI resource
Replace the api-key header with Authorization: Bearer {token} where the token is obtained from the Azure Instance Metadata Service (IMDS)
With Managed Identity, no key is stored anywhere — authentication is entirely identity-based and rotates automatically. This is the approach Microsoft recommends for production workloads.
The Azure OpenAI API typically responds in 1–5 seconds. A synchronous HTTP call from a form button click or a table insert() override blocks the AOS thread for that duration, degrading performance for all concurrent users on the same AOS instance. Always invoke AI calls from a batch job or from an asynchronous context. The batch class pattern in this article is the correct approach.
If your invoice lines contain personally identifiable information — employee names on expense invoices, patient identifiers on medical supply invoices — review your data residency and privacy obligations before sending them to Azure OpenAI. In most cases you can anonymise or omit the sensitive fields from the prompt without reducing the quality of the anomaly detection. Use the vendor account number instead of the vendor name, and the item ID instead of the full item description, when full names are not necessary for the analysis.
Conclusion :-
Calling Azure OpenAI from X++ is not architecturally complex — it is a standard CLR interop HTTP call to a well-documented REST endpoint. The X++ patterns (System.Net.HttpWebRequest, Newtonsoft.Json for parsing, InteropPermission, strReplace for JSON escaping) are all established and verified against real community implementations.
What makes this pattern powerful is where it sits in the architecture. The AI call runs inside a D365 F&O batch job, against live D365 F&O data, and writes its results back into D365 F&O tables. The output is immediately visible to AP clerks in a standard filtered form view. No middleware, no external pipeline, no data leaving the ERP ecosystem unnecessarily.
The invoice anomaly detector built in this article is one pattern. The same architecture works for any scenario where you need AI reasoning applied to ERP data: purchase order line classification, customer credit risk assessment, inventory demand narrative generation, or supplier email triage. The service class is reusable — swap the prompts and the output table, and the pattern adapts to any use case.
That is what AI in ERP actually looks like at the code level. Not a chatbot. Not a button that says "Ask Copilot." Custom intelligence embedded directly in your business processes, running on your data, producing structured results your other X++ code can act on.