Batch Processing Patterns

Disclaimer: This is unofficial, community-created documentation for Epicor Prophet 21 APIs. It is not affiliated with, endorsed by, or supported by Epicor Software Corporation. All product names, trademarks, and registered trademarks are property of their respective owners. Use at your own risk.


Overview

The existing Interactive API documentation covers single-operation workflows. Real production use often requires processing dozens or hundreds of operations in sequence, which introduces session timeout, error recovery, and batching concerns.

This document covers patterns learned from operating a production system that created 700+ price pages across 25+ suppliers using the Interactive API (v2).


Session-Per-Batch Pattern

Interactive API sessions have a default idle timeout of approximately 6 minutes. When processing many operations, a single session will time out between operations if any individual operation takes longer than expected or if there are delays between operations.

Pattern

Start a new session for each batch of ~25 operations. This keeps each session well within the timeout window while minimizing session creation overhead.

async def process_in_batches(
    client: P21Client,
    items: list[dict],
    batch_size: int = 25
) -> list[dict]:
    """Process items in batches with a fresh session per batch.

    Args:
        client: Authenticated P21Client (no active session needed)
        items: List of items to process
        batch_size: Number of operations per session (default 25)

    Returns:
        List of results for each item
    """
    results = []

    for i in range(0, len(items), batch_size):
        batch = items[i:i + batch_size]
        batch_num = (i // batch_size) + 1
        total_batches = (len(items) + batch_size - 1) // batch_size

        logger.info(f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)")

        # New session for each batch
        async with client.session() as session:
            batch_results = await process_batch(session, batch)
            results.extend(batch_results)

    return results
public async Task<List<Dictionary<string, object>>> ProcessInBatchesAsync(
    P21Client client,
    List<Dictionary<string, object>> items,
    int batchSize = 25)
{
    // Process items in batches with a fresh session per batch.
    var results = new List<Dictionary<string, object>>();

    for (int i = 0; i < items.Count; i += batchSize)
    {
        var batch = items.Skip(i).Take(batchSize).ToList();
        int batchNum = (i / batchSize) + 1;
        int totalBatches = (items.Count + batchSize - 1) / batchSize;

        _logger.LogInformation(
            "Processing batch {BatchNum}/{TotalBatches} ({Count} items)",
            batchNum, totalBatches, batch.Count);

        // New session for each batch
        await using var session = await client.CreateSessionAsync();
        var batchResults = await ProcessBatchAsync(session, batch);
        results.AddRange(batchResults);
    }

    return results;
}

Verified Timing Data

From production measurements creating price pages with book linking:

Metric Value
Time per page creation (including book linking) ~2.5s
Time for 25-page batch ~62s
Session overhead (start + end) ~1s
Total for 700 pages (28 batches) ~30 min

Window Reuse Within a Batch

Opening a P21 window is expensive (~500ms). Within a batch, open the window once and reuse it for multiple operations by calling clear_data() between records.

Pattern

async def process_batch(
    session: P21Session,
    items: list[dict]
) -> list[dict]:
    """Process a batch of items using a single window.

    Opens the window once, processes all items, then closes.
    Uses clear_data() between records to reset the form.
    """
    results = []

    # Open window once for the entire batch
    window = await session.open_window(service_name="SalesPricePage")

    try:
        for i, item in enumerate(items):
            try:
                result = await create_price_page(window, item)
                results.append({"item": item, "success": True, "result": result})

                # Clear the form for the next record (skip on last item)
                if i < len(items) - 1:
                    await window.clear_data()

            except Exception as e:
                logger.error(f"Failed to process item {item}: {e}")
                results.append({"item": item, "success": False, "error": str(e)})

                # On error, close and reopen the window (see Error Recovery below)
                await window.close()
                window = await session.open_window(service_name="SalesPricePage")

    finally:
        await window.close()

    return results
public async Task<List<Dictionary<string, object>>> ProcessBatchAsync(
    P21Session session,
    List<Dictionary<string, object>> items)
{
    // Process a batch of items using a single window.
    // Opens the window once, processes all items, then closes.
    // Uses ClearData() between records to reset the form.
    var results = new List<Dictionary<string, object>>();

    // Open window once for the entire batch
    var window = await session.OpenWindowAsync(serviceName: "SalesPricePage");

    try
    {
        for (int i = 0; i < items.Count; i++)
        {
            var item = items[i];
            try
            {
                var result = await CreatePricePageAsync(window, item);
                results.Add(new Dictionary<string, object>
                {
                    ["item"] = item, ["success"] = true, ["result"] = result
                });

                // Clear the form for the next record (skip on last item)
                if (i < items.Count - 1)
                    await window.ClearDataAsync();
            }
            catch (Exception ex)
            {
                _logger.LogError("Failed to process item {Item}: {Error}", item, ex.Message);
                results.Add(new Dictionary<string, object>
                {
                    ["item"] = item, ["success"] = false, ["error"] = ex.Message
                });

                // On error, close and reopen the window (see Error Recovery below)
                await window.CloseAsync();
                window = await session.OpenWindowAsync(serviceName: "SalesPricePage");
            }
        }
    }
    finally
    {
        await window.CloseAsync();
    }

    return results;
}

Key Points


Error Recovery Pattern

When an error occurs during an Interactive API operation, the window state may be corrupted (partial field values, unsaved changes, open dialogs). Attempting to continue using a corrupted window leads to cascading failures.

Pattern: Close and Reopen on Error

async def create_price_page_with_recovery(
    session: P21Session,
    window: Window,
    item: dict,
    max_retries: int = 2
) -> tuple[Window, dict]:
    """Create a price page with error recovery.

    On failure, closes the corrupted window and opens a fresh one.

    Args:
        session: Active P21 session
        window: Current window (may be replaced on error)
        item: Price page data to create
        max_retries: Number of retry attempts

    Returns:
        Tuple of (current_window, result_dict)
    """
    for attempt in range(max_retries + 1):
        try:
            result = await create_price_page(window, item)
            return window, {"success": True, "result": result}

        except Exception as e:
            logger.warning(
                f"Attempt {attempt + 1} failed for {item.get('description', '?')}: {e}"
            )

            # Close the potentially corrupted window
            try:
                await window.close()
            except Exception:
                pass  # Window may already be in bad state

            if attempt < max_retries:
                # Open a fresh window and retry
                window = await session.open_window(service_name="SalesPricePage")
            else:
                # All retries exhausted - reopen window for next item
                window = await session.open_window(service_name="SalesPricePage")
                return window, {"success": False, "error": str(e)}

    # Should not reach here, but just in case
    return window, {"success": False, "error": "Unexpected retry exhaustion"}
public async Task<(Window Window, Dictionary<string, object> Result)>
    CreatePricePageWithRecoveryAsync(
        P21Session session,
        Window window,
        Dictionary<string, object> item,
        int maxRetries = 2)
{
    // Create a price page with error recovery.
    // On failure, closes the corrupted window and opens a fresh one.
    Exception? lastException = null;

    for (int attempt = 0; attempt <= maxRetries; attempt++)
    {
        try
        {
            var result = await CreatePricePageAsync(window, item);
            return (window, new Dictionary<string, object>
            {
                ["success"] = true, ["result"] = result
            });
        }
        catch (Exception ex)
        {
            lastException = ex;
            var description = item.TryGetValue("description", out var desc)
                ? desc?.ToString() ?? "?" : "?";
            _logger.LogWarning(
                "Attempt {Attempt} failed for {Description}: {Error}",
                attempt + 1, description, ex.Message);

            // Close the potentially corrupted window
            try { await window.CloseAsync(); }
            catch { /* Window may already be in bad state */ }

            if (attempt < maxRetries)
            {
                // Open a fresh window and retry
                window = await session.OpenWindowAsync(serviceName: "SalesPricePage");
            }
            else
            {
                // All retries exhausted - reopen window for next item
                window = await session.OpenWindowAsync(serviceName: "SalesPricePage");
                return (window, new Dictionary<string, object>
                {
                    ["success"] = false, ["error"] = ex.Message
                });
            }
        }
    }

    // Should not reach here, but just in case
    return (window, new Dictionary<string, object>
    {
        ["success"] = false, ["error"] = "Unexpected retry exhaustion"
    });
}

Why Not Recover In-Place?

Recovery Strategy Outcome
Clear data and retry May fail - unsaved changes can persist
Cancel changes and retry May fail - dialogs may be blocking
Close window and reopen Reliable - guaranteed clean state

The close-and-reopen strategy costs ~500ms but guarantees a clean state. For bulk operations, this reliability far outweighs the small performance cost.


Page Expiration Workflow

Creating new price pages often requires expiring old ones to prevent pricing conflicts. If both an old and new page are active for the same supplier/product group, P21 may apply either one unpredictably.

Pattern: Expire Before Replace

async def expire_price_page(
    window: Window,
    price_page_uid: int,
    expiration_date: str
) -> bool:
    """Expire a price page by setting its expiration date.

    Args:
        window: Open SalesPricePage window
        price_page_uid: UID of the page to expire
        expiration_date: Date to expire the page (YYYY-MM-DD format)

    Returns:
        True if successful
    """
    # Load the page by UID
    result = await window.change_data(
        "FORM", "form", "price_page_uid", str(price_page_uid)
    )
    if not result.success:
        logger.error(f"Failed to load page {price_page_uid}: {result.messages}")
        return False

    # Set the expiration date
    result = await window.change_data(
        "FORM", "form", "expiration_date", expiration_date
    )
    if not result.success:
        logger.error(f"Failed to set expiration date: {result.messages}")
        return False

    # Save
    result = await window.save_data()
    if not result.success:
        logger.error(f"Failed to save expiration: {result.messages}")
        return False

    logger.info(f"Expired page {price_page_uid} (expires {expiration_date})")
    return True
public async Task<bool> ExpirePricePageAsync(
    Window window,
    int pricePageUid,
    string expirationDate)
{
    // Expire a price page by setting its expiration date.

    // Load the page by UID
    var result = await window.ChangeDataAsync(
        "FORM", "form", "price_page_uid", pricePageUid.ToString());
    if (!result.Success)
    {
        _logger.LogError("Failed to load page {Uid}: {Messages}",
            pricePageUid, string.Join("; ", result.Messages));
        return false;
    }

    // Set the expiration date
    result = await window.ChangeDataAsync(
        "FORM", "form", "expiration_date", expirationDate);
    if (!result.Success)
    {
        _logger.LogError("Failed to set expiration date: {Messages}",
            string.Join("; ", result.Messages));
        return false;
    }

    // Save
    result = await window.SaveDataAsync();
    if (!result.Success)
    {
        _logger.LogError("Failed to save expiration: {Messages}",
            string.Join("; ", result.Messages));
        return false;
    }

    _logger.LogInformation("Expired page {Uid} (expires {Date})",
        pricePageUid, expirationDate);
    return true;
}

Bulk Expiration

Expiration follows the same batch patterns as creation. Process in batches with session-per-batch:

async def expire_old_pages(
    client: P21Client,
    page_uids: list[int],
    expiration_date: str,
    batch_size: int = 25
) -> dict:
    """Bulk expire price pages.

    Args:
        client: Authenticated P21Client
        page_uids: List of price page UIDs to expire
        expiration_date: Expiration date (YYYY-MM-DD)
        batch_size: Pages per session batch

    Returns:
        Summary with success/failure counts
    """
    succeeded = 0
    failed = 0

    for i in range(0, len(page_uids), batch_size):
        batch = page_uids[i:i + batch_size]

        async with client.session() as session:
            window = await session.open_window(service_name="SalesPricePage")

            try:
                for uid in batch:
                    success = await expire_price_page(window, uid, expiration_date)
                    if success:
                        succeeded += 1
                        await window.clear_data()
                    else:
                        failed += 1
                        # Reopen window on failure
                        await window.close()
                        window = await session.open_window(
                            service_name="SalesPricePage"
                        )
            finally:
                await window.close()

    return {"succeeded": succeeded, "failed": failed, "total": len(page_uids)}
public async Task<Dictionary<string, int>> ExpireOldPagesAsync(
    P21Client client,
    List<int> pageUids,
    string expirationDate,
    int batchSize = 25)
{
    // Bulk expire price pages.
    int succeeded = 0;
    int failed = 0;

    for (int i = 0; i < pageUids.Count; i += batchSize)
    {
        var batch = pageUids.Skip(i).Take(batchSize).ToList();

        await using var session = await client.CreateSessionAsync();
        var window = await session.OpenWindowAsync(serviceName: "SalesPricePage");

        try
        {
            foreach (int uid in batch)
            {
                bool success = await ExpirePricePageAsync(window, uid, expirationDate);
                if (success)
                {
                    succeeded++;
                    await window.ClearDataAsync();
                }
                else
                {
                    failed++;
                    // Reopen window on failure
                    await window.CloseAsync();
                    window = await session.OpenWindowAsync(
                        serviceName: "SalesPricePage");
                }
            }
        }
        finally
        {
            await window.CloseAsync();
        }
    }

    return new Dictionary<string, int>
    {
        ["succeeded"] = succeeded,
        ["failed"] = failed,
        ["total"] = pageUids.Count
    };
}

Production-Grade Async Client

The example scripts in this project use synchronous httpx. For production batch processing, a full async client is recommended. Below is a complete, production-tested client architecture.

Result Class

Parse Interactive API responses into a structured result:

from dataclasses import dataclass, field


@dataclass
class Result:
    """Parsed result from an Interactive API response."""

    status_code: int  # 0=None, 1=Success, 2=Failure, 3=Blocked
    success: bool
    messages: list[str] = field(default_factory=list)
    events: list[dict] = field(default_factory=list)
    raw: dict = field(default_factory=dict)

    @classmethod
    def from_response(cls, response_data: dict) -> "Result":
        """Parse an API response dict into a Result.

        Status codes match the official ResultStatus enum from
        P21.UI.Service.Model.Interactive.V2.ResultWrapper:
            None=0, Success=1, Failure=2, Blocked=3

        The API may return Status as an integer (1, 2, 3) or a string
        ("Success", "Failure", "Blocked") depending on context.
        """
        status = response_data.get("Status", 0)

        # Official ResultStatus enum: None=0, Success=1, Failure=2, Blocked=3
        status_code = {
            "None": 0,
            "Success": 1,
            "Failure": 2,
            "Blocked": 3,
        }.get(status, 0) if isinstance(status, str) else status

        messages = []
        for event in response_data.get("Events", []):
            if event.get("Name") == "message":
                messages.append(event.get("Data", {}).get("Message", ""))

        return cls(
            status_code=status_code,
            success=status_code == 1,
            messages=messages,
            events=response_data.get("Events", []),
            raw=response_data,
        )

    def get_event(self, event_name: str) -> dict | None:
        """Get the first event matching the given name."""
        for event in self.events:
            if event.get("Name") == event_name:
                return event.get("Data", {})
        return None
using Newtonsoft.Json.Linq;

/// <summary>
/// Parsed result from an Interactive API response.
/// Status codes match P21.UI.Service.Model.Interactive.V2.ResultWrapper:
///   None=0, Success=1, Failure=2, Blocked=3
/// </summary>
public class Result
{
    public int StatusCode { get; init; }  // 0=None, 1=Success, 2=Failure, 3=Blocked
    public bool Success { get; init; }
    public List<string> Messages { get; init; } = new();
    public List<JObject> Events { get; init; } = new();
    public JObject Raw { get; init; } = new();

    private static readonly Dictionary<string, int> StatusMap = new()
    {
        ["None"] = 0, ["Success"] = 1, ["Failure"] = 2, ["Blocked"] = 3
    };

    /// <summary>
    /// Parse an API response into a Result.
    /// The API may return Status as an integer or string depending on context.
    /// </summary>
    public static Result FromResponse(JObject responseData)
    {
        var statusToken = responseData["Status"];
        int statusCode = 0;

        if (statusToken?.Type == JTokenType.String)
        {
            string statusStr = statusToken.ToString();
            StatusMap.TryGetValue(statusStr, out statusCode);
        }
        else if (statusToken?.Type == JTokenType.Integer)
        {
            statusCode = statusToken.Value<int>();
        }

        var messages = new List<string>();
        var events = responseData["Events"]?.ToObject<List<JObject>>() ?? new();

        foreach (var evt in events)
        {
            if (evt["Name"]?.ToString() == "message")
            {
                string msg = evt["Data"]?["Message"]?.ToString() ?? "";
                messages.Add(msg);
            }
        }

        return new Result
        {
            StatusCode = statusCode,
            Success = statusCode == 1,
            Messages = messages,
            Events = events,
            Raw = responseData
        };
    }

    /// <summary>Get the first event matching the given name.</summary>
    public JObject? GetEvent(string eventName)
    {
        return Events.FirstOrDefault(e => e["Name"]?.ToString() == eventName)
            ?["Data"]?.ToObject<JObject>();
    }
}

Event Parsing Helpers

Common events you need to extract from API responses:

def get_generated_key(result: Result) -> int | None:
    """Extract auto-generated key (e.g., price_page_uid) from result events.

    After saving a new record, P21 fires a 'keygenerated' event
    containing the new UID.
    """
    event_data = result.get_event("keygenerated")
    if event_data:
        return int(event_data.get("Value", 0))
    return None


def get_opened_window_id(result: Result) -> str | None:
    """Extract window ID from a 'windowopened' event.

    When a response window/dialog opens, the API returns this event
    with the new window's ID.
    """
    event_data = result.get_event("windowopened")
    if event_data:
        return event_data.get("WindowId")
    return None
/// <summary>
/// Extract auto-generated key (e.g., price_page_uid) from result events.
/// After saving a new record, P21 fires a 'keygenerated' event
/// containing the new UID.
/// </summary>
public static int? GetGeneratedKey(Result result)
{
    var eventData = result.GetEvent("keygenerated");
    if (eventData != null)
    {
        string value = eventData["Value"]?.ToString() ?? "0";
        return int.TryParse(value, out int key) ? key : null;
    }
    return null;
}

/// <summary>
/// Extract window ID from a 'windowopened' event.
/// When a response window/dialog opens, the API returns this event
/// with the new window's ID.
/// </summary>
public static string? GetOpenedWindowId(Result result)
{
    var eventData = result.GetEvent("windowopened");
    return eventData?["WindowId"]?.ToString();
}

Complete Window Class

class Window:
    """Represents an open P21 Interactive API window."""

    def __init__(self, client: "P21Client", window_id: str):
        self.client = client
        self.window_id = window_id

    async def change_data(
        self,
        tab_name: str,
        datawindow_name: str,
        field_name: str,
        value: str,
    ) -> Result:
        """Change a single field value.

        Note: datawindow_name is required in P21 25.2+. Window data
        structures changed so the server can no longer auto-resolve
        the target datawindow from TabName alone.
        """
        payload = {
            "WindowId": self.window_id,
            "List": [
                {
                    "TabName": tab_name,
                    "DatawindowName": datawindow_name,
                    "FieldName": field_name,
                    "Value": value,
                }
            ],
        }
        resp = await self.client._put("/api/ui/interactive/v2/change", json=payload)
        return Result.from_response(resp)

    async def change_data_batch(
        self,
        changes: list[dict],
    ) -> Result:
        """Change multiple field values in a single request.

        Args:
            changes: List of dicts with keys: tab_name, datawindow_name,
                     field_name, value
        """
        payload = {
            "WindowId": self.window_id,
            "List": [
                {
                    "TabName": c["tab_name"],
                    "DatawindowName": c["datawindow_name"],
                    "FieldName": c["field_name"],
                    "Value": c["value"],
                }
                for c in changes
            ],
        }
        resp = await self.client._put("/api/ui/interactive/v2/change", json=payload)
        return Result.from_response(resp)

    async def select_tab(self, page_name: str) -> Result:
        """Switch to a different tab."""
        payload = {"WindowId": self.window_id, "PageName": page_name}
        resp = await self.client._put("/api/ui/interactive/v2/tab", json=payload)
        return Result.from_response(resp)

    async def change_row(self, row: int, datawindow_name: str) -> Result:
        """Select a specific row in a datawindow."""
        payload = {
            "WindowId": self.window_id,
            "DatawindowName": datawindow_name,
            "Row": row,
        }
        resp = await self.client._put("/api/ui/interactive/v2/row", json=payload)
        return Result.from_response(resp)

    async def add_row(self, datawindow_name: str) -> Result:
        """Add a new row to a datawindow."""
        payload = {
            "WindowId": self.window_id,
            "DatawindowName": datawindow_name,
        }
        resp = await self.client._post("/api/ui/interactive/v2/row", json=payload)
        return Result.from_response(resp)

    async def save_data(self) -> Result:
        """Save the current window data."""
        # v2 sends just the window ID string as the body
        resp = await self.client._put(
            "/api/ui/interactive/v2/data", content=f'"{self.window_id}"'
        )
        return Result.from_response(resp)

    async def clear_data(self) -> Result:
        """Clear the current window data (reset form for next record)."""
        resp = await self.client._delete(
            f"/api/ui/interactive/v2/data?id={self.window_id}"
        )
        return Result.from_response(resp)

    async def get_data(self) -> dict:
        """Get the current window data."""
        resp = await self.client._get(
            f"/api/ui/interactive/v2/data?id={self.window_id}"
        )
        return resp

    async def get_state(self) -> dict:
        """Get the current window state."""
        resp = await self.client._get(
            f"/api/ui/interactive/v2/window?windowId={self.window_id}"
        )
        return resp

    async def get_tools(self) -> list[dict]:
        """Get available tools (buttons) for the window."""
        resp = await self.client._get(
            f"/api/ui/interactive/v2/tools?windowId={self.window_id}"
        )
        return resp

    async def run_tool(self, tool_name: str, tool_text: str = "") -> Result:
        """Run a tool (click a button) in the window."""
        payload = {
            "WindowId": self.window_id,
            "ToolName": tool_name,
            "ToolText": tool_text,
        }
        resp = await self.client._post("/api/ui/interactive/v2/tools", json=payload)
        return Result.from_response(resp)

    async def close(self) -> None:
        """Close this window."""
        await self.client._delete(
            f"/api/ui/interactive/v2/window?windowId={self.window_id}"
        )
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

/// <summary>Represents an open P21 Interactive API window.</summary>
public class Window : IAsyncDisposable
{
    private readonly P21Client _client;
    public string WindowId { get; }

    public Window(P21Client client, string windowId)
    {
        _client = client;
        WindowId = windowId;
    }

    /// <summary>
    /// Change a single field value.
    /// DatawindowName is required in P21 25.2+. Window data structures
    /// changed so the server can no longer auto-resolve the target
    /// datawindow from TabName alone.
    /// </summary>
    public async Task<Result> ChangeDataAsync(
        string tabName, string datawindowName,
        string fieldName, string value)
    {
        var payload = new JObject
        {
            ["WindowId"] = WindowId,
            ["List"] = new JArray
            {
                new JObject
                {
                    ["TabName"] = tabName,
                    ["DatawindowName"] = datawindowName,
                    ["FieldName"] = fieldName,
                    ["Value"] = value
                }
            }
        };
        var resp = await _client.PutAsync("/api/ui/interactive/v2/change", payload);
        return Result.FromResponse(resp);
    }

    /// <summary>Change multiple field values in a single request.</summary>
    public async Task<Result> ChangeDataBatchAsync(
        List<ChangeField> changes)
    {
        var list = new JArray(changes.Select(c => new JObject
        {
            ["TabName"] = c.TabName,
            ["DatawindowName"] = c.DatawindowName,
            ["FieldName"] = c.FieldName,
            ["Value"] = c.Value
        }));

        var payload = new JObject
        {
            ["WindowId"] = WindowId,
            ["List"] = list
        };
        var resp = await _client.PutAsync("/api/ui/interactive/v2/change", payload);
        return Result.FromResponse(resp);
    }

    /// <summary>Switch to a different tab.</summary>
    public async Task<Result> SelectTabAsync(string pageName)
    {
        var payload = new JObject
        {
            ["WindowId"] = WindowId, ["PageName"] = pageName
        };
        var resp = await _client.PutAsync("/api/ui/interactive/v2/tab", payload);
        return Result.FromResponse(resp);
    }

    /// <summary>Select a specific row in a datawindow.</summary>
    public async Task<Result> ChangeRowAsync(int row, string datawindowName)
    {
        var payload = new JObject
        {
            ["WindowId"] = WindowId,
            ["DatawindowName"] = datawindowName,
            ["Row"] = row
        };
        var resp = await _client.PutAsync("/api/ui/interactive/v2/row", payload);
        return Result.FromResponse(resp);
    }

    /// <summary>Add a new row to a datawindow.</summary>
    public async Task<Result> AddRowAsync(string datawindowName)
    {
        var payload = new JObject
        {
            ["WindowId"] = WindowId,
            ["DatawindowName"] = datawindowName
        };
        var resp = await _client.PostAsync("/api/ui/interactive/v2/row", payload);
        return Result.FromResponse(resp);
    }

    /// <summary>Save the current window data.</summary>
    public async Task<Result> SaveDataAsync()
    {
        // v2 sends just the window ID string as the body (bare JSON string)
        var resp = await _client.PutRawAsync(
            "/api/ui/interactive/v2/data",
            $"\"{WindowId}\"");
        return Result.FromResponse(resp);
    }

    /// <summary>Clear the current window data (reset form for next record).</summary>
    public async Task<Result> ClearDataAsync()
    {
        var resp = await _client.DeleteAsync(
            $"/api/ui/interactive/v2/data?id={WindowId}");
        return Result.FromResponse(resp!);
    }

    /// <summary>Get the current window data.</summary>
    public async Task<JObject> GetDataAsync()
    {
        return await _client.GetAsync(
            $"/api/ui/interactive/v2/data?id={WindowId}");
    }

    /// <summary>Get the current window state.</summary>
    public async Task<JObject> GetStateAsync()
    {
        return await _client.GetAsync(
            $"/api/ui/interactive/v2/window?windowId={WindowId}");
    }

    /// <summary>Get available tools (buttons) for the window.</summary>
    public async Task<JArray> GetToolsAsync()
    {
        var resp = await _client.GetAsync(
            $"/api/ui/interactive/v2/tools?windowId={WindowId}");
        return resp["Tools"]?.ToObject<JArray>() ?? new JArray();
    }

    /// <summary>Run a tool (click a button) in the window.</summary>
    public async Task<Result> RunToolAsync(string toolName, string toolText = "")
    {
        var payload = new JObject
        {
            ["WindowId"] = WindowId,
            ["ToolName"] = toolName,
            ["ToolText"] = toolText
        };
        var resp = await _client.PostAsync("/api/ui/interactive/v2/tools", payload);
        return Result.FromResponse(resp);
    }

    /// <summary>Close this window.</summary>
    public async Task CloseAsync()
    {
        await _client.DeleteAsync(
            $"/api/ui/interactive/v2/window?windowId={WindowId}");
    }

    public async ValueTask DisposeAsync()
    {
        try { await CloseAsync(); }
        catch { /* Window may already be closed */ }
    }
}

/// <summary>Field change descriptor for batch operations.</summary>
public record ChangeField(
    string TabName, string DatawindowName,
    string FieldName, string Value);

Complete P21Client Class

import httpx
import logging

logger = logging.getLogger(__name__)


class P21Client:
    """Production async client for P21 Interactive API.

    Handles authentication, session management, and window operations.
    Tested against 700+ operations in production.

    Usage:
        async with P21Client(base_url, username, password) as client:
            window = await client.open_window(service_name="SalesPricePage")
            result = await window.change_data("FORM", "description", "Test")
            await window.save_data()
            await window.close()
    """

    def __init__(
        self,
        base_url: str,
        username: str,
        password: str,
        verify_ssl: bool = True,
        timeout: float = 60.0,
    ):
        self.base_url = base_url.rstrip("/")
        self.username = username
        self.password = password
        self.verify_ssl = verify_ssl
        self.timeout = timeout
        self.token: str | None = None
        self.ui_server_url: str | None = None
        self._client: httpx.AsyncClient | None = None

    def _get_client(self) -> httpx.AsyncClient:
        if self._client is None or self._client.is_closed:
            self._client = httpx.AsyncClient(
                verify=self.verify_ssl,
                timeout=self.timeout,
                follow_redirects=True,
            )
        return self._client

    @property
    def _headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Bearer {self.token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    async def authenticate(self) -> None:
        """Obtain a bearer token from P21."""
        client = self._get_client()
        response = await client.post(
            f"{self.base_url}/api/security/token",
            headers={
                "Content-Type": "application/json",
                "Accept": "application/json",
                "username": self.username,
                "password": self.password,
            },
            content="",
        )
        response.raise_for_status()
        self.token = response.json()["AccessToken"]

    async def get_ui_server(self) -> None:
        """Discover the UI server URL."""
        client = self._get_client()
        response = await client.get(
            f"{self.base_url}/api/ui/router/v1?urlType=external",
            headers=self._headers,
        )
        response.raise_for_status()
        self.ui_server_url = response.json()["Url"].rstrip("/")

    async def start_session(self) -> None:
        """Start an Interactive API session."""
        await self._post(
            "/api/ui/interactive/sessions/",
            json={"ResponseWindowHandlingEnabled": False},
        )

    async def end_session(self) -> None:
        """End the current Interactive API session."""
        try:
            await self._delete("/api/ui/interactive/sessions/")
        except Exception as e:
            logger.debug(f"Session cleanup error (ignored): {e}")

    async def open_window(self, service_name: str) -> Window:
        """Open a P21 window by service name."""
        resp = await self._post(
            "/api/ui/interactive/v2/window",
            json={"ServiceName": service_name},
        )
        window_id = resp.get("WindowId", resp.get("windowId", ""))
        return Window(self, window_id)

    # --- HTTP helpers ---

    async def _get(self, path: str, **kwargs) -> dict:
        client = self._get_client()
        resp = await client.get(
            f"{self.ui_server_url}{path}", headers=self._headers, **kwargs
        )
        resp.raise_for_status()
        return resp.json()

    async def _post(self, path: str, **kwargs) -> dict:
        client = self._get_client()
        resp = await client.post(
            f"{self.ui_server_url}{path}", headers=self._headers, **kwargs
        )
        resp.raise_for_status()
        return resp.json()

    async def _put(self, path: str, **kwargs) -> dict:
        client = self._get_client()
        resp = await client.put(
            f"{self.ui_server_url}{path}", headers=self._headers, **kwargs
        )
        resp.raise_for_status()
        return resp.json()

    async def _delete(self, path: str, **kwargs) -> dict | None:
        client = self._get_client()
        resp = await client.delete(
            f"{self.ui_server_url}{path}", headers=self._headers, **kwargs
        )
        resp.raise_for_status()
        if resp.content:
            return resp.json()
        return None

    # --- Context manager ---

    async def __aenter__(self) -> "P21Client":
        await self.authenticate()
        await self.get_ui_server()
        await self.start_session()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool:
        await self.end_session()
        if self._client and not self._client.is_closed:
            await self._client.aclose()
            self._client = None
        return False
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

/// <summary>
/// Production async client for P21 Interactive API.
/// Handles authentication, session management, and window operations.
/// Tested against 700+ operations in production.
///
/// Usage:
///   await using var client = new P21Client(baseUrl, username, password, logger);
///   await client.InitializeAsync();
///   var window = await client.OpenWindowAsync("SalesPricePage");
///   var result = await window.ChangeDataAsync("FORM", "form", "description", "Test");
///   await window.SaveDataAsync();
///   await window.CloseAsync();
/// </summary>
public class P21Client : IAsyncDisposable
{
    private readonly string _baseUrl;
    private readonly string _username;
    private readonly string _password;
    private readonly ILogger<P21Client> _logger;
    private readonly HttpClient _httpClient;

    private string? _token;
    private string? _uiServerUrl;

    public P21Client(
        string baseUrl,
        string username,
        string password,
        ILogger<P21Client> logger,
        bool verifySsl = true,
        TimeSpan? timeout = null)
    {
        _baseUrl = baseUrl.TrimEnd('/');
        _username = username;
        _password = password;
        _logger = logger;

        var handler = new HttpClientHandler();
        if (!verifySsl)
            handler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true;

        _httpClient = new HttpClient(handler)
        {
            Timeout = timeout ?? TimeSpan.FromSeconds(60)
        };
    }

    private void SetAuthHeaders()
    {
        _httpClient.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", _token);
        _httpClient.DefaultRequestHeaders.Accept.Clear();
        _httpClient.DefaultRequestHeaders.Accept
            .Add(new MediaTypeWithQualityHeaderValue("application/json"));
    }

    /// <summary>Obtain a bearer token from P21.</summary>
    public async Task AuthenticateAsync()
    {
        var request = new HttpRequestMessage(HttpMethod.Post,
            $"{_baseUrl}/api/security/token");
        request.Content = new StringContent("", Encoding.UTF8, "application/json");
        request.Headers.Add("username", _username);
        request.Headers.Add("password", _password);

        var response = await _httpClient.SendAsync(request);
        response.EnsureSuccessStatusCode();

        var json = JObject.Parse(await response.Content.ReadAsStringAsync());
        _token = json["AccessToken"]!.ToString();
        SetAuthHeaders();
    }

    /// <summary>Discover the UI server URL.</summary>
    public async Task DiscoverUiServerAsync()
    {
        var resp = await GetAsync(
            $"{_baseUrl}/api/ui/router/v1?urlType=external", useUiServer: false);
        _uiServerUrl = resp["Url"]!.ToString().TrimEnd('/');
    }

    /// <summary>Start an Interactive API session.</summary>
    public async Task StartSessionAsync()
    {
        var payload = new JObject { ["ResponseWindowHandlingEnabled"] = false };
        await PostAsync("/api/ui/interactive/sessions/", payload);
    }

    /// <summary>End the current Interactive API session.</summary>
    public async Task EndSessionAsync()
    {
        try { await DeleteAsync("/api/ui/interactive/sessions/"); }
        catch (Exception ex) { _logger.LogDebug("Session cleanup error (ignored): {Error}", ex.Message); }
    }

    /// <summary>
    /// Initialize the client: authenticate, discover UI server, start session.
    /// </summary>
    public async Task InitializeAsync()
    {
        await AuthenticateAsync();
        await DiscoverUiServerAsync();
        await StartSessionAsync();
    }

    /// <summary>
    /// Create a scoped session that auto-disposes (start + end).
    /// Use with "await using" for session-per-batch pattern.
    /// </summary>
    public async Task<P21Session> CreateSessionAsync()
    {
        var session = new P21Session(this);
        await session.StartAsync();
        return session;
    }

    /// <summary>Open a P21 window by service name.</summary>
    public async Task<Window> OpenWindowAsync(string serviceName)
    {
        var payload = new JObject { ["ServiceName"] = serviceName };
        var resp = await PostAsync("/api/ui/interactive/v2/window", payload);
        string windowId = resp["WindowId"]?.ToString()
            ?? resp["windowId"]?.ToString() ?? "";
        return new Window(this, windowId);
    }

    // --- HTTP helpers ---

    public async Task<JObject> GetAsync(string path, bool useUiServer = true)
    {
        string url = useUiServer ? $"{_uiServerUrl}{path}" : path;
        var resp = await _httpClient.GetAsync(url);
        resp.EnsureSuccessStatusCode();
        return JObject.Parse(await resp.Content.ReadAsStringAsync());
    }

    public async Task<JObject> PostAsync(string path, JObject payload)
    {
        var content = new StringContent(
            payload.ToString(), Encoding.UTF8, "application/json");
        var resp = await _httpClient.PostAsync($"{_uiServerUrl}{path}", content);
        resp.EnsureSuccessStatusCode();
        return JObject.Parse(await resp.Content.ReadAsStringAsync());
    }

    public async Task<JObject> PutAsync(string path, JObject payload)
    {
        var content = new StringContent(
            payload.ToString(), Encoding.UTF8, "application/json");
        var resp = await _httpClient.PutAsync($"{_uiServerUrl}{path}", content);
        resp.EnsureSuccessStatusCode();
        return JObject.Parse(await resp.Content.ReadAsStringAsync());
    }

    /// <summary>PUT with a raw string body (used for save_data v2).</summary>
    public async Task<JObject> PutRawAsync(string path, string rawBody)
    {
        var content = new StringContent(
            rawBody, Encoding.UTF8, "application/json");
        var resp = await _httpClient.PutAsync($"{_uiServerUrl}{path}", content);
        resp.EnsureSuccessStatusCode();
        return JObject.Parse(await resp.Content.ReadAsStringAsync());
    }

    public async Task<JObject?> DeleteAsync(string path)
    {
        var resp = await _httpClient.DeleteAsync($"{_uiServerUrl}{path}");
        resp.EnsureSuccessStatusCode();
        var body = await resp.Content.ReadAsStringAsync();
        return string.IsNullOrEmpty(body) ? null : JObject.Parse(body);
    }

    public async ValueTask DisposeAsync()
    {
        await EndSessionAsync();
        _httpClient.Dispose();
    }
}

/// <summary>
/// Scoped session for session-per-batch pattern. Use with "await using".
/// </summary>
public class P21Session : IAsyncDisposable
{
    private readonly P21Client _client;

    public P21Session(P21Client client) => _client = client;

    public async Task StartAsync() => await _client.StartSessionAsync();

    public async Task<Window> OpenWindowAsync(string serviceName)
        => await _client.OpenWindowAsync(serviceName);

    public async ValueTask DisposeAsync() => await _client.EndSessionAsync();
}

Complete Batch Workflow Example

Putting it all together: expire old pages, create new ones, and link to books.

import asyncio
import logging

logger = logging.getLogger(__name__)


async def replace_supplier_pages(
    client: P21Client,
    supplier_id: int,
    old_page_uids: list[int],
    new_pages: list[dict],
    book_ids: list[str],
    expiration_date: str,
    batch_size: int = 25,
) -> dict:
    """Replace supplier price pages: expire old, create new, link to books.

    Args:
        client: Authenticated P21Client (no active session needed)
        supplier_id: Supplier ID
        old_page_uids: UIDs of pages to expire
        new_pages: List of page definitions to create
        book_ids: Price book IDs to link new pages to
        expiration_date: Date to set on old pages
        batch_size: Operations per session batch

    Returns:
        Summary dict with counts
    """
    summary = {"expired": 0, "created": 0, "linked": 0, "errors": []}

    # Phase 1: Expire old pages
    if old_page_uids:
        logger.info(f"Expiring {len(old_page_uids)} old pages...")
        result = await expire_old_pages(
            client, old_page_uids, expiration_date, batch_size
        )
        summary["expired"] = result["succeeded"]

    # Phase 2: Create new pages
    created_uids = []
    for i in range(0, len(new_pages), batch_size):
        batch = new_pages[i:i + batch_size]

        async with client.session() as session:
            window = await session.open_window(service_name="SalesPricePage")

            try:
                for page_def in batch:
                    try:
                        uid = await create_single_page(window, page_def)
                        created_uids.append(uid)
                        summary["created"] += 1
                        await window.clear_data()
                    except Exception as e:
                        summary["errors"].append(str(e))
                        await window.close()
                        window = await session.open_window(
                            service_name="SalesPricePage"
                        )
            finally:
                await window.close()

    # Phase 3: Link new pages to books
    for i in range(0, len(created_uids), batch_size):
        batch = created_uids[i:i + batch_size]

        async with client.session() as session:
            for uid in batch:
                for book_id in book_ids:
                    try:
                        await link_page_to_book(session, uid, book_id)
                        summary["linked"] += 1
                    except Exception as e:
                        summary["errors"].append(f"Link {uid}->{book_id}: {e}")

    logger.info(
        f"Complete: {summary['expired']} expired, "
        f"{summary['created']} created, "
        f"{summary['linked']} linked, "
        f"{len(summary['errors'])} errors"
    )
    return summary
using Microsoft.Extensions.Logging;

public class BatchProcessor
{
    private readonly ILogger<BatchProcessor> _logger;

    public BatchProcessor(ILogger<BatchProcessor> logger) => _logger = logger;

    /// <summary>
    /// Replace supplier price pages: expire old, create new, link to books.
    /// </summary>
    public async Task<BatchSummary> ReplaceSupplierPagesAsync(
        P21Client client,
        int supplierId,
        List<int> oldPageUids,
        List<Dictionary<string, object>> newPages,
        List<string> bookIds,
        string expirationDate,
        int batchSize = 25)
    {
        var summary = new BatchSummary();

        // Phase 1: Expire old pages
        if (oldPageUids.Count > 0)
        {
            _logger.LogInformation("Expiring {Count} old pages...", oldPageUids.Count);
            var result = await ExpireOldPagesAsync(
                client, oldPageUids, expirationDate, batchSize);
            summary.Expired = result["succeeded"];
        }

        // Phase 2: Create new pages
        var createdUids = new List<int>();
        for (int i = 0; i < newPages.Count; i += batchSize)
        {
            var batch = newPages.Skip(i).Take(batchSize).ToList();

            await using var session = await client.CreateSessionAsync();
            var window = await session.OpenWindowAsync(serviceName: "SalesPricePage");

            try
            {
                foreach (var pageDef in batch)
                {
                    try
                    {
                        int uid = await CreateSinglePageAsync(window, pageDef);
                        createdUids.Add(uid);
                        summary.Created++;
                        await window.ClearDataAsync();
                    }
                    catch (Exception ex)
                    {
                        summary.Errors.Add(ex.Message);
                        await window.CloseAsync();
                        window = await session.OpenWindowAsync(
                            serviceName: "SalesPricePage");
                    }
                }
            }
            finally
            {
                await window.CloseAsync();
            }
        }

        // Phase 3: Link new pages to books
        for (int i = 0; i < createdUids.Count; i += batchSize)
        {
            var batch = createdUids.Skip(i).Take(batchSize).ToList();

            await using var session = await client.CreateSessionAsync();
            foreach (int uid in batch)
            {
                foreach (string bookId in bookIds)
                {
                    try
                    {
                        await LinkPageToBookAsync(session, uid, bookId);
                        summary.Linked++;
                    }
                    catch (Exception ex)
                    {
                        summary.Errors.Add($"Link {uid}->{bookId}: {ex.Message}");
                    }
                }
            }
        }

        _logger.LogInformation(
            "Complete: {Expired} expired, {Created} created, " +
            "{Linked} linked, {Errors} errors",
            summary.Expired, summary.Created,
            summary.Linked, summary.Errors.Count);
        return summary;
    }
}

public class BatchSummary
{
    public int Expired { get; set; }
    public int Created { get; set; }
    public int Linked { get; set; }
    public List<string> Errors { get; set; } = new();
}

Performance Summary

Measured from production use creating and managing 700+ price pages:

Operation Time Notes
Authenticate ~200ms One-time per client
Start session ~300ms Once per batch
Open window ~500ms Once per batch
Change field ~100ms Per field
Save record ~400ms Per record
Clear data ~200ms Between records
Close window ~200ms Once per batch
End session ~100ms Once per batch
Full page creation ~2.5s Including all fields + save
25-page batch ~62s Including session overhead