On this page

Attach a Document to an Item, Assembly or Kit (Document Links)

Upload a file and add it to an item's Document Links tab through the API, the way the P21 client does it — including on assemblies and kits, on exactly the display areas you name (Assembly Maintenance, the sales-order pick ticket), or on all of them.

API: File handler REST (/api/filehandler) + Interactive (Item window) + OData read-back · Service: Item · Deep dive: 04 § Adding a Document Link in the Item Window · 15 § filehandler · Full schema: Item.json · Assembly.json

Prerequisites

The Transaction API cannot add a link. Both the Item and the Assembly services list a DOCUMENT_LINK.document_link datawindow in their definitions, but a POST that sets link_name fails with Column is disabled: link_name on both. The grid is the display list (d_dw_document_link_display); links are added through the Add Link dialog, which only the Interactive API reaches.

Where an assembly's document lives

An assembly or kit has no document links of its own. It is an item, and its links are item links:

document_link column Value for an item link
source_area_cd 1314 (Item Maintenance)
key1_cd / key1_value inv_mast_uid / the item's inv_mast_uid as a string
row_status_flag 704 active

Each link has one document_link_area row per display area — the windows and printed forms where it shows. Assembly Maintenance only views links: its Document Links tab offers m_viewlink and no m_addlink.

Display areas that matter for assemblies and kits (code values, the same on every tenant):

display_area_cd Area
1314 Item Maintenance (always selected — the window the link was made in)
1445 Item Master Inquiry
1302 Assembly Maintenance
3453 Sales Order Pick Ticket
1319 Order Entry/RMA
1332 Shipping
999 Packing List
998 Order Acknowledgement
1325 Production Order Entry/Processing
2750 Production Order Form

The Add Link dialog lists the rest in its Available Area list (26 areas on 26.1.5950.0). A document reaches a printed form only through that form's area — for the sales-order pick ticket, 3453 — and only when the link is Outside Use and the location sends outside-use documents to print. See Printing the document with a form.

Call sequence

  1. Upload the file into the folder the client's own Add Link dialog uses — the item_maintenance_doc_link_upload_folder_path system setting: POST /api/filehandler/file/path?fileName={name}&path={folder} with the raw bytes as the body.
  2. Open the Item window, change item_id on TABPAGE_1, select the DOCUMENT_LINK tab.
  3. Add Link: POST /v2/tools with ToolName: "m_addlink" and DatawindowName: "document_link". It opens the tabless Document Link dialog (w_inventory_sheet).
  4. Fill the dialog's _dw_link: link_path, link_name, outside_use_flag, mandatory_flag.
  5. Display areas: the dialog starts with Item Maintenance and Item Master Inquiry selected. To add one area, select its row in the source (Available Areas) list with the web client's POST {ui_server}/ui/full/v2/data/select — 1-based row, read fresh from GET /v2/data?id={dialog id} — then run cb_select. cb_selectall moves every available area at once.
  6. cb_ok closes the dialog. Nothing is written yet.
  7. Save the Item window (PUT /v2/data, body = the bare window id). Only the save writes the document_link row and its document_link_area rows. A business rule can answer the save with a prompt (Status 3) — see Gotchas.
  8. Read back document_link and document_link_area over OData.
POST {ui_server}/api/ui/interactive/v2/tools
{ "WindowId": "<item window id>", "ToolName": "m_addlink", "DatawindowName": "document_link" }

PUT {ui_server}/api/ui/interactive/v2/change
{ "WindowId": "<dialog id>", "List": [
  { "TabName": null, "DatawindowName": "_dw_link", "FieldName": "link_path",
    "Value": "\\\\fileserver\\share\\P21\\Files\\ItemMaint\\WIDGET-001 drawing.pdf" } ] }

POST {ui_server}/ui/full/v2/data/select
{ "WindowId": "<dialog id>", "DatawindowName": "source", "TabPageName": null,
  "RowsToSelect": [3], "ActiveRowSpecification": "*" }

POST {ui_server}/api/ui/interactive/v2/tools
{ "WindowId": "<dialog id>", "ToolName": "cb_select" }

RowsToSelect holds the area's 1-based position in the current source list (3 here is an example). The answer is HTTP 200 with Result.SelectedRows and a buttonenabled event for cb_select.

Complete example

Attach a local file to an item — an assembly or kit works the same way. AREAS / Areas names the display areas to add to the dialog's defaults, spelled as the Available Areas list spells them; leave it empty for the defaults, or set "ALL" / { "ALL" } for every area. A second run finds the existing link and adds nothing.

