Read an SAP API's contract, then count, page, expand and save real sales order data, and learn what changes when you move from the sandbox to a company system.
An AI assistant for SAP is only as good as the data it can read. That data comes through SAP's published APIs: documented doors into S/4HANA for sales orders, invoices, business partners and much more.
Calling one well takes four habits:
Read the contract first. Every SAP OData API describes itself: which records it offers, which fields each record has, and how records link together. You check that description before you write code.
Ask a precise question. Only the fields you need, a page at a time, never "everything".
Follow the links. A sales order links to its items, partners and prices. You ask for those links when you need them.
Save a clean result. Dates, amounts and blank codes arrive in SAP's formats. Tidy them once, so every later step works from the same clean file.
You can practise all four today on SAP's free sandbox. Calling your company's system needs the same code but different setup, done by an administrator.
Take the running example: blocked sales orders in order-to-cash. An assistant that helps a credit analyst needs more than the order header. It needs the items (what was ordered, how much), the customer, and the block reasons. Each of those sits in a different part of the Sales Order API.
How the team reads that data decides three things a leader cares about:
Cost and speed. Reading 50 fields for 10,000 orders when the assistant needs 8 fields for the 40 blocked ones wastes time, money and system capacity.
Data protection. Every field read is a field that can end up in a prompt, a log or a screenshot. Precise questions are the cheapest privacy control there is.
Durability. SAP's published APIs are meant to stay stable across upgrades. Reading SAP tables directly, or using unpublished interfaces, can break at the next release.
There is a fourth, newer reason. As of April 2026, SAP's API policy places restrictions on using its APIs with AI systems that plan and execute sequences of API calls on their own, and points those uses to SAP-endorsed routes. A script that reads orders for a report is a different case from an autonomous agent. Leaders should know which one their team is building.
SAP Business Accelerator Hub (api.sap.com) lists the APIs for SAP products. Each API page has an Overview and an API Reference, a Try Out button that calls the API from the browser against a sandbox with test data, and Show API Key for the sandbox key.
The Sales Order (A2X) API, technical name API_SALES_ORDER_SRV, is SAP's OData API for creating, reading, changing and deleting sales orders in S/4HANA Cloud.
In a company's S/4HANA Cloud system, an administrator opens an API with three objects in dedicated apps: a communication user (the technical identity), a communication system (your application) and a communication arrangement that links them to the API's communication scenario. For the Sales Order API that scenario is listed as Sales Order Integration (SAP_COM_0109).
For AI agents, SAP's April 2026 API policy names SAP-endorsed architectures as the route for agentic use. Unit 9 covers Joule and agents; Unit 11 covers governance.
"The API gives us the whole sales order." It gives what you ask for. Items, partners and prices are separate, linked records. A careless design either misses them or downloads far too much.
"If it works on the sandbox, it works on our system." The API shape is the same. Your configuration, codes, data volumes and authorizations are not.
"Status and block codes mean the same everywhere." Values such as a delivery block reason 01 are configured per system. Ask the process owner what they mean in yours.
"Any API that returns data is fine to use." Use published APIs for their documented purpose. Unpublished interfaces carry no stability promise and may conflict with SAP's API policy.
"An AI agent can just call SAP directly." For autonomous agents, SAP's policy points to endorsed routes. Check before you design.
Pick one answer for each question. The explanation appears after you choose.
1Which list is the four habits of calling an SAP API well?
Answer: C. Read the contract first, ask a precise question, follow the links only when you need them, and save a clean result that every later step can use.
2Why do precise API questions matter to the business?
Answer: B. They save cost, time and system capacity; they are the cheapest privacy control, because a field never read can't leak into a prompt, log or screenshot; and published APIs keep working across upgrades.
3What is the difference between the sandbox and your company's system?
Answer: A. The sandbox holds shared demo data and you set it up in minutes with a personal key. Your company's system holds real data, needs an administrator, a communication user and approval, and shows only what the communication arrangement allows.
4What should a sandbox prototype answer before anyone asks for system access?
Answer: D. Whether the data the use case needs is actually in the standard API. That is the fastest, cheapest way to find out.
5Why is "the API gives us the whole sales order" a misconception?
Answer: C. It gives what you ask for. Items, partners and prices are separate, linked records. A careless design either misses them or downloads far too much.
6Do block reasons and status codes mean the same thing in every SAP system?
Answer: B. No. Values such as a delivery block reason 01 are configured per system. Ask the process owner what they mean in yours.
7Your team wants an AI agent that decides on its own which SAP APIs to call. What do you check first?
Answer: A. SAP's April 2026 API policy, which restricts autonomous agentic use of its APIs and points it to SAP-endorsed architectures. Check the design against it with your SAP account team before building.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
In Python and APIs for AI engineers you learned the mechanics of one HTTP call. This topic is about working with a real SAP API as a whole.
The one idea: an SAP OData service describes itself, and that description is the contract. Ask any OData V2 service for $metadata and you get an XML document listing every entity type (the shape of a record), its properties (fields, with types and lengths), its key, and its navigation properties (links to related records).
Everything else follows from the contract:
Field names in your $select must exist in it.
Keys tell you how to address one record: A_SalesOrder('9000001').
Navigation properties tell you how to walk from an order to its items: A_SalesOrder('9000001')/to_Item.
Types tell you how to read values: an Edm.DateTime arrives as /Date(...)/, an Edm.Decimal as text.
Experienced integrators read the contract before they write a line of code, and they check it again when an upgrade lands. You will make your script do the same.
The Sales Order (A2X) API lives at the service path /sap/opu/odata/sap/API_SALES_ORDER_SRV. On the sandbox, that path sits under https://sandbox.api.sap.com/s4hanacloud.
It is not one table. It is a small graph of entity types. The SAP Cloud SDK's generated model of the service lists these for the header, A_SalesOrder, among others:
Navigation from A_SalesOrder
Leads to
Typical use
to_Item
A_SalesOrderItem
What was ordered: material, quantity, net amount
to_Partner
A_SalesOrderHeaderPartner
Sold-to, ship-to and other partner roles
to_PricingElement
A_SalesOrderHeaderPrElement
Price conditions at header level
to_Text
A_SalesOrderText
Header texts
to_PrecedingProcFlowDoc, to_SubsequentProcFlowDoc
Process flow entities
Documents before and after the order
And each item links onward again, for example to_ScheduleLine and to_PricingElement on A_SalesOrderItem.
flowchart LR
H["A_SalesOrder<br/>header"] -->|to_Item| I["A_SalesOrderItem"]
H -->|to_Partner| P["Header partners"]
H -->|to_PricingElement| PR["Header pricing"]
I -->|to_ScheduleLine| S["Schedule lines"]
I -->|to_PricingElement| IP["Item pricing"]
The fields this topic uses, with their types from the same model:
One order, by key. String keys go in single quotes
/A_SalesOrder('9000001')/to_Item
The items of that order
/A_SalesOrder?$expand=to_Item
Orders with their items embedded in one answer
Two ways to get items, then: navigate per order (.../to_Item), or expand inline ($expand=to_Item). Expanding saves calls but makes each answer much larger. Navigating is easier to read and to page. This topic navigates, and only for the few orders that matter.
In a V2 answer, a navigation property you did not expand appears as a __deferred object holding the address to fetch it later. That is a clue, not data.
A company system can hold hundreds of thousands of orders. You never ask for all of them in one call.
Counting. Add $inlinecount=allpages to a collection request. The OData V2 JSON format then includes the total as __count next to results, as text. Combine it with $top=1 to count without downloading.
Client-driven paging. You ask for slices with $top (how many) and $skip (how many to jump over). Stop when a page comes back shorter than you asked for.
Server-driven paging. A service may cut a large answer short on its own and add a __next link with the address of the next slice. If you see __next, follow it.
Stable order. Add $orderby on the key when you page with $skip. Without a fixed sort, rows could shift between pages.
sequenceDiagram
participant C as Your script
participant S as Sales Order API
C->>S: GET $metadata
S-->>C: XML contract
C->>S: GET A_SalesOrder?$top=1&$inlinecount=allpages
S-->>C: __count
loop until a short page
C->>S: GET A_SalesOrder?$top=50&$skip=n&$select=...
S-->>C: up to 50 orders
end
C->>S: GET A_SalesOrder('id')/to_Item
S-->>C: items
Reading is a plain GET. Creating or changing (POST, PATCH, DELETE) on SAP OData services usually needs one extra step: a CSRF token. The client first sends a request with the header X-CSRF-Token: Fetch, receives a token and session cookies, and sends both with the change request. The SAP Cloud SDK does this by default for every non-GET request.
This topic only reads. Writing to SAP from an AI system is a design decision with approvals, not a coding detail. Unit 9 returns to it.
#Build it yourself: read, page, expand and save blocked orders
You will build one script, first_sap_api.py. It reads the Sales Order API's contract, counts the orders, reads them page by page, follows to_Item for the blocked ones and saves a clean file, blocked_orders.json. That file is the input for later units: it is the kind of grounded business context a model will reason over.
flowchart LR
A["A. $metadata<br/>check fields"] --> B["B. Count"]
B --> C["C. Read pages"]
C --> D["D. to_Item for<br/>blocked orders"]
D --> E["E. blocked_orders.json"]
The script runs in two modes. --sample uses made-up data built into the script: no account, no internet, and the output matches this page exactly. Without --sample it calls SAP's sandbox with your key.
Before you start: complete Set up your computer for this course. It creates your orchestrate-course folder with its .venv, installs requests and python-dotenv, and stores SAP_API_KEY in .env. Doing Python and APIs for AI engineers first helps; this walkthrough builds on it.
The course setup above. Nothing new to install: the script uses only requests, python-dotenv and modules built into Python.
For the sample mode: nothing else. Free, works offline.
For the sandbox mode: your free SAP Business Accelerator Hub key in .env as SAP_API_KEY, and a network that can reach sandbox.api.sap.com. Free.
About 60 to 90 minutes.
#Step 1: Look at the API on the Business Accelerator Hub
Before code, look at the contract the way SAP presents it.
Open api.sap.com in your browser and click Log On at the top right.
In the search box, type Sales Order (A2X) and open the API whose technical name is API_SALES_ORDER_SRV. If several results appear, check the technical name on the API's page.
Read the Overview: what the API does and which product it belongs to.
Open API Reference. On the left, find the group for A_SalesOrder and open the GET operation for /A_SalesOrder. Note the query options it offers, such as $top, $select and $expand.
Click Try Out. Set $top to 2 and click Run. In the response section you should see JSON starting with {"d": {"results": [. That is the same answer your script will get.
#Step 2: Open your course folder and turn on the environment
Open VS Code, choose File > Open Folder and open your orchestrate-course folder.
Open a terminal with Terminal > New Terminal.
Turn on the virtual environment:
Windows (PowerShell):
.venv\Scripts\Activate.ps1
macOS / Linux:
source .venv/bin/activate
Check that the prompt starts with (.venv).
Move into the Unit 1 folder, creating it first if it doesn't exist:
Windows (PowerShell):
New-Item -ItemType Directory -Force unit01
cd unit01
In VS Code's file list, right-click unit01, choose New File and name it first_sap_api.py.
Paste the whole script below and save with Ctrl+S (Windows, Linux) or Cmd+S (macOS).
"""Calling your first SAP API: read the contract, count, page, expand, save.
How to run (from the unit01 folder, with the course .venv turned on):
python first_sap_api.py --sample made-up data on your computer; no account, no internet
python first_sap_api.py SAP Business Accelerator Hub sandbox (needs SAP_API_KEY in .env)
Options:
--max 200 read at most this many orders (a safety limit)
--page-size 50 how many orders to ask for per call
The script only reads. It writes blocked_orders.json next to itself.
"""
import argparse
import json
import os
import re
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
SERVICE = "/sap/opu/odata/sap/API_SALES_ORDER_SRV"
SANDBOX = "https://sandbox.api.sap.com/s4hanacloud" + SERVICE
# The header fields we read, and the item fields we read. Names come from the API's metadata.
ORDER_FIELDS = ["SalesOrder", "SoldToParty", "CreationDate", "TotalNetAmount", "TransactionCurrency",
"DeliveryBlockReason", "HeaderBillingBlockReason", "TotalCreditCheckStatus"]
ITEM_FIELDS = ["SalesOrderItem", "Material", "SalesOrderItemText", "RequestedQuantity",
"RequestedQuantityUnit", "NetAmount", "ItemBillingBlockReason"]
# ------------------------------------------------------------------ talking to the API
class LiveSAP:
"""Sends real HTTP GET requests to SAP's sandbox."""
def __init__(self, key: str):
import requests # imported here so --sample works even without the library
self.requests = requests
self.session = requests.Session()
self.session.headers.update({"APIKey": key})
def get(self, path: str, params: dict | None = None) -> tuple[int, str]:
try:
accept = "application/xml" if path == "/$metadata" else "application/json"
resp = self.session.get(SANDBOX + path, params=params, headers={"Accept": accept},
timeout=30)
except self.requests.exceptions.RequestException as err:
sys.exit(f"Could not reach sandbox.api.sap.com ({type(err).__name__}). "
"Check your network or proxy, or run with --sample.")
return resp.status_code, resp.text
class SampleSAP:
"""Answers like the sandbox would, from made-up data. Understands $top, $skip,
$select and $inlinecount, which is all this script uses."""
def get(self, path: str, params: dict | None = None) -> tuple[int, str]:
params = params or {}
if path == "/$metadata":
return 200, SAMPLE_METADATA
items = re.fullmatch(r"/A_SalesOrder\('(\d+)'\)/to_Item", path)
if path == "/A_SalesOrder":
rows = sorted(SAMPLE_ORDERS, key=lambda o: o["SalesOrder"])
elif items:
rows = SAMPLE_ITEMS.get(items.group(1), [])
else:
return 404, json.dumps({"error": {"message": {"value": f"Resource not found: {path}"}}})
total = len(rows)
skip, top = int(params.get("$skip", 0)), int(params.get("$top", total))
rows = rows[skip:skip + top]
if "$select" in params:
keep = params["$select"].split(",")
rows = [{k: r[k] for k in keep if k in r} for r in rows]
body = {"d": {"results": rows}}
if params.get("$inlinecount") == "allpages":
body["d"]["__count"] = str(total)
return 200, json.dumps(body)
# ------------------------------------------------------------------ the five steps
def read_contract(api) -> dict:
"""Step A: download $metadata (XML) and list the properties of each entity type."""
status, text = api.get("/$metadata")
if status != 200:
stop(status, text)
root = ET.fromstring(text.encode("utf-8"))
types = {}
for node in root.iter():
if node.tag.endswith("}EntityType"):
props = {}
for p in node:
if p.tag.endswith("}Property"):
label = next((v for k, v in p.attrib.items() if k.endswith("}label")), "-")
props[p.get("Name")] = {"type": p.get("Type"), "max": p.get("MaxLength", ""),
"label": label}
navs = [p.get("Name") for p in node if p.tag.endswith("}NavigationProperty")]
types[node.get("Name")] = {"properties": props, "navigation": navs}
return types
def count_orders(api) -> int:
"""Step B: ask how many orders exist without downloading them."""
status, text = api.get("/A_SalesOrder", {"$top": "1", "$select": "SalesOrder",
"$inlinecount": "allpages"})
if status != 200:
stop(status, text)
return int(json.loads(text)["d"]["__count"])
def read_orders(api, page_size: int, max_records: int) -> list:
"""Step C: read orders page by page with $top and $skip, only the fields we need."""
orders, skip = [], 0
while len(orders) < max_records:
top = min(page_size, max_records - len(orders))
params = {"$top": str(top), "$skip": str(skip), "$select": ",".join(ORDER_FIELDS),
"$orderby": "SalesOrder"}
status, text = api.get("/A_SalesOrder", params)
if status != 200:
stop(status, text)
page = json.loads(text)["d"]["results"]
print(f" page {skip // page_size + 1}: asked for {top} from position {skip}, got {len(page)}")
orders += page
if len(page) < top: # a short page means there is nothing more
break
skip += top
return orders
def read_items(api, order_id: str) -> list:
"""Step D: follow the to_Item navigation from one order to its items."""
status, text = api.get(f"/A_SalesOrder('{order_id}')/to_Item", {"$select": ",".join(ITEM_FIELDS)})
if status != 200:
stop(status, text)
return json.loads(text)["d"]["results"]
def odata_date(value: str | None) -> str | None:
"""OData V2 JSON sends dates as /Date(milliseconds since 1970)/. Turn that into 2026-09-29."""
match = re.search(r"/Date\((-?\d+)", value or "")
if not match:
return value
return datetime.fromtimestamp(int(match.group(1)) / 1000, tz=timezone.utc).date().isoformat()
def is_blocked(order: dict) -> bool:
"""Our working definition: a delivery block or a billing block is set (any value)."""
return bool(order.get("DeliveryBlockReason") or order.get("HeaderBillingBlockReason"))
def clean_order(order: dict, items: list) -> dict:
"""Step E: turn raw API records into a tidy record for later units."""
return {
"sales_order": order["SalesOrder"],
"sold_to_party": order.get("SoldToParty"),
"created_on": odata_date(order.get("CreationDate")),
"net_amount": str(Decimal(order.get("TotalNetAmount") or "0")),
"currency": order.get("TransactionCurrency"),
"delivery_block_reason": order.get("DeliveryBlockReason") or None,
"billing_block_reason": order.get("HeaderBillingBlockReason") or None,
"credit_check_status": order.get("TotalCreditCheckStatus") or None,
"is_blocked": is_blocked(order),
"items": [{
"item": i.get("SalesOrderItem"),
"material": i.get("Material"),
"text": i.get("SalesOrderItemText"),
"quantity": str(Decimal(i.get("RequestedQuantity") or "0")),
"unit": i.get("RequestedQuantityUnit"),
"net_amount": str(Decimal(i.get("NetAmount") or "0")),
"billing_block_reason": i.get("ItemBillingBlockReason") or None,
} for i in items],
}
def stop(status: int, text: str) -> None:
hints = {401: "the key is missing or wrong: check SAP_API_KEY in .env",
403: "the key is valid but not allowed to do this",
404: "the address is wrong: check the entity set or key",
429: "too many calls: wait a minute and run again"}
sys.exit(f"Stopped: HTTP {status}, {hints.get(status, 'see the message below')}\n{text[:300]}")
# ------------------------------------------------------------------ main
def main() -> None:
parser = argparse.ArgumentParser(description="Read blocked sales orders from SAP's Sales Order API.")
parser.add_argument("--sample", action="store_true", help="use made-up data; no key or internet")
parser.add_argument("--max", type=int, default=200, help="read at most this many orders")
parser.add_argument("--page-size", type=int, default=50, help="orders per call")
args = parser.parse_args()
if args.sample:
api = SampleSAP()
args.page_size = min(args.page_size, 3) # small pages so you can see paging on 7 orders
print("Target: sample data on your computer (made up, shaped like SAP's API)\n")
else:
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
key = os.environ.get("SAP_API_KEY")
if not key:
sys.exit("SAP_API_KEY is not set. Add it to .env, or run with --sample.")
api = LiveSAP(key)
print("Target: SAP Business Accelerator Hub sandbox\n")
print("A. Read the contract ($metadata)")
types = read_contract(api)
header, item = types["A_SalesOrder"], types["A_SalesOrderItem"]
print(f" A_SalesOrder has {len(header['properties'])} properties; "
f"A_SalesOrderItem has {len(item['properties'])}")
print(f" Navigation from an order: {', '.join(header['navigation'])}")
for name in ORDER_FIELDS + ITEM_FIELDS:
props = header["properties"] if name in header["properties"] else item["properties"]
p = props.get(name)
if not p:
sys.exit(f" Field {name} is not in this API's metadata. Check the field list.")
print(f" {name:<26} {p['type']:<14} {p['max']:>3} {p['label']}")
print("\nB. Count the orders ($inlinecount)")
total = count_orders(api)
print(f" The service holds {total} sales orders")
print(f"\nC. Read orders in pages ($top, $skip, $select), at most {args.max}")
orders = read_orders(api, args.page_size, args.max)
blocked = [o for o in orders if is_blocked(o)]
print(f" Read {len(orders)} orders; {len(blocked)} have a delivery or billing block")
chosen = blocked[:5]
if not chosen:
print(" No blocked orders in this data. That is a valid answer; "
"saving the first 3 orders instead so you can see the file.")
chosen = orders[:3]
print("\nD. Follow to_Item for each chosen order")
dossier = []
for order in chosen:
items = read_items(api, order["SalesOrder"])
print(f" {order['SalesOrder']}: {len(items)} item(s)")
dossier.append(clean_order(order, items))
print("\nE. Save a clean file for later units")
out = Path(__file__).with_name("blocked_orders.json")
out.write_text(json.dumps({"source": "sample" if args.sample else "sandbox",
"orders_read": len(orders), "orders": dossier}, indent=2),
encoding="utf-8")
print(f" Wrote {len(dossier)} orders to {out.name}. First one:")
print(" " + json.dumps(dossier[0], indent=2).replace("\n", "\n "))
# ------------------------------------------------------------------ made-up sample data
SAMPLE_METADATA = """<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="1.0" xmlns:edmx="http://schemas.microsoft.com/ado/2007/06/edmx"
xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
<edmx:DataServices m:DataServiceVersion="2.0">
<Schema Namespace="API_SALES_ORDER_SRV" xmlns="http://schemas.microsoft.com/ado/2008/09/edm">
<EntityType Name="A_SalesOrder">
<Key><PropertyRef Name="SalesOrder"/></Key>
<Property Name="SalesOrder" Type="Edm.String" MaxLength="10"/>
<Property Name="SalesOrderType" Type="Edm.String" MaxLength="4"/>
<Property Name="SalesOrganization" Type="Edm.String" MaxLength="4"/>
<Property Name="SoldToParty" Type="Edm.String" MaxLength="10"/>
<Property Name="CreationDate" Type="Edm.DateTime"/>
<Property Name="TotalNetAmount" Type="Edm.Decimal"/>
<Property Name="TransactionCurrency" Type="Edm.String" MaxLength="5"/>
<Property Name="HeaderBillingBlockReason" Type="Edm.String" MaxLength="2"/>
<Property Name="DeliveryBlockReason" Type="Edm.String" MaxLength="2"/>
<Property Name="OverallSDProcessStatus" Type="Edm.String" MaxLength="1"/>
<Property Name="TotalCreditCheckStatus" Type="Edm.String" MaxLength="1"/>
<NavigationProperty Name="to_Item"/>
<NavigationProperty Name="to_Partner"/>
<NavigationProperty Name="to_PricingElement"/>
</EntityType>
<EntityType Name="A_SalesOrderItem">
<Key><PropertyRef Name="SalesOrder"/><PropertyRef Name="SalesOrderItem"/></Key>
<Property Name="SalesOrder" Type="Edm.String" MaxLength="10"/>
<Property Name="SalesOrderItem" Type="Edm.String" MaxLength="6"/>
<Property Name="SalesOrderItemText" Type="Edm.String" MaxLength="40"/>
<Property Name="Material" Type="Edm.String" MaxLength="40"/>
<Property Name="RequestedQuantity" Type="Edm.Decimal"/>
<Property Name="RequestedQuantityUnit" Type="Edm.String" MaxLength="3"/>
<Property Name="NetAmount" Type="Edm.Decimal"/>
<Property Name="ItemBillingBlockReason" Type="Edm.String" MaxLength="2"/>
<NavigationProperty Name="to_SalesOrder"/>
<NavigationProperty Name="to_ScheduleLine"/>
</EntityType>
</Schema>
</edmx:DataServices>
</edmx:Edmx>"""
def _order(no, party, day_ms, amount, dblock="", bblock="", credit=""):
return {"SalesOrder": no, "SoldToParty": party, "CreationDate": f"/Date({day_ms})/",
"TotalNetAmount": amount, "TransactionCurrency": "USD", "DeliveryBlockReason": dblock,
"HeaderBillingBlockReason": bblock, "TotalCreditCheckStatus": credit}
SAMPLE_ORDERS = [
_order("9000001", "CUST-A", 1788998400000, "18250.00", dblock="01", credit="B"),
_order("9000002", "CUST-B", 1789084800000, "940.00", bblock="02"),
_order("9000003", "CUST-C", 1789171200000, "5100.00"),
_order("9000004", "CUST-A", 1789257600000, "7300.00", dblock="01", credit="B"),
_order("9000005", "CUST-D", 1789344000000, "12000.00"),
_order("9000006", "CUST-E", 1789430400000, "2650.00"),
_order("9000007", "CUST-B", 1789516800000, "480.00"),
]
def _item(no, material, text, qty, amount, bblock=""):
return {"SalesOrderItem": no, "Material": material, "SalesOrderItemText": text,
"RequestedQuantity": qty, "RequestedQuantityUnit": "PC", "NetAmount": amount,
"ItemBillingBlockReason": bblock}
SAMPLE_ITEMS = {
"9000001": [_item("10", "PUMP-100", "Centrifugal pump", "10", "15000.00"),
_item("20", "SEAL-7", "Seal kit", "50", "3250.00")],
"9000002": [_item("10", "FILTER-2", "Filter cartridge", "20", "940.00", bblock="02")],
"9000004": [_item("10", "VALVE-40", "Control valve", "4", "7300.00")],
}
if __name__ == "__main__":
main()
On macOS or Linux, use python3 if python isn't found.
You should see exactly this:
Target: sample data on your computer (made up, shaped like SAP's API)
A. Read the contract ($metadata)
A_SalesOrder has 11 properties; A_SalesOrderItem has 8
Navigation from an order: to_Item, to_Partner, to_PricingElement
SalesOrder Edm.String 10 -
SoldToParty Edm.String 10 -
CreationDate Edm.DateTime -
TotalNetAmount Edm.Decimal -
TransactionCurrency Edm.String 5 -
DeliveryBlockReason Edm.String 2 -
HeaderBillingBlockReason Edm.String 2 -
TotalCreditCheckStatus Edm.String 1 -
SalesOrderItem Edm.String 6 -
Material Edm.String 40 -
SalesOrderItemText Edm.String 40 -
RequestedQuantity Edm.Decimal -
RequestedQuantityUnit Edm.String 3 -
NetAmount Edm.Decimal -
ItemBillingBlockReason Edm.String 2 -
B. Count the orders ($inlinecount)
The service holds 7 sales orders
C. Read orders in pages ($top, $skip, $select), at most 200
page 1: asked for 3 from position 0, got 3
page 2: asked for 3 from position 3, got 3
page 3: asked for 3 from position 6, got 1
Read 7 orders; 3 have a delivery or billing block
D. Follow to_Item for each chosen order
9000001: 2 item(s)
9000002: 1 item(s)
9000004: 1 item(s)
E. Save a clean file for later units
Wrote 3 orders to blocked_orders.json. First one:
{
"sales_order": "9000001",
"sold_to_party": "CUST-A",
"created_on": "2026-09-10",
"net_amount": "18250.00",
"currency": "USD",
"delivery_block_reason": "01",
"billing_block_reason": null,
"credit_check_status": "B",
"is_blocked": true,
"items": [
{
"item": "10",
"material": "PUMP-100",
"text": "Centrifugal pump",
"quantity": "10",
"unit": "PC",
"net_amount": "15000.00",
"billing_block_reason": null
},
{
"item": "20",
"material": "SEAL-7",
"text": "Seal kit",
"quantity": "50",
"unit": "PC",
"net_amount": "3250.00",
"billing_block_reason": null
}
]
}
Read it section by section:
Section
What you see
What it teaches
A
Field names with types and lengths, and the links to_Item, to_Partner, to_PricingElement
The script checks every field it will use against the contract before it asks for data. The last column shows a label if the service provides one; the sample doesn't, so it shows -
B
7 sales orders
Counting with $inlinecount without downloading everything
C
Three pages: 3, 3, then 1
Paging with $top and $skip. The short last page tells the script to stop
D
Item counts per blocked order
Following the to_Item link for only the orders that matter
E
A tidy record with a real date, text amounts and null for empty blocks
Cleaning SAP's formats once, so later steps don't have to
Open blocked_orders.json in VS Code. It holds the three blocked sample orders with their items.
Try the options. Run python first_sap_api.py --sample --page-size 2 and watch section C use four pages. Then run python first_sap_api.py --sample --max 2: section C stops after one page of two orders, even though the data holds seven. The safety limit always wins over "read everything".
Check that .env in your course folder has a line SAP_API_KEY="...". If not, follow the key steps in the setup topic.
Run, without --sample:
python first_sap_api.py
You should see Target: SAP Business Accelerator Hub sandbox, then the same five sections with SAP's demo data. Expect these differences, which are all valid:
Section A shows far more properties, more than 80 on each entity, and more navigation properties. The fourth column may show text labels.
Section B shows the number of orders in the shared sandbox. It changes over time.
Section C reads up to 200 orders, the default safety limit, in pages of 50.
Section C may report 0 have a delivery or billing block. That is a valid answer about demo data, not an error. The script then saves the first three orders so you still get a file.
Open blocked_orders.json and check that "source" is "sandbox".
Near the top of the script, add "OverallSDProcessStatus" to the ORDER_FIELDS list (inside the square brackets, with a comma). Save.
Run python first_sap_api.py --sample. Section A now lists OverallSDProcessStatus too: the field exists in the sample contract.
Now add a field that doesn't exist, "DeliveryBlock", to the same list. Run again. The script stops in section A with Field DeliveryBlock is not in this API's metadata. That is the point of reading the contract first: a wrong field name fails early with a clear message instead of a confusing error from SAP.
Start on the SAP Business Accelerator Hub. SAP Learning describes the sandbox APIs, Try Out and Show API Key; the hub also gives each API's reference and schema. Use the hub page as the source for technical names and fields.
Prefer APIs SAP publishes for integration, such as the A2X APIs. As reported in April 2026, SAP's updated API policy keeps published APIs as the supported route and is stricter about other interfaces.
SAP Learning describes the S/4HANA Cloud setup, done by an administrator:
Display Communication Scenarios shows the predefined integration scenarios. The Sales Order API belongs to Sales Order Integration (SAP_COM_0109).
Maintain Communication Users creates the technical user your application signs in with.
Communication Systems registers your application as a communication partner.
Communication Arrangements links the scenario, the system and the user. SAP Learning describes this as defining which system and which user can call which APIs.
For side-by-side apps on SAP BTP, the Maintain Extensions on SAP BTP app can create the system, user and arrangement automatically. Unit 3 and Unit 6 cover BTP.
# Sketch: needs an S/4HANA Cloud system and a communication arrangement for SAP_COM_0109.
# The host, user and password come from your administrator and live in .env, never in code.
session = requests.Session()
session.auth = (os.environ["S4_USER"], os.environ["S4_PASSWORD"]) # basic authentication
base = f"https://{os.environ['S4_HOST']}/sap/opu/odata/sap/API_SALES_ORDER_SRV"
resp = session.get(base + "/A_SalesOrder", params={"$top": "5"}, timeout=30)
Only the LiveSAP class of your script would change: the address and how it signs in. Certificate-based authentication and OAuth are common alternatives to a password; your administrator decides.
SAP Cloud SDK (Java, JavaScript/TypeScript) generates typed clients from an API's metadata. The generated sales order model used as a source for this topic is one of them. It also handles CSRF tokens and BTP destinations for you.
pyodata is an open-source Python OData client from SAP. Its README says it supports OData V2 only. It reads $metadata and gives you Python objects for entity sets, which is the contract-first idea done for you.
CAP (SAP Cloud Application Programming Model) is SAP's framework for building apps on BTP; Unit 6 covers calling S/4HANA APIs from it.
API_SALES_ORDER_SRV is an OData V2 service. SAP also publishes OData V4 APIs for many business objects, and their JSON looks different: records under value, counts with $count. Check the version on the hub before you write the code that reads the answer.
Least privilege. One communication arrangement per purpose. A read-only assistant gets a user and scenario that can only read.
Authorizations. A communication user sees data by its own rights, not the end user's. If an analyst may only see their sales organization, the assistant must enforce that too. Unit 7 covers grounding without breaking authorizations.
Data minimization.$select only what the use case needs. Keep personal data out of logs and prompts. Unit 11 goes deeper.
Limits. Cap the records read per run, page with a stable $orderby, and back off on 429 and 503.
Contract drift. Check $metadata in automated tests. A field that disappears after an upgrade should fail a test, not a morning run.
Idempotent writes. If you ever write, fetch a CSRF token, and design so a retried request can't create the same order twice.
Secrets. Local .env for learning; a secrets store or BTP service bindings in production. Rotate communication user passwords, or prefer certificates.
Policy. Record which SAP APIs an AI component calls and whether it acts autonomously. Review against SAP's API policy with your SAP account team.
Clean core. Published APIs, not direct table reads or modifications. Your integration then survives upgrades.
Guessing field names.DeliveryBlock fails; DeliveryBlockReason works. Copy names from the hub or from $metadata.
Paging without $orderby. Rows can shift between pages, so you miss or duplicate orders.
Stopping at the first page. A result of exactly 50 rows probably means "there are more", not "that's all".
Ignoring __next. If the server pages for you, following the link is not optional.
$expand everything. One call, huge answer, slow and heavy. Expand only what you need, or navigate for a few records.
Reading dates as text./Date(1788998400000)/ sorted as text or shown to a user is a bug.
float for money. Use Decimal to avoid rounding errors in totals.
Treating empty as a code."" means no block. Don't count it as a reason.
Interpreting codes from memory. Block reasons and statuses are configured per system. Map them from the customer's configuration.
#Exercise: add partners to your blocked-orders file
Extend the script so each saved order also lists its partners, using another navigation property.
Copy first_sap_api.py to first_sap_api_partners.py in unit01.
Open the API Reference for API_SALES_ORDER_SRV on the hub. Find the GET operation for /A_SalesOrder('{SalesOrder}')/to_Partner. Note two field names it returns: the partner function and the customer number.
In SampleSAP.get, add support for to_Partner: copy the three lines that handle to_Item and make a version for to_Partner, backed by a new SAMPLE_PARTNERS dictionary with one or two made-up partners per blocked order, using the field names from step 2.
Add a function read_partners(api, order_id) modelled on read_items.
In clean_order, add a "partners" list with the two fields, and pass the partners in from main.
Run python first_sap_api_partners.py --sample, then without --sample if you have a key.
Save your work in Git from the course folder:
git add unit01/first_sap_api.py unit01/first_sap_api_partners.py
git commit -m "Read sales orders with items and partners"
Done when:blocked_orders.json lists each chosen order with its items and a partners list, the sample run works without a key, and git log shows the commit. Keep this file: later units use it as the business context a model reasons over, starting with prompt and context engineering in Unit 5.
Pick one answer for each question. The explanation appears after you choose.
1What is in an OData service's $metadata, and why should your script read it before asking for data?
Answer: D. The service's contract: entity types, their properties with types and lengths, their keys and navigation properties. Checking your field names against it turns confusing SAP errors into one clear message, and it catches changes after an upgrade.
2Which address returns the items of sales order 9000001 in API_SALES_ORDER_SRV?
Answer: C. /sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder('9000001')/to_Item, under the system's host. On the sandbox, the service path sits under https://sandbox.api.sap.com/s4hanacloud.
3You read orders with $top=50 and get exactly 50 back. What do you do next, and which option keeps the pages stable?
Answer: B. Assume there are more and ask for the next page with $skip=50, stopping when a page comes back short. Add $orderby on the key so rows can't shift between pages, and follow any __next link the server returns.
4How does OData V2 JSON represent a date, and why convert it before saving?
Answer: A. As text like /Date(1788998400000)/, milliseconds since 1 January 1970 (UTC). Convert it once to a real date so later steps can sort, compare and display it correctly.
5When would you navigate with to_Item instead of using $expand=to_Item?
Answer: D. When you need items for only a few orders, such as the blocked ones. Expanding saves calls but makes every answer much larger; navigating is easier to read and page.
6Why does a change request need an extra step that a read doesn't?
Answer: C. SAP OData services usually expect a CSRF token for POST, PATCH and DELETE. The client fetches it with X-CSRF-Token: Fetch, then sends it with the session cookies. Writing from an AI system is a design decision with approvals, not a coding detail.
7Name the three objects an administrator creates to open the Sales Order API in S/4HANA Cloud, and the scenario it belongs to.
Answer: B. A communication user, a communication system and a communication arrangement. The Sales Order API belongs to the Sales Order Integration scenario, SAP_COM_0109.
8An analyst may only see their own sales organization. What does that mean for an assistant using a communication user?
Answer: A. A communication user sees data by its own rights, not the end user's. The assistant must enforce the analyst's restrictions itself, or be designed so it reads with the user's rights. Unit 7 covers grounding without breaking authorizations.
9Your team wants an AI agent that decides by itself which SAP APIs to call. What should you check before building it?
Answer: D. SAP's current API policy: as of April 2026 it restricts autonomous agentic use of its APIs and points to SAP-endorsed architectures. Record which APIs the component calls and review the design with your SAP account team.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.