Tuesday, August 11, 2026

Implementation of Multithreading in D365 F&O through X++

A single SysOperation batch job is enough for most scenarios. If your batch job processes 10,000 records in 20 minutes and that is acceptable for your business, you do not need multithreading.

But there are real situations where a single thread is not enough — a high-volume eCommerce integration pushing thousands of sales orders per hour into a staging table, a month-end calculation running across hundreds of thousands of ledger entries, or a migration batch that needs to complete in a maintenance window rather than across multiple days.

In these situations, multithreading in D365 F&O — running multiple batch tasks in parallel across available AOS batch threads — can compress hours of processing into minutes. Done correctly, it scales linearly with the number of threads. Done incorrectly, it either processes the same records multiple times or has threads sitting idle while one thread does all the work.

This article covers the complete implementation from the beginning: the three approaches to parallelism, the full Top Picking pattern end to end with verified X++ code, how pessimisticlock and readPast actually work together to prevent duplicate processing, production error handling, and how to verify your threads are genuinely working in parallel.

Before you reach for multithreading — check these first

Multithreading adds architectural complexity. Before implementing it, verify these simpler optimisations are already in place — they often resolve performance problems without parallelism:

CheckWhy it matters
Set-based operations instead of row-by-row while selectupdate_recordset, insert_recordset, and delete_from can be 10–100x faster than looping. Adding threads to a slow loop does not fix the underlying problem.
Index coverage on your queryA missing index on the fields in your where clause causes a full table scan on every iteration. Adding the right index can reduce batch time from hours to minutes without any code change.
Correct use of firstOnly in select statementsFetching more rows than you need wastes memory and IO on every iteration.
Avoid cross-company queries in tight loopschangecompany inside a loop is expensive — pull the company context outside the loop or restructure the query.

If these are already optimised and the batch is still too slow — then multithreading is the right next step.

The three approaches to parallel batch processing

FIRST

Individual Task Modelling

Create one batch task per work item. If you have 500 records to process, you create 500 tasks. Each task processes exactly one record.

When to use: Only when you have a small, known, fixed set of work items (e.g. process 5 specific companies).

Why to avoid at scale: In D365 F&O, creating thousands of batch tasks adds significant overhead — each task has its own record in BatchJob and BatchTask tables. The batch scheduler itself becomes a bottleneck. Community benchmarks show this approach performs significantly worse than alternatives when task counts exceed the number of available batch threads.

SECOND

Batch Bundling

Divide work items into fixed-size bundles and assign each bundle to one task. If you have 1,000 records and 8 threads, each thread gets a bundle of 125 records.

When to use: When work items are known upfront and processing time per item is roughly uniform.

The problem: If item processing time varies — some sales orders have 2 lines, others have 200 — bundles finish at very different times. You end up with some threads finishing in minutes while others run for an hour, with the idle threads doing nothing. This is the thread imbalance problem.

THIRD — Recommended approach

Top Picking

Create a fixed number of tasks (threads). Each task independently picks the next unprocessed work item, processes it, marks it as done, and immediately picks the next one — until no items remain. Work is distributed dynamically at runtime, not upfront.

Why it is the best approach: Thread imbalance is impossible — a fast thread automatically processes more items than a slow one. No items are processed twice because pessimisticlock ensures only one thread can claim a given record at a time. This is the pattern used throughout the D365 F&O standard application.

The Top Picking pattern — full implementation

The architecture has four components:

┌─────────────────────────────────────────────────┐ │ Component 1: CSTMultiThreadContract │ │ DataContract class — thread count parameter │ └──────────────────────┬──────────────────────────┘ │ ┌──────────────────────▼──────────────────────────┐ │ Component 2: CSTMultiThreadController │ │ SysOperationServiceController — entry point │ │ Has Action Menu Item — user schedules this │ └──────────────────────┬──────────────────────────┘ │ creates N tasks ┌──────────────────────▼──────────────────────────┐ │ Component 3: CSTMultiThreadService │ │ Creates N batch tasks on the BatchHeader │ │ Sets InProcessing status before spawning tasks │ └──────────────────────┬──────────────────────────┘ │ each task runs ┌──────────────────────▼──────────────────────────┐ │ Component 4: CSTWorkerController + │ │ CSTWorkerService │ │ No menu item — spawned by Component 3 only │ │ pessimisticlock + readPast top-picking loop │ │ Calls CSTWorkItemProcessor per record │ └─────────────────────────────────────────────────┘

The staging table

Create a table named CSTVendStagingTable with at minimum these fields:

FieldTypeDescription
RecIdInt64Standard RecId — used as the work item identifier
ProcessingStatusEnum: CSTProcessingStatusToBeProcessed = 0, InProcessing = 1, Processed = 2, Error = 3
ErrorMessagestr 500Populated when ProcessingStatus = Error
ProcessedDateTimeUtcDateTimeWhen the record was completed
VendAccountVendAccountYour actual business data fields go here
InvoiceAmountAmountCurExample business data field
⚠️ The InProcessing status is not optional — it is critical

Without an InProcessing status, there is a race condition where threads that finish early and find no remaining records stop permanently. If new records arrive in the staging table while the remaining threads are still running, only those threads process the new records — the finished threads do not restart. The InProcessing approach snapshots all available records at job start, ensuring all threads stop together when that snapshot is exhausted, and the next job recurrence picks up any new records.

Component 1 — The DataContract class


/// <summary>/// DataContract for the multithreading coordinator batch job.
/// Exposes the thread count parameter on the batch dialog.
/// </summary>
[DataContract]
public class CSTMultiThreadContract
{
    private int numberOfThreads;

    [DataMember,
     SysOperationLabel(literalStr("Number of parallel threads")),
     SysOperationHelpText(literalStr("Maximum is 16 — see batch server configuration."))]
    public int parmNumberOfThreads(int _numberOfThreads = numberOfThreads)
    {
        numberOfThreads = _numberOfThreads;
        return numberOfThreads;
    }
}

Component 2 — The coordinator Controller class


/// <summary>/// SysOperationServiceController for the multithreading coordinator.
/// This is the class that has an Action Menu Item and that users schedule.
/// It is responsible ONLY for creating the worker tasks — it finishes in seconds.
/// </summary>
public class CSTMultiThreadController extends SysOperationServiceController
{
    protected void new()
    {
        // Points to the process() method on CSTMultiThreadService
        super(classStr(CSTMultiThreadService),
              methodStr(CSTMultiThreadService, process),
              SysOperationExecutionMode::Synchronous);
    }

    public ClassDescription defaultCaption()
    {
        return "Vendor Staging — Multithreaded Processor";
    }

    public static CSTMultiThreadController construct(
        SysOperationExecutionMode _executionMode = SysOperationExecutionMode::Synchronous)
    {
        CSTMultiThreadController controller = new CSTMultiThreadController();
        controller.parmExecutionMode(_executionMode);
        return controller;
    }

    public static void main(Args _args)
    {
        CSTMultiThreadController controller = CSTMultiThreadController::construct();
        controller.parmArgs(_args);
        controller.startOperation();
    }
}

Component 3 — The coordinator Service class

This is the most important class in the pattern. It does three things: moves records to InProcessing status (the snapshot), creates one worker task per requested thread, and saves all tasks to the batch header in a single operation.


/// <summary>
/// Service class for the coordinator batch job.
/// Responsible for:
///   1. Snapshotting records by setting them to InProcessing status
///   2. Spawning N worker tasks on the current batch header
/// This class finishes in seconds — all actual processing is done by the worker tasks.
/// </summary>
public class CSTMultiThreadService extends SysOperationServiceBase
{
    public void process(CSTMultiThreadContract _contract)
    {
        CSTVendStagingTable            stagingTable;
        SysOperationServiceController  workerController;
        BatchHeader                    batchHeader;
        int                             threadCount;
        int                             totalThreads = _contract.parmNumberOfThreads();
        RecordInsertList                logList;

        // Validate thread count — hard limit of 16 from Microsoft documentation
        if (totalThreads <= 0 || totalThreads > 16)
        {
            throw error("Number of threads must be between 1 and 16.");
        }

        // Step 1: Check whether any records are waiting to be processed
        select count(RecId) from stagingTable
            where stagingTable.ProcessingStatus == CSTProcessingStatus::ToBeProcessed;

        if (stagingTable.RecId == 0)
        {
            info("No records in ToBeProcessed status. Nothing to do.");
            return;
        }

        // Step 2: Snapshot — move all ToBeProcessed records to InProcessing
        // This prevents new records arriving mid-run from causing thread imbalance
        update_recordset stagingTable
            setting ProcessingStatus = CSTProcessingStatus::InProcessing
            where stagingTable.ProcessingStatus == CSTProcessingStatus::ToBeProcessed;

        info(strFmt("%1 records moved to InProcessing status.", stagingTable.RecId));

        // Step 3: Get the current batch header to add tasks to
        // getCurrentBatchHeader() returns the BatchHeader of the currently running batch job
        // This ensures worker tasks are children of THIS job — not independent jobs
        batchHeader = this.getCurrentBatchHeader();

        if (!batchHeader)
        {
            // Fallback: if running interactively (not as a batch), create a new header
            batchHeader = BatchHeader::construct();
        }

        // Step 4: Create one worker controller per requested thread
        for (threadCount = 1; threadCount <= totalThreads; threadCount++)
        {
            workerController = CSTWorkerController::construct();
            workerController.parmDialogCaption(
                strFmt("Vendor Staging Worker — Thread %1 of %2", threadCount, totalThreads));

            batchHeader.addTask(workerController);
        }

        // Step 5: Save all tasks at once
        batchHeader.save();

        info(strFmt("%1 worker tasks created and queued.", totalThreads));
    }
}
✅ Why getCurrentBatchHeader() instead of BatchHeader::construct()

When the coordinator runs as a batch job (which it always should), getCurrentBatchHeader() returns the BatchHeader of the running job. Tasks added to this header become child tasks of the coordinator job — they are visible under the same batch job ID and their status is tracked together. Using BatchHeader::construct() creates a new, independent batch job each time, which makes monitoring and troubleshooting much harder.

Component 4a — The worker Controller class


/// <summary>/// SysOperationServiceController for the worker tasks.
/// This class does NOT have a menu item — it is only instantiated
/// programmatically by CSTMultiThreadService.
/// </summary>
public class CSTWorkerController extends SysOperationServiceController
{
    protected void new()
    {
        super(classStr(CSTWorkerService),
              methodStr(CSTWorkerService, process),
              SysOperationExecutionMode::Synchronous);
    }

    public ClassDescription defaultCaption()
    {
        return "Vendor Staging Worker";
    }

    public static CSTWorkerController construct(
        SysOperationExecutionMode _executionMode = SysOperationExecutionMode::Synchronous)
    {
        CSTWorkerController controller = new CSTWorkerController();
        controller.parmExecutionMode(_executionMode);
        return controller;
    }

    public static void main(Args _args)
    {
        CSTWorkerController controller = CSTWorkerController::construct();
        controller.parmArgs(_args);
        controller.startOperation();
    }
}

Component 4b — The worker Service class

This is the heart of the Top Picking pattern. The combination of readPast(true), pessimisticlock, and firstOnly is what makes parallel processing safe.


/// <summary>/// Service class for each worker task.
/// Uses pessimistic locking with readPast to implement Top Picking:
///   - pessimisticlock: locks the selected record so no other thread can select it
///   - readPast(true): skips records that are locked by other threads
/// This combination guarantees each record is processed by exactly one thread.
/// </summary>
public class CSTWorkerService extends SysOperationServiceBase
{
    public void process()
    {
        CSTVendStagingTable stagingTable;

        // readPast(true) is the critical call that enables Top Picking.
        // When true: if this thread tries to select a record that is locked
        // by another thread, it skips that record and moves to the next one.
        // Without this, the thread would wait (block) until the lock is released,
        // causing all threads to queue up behind the same records.
        stagingTable.readPast(true);

        do
        {
            try
            {
                ttsBegin;

                // pessimisticlock: locks the selected record at the database level.
                // firstOnly: gets exactly one record.
                // Together: this thread claims one record that no other thread can claim.
                select pessimisticlock firstOnly stagingTable
                    where stagingTable.ProcessingStatus == CSTProcessingStatus::InProcessing;

                if (stagingTable)
                {
                    // Process the claimed record
                    CSTWorkItemProcessor::processRecord(stagingTable);

                    // Mark as processed within the same transaction as the lock
                    stagingTable.ProcessingStatus   = CSTProcessingStatus::Processed;
                    stagingTable.ProcessedDateTime  = DateTimeUtil::utcNow();
                    stagingTable.update();
                }

                ttsCommit;
                // Lock is released on ttsCommit — other threads can now access
                // the next InProcessing records
            }
            catch (Exception::Deadlock)
            {
                // SQL Server deadlock — safe to retry immediately
                // The transaction is automatically rolled back on deadlock
                ttsAbort;
                retry;
            }
            catch (Exception::UpdateConflict)
            {
                // Optimistic concurrency conflict — retry
                ttsAbort;
                if (appl.ttsLevel() == 0)
                {
                    retry;
                }
                else
                {
                    throw Exception::UpdateConflict;
                }
            }
            catch (Exception::Error)
            {
                // Business logic error on this specific record
                // Do NOT re-throw — mark the record as Error and continue
                // to the next record so one bad record does not kill the thread
                ttsAbort;

                // Re-select for update outside the failed transaction
                CSTVendStagingTable errorRecord;
                select forUpdate firstOnly errorRecord
                    where errorRecord.RecId == stagingTable.RecId;

                if (errorRecord)
                {
                    ttsBegin;
                    errorRecord.ProcessingStatus = CSTProcessingStatus::Error;
                    errorRecord.ErrorMessage     = infolog.text();
                    errorRecord.update();
                    ttsCommit;
                }
            }
        }
        while (stagingTable.RecId != 0); // Loop until no InProcessing records remain
    }
}
⚠️ The readPast(true) call must be OUTSIDE the do-while loop

readPast(true) sets a property on the table buffer object. It needs to be set once before the loop begins — not inside the loop on each iteration. Setting it inside the loop still works but is misleading about its scope. Placing it outside makes it clear that it applies to all selects on that buffer throughout the entire loop execution.

⚠️ Never use ttsBegin/ttsCommit outside the do-while when processing records

Wrapping the entire do-while loop in a single transaction means one failed record rolls back every record processed by that thread since the transaction began. Each record must be processed in its own transaction — ttsBegin inside the loop, ttsCommit after the update, and ttsAbort on error. The lock is held for exactly one record at a time.

Component 5 — The work item processor

Separating the actual business logic into its own class is not just good practice — it enforces that each work item is truly autonomous. If your logic cannot fit cleanly into a standalone static method called with a single table buffer, that is a signal that your work item definition is too large or too dependent on other records.


/// <summary>/// Processes a single record from CSTVendStagingTable.
/// This class is intentionally NOT a SysOperation class — it is a pure
/// business logic class called by CSTWorkerService.
/// Keep all logic for one work item here — autonomous and self-contained.
/// </summary>
public class CSTWorkItemProcessor
{
    public static void processRecord(CSTVendStagingTable _stagingRecord)
    {
        VendTable   vendTable;
        VendTrans   vendTrans;

        // Validate the vendor exists before processing
        vendTable = VendTable::find(_stagingRecord.VendAccount);

        if (!vendTable)
        {
            throw error(strFmt("Vendor %1 not found. Record %2 cannot be processed.",
                _stagingRecord.VendAccount, _stagingRecord.RecId));
        }

        // Your actual business logic goes here
        // This example creates a vendor transaction record from staging data
        // In a real implementation this would be your order creation,
        // data validation, GL posting, or whatever the batch job is doing

        ttsBegin;

        vendTrans.AccountNum    = _stagingRecord.VendAccount;
        vendTrans.TransDate     = today();
        vendTrans.AmountMST     = _stagingRecord.InvoiceAmount;
        vendTrans.TransType     = LedgerTransType::Purch;
        // ... set other required fields
        vendTrans.insert();

        ttsCommit;
    }
}

How pessimisticlock and readPast work together

This is the mechanism that prevents duplicate processing. Understanding it at the database level explains why the pattern works and what happens when it is implemented incorrectly.

Thread A Thread B ──────────────────────────────────────────────────── ttsBegin ttsBegin SELECT TOP 1 WITH (UPDLOCK) SELECT TOP 1 WITH (UPDLOCK, READPAST) FROM CSTVendStagingTable FROM CSTVendStagingTable WHERE ProcessingStatus = 1 WHERE ProcessingStatus = 1 ── Gets RecId 1001 ── RecId 1001 is LOCKED by Thread A ── Locks RecId 1001 ── READPAST skips it ── Gets RecId 1002 ── Locks RecId 1002 Processes RecId 1001 Processes RecId 1002 UPDATE RecId 1001 → Processed UPDATE RecId 1002 → Processed ttsCommit ttsCommit ── Lock on 1001 released ── Lock on 1002 released Next iteration: Gets RecId 1003 Next iteration: Gets RecId 1004

The SQL translation is exact: pessimisticlock in X++ generates WITH (UPDLOCK) in the SQL query. readPast(true) adds READPAST to the hint. The combination — WITH (UPDLOCK, READPAST) — is the standard SQL Server pattern for queue processing: lock what you claim, skip what others have claimed.

⚠️ Without readPast(true), threads block each other instead of skipping

If you use pessimisticlock without readPast(true), Thread B does not skip RecId 1001 — it waits for Thread A to release the lock. Both threads then race to claim RecId 1002. This causes lock contention, threads effectively serialise behind each other, and you get no parallelism benefit. The readPast(true) call is what makes the pattern genuinely parallel.

Configuring the batch server for maximum threads

Creating 8 worker tasks in code does not guarantee 8 tasks run simultaneously. The batch server has a configurable maximum thread limit.

  1. Navigate to System Administration → Setup → Server Configuration
  2. Find the AOS instance that runs your batch jobs
  3. Set Maximum batch threads — Microsoft's documented maximum is 16
  4. Save and restart the batch service if required
⚠️ Setting threads higher than 16 has documented negative consequences

Microsoft's official documentation states that setting Maximum batch threads above 16 can have negative performance consequences on the AOS instance and the SQL Server database. The AOS is not designed to scale batch parallelism beyond this limit. More threads above 16 does not mean faster processing — it means more contention for SQL connections, more lock waits, and potential AOS instability.

Verifying your threads are genuinely running in parallel

After running the coordinator job, navigate to System Administration → Inquiries → Batch jobs. Find the coordinator job by its description. Click the Job ID link to drill into the tasks.

You should see N tasks — one per thread you specified — all showing status Executing simultaneously. When processing is complete they will all move to Ended.

Performance verification calculation is mentioned below : - 

Scenario: 1,000 records, each takes ~2 seconds to process Single thread expected time: 1,000 × 2s = ~2,000 seconds (~33 minutes) 8 threads expected time: 2,000s ÷ 8 = ~250 seconds (~4 minutes) What you actually see: First task started: 09:00:00 Last task ended: 09:04:18 Total elapsed: 258 seconds ✓ — multithreading is working Red flag — multithreading is NOT working: Total elapsed: ~2,000 seconds Root cause check 1: Is readPast(true) set on the buffer? Root cause check 2: Is Maximum batch threads > 1 on the server? Root cause check 3: Are all tasks assigned to the same batch group?
⚠️ Only one thread processing while others sit idle — the most common symptom

This is reported frequently in the D365 community. The three most common causes are: (1) readPast(true) is missing — threads block instead of skip; (2) the batch server Maximum batch threads is set to 1; (3) all tasks are in a batch group that only one AOS instance serves. Check all three before assuming the code is wrong.

Complete class list for your Visual Studio project

ClassTypeMenu Item?Purpose
CSTProcessingStatusEnumToBeProcessed, InProcessing, Processed, Error
CSTVendStagingTableTableStaging table with ProcessingStatus field
CSTMultiThreadContractClass (DataContract)Thread count parameter
CSTMultiThreadControllerClass (SysOperationServiceController)✅ Action Menu ItemUser-facing coordinator — schedules and spawns tasks
CSTMultiThreadServiceClass (SysOperationServiceBase)Snapshots records, creates worker tasks
CSTWorkerControllerClass (SysOperationServiceController)❌ No menu itemWorker task controller — spawned only by CSTMultiThreadService
CSTWorkerServiceClass (SysOperationServiceBase)Top-picking loop with pessimisticlock + readPast
CSTWorkItemProcessorClass (plain)Business logic for one record — autonomous and standalone

Conclusion :-

Multithreading in D365 F&O batch jobs is not architecturally complex once you understand the pattern. The coordinator creates tasks. The worker tasks use pessimisticlock + readPast(true) to claim and process one record at a time without conflicts. The InProcessing status snapshots the work at job start, preventing thread imbalance from mid-run record arrivals.

The most important implementation detail is the combination of pessimisticlock and readPast(true). Without both, the pattern either causes duplicate processing or serialises threads behind locks. With both, each thread independently and safely claims work items, and the parallelism scales linearly up to the configured batch thread maximum of 16.

Before implementing multithreading, always verify that set-based operations and index optimisation have been applied first. Parallelising a slow, unoptimised batch job gives you multiple slow threads instead of one — the underlying performance problem remains. Multithreading is the right answer when a well-optimised single-threaded batch job is still too slow for the volume. In that scenario, the Top Picking pattern in this article will compress hours of processing into minutes.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Wednesday, August 5, 2026

Integrating Azure OpenAI with D365 F&O Using X++ - A Practical AI in ERP example

Prerequisites: We need an active Azure subscription with an Azure OpenAI resource deployed, a GPT-4o or GPT-4o-mini deployment created in Azure AI Foundry, and D365 F&O version 10.0.40 or later. The Azure OpenAI endpoint and API key are referenced throughout — store them in a parameters table, never hardcode them.

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:

  1. Queries unposted vendor invoice lines where the unit price deviates significantly from the average historical price for that item and vendor
  2. Builds a prompt containing the invoice line context and sends it to Azure OpenAI via a verified REST call from X++
  3. Parses the AI response — which returns a structured anomaly assessment — and writes the result to a custom staging table CSTVendInvoiceAIFlag
  4. Marks flagged lines so the AP clerk sees them in a filtered view before approving the payment run
X++ Batch Class (CSTVendInvoiceAIAnomalyDetector) │ ├── Query VendInvoiceTrans (unposted lines) │ Query InventTable for item name │ Query historical avg price from VendTrans │ ├── Build JSON prompt │ strFmt() constructs the request body │ ├── HTTP POST to Azure OpenAI │ System.Net.HttpWebRequest (CLR Interop) │ Header: api-key: {your Azure OpenAI key} │ Header: Content-Type: application/json │ Endpoint: https://{resource}.openai.azure.com/ │ openai/deployments/{deployment}/ │ chat/completions?api-version=2024-10-21 │ ├── Parse JSON response │ Extract choices[0].message.content │ Parse structured AI output fields │ └── Write to CSTVendInvoiceAIFlag table Mark VendInvoiceTrans line for AP clerk review

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:

Field nameTypeDescription
KeyIntegerSingleton key — always 1. Set as primary key.
EndpointURL (str 255)Your Azure OpenAI resource endpoint, e.g. https://myresource.openai.azure.com
ApiKeystr 255Your Azure OpenAI API key — mark as password field in AOT properties
DeploymentNamestr 100The name of your GPT-4o or GPT-4o-mini deployment in Azure AI Foundry
ApiVersionstr 30Azure OpenAI API version string, e.g. 2024-10-21
MaxTokensIntegerMaximum tokens in the response. Default: 500

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:

Field nameTypeDescription
InvoiceIdVendInvoiceInfoTable.PurchId (str)The vendor invoice ID
LineNumLineNum (real)The invoice line number
ItemIdItemIdThe item number
VendAccountVendAccountThe vendor account
IsAnomalyNoYesWhether the AI flagged this line
AnomalyReasonstr 500Plain-language explanation from the AI
ConfidenceScorerealAI-returned confidence score (0.0 to 1.0)
ReviewedByUserIdCleared when AP clerk reviews
AnalysedDateTimeUtcDateTimeWhen the AI analysis ran

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;
    }
}

⚠️ Azure OpenAI uses 'api-key' header — not 'Authorization: Bearer'

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.

⚠️ Always escape user data before embedding in JSON strings

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

FieldValue
InvoiceIdPO-004521
LineNum1.00
ItemIdPAPER-A4-80
VendAccountUS-001
IsAnomalyYes
AnomalyReasonInvoice unit price of 89.50 USD is 71% above the 12-month historical average of 52.30 USD, significantly exceeding the 20% deviation threshold.
ConfidenceScore0.91
AnalysedDateTime2026-06-03T09:45:12Z

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.


Retry logic for transient failures

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.
⚠️ Never make synchronous HTTP calls from a form event handler

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.

⚠️ Do not send sensitive personal data to Azure OpenAI

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.


That's all for now. Please let us know your questions or feedback in comments section !!!!

Implementation of Multithreading in D365 F&O through X++

A single SysOperation batch job is enough for most scenarios. If your batch job processes 10,000 records in 20 minutes and that is acceptabl...