"""Attach a file to an item's Document Links tab, the way the P21 client does it.

An assembly or kit is an item: its Assembly Maintenance Document Links tab and
its pick tickets use the same link, through the link's display areas.

1. Upload the file into Item Maintenance's document upload folder (file handler).
2. Add the link in the Item window's "Add Item Maintenance Link" dialog (Interactive),
   on the dialog's default display areas plus the ones named in AREAS, or on every
   area when AREAS is "ALL".
3. Save the Item window -- the dialog's OK alone writes nothing.
4. Read the link and its display areas back over OData.
"""
import re
import sys
from pathlib import Path

import httpx

# ---- EDIT THESE -----------------------------------------------------------
BASE_URL = "https://play.example.com"   # your P21 server
USERNAME = "apiuser"
PASSWORD = "your-password"
VERIFY_SSL = False                        # True once you trust the cert chain
ITEM_ID = "WIDGET-001"                    # the item to attach to
FILE_PATH = "WIDGET-001 drawing.pdf"      # the local file to attach
OUTSIDE_USE = "Y"                         # Outside Use: required to print with a form; "N" = internal only
AREAS = ["Assembly Maintenance", "Sales Order Pick Ticket"]  # extra display areas, by name;
                                          # [] = the dialog's defaults, "ALL" = every area
SAVE_PROMPT_ANSWER = ""                   # e.g. "cb_1" -- the button for a prompt the save raises
# ---------------------------------------------------------------------------

ITEM_MAINTENANCE_AREA = 1314              # document_link.source_area_cd for an item
UPLOAD_FOLDER_SETTING = "item_maintenance_doc_link_upload_folder_path"


def get_token(client: httpx.Client) -> str:
    """v2 token endpoint -- credentials go in the body, never in headers."""
    r = client.post(
        f"{BASE_URL}/api/security/token/v2",
        json={"username": USERNAME, "password": PASSWORD},
        headers={"Accept": "application/json"},
    )
    r.raise_for_status()
    try:
        return r.json()["AccessToken"]
    except (ValueError, KeyError):  # some middleware answers in XML
        match = re.search(r"<AccessToken>([^<]+)</AccessToken>", r.text)
        if not match:
            raise ValueError(f"No AccessToken in response: {r.text[:200]}") from None
        return match.group(1)


def get_ui_server(client: httpx.Client, token: str) -> str:
    """Transaction and Interactive calls go to the UI server, not BASE_URL."""
    r = client.get(
        f"{BASE_URL}/api/ui/router/v1/?urlType=external",  # trailing slash avoids a 307
        headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
    )
    r.raise_for_status()
    try:
        return r.json()["Url"].rstrip("/")
    except (ValueError, KeyError):
        match = re.search(r"<Url>([^<]+)</Url>", r.text)
        if not match:
            raise ValueError(f"No Url in router response: {r.text[:200]}") from None
        return match.group(1).rstrip("/")


def odata(client: httpx.Client, headers: dict, table: str, select: str, where: str) -> list[dict]:
    """One OData table read."""
    r = client.get(
        f"{BASE_URL}/odataservice/odata/table/{table}",
        params={"$select": select, "$filter": where},
        headers=headers,
    )
    r.raise_for_status()
    return r.json()["value"]


def opened_window(answer: dict) -> str:
    """The window id a tool opened (its windowopened event), or ""."""
    for event in answer.get("Events") or []:
        if event.get("Name") == "windowopened":
            for pair in event.get("Data") or []:
                if pair.get("Key") == "windowid":
                    return pair.get("Value") or ""
    return ""


def checked(r: httpx.Response, what: str) -> dict:
    """The answer, refusing HTTP errors and P21's Status 2 (failure)."""
    if r.status_code >= 400:
        raise RuntimeError(f"{what}: HTTP {r.status_code} {r.text[:300]}")
    body = r.json() if r.content else {}
    if isinstance(body, dict) and body.get("Status") == 2:
        raise RuntimeError(f"{what}: P21 refused it: {body.get('Messages')}")
    return body if isinstance(body, dict) else {}


source = Path(FILE_PATH)
content = source.read_bytes()

with httpx.Client(verify=VERIFY_SSL, timeout=120, follow_redirects=True) as client:
    token = get_token(client)
    ui_server = get_ui_server(client, token)
    auth = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
    headers = {**auth, "Content-Type": "application/json"}

    # The folder P21's own Add Link dialog uploads into, and the item's uid.
    settings = odata(client, auth, "system_setting", "value", f"name eq '{UPLOAD_FOLDER_SETTING}'")
    if not settings or not settings[0]["value"]:
        sys.exit(f"System setting {UPLOAD_FOLDER_SETTING} is not set")
    folder = settings[0]["value"].rstrip("\\")
    quoted_id = ITEM_ID.replace("'", "''")  # OData doubles a quote inside a string literal
    items = odata(client, auth, "inv_mast", "inv_mast_uid", f"item_id eq '{quoted_id}'")
    if not items:
        sys.exit(f"{ITEM_ID} is not in P21")
    uid = str(int(items[0]["inv_mast_uid"]))
    link_path = f"{folder}\\{source.name}"

    # A second run must not add a second link.
    links_on_item = (
        f"source_area_cd eq {ITEM_MAINTENANCE_AREA} and key1_cd eq 'inv_mast_uid' "
        f"and key1_value eq '{uid}' and row_status_flag eq 704"
    )
    existing = odata(client, auth, "document_link", "document_link_uid,link_path", links_on_item)
    if any(row["link_path"].lower() == link_path.lower() for row in existing):
        sys.exit(f"{ITEM_ID} already links {link_path}")

    # 1. Upload. The body is the raw file; path names the upload folder.
    r = client.post(
        f"{BASE_URL}/api/filehandler/file/path",
        params={"fileName": source.name, "path": folder},
        headers=auth,
        content=content,
    )
    r.raise_for_status()
    uploaded = r.json()
    if uploaded.get("SizeInBytes") != len(content):
        sys.exit(f"Upload landed {uploaded.get('SizeInBytes')} bytes of {len(content)}")
    print(f"Uploaded {uploaded['FullPath']}")

    # 2. The Item window, the item, its Document Links tab, Add Link.
    session = checked(client.post(f"{ui_server}/api/ui/interactive/sessions/",
                                  json={"ResponseWindowHandlingEnabled": True},
                                  headers=headers), "session")
    session_id = session.get("Id") or session.get("SessionId")
    try:
        window = checked(client.post(f"{ui_server}/api/ui/interactive/v2/window",
                                     json={"SessionId": session_id, "ServiceName": "Item"},
                                     headers=headers), "open Item")
        window_id = window["WindowId"]

        def tab(name: str) -> None:
            checked(client.put(f"{ui_server}/api/ui/interactive/v2/tab",
                               json={"SessionId": session_id, "WindowId": window_id,
                                     "TabName": name, "PageName": name},
                               headers=headers), f"tab {name}")

        def change(target: str, field: str, value: str, *, tab_name=None, datawindow=None):
            item = {"TabName": tab_name, "FieldName": field, "Value": value}
            if datawindow:
                item["DatawindowName"] = datawindow
            return checked(client.put(f"{ui_server}/api/ui/interactive/v2/change",
                                      json={"SessionId": session_id, "WindowId": target,
                                            "List": [item]},
                                      headers=headers), f"change {field}")

        tab("TABPAGE_1")
        change(window_id, "item_id", ITEM_ID, tab_name="TABPAGE_1")
        tab("DOCUMENT_LINK")
        answer = checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
                                     json={"SessionId": session_id, "WindowId": window_id,
                                           "ToolName": "m_addlink",
                                           "DatawindowName": "document_link"},
                                     headers=headers), "Add Link")
        dialog = opened_window(answer)
        if not dialog:
            raise RuntimeError(f"Add Link opened no dialog: {answer}")

        # The dialog's own datawindow is _dw_link; it has no tabs.
        for field, value in (("link_path", link_path), ("link_name", source.name),
                             ("outside_use_flag", OUTSIDE_USE), ("mandatory_flag", "N")):
            change(dialog, field, value, datawindow="_dw_link")

        def area_names(datawindow: str) -> list[str]:
            """The dialog's Available ("source") or Selected ("target") areas, in order."""
            r = client.get(f"{ui_server}/api/ui/interactive/v2/data",
                           params={"id": dialog}, headers=headers)
            r.raise_for_status()
            for dw in r.json():
                if dw.get("Name") == datawindow:
                    column = dw["Columns"].index("code_description")
                    return [row[column] for row in dw.get("Data") or []]
            return []

        if AREAS == "ALL":
            # Select All moves every Available Area to Selected Areas.
            checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
                                json={"SessionId": session_id, "WindowId": dialog,
                                      "ToolName": "cb_selectall"},
                                headers=headers), "Select All")
        else:
            for area in AREAS:
                if area in area_names("target"):
                    continue  # one of the dialog's defaults
                available = area_names("source")  # re-read: the list shrinks as areas move
                if area not in available:
                    raise RuntimeError(f"'{area}' is not an Available Area: {available}")
                # Selecting the row is a web-client call: ui/full, no /api/ prefix,
                # 1-based row. It is what enables the dialog's Select (cb_select) button.
                checked(client.post(f"{ui_server}/ui/full/v2/data/select",
                                    json={"WindowId": dialog, "DatawindowName": "source",
                                          "TabPageName": None,
                                          "RowsToSelect": [available.index(area) + 1],
                                          "ActiveRowSpecification": "*"},
                                    headers=headers), f"select {area}")
                checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
                                    json={"SessionId": session_id, "WindowId": dialog,
                                          "ToolName": "cb_select"},
                                    headers=headers), f"Select {area}")
        checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
                            json={"SessionId": session_id, "WindowId": dialog,
                                  "ToolName": "cb_ok"},
                            headers=headers), "OK")

        # 3. Save the Item window: the body is the bare window id.
        saved = checked(client.put(f"{ui_server}/api/ui/interactive/v2/data",
                                   json=window_id, headers=headers), "save")
        # A business rule on the Item window can stop the save with a prompt
        # (Status 3). Name it and its buttons, and answer only when told to.
        while saved.get("Status") == 3 and opened_window(saved):
            prompt = opened_window(saved)
            title = checked(client.get(f"{ui_server}/api/ui/interactive/v2/window",
                                       params={"id": prompt}, headers=headers),
                            "prompt")["Definition"]["Title"]
            buttons = {t["ToolName"]: t.get("Text") for t in client.get(
                f"{ui_server}/api/ui/interactive/v2/tools", params={"windowId": prompt},
                headers=headers).json() if t["ToolName"].startswith("cb_") and t.get("Text")}
            if SAVE_PROMPT_ANSWER not in buttons:
                raise RuntimeError(f"The save opened '{title}' with buttons {buttons}; "
                                   "set SAVE_PROMPT_ANSWER to one of them")
            print(f"Answered '{title}' with {buttons[SAVE_PROMPT_ANSWER]}")
            saved = checked(client.post(f"{ui_server}/api/ui/interactive/v2/tools",
                                        json={"SessionId": session_id, "WindowId": prompt,
                                              "ToolName": SAVE_PROMPT_ANSWER},
                                        headers=headers), "answer")
        if saved.get("Status") != 1:
            raise RuntimeError(f"The save did not finish (Status {saved.get('Status')}): "
                               f"{saved.get('Messages')}")
        client.delete(f"{ui_server}/api/ui/interactive/v2/window",
                      params={"id": window_id}, headers=headers)
    finally:
        client.delete(f"{ui_server}/api/ui/interactive/sessions/", headers=headers)

    # 4. Read it back: the link, and where P21 shows it.
    link = next(row for row in odata(client, auth, "document_link",
                                     "document_link_uid,link_name,link_path,outside_use_flag",
                                     links_on_item)
                if row["link_path"].lower() == link_path.lower())
    areas = odata(client, auth, "document_link_area", "display_area_cd",
                  f"document_link_uid eq {int(link['document_link_uid'])} and row_status_flag eq 704")
    print(f"Linked {link['link_name']} to {ITEM_ID} as document_link_uid "
          f"{int(link['document_link_uid'])}, outside use {link['outside_use_flag']}, "
          f"shown in areas {sorted(int(a['display_area_cd']) for a in areas)}")
// Attach a file to an item's Document Links tab, the way the P21 client does it.
// An assembly or kit is an item: its Assembly Maintenance Document Links tab and
// its pick tickets use the same link, through the link's display areas.
// 1. Upload the file into Item Maintenance's document upload folder (file handler).
// 2. Add the link in the Item window's "Add Item Maintenance Link" dialog (Interactive),
//    on the dialog's default display areas plus the ones named in Areas, or on every
//    area when Areas is { "ALL" }.
// 3. Save the Item window -- the dialog's OK alone writes nothing.
// 4. Read the link and its display areas back over OData.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

// ---- EDIT THESE -----------------------------------------------------------
const string BaseUrl = "https://play.example.com";   // your P21 server
const string Username = "apiuser";
const string Password = "your-password";
const string ItemId = "WIDGET-001";                   // the item to attach to
const string FilePath = "WIDGET-001 drawing.pdf";     // the local file to attach
const string OutsideUse = "Y";                        // Outside Use: required to print with a form; "N" = internal only
string[] Areas = { "Assembly Maintenance", "Sales Order Pick Ticket" };  // extra display areas, by name;
                                                      // { } = the dialog's defaults, { "ALL" } = every area
const string SavePromptAnswer = "";                   // e.g. "cb_1" -- the button for a prompt the save raises
// ---------------------------------------------------------------------------

const int ItemMaintenanceArea = 1314;                 // document_link.source_area_cd for an item
const string UploadFolderSetting = "item_maintenance_doc_link_upload_folder_path";

var handler = new HttpClientHandler
{
    // Test tenants often present a self-signed cert. Delete this line in production.
    ServerCertificateCustomValidationCallback =
        HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
};
using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

var token = await GetTokenAsync(client);
var uiServer = await GetUiServerAsync(client, token);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var fileName = Path.GetFileName(FilePath);
var content = await File.ReadAllBytesAsync(FilePath);

// The folder P21's own Add Link dialog uploads into, and the item's uid.
var settings = await ODataAsync("system_setting", "value", $"name eq '{UploadFolderSetting}'");
var folder = settings.Count == 0 ? null : settings[0].GetProperty("value").GetString();
if (string.IsNullOrEmpty(folder)) { Console.WriteLine($"System setting {UploadFolderSetting} is not set"); return; }
folder = folder.TrimEnd('\\');
var quotedId = ItemId.Replace("'", "''");  // OData doubles a quote inside a string literal
var items = await ODataAsync("inv_mast", "inv_mast_uid", $"item_id eq '{quotedId}'");
if (items.Count == 0) { Console.WriteLine($"{ItemId} is not in P21"); return; }
var uid = items[0].GetProperty("inv_mast_uid").GetDecimal().ToString("0");
var linkPath = $"{folder}\\{fileName}";

// A second run must not add a second link.
var linksOnItem = $"source_area_cd eq {ItemMaintenanceArea} and key1_cd eq 'inv_mast_uid' " +
                  $"and key1_value eq '{uid}' and row_status_flag eq 704";
var existing = await ODataAsync("document_link", "document_link_uid,link_path", linksOnItem);
if (existing.Any(r => string.Equals(r.GetProperty("link_path").GetString(), linkPath,
        StringComparison.OrdinalIgnoreCase)))
{
    Console.WriteLine($"{ItemId} already links {linkPath}");
    return;
}

// 1. Upload. The body is the raw file; path names the upload folder.
var uploadUrl = $"{BaseUrl}/api/filehandler/file/path?fileName={Uri.EscapeDataString(fileName)}" +
                $"&path={Uri.EscapeDataString(folder)}";
var upload = await client.PostAsync(uploadUrl, new ByteArrayContent(content));
upload.EnsureSuccessStatusCode();
using (var uploaded = JsonDocument.Parse(await upload.Content.ReadAsStringAsync()))
{
    var size = uploaded.RootElement.GetProperty("SizeInBytes").GetInt64();
    if (size != content.Length)
        throw new InvalidOperationException($"Upload landed {size} bytes of {content.Length}");
    Console.WriteLine($"Uploaded {uploaded.RootElement.GetProperty("FullPath").GetString()}");
}

// 2. The Item window, the item, its Document Links tab, Add Link.
var session = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/sessions/",
    new { ResponseWindowHandlingEnabled = true }, "session");
var sessionId = session.TryGetProperty("Id", out var id) ? id.GetString()
              : session.GetProperty("SessionId").GetString();
try
{
    var window = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/window",
        new { SessionId = sessionId, ServiceName = "Item" }, "open Item");
    var windowId = window.GetProperty("WindowId").GetString()!;

    await TabAsync("TABPAGE_1");
    await ChangeAsync(windowId, "item_id", ItemId, tabName: "TABPAGE_1");
    await TabAsync("DOCUMENT_LINK");
    var answer = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
        new { SessionId = sessionId, WindowId = windowId, ToolName = "m_addlink",
              DatawindowName = "document_link" }, "Add Link");
    var dialog = OpenedWindow(answer)
        ?? throw new InvalidOperationException($"Add Link opened no dialog: {answer}");

    // The dialog's own datawindow is _dw_link; it has no tabs.
    foreach (var (field, value) in new[] {
                 ("link_path", linkPath), ("link_name", fileName),
                 ("outside_use_flag", OutsideUse), ("mandatory_flag", "N") })
        await ChangeAsync(dialog, field, value, datawindow: "_dw_link");
    if (Areas.SequenceEqual(new[] { "ALL" }))
        // Select All moves every Available Area to Selected Areas.
        await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
            new { SessionId = sessionId, WindowId = dialog, ToolName = "cb_selectall" }, "Select All");
    else
        foreach (var area in Areas)
        {
            if ((await AreaNamesAsync(dialog, "target")).Contains(area))
                continue;  // one of the dialog's defaults
            var available = await AreaNamesAsync(dialog, "source");  // re-read: the list shrinks
            if (!available.Contains(area))
                throw new InvalidOperationException(
                    $"'{area}' is not an Available Area: {string.Join(", ", available)}");
            // Selecting the row is a web-client call: ui/full, no /api/ prefix, 1-based
            // row. It is what enables the dialog's Select (cb_select) button.
            await SendAsync(HttpMethod.Post, $"{uiServer}/ui/full/v2/data/select",
                new { WindowId = dialog, DatawindowName = "source", TabPageName = (string?)null,
                      RowsToSelect = new[] { available.IndexOf(area) + 1 },
                      ActiveRowSpecification = "*" }, $"select {area}");
            await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
                new { SessionId = sessionId, WindowId = dialog, ToolName = "cb_select" },
                $"Select {area}");
        }
    await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
        new { SessionId = sessionId, WindowId = dialog, ToolName = "cb_ok" }, "OK");

    // 3. Save the Item window: the body is the bare window id.
    var saved = await SendAsync(HttpMethod.Put, $"{uiServer}/api/ui/interactive/v2/data",
        windowId, "save");
    // A business rule on the Item window can stop the save with a prompt
    // (Status 3). Name it and its buttons, and answer only when told to.
    while (saved.GetProperty("Status").GetInt32() == 3 && OpenedWindow(saved) is string prompt)
    {
        using var promptWindow = JsonDocument.Parse(await client.GetStringAsync(
            $"{uiServer}/api/ui/interactive/v2/window?id={prompt}"));
        var title = promptWindow.RootElement.GetProperty("Definition").GetProperty("Title").GetString();
        using var tools = JsonDocument.Parse(await client.GetStringAsync(
            $"{uiServer}/api/ui/interactive/v2/tools?windowId={prompt}"));
        var buttons = tools.RootElement.EnumerateArray()
            .Where(t => t.GetProperty("ToolName").GetString()!.StartsWith("cb_") &&
                        !string.IsNullOrEmpty(t.GetProperty("Text").GetString()))
            .ToDictionary(t => t.GetProperty("ToolName").GetString()!,
                          t => t.GetProperty("Text").GetString()!);
        if (!buttons.ContainsKey(SavePromptAnswer))
            throw new InvalidOperationException($"The save opened '{title}' with buttons " +
                string.Join(", ", buttons.Select(b => $"{b.Key} = {b.Value}")) +
                "; set SavePromptAnswer to one of them");
        Console.WriteLine($"Answered '{title}' with {buttons[SavePromptAnswer]}");
        saved = await SendAsync(HttpMethod.Post, $"{uiServer}/api/ui/interactive/v2/tools",
            new { SessionId = sessionId, WindowId = prompt, ToolName = SavePromptAnswer }, "answer");
    }
    if (saved.GetProperty("Status").GetInt32() != 1)
        throw new InvalidOperationException($"The save did not finish: {saved}");
    await client.DeleteAsync($"{uiServer}/api/ui/interactive/v2/window?id={windowId}");

    async Task TabAsync(string name) =>
        await SendAsync(HttpMethod.Put, $"{uiServer}/api/ui/interactive/v2/tab",
            new { SessionId = sessionId, WindowId = windowId, TabName = name, PageName = name },
            $"tab {name}");
}
finally
{
    await client.DeleteAsync($"{uiServer}/api/ui/interactive/sessions/");
}

// 4. Read it back: the link, and where P21 shows it.
var link = (await ODataAsync("document_link",
        "document_link_uid,link_name,link_path,outside_use_flag", linksOnItem))
    .First(r => string.Equals(r.GetProperty("link_path").GetString(), linkPath,
        StringComparison.OrdinalIgnoreCase));
var linkUid = link.GetProperty("document_link_uid").GetDecimal().ToString("0");
var areas = (await ODataAsync("document_link_area", "display_area_cd",
        $"document_link_uid eq {linkUid} and row_status_flag eq 704"))
    .Select(a => (int)a.GetProperty("display_area_cd").GetDecimal()).Order();
Console.WriteLine($"Linked {link.GetProperty("link_name").GetString()} to {ItemId} as " +
                  $"document_link_uid {linkUid}, outside use " +
                  $"{link.GetProperty("outside_use_flag").GetString()}, shown in areas " +
                  $"[{string.Join(", ", areas)}]");

// --- helpers ---------------------------------------------------------------

// One OData table read.
async Task<List<JsonElement>> ODataAsync(string table, string select, string where)
{
    var url = $"{BaseUrl}/odataservice/odata/table/{table}?$select={select}" +
              $"&$filter={Uri.EscapeDataString(where)}";
    var response = await client.GetAsync(url);
    response.EnsureSuccessStatusCode();
    using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    return doc.RootElement.GetProperty("value").EnumerateArray().Select(e => e.Clone()).ToList();
}

// The dialog's Available ("source") or Selected ("target") areas, in order.
async Task<List<string>> AreaNamesAsync(string window, string datawindow)
{
    using var doc = JsonDocument.Parse(await client.GetStringAsync(
        $"{uiServer}/api/ui/interactive/v2/data?id={window}"));
    foreach (var dw in doc.RootElement.EnumerateArray())
    {
        if (dw.GetProperty("Name").GetString() != datawindow) continue;
        var column = dw.GetProperty("Columns").EnumerateArray()
            .Select(c => c.GetString()).ToList().IndexOf("code_description");
        return dw.TryGetProperty("Data", out var data) && data.ValueKind == JsonValueKind.Array
            ? data.EnumerateArray().Select(row => row[column].GetString()!).ToList()
            : new List<string>();
    }
    return new List<string>();
}

// One Interactive call; refuses HTTP errors and P21's Status 2 (failure).
async Task<JsonElement> SendAsync(HttpMethod method, string url, object body, string what)
{
    using var request = new HttpRequestMessage(method, url)
    {
        Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8,
            "application/json"),
    };
    var response = await client.SendAsync(request);
    var text = await response.Content.ReadAsStringAsync();
    if (!response.IsSuccessStatusCode)
        throw new InvalidOperationException($"{what}: HTTP {(int)response.StatusCode} {text}");
    var answer = text.Length == 0 ? default : JsonDocument.Parse(text).RootElement.Clone();
    if (answer.ValueKind == JsonValueKind.Object &&
        answer.TryGetProperty("Status", out var status) && status.GetInt32() == 2)
        throw new InvalidOperationException($"{what}: P21 refused it: {text}");
    return answer;
}

// One field change, on a tab of the window or on a tabless dialog's datawindow.
async Task ChangeAsync(string target, string field, string value,
    string? tabName = null, string? datawindow = null)
{
    var item = new Dictionary<string, string?> { ["TabName"] = tabName, ["FieldName"] = field,
                                                 ["Value"] = value };
    if (datawindow is not null) item["DatawindowName"] = datawindow;
    await SendAsync(HttpMethod.Put, $"{uiServer}/api/ui/interactive/v2/change",
        new { SessionId = sessionId, WindowId = target, List = new[] { item } },
        $"change {field}");
}

// The window id a tool opened (its windowopened event), or null.
static string? OpenedWindow(JsonElement answer)
{
    if (!answer.TryGetProperty("Events", out var events)) return null;
    foreach (var e in events.EnumerateArray())
        if (e.GetProperty("Name").GetString() == "windowopened")
            foreach (var pair in e.GetProperty("Data").EnumerateArray())
                if (pair.GetProperty("Key").GetString() == "windowid")
                    return pair.GetProperty("Value").GetString();
    return null;
}

// v2 token endpoint — credentials go in the body, never in headers.
static async Task<string> GetTokenAsync(HttpClient client)
{
    var payload = JsonSerializer.Serialize(new { username = Username, password = Password });
    var response = await client.PostAsync(
        $"{BaseUrl}/api/security/token/v2",
        new StringContent(payload, Encoding.UTF8, "application/json"));
    response.EnsureSuccessStatusCode();
    return ReadField(await response.Content.ReadAsStringAsync(), "AccessToken");
}

// Transaction and Interactive calls go to the UI server, not BaseUrl.
static async Task<string> GetUiServerAsync(HttpClient client, string token)
{
    using var request = new HttpRequestMessage(
        HttpMethod.Get, $"{BaseUrl}/api/ui/router/v1/?urlType=external");
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    var response = await client.SendAsync(request);
    response.EnsureSuccessStatusCode();
    return ReadField(await response.Content.ReadAsStringAsync(), "Url").TrimEnd('/');
}

// Some middleware answers these two endpoints in XML even when asked for JSON.
static string ReadField(string payload, string field)
{
    try
    {
        var value = JsonDocument.Parse(payload).RootElement.GetProperty(field).GetString();
        if (!string.IsNullOrEmpty(value)) return value;
    }
    catch (Exception ex) when (ex is JsonException or KeyNotFoundException) { }

    var match = System.Text.RegularExpressions.Regex.Match(payload, $"<{field}>([^<]+)</{field}>");
    if (!match.Success)
        throw new InvalidOperationException(
            $"No {field} in response: {payload[..Math.Min(200, payload.Length)]}");
    return match.Groups[1].Value;
}

Printing the document with a form

P21 never merges a linked document into a form's PDF. When a form prints, P21 renders the form by itself and names each linked file beside it. The client then prints each file as its own job, on the same printer.

What has to be true

# Condition Where it is set
1 The link is on an item that appears on the form, and it has the form's display area: 3453 sales-order pick ticket, 999 packing list, 2750 production order form The link's areas (this recipe)
2 The link is Outside Use (document_link.outside_use_flag = Y) The link (OUTSIDE_USE / OutsideUse in the example)
3 The entity the form prints for sends outside-use documents to print. That is the location for the sales-order pick ticket and the production order form, and the ship-to for the packing list document_link_entity_req: send_outside_use_docs_flag = Y and send_outside_use_print_flag = Y (Location Maintenance or Ship To: Send Outside Use Documents and its Print box)
4 Transmit Document Links (process_doclinks) is on when the form prints The print window. It defaults off in Pick Tickets, Reprint Pick Tickets, Production Order Report and Packing Lists. The Order service's print_tix sent the links without it

A link that misses any one of these prints nothing. Neither the client nor the API reports an error, and the form prints alone.

In the test the location was the sales, source and pick location at once, so which of the three counts was not separated.

Turn the location requirement on with the Transaction API Location service:

{ "Name": "Location", "UseCodeValues": false, "IgnoreDisabled": true,
  "Transactions": [{ "Status": "New", "DataElements": [{
    "Name": "TABPAGE_1.tp_1_dw_1", "Type": "Form", "Keys": ["id", "company_id"],
    "Rows": [{ "Edits": [
      { "Name": "id", "Value": "10" }, { "Name": "company_id", "Value": "ACME" },
      { "Name": "send_outside_use_docs_flag", "Value": "Yes" },
      { "Name": "send_outside_use_print_flag", "Value": "ON" },
      { "Name": "outside_use_number_of_copies", "Value": "1" } ] }] }] }] }

send_outside_use_docs_flag takes the label Yes or No; ON is refused with "Invalid send_outside_use_docs_flag value: ON". Read the row back from document_link_entity_req (entity_type 1370 = location, entity_id = the location ID). The ShipTo service sets the same fields on TABPAGE_7.tp_7_dw_7 (entity_type 1328).

Assemblies and kits

What the client and the API return

Web client (ui/full, the Print, Print Immediately or Print Preview tool). The tool answers with a formdatastreamready event for the form and one filerequested event per linked file:

{ "Name": "filerequested", "EventData": {
    "print": "\\\\fileserver\\share\\P21\\Files\\ItemMaint\\DRAWING.pdf", "link_name": "DRAWING.pdf" } }

The client renders the form with POST /api/document/generate/form, asks "Do you want to print the associated document links?", downloads each file with GET /api/filehandler/file/path?fileName=…&path=…, and sends it to the current printer. formdatastreamready.EventData.documentLinks repeats the list as ticket|name|path|….

Report API and Transaction print flags. POST /api/v2/process/pdfreport (m_reprintpicktickets with process_doclinks ON) and the Order service's print_tix return the form PDF alone. Each document carries the links in DocumentAssociations:

"DocumentAssociations": [
  { "AssociationType": 0, "DocumentId": "2840476", "Name": "DRAWING.pdf",
    "Uri": "\\\\fileserver\\share\\P21\\Files\\ItemMaint\\DRAWING.pdf" } ]

DocumentId is the pick-ticket number. To print what the client prints, fetch each Uri through the file handler and send it after the form. With process_doclinks OFF on pdfreport, or with the location requirement off, the list is [].

Gotchas (verified on play 26.1.5950.0, 2026-10-17)

Verify

SELECT dl.document_link_uid, dl.link_name, dl.link_path, dl.outside_use_flag, a.display_area_cd
FROM document_link dl
JOIN document_link_area a ON a.document_link_uid = dl.document_link_uid AND a.row_status_flag = 704
WHERE dl.source_area_cd = 1314 AND dl.key1_cd = 'inv_mast_uid'
  AND dl.key1_value = '<inv_mast_uid>' AND dl.row_status_flag = 704
ORDER BY dl.document_link_uid, a.display_area_cd

Over OData the example reads the same two tables (document_link, then document_link_area by document_link_uid). Expected after a run with AREAS = ["Assembly Maintenance", "Sales Order Pick Ticket"]: one link and four area rows — 1302, 1314, 1445, 3453. With the defaults, two rows (1314, 1445); with "ALL", 26 rows. Then open the assembly in Assembly Maintenance: the link is on the header Document Links tab only when 1302 is among its areas.

Credit: @mrwuss — Item-window flow and file-handler upload found and verified on play 26.1 (#200); display areas (single-area selection through ui/full/v2/data/select included), assemblies and kits, and the pick-ticket check verified on play 26.1.5950.0, 2026-10-17. Printing with a form (the location and Outside Use conditions, DocumentAssociations, and the client's filerequested jobs) verified on play 26.1.5950.0, 2026-10-21, with a kit, a production-order assembly and two components through ship, invoice and the production order form (#222).