On this page

Modify an Existing Sales Order

Change a line's quantity in place and add a new line to an existing sales order — one stateless Transaction API POST, no session, no window.

API: Transaction · Service: Order · Deep dive: Keys — Row Identity and the Collapse Trap · Design for updates: line handles · Full schema: Order.json

Everything here was verified live on 26.1 (August 2026) with /transaction/get read-backs: the in-place update, the insert, both together in one POST, and every gotcha below.

Prerequisites

How it works

A keyed Status: "New" row is an upsert: when the key matches an existing row it updates that row, when it doesn't it inserts a new one. So one TP_ITEMS.items element keyed on user_line_no does both jobs — send an existing handle to edit that line, a new handle to add a line.

The header element does the loading: Keys: ["order_no"] with order_no as its only edit.

Payload

Updates line 010 to quantity 4 and adds a new line 030 — in one transaction:

POST {ui_server}/api/v2/transaction

{
    "Name": "Order",
    "UseCodeValues": false,
    "Transactions": [{
        "Status": "New",
        "DataElements": [
            {
                "Name": "TABPAGE_1.order",
                "Type": "Form",
                "Keys": ["order_no"],
                "Rows": [{
                    "Edits": [
                        {"Name": "order_no", "Value": "1013938"}
                    ],
                    "RelativeDateEdits": []
                }]
            },
            {
                "Name": "TP_ITEMS.items",
                "Type": "List",
                "Keys": ["user_line_no"],
                "Rows": [
                    {
                        "Edits": [
                            {"Name": "user_line_no",     "Value": "010"},
                            {"Name": "oe_order_item_id", "Value": "WIDGET-001"},
                            {"Name": "unit_quantity",    "Value": "4"}
                        ],
                        "RelativeDateEdits": []
                    },
                    {
                        "Edits": [
                            {"Name": "user_line_no",     "Value": "030"},
                            {"Name": "oe_order_item_id", "Value": "WIDGET-002"},
                            {"Name": "unit_quantity",    "Value": "1"}
                        ],
                        "RelativeDateEdits": []
                    }
                ]
            }
        ]
    }]
}

Payload files (validator-verified): JSON · XML

Finding the handles

When you don't know the order's user_line_no values, read them back first — POST /api/v2/transaction/get returns every line with its handle:

POST {ui_server}/api/v2/transaction/get

{
    "ServiceName": "Order",
    "TransactionStates": [{
        "DataElementName": "TABPAGE_1.order",
        "Keys": [{"Name": "order_no", "Value": "1013938"}]
    }]
}

Walk the response's TP_ITEMS.items rows and map user_line_no → (oe_order_item_id, unit_quantity). P21 auto-assigns 001, 002, … when the creator didn't.

Complete example

"""Modify an existing sales order: update a line in place + add a line.

Dry run by default; pass --execute to POST. Mirrors docs/recipes/update-order-lines.md.
"""

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

import httpx
from common.auth import get_token, get_auth_headers, get_ui_server_url
from common.config import load_config

import warnings
warnings.filterwarnings("ignore")

# --- Configuration (generic placeholders -- substitute your own) ------------
ORDER_NO = "1013938"

# (user_line_no, item_id, quantity)
# An EXISTING handle updates that line; a NEW handle inserts a line.
LINES = [
    ("010", "WIDGET-001", "4"),   # existing handle -> quantity updated in place
    ("030", "WIDGET-002", "1"),   # new handle      -> line inserted
]


def build_payload() -> dict:
    """Header loads the order by key; the keyed items list upserts each row."""
    return {
        "Name": "Order",
        "UseCodeValues": False,
        "Transactions": [{
            "Status": "New",
            "DataElements": [
                {
                    "Name": "TABPAGE_1.order",
                    "Type": "Form",
                    "Keys": ["order_no"],
                    # ONLY the key. Re-sending header fields fails on
                    # disabled columns (customer_id) once the order exists.
                    "Rows": [{
                        "Edits": [{"Name": "order_no", "Value": ORDER_NO}],
                        "RelativeDateEdits": [],
                    }],
                },
                {
                    "Name": "TP_ITEMS.items",
                    "Type": "List",
                    "Keys": ["user_line_no"],
                    "Rows": [
                        {"Edits": [
                            {"Name": "user_line_no",     "Value": handle},
                            {"Name": "oe_order_item_id", "Value": item_id},
                            {"Name": "unit_quantity",    "Value": qty},
                        ], "RelativeDateEdits": []}
                        for handle, item_id, qty in LINES
                    ],
                },
            ],
        }],
    }


def read_lines(ui_server: str, headers: dict, verify_ssl: bool) -> list[dict]:
    """Read the order's lines back -- the only proof the write landed."""
    resp = httpx.post(
        f"{ui_server}/api/v2/transaction/get",
        headers=headers, verify=verify_ssl, follow_redirects=True, timeout=120,
        json={"ServiceName": "Order", "TransactionStates": [{
            "DataElementName": "TABPAGE_1.order",
            "Keys": [{"Name": "order_no", "Value": ORDER_NO}],
        }]},
    )
    resp.raise_for_status()
    for element in resp.json()["Transactions"][0]["DataElements"]:
        if element["Name"] != "TP_ITEMS.items":
            continue
        rows = [{edit["Name"]: edit["Value"] for edit in row.get("Edits", [])}
                for row in element.get("Rows", [])]
        return [row for row in rows if row.get("oe_order_item_id")]
    return []


def main() -> None:
    """Entry point: build the payload, POST on --execute, read the lines back."""
    parser = argparse.ArgumentParser(
        description="Modify an existing sales order (docs/recipes/update-order-lines.md)")
    parser.add_argument("--execute", action="store_true",
                        help="Actually POST the transaction (default: dry run)")
    args = parser.parse_args()

    print("Recipe - Modify an Existing Sales Order")
    print("=" * 60)

    payload = build_payload()
    if not args.execute:
        print("\nDRY RUN - would POST to {ui_server}/api/v2/transaction:")
        print(json.dumps(payload, indent=2))
        print("\nRe-run with --execute to POST the transaction.")
        return

    config = load_config()
    token_data = get_token(config)
    headers = get_auth_headers(token_data["AccessToken"])
    ui_server = get_ui_server_url(config.base_url, token_data["AccessToken"],
                                  config.verify_ssl)
    print(f"UI Server: {ui_server}")

    print("\nBefore:")
    for row in read_lines(ui_server, headers, config.verify_ssl):
        print(f"  {row.get('user_line_no')}: {row.get('oe_order_item_id')}"
              f" x {row.get('unit_quantity')}")

    resp = httpx.post(f"{ui_server}/api/v2/transaction", headers=headers,
                      json=payload, verify=config.verify_ssl,
                      follow_redirects=True, timeout=120)
    resp.raise_for_status()          # HTTP 200 does NOT mean the write succeeded
    result = resp.json()
    print(f"\nSummary: {result['Summary']}")
    for message in result.get("Messages") or []:
        print(f"  Message: {message}")

    print("\nAfter (read-back is the only proof):")
    for row in read_lines(ui_server, headers, config.verify_ssl):
        print(f"  {row.get('user_line_no')}: {row.get('oe_order_item_id')}"
              f" x {row.get('unit_quantity')}")


if __name__ == "__main__":
    main()
// Modify an existing sales order: update a line in place + add a line.
// Mirrors docs/recipes/update-order-lines.md. See the runnable class at
// examples/csharp/Recipes/UpdateOrderLines.cs (menu option in the Recipes
// project; prints the payload and asks for EXECUTE before posting).

using System.Text;
using Newtonsoft.Json.Linq;
using P21Examples.Common;
using static P21Examples.Recipes.RecipeHelpers;

namespace P21Examples.Recipes;

public static class UpdateOrderLines
{
    private const string OrderNo = "1013938";

    // (handle, item, qty): existing handle -> update; new handle -> insert.
    private static readonly (string Handle, string ItemId, string Qty)[] Lines =
    {
        ("010", "WIDGET-001", "4"),
        ("030", "WIDGET-002", "1"),
    };

    public static async Task RunAsync()
    {
        Console.WriteLine("Recipe - Modify an Existing Sales Order");
        Console.WriteLine(new string('=', 60));

        using var client = await P21Client.CreateAsync();

        Console.WriteLine("\nBefore:");
        await PrintLinesAsync();

        var payload = BuildPayload();
        PrintPayload("Payload", payload);
        if (!ConfirmExecute())
            return;

        var result = await client.Transaction.CreateAsync(payload);
        if (!CheckResult(result))
            return;

        Console.WriteLine("\nAfter (read-back is the only proof):");
        await PrintLinesAsync();
    }

    private static JObject BuildPayload()
    {
        var itemRows = new JArray();
        foreach (var (handle, itemId, qty) in Lines)
        {
            itemRows.Add(new JObject
            {
                ["Edits"] = new JArray
                {
                    Edit("user_line_no", handle),
                    Edit("oe_order_item_id", itemId),
                    Edit("unit_quantity", qty),
                },
                ["RelativeDateEdits"] = new JArray(),
            });
        }

        return new JObject
        {
            ["Name"] = "Order",
            ["UseCodeValues"] = false,
            ["Transactions"] = new JArray
            {
                new JObject
                {
                    ["Status"] = "New",
                    ["DataElements"] = new JArray
                    {
                        new JObject
                        {
                            // ONLY the key -- re-sending header fields fails on
                            // disabled columns (customer_id) once the order exists.
                            ["Name"] = "TABPAGE_1.order", ["Type"] = "Form",
                            ["Keys"] = new JArray { "order_no" },
                            ["Rows"] = new JArray { new JObject {
                                ["Edits"] = new JArray { Edit("order_no", OrderNo) },
                                ["RelativeDateEdits"] = new JArray() } },
                        },
                        new JObject
                        {
                            ["Name"] = "TP_ITEMS.items", ["Type"] = "List",
                            ["Keys"] = new JArray { "user_line_no" },
                            ["Rows"] = itemRows,
                        },
                    },
                },
            },
        };
    }

    private static async Task PrintLinesAsync()
    {
        var (http, uiServer, _) = await CreateRawClientAsync();
        var request = new JObject
        {
            ["ServiceName"] = "Order",
            ["TransactionStates"] = new JArray { new JObject {
                ["DataElementName"] = "TABPAGE_1.order",
                ["Keys"] = new JArray { new JObject {
                    ["Name"] = "order_no", ["Value"] = OrderNo } } } },
        };
        var response = await http.PostAsync(
            $"{uiServer}/api/v2/transaction/get",
            new StringContent(request.ToString(), Encoding.UTF8, "application/json"));
        response.EnsureSuccessStatusCode();

        var body = JObject.Parse(await response.Content.ReadAsStringAsync());
        foreach (var element in body["Transactions"]![0]!["DataElements"]!)
        {
            if ((string?)element["Name"] != "TP_ITEMS.items")
                continue;
            foreach (var row in element["Rows"]!)
            {
                var edits = row["Edits"]!.ToDictionary(
                    e => (string)e["Name"]!, e => (string?)e["Value"]);
                if (string.IsNullOrEmpty(edits.GetValueOrDefault("oe_order_item_id")))
                    continue;
                Console.WriteLine(
                    $"  {edits.GetValueOrDefault("user_line_no")}: " +
                    $"{edits.GetValueOrDefault("oe_order_item_id")} x " +
                    $"{edits.GetValueOrDefault("unit_quantity")}");
            }
        }
    }
}

End-to-end files (runnable from the repo with a .env, dry-run by default): examples/python/recipes/update_order_lines.py · examples/csharp/Recipes/UpdateOrderLines.cs. The snippets above are self-contained; the files use the repo's shared common / P21Examples.Common helpers like every other example.

Gotchas

All verified live on 26.1 — details in Keys — Row Identity and the Collapse Trap:

Verify

Read the order back — a Succeeded response is not proof: