Orchestrate

Calling your first SAP API

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.

Updated Sep 29, 2026Foundational 8 minDeep 40 min
Foundational layer · 8 min read

The 60-second version

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:

  1. 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.
  2. Ask a precise question. Only the fields you need, a page at a time, never "everything".
  3. Follow the links. A sales order links to its items, partners and prices. You ask for those links when you need them.
  4. 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.

Why it matters to the business

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.

How SAP does it

As of September 2026:

  • 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.

Sandbox vs. your company's system

The same code can read the sandbox or a real system. Almost everything around it changes.

Sandbox (api.sap.com) Your company's S/4HANA Cloud system
Data Shared demo data Real customers, prices and amounts
Who sets it up You, in five minutes An administrator, with a request and approval
Identity Personal API key Communication user with a password or certificate, or OAuth
What you can see Whatever the sandbox holds What the communication arrangement and scenario allow
Writes (create, change) Don't rely on them; treat the sandbox as read-only Possible if the scenario allows it; needs design review
Good for Learning, prototypes, demos, estimating effort Pilots and production, after security review
Risk if misused Low Data leaks, wrong changes, policy breaches

Questions to ask

Ask your team, vendor or partner:

  • Which published SAP APIs does this read, and which communication scenarios do they belong to?
  • Which fields does it read? Could it read fewer?
  • Does it only read, or does it also create or change records? Who approved the write access?
  • Which communication user does it use, and which authentication method? Who rotates the credentials?
  • How many API calls does one business transaction make? What happens when SAP answers slowly or not at all?
  • Is any part of this an AI agent that decides on its own which SAP APIs to call? If so, how does it fit SAP's current API policy?

Common misconceptions

  • "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.

Key terms

  • SAP Business Accelerator Hub: SAP's catalog of APIs, with documentation, a sandbox and Try Out (api.sap.com).
  • A2X API: an SAP API meant for integration with other applications, such as API_SALES_ORDER_SRV.
  • Metadata ($metadata): the machine-readable contract of an OData API: records, fields, types and links.
  • Entity set: a collection of records you can ask for, such as A_SalesOrder.
  • Navigation property: a named link from one record to related records, such as to_Item from an order to its items.
  • Paging: reading a large result a slice at a time.
  • Communication scenario / arrangement / user: SAP's way of opening a specific API to a specific external system with a specific technical identity.
  • CSRF token: a short-lived value SAP services expect before they accept a change request.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
Deep layer · 40 min read

Mental model: the metadata is the contract

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.

How it works

The shape of the Sales Order API

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:

Entity Field Type, length Why we read it
A_SalesOrder SalesOrder String, 10 (key) Identify the order
SoldToParty String, 10 Who the customer is
CreationDate Date When it was created
TotalNetAmount, TransactionCurrency Decimal; String, 5 How much is at stake
DeliveryBlockReason, HeaderBillingBlockReason String, 2 each Why it is blocked
TotalCreditCheckStatus String, 1 Credit check result, kept raw
A_SalesOrderItem SalesOrderItem String, 6 (key with SalesOrder) Identify the item
Material, SalesOrderItemText String, 40 each What was ordered
RequestedQuantity, RequestedQuantityUnit Decimal; String, 3 How much
NetAmount Decimal Item value
ItemBillingBlockReason String, 2 Block at item level

Addressing records

OData V2 addresses follow a small grammar:

Address after the service path Returns
/$metadata The contract, as XML
/A_SalesOrder A collection of orders
/A_SalesOrder('9000001') 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.

Counting and paging

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 values correctly

The OData V2 JSON format has a few quirks that catch every beginner:

In the JSON Means Do this
{"d": {"results": [...]}} A collection Read ["d"]["results"]
"__count": "7" Total with $inlinecount Convert with int(...)
"/Date(1788998400000)/" A date or time, as milliseconds since 1 January 1970 (UTC) Convert to a real date
"18250.00" An Edm.Decimal Use Decimal, not float, for money
"" Field is empty, for example no block Treat as "none", not as a code
"__metadata": {...} Record metadata, such as its address and type Ignore for business logic

Changing data: why GET is different

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.

What you need

  • 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.

  1. Open api.sap.com in your browser and click Log On at the top right.
  2. 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.
  3. Read the Overview: what the API does and which product it belongs to.
  4. 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.
  5. 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

  1. Open VS Code, choose File > Open Folder and open your orchestrate-course folder.

  2. Open a terminal with Terminal > New Terminal.

  3. Turn on the virtual environment:

    • Windows (PowerShell):

      .venv\Scripts\Activate.ps1
    • macOS / Linux:

      source .venv/bin/activate
  4. Check that the prompt starts with (.venv).

  5. Move into the Unit 1 folder, creating it first if it doesn't exist:

    • Windows (PowerShell):

      New-Item -ItemType Directory -Force unit01
      cd unit01
    • macOS / Linux:

      mkdir -p unit01
      cd unit01

Step 3: Save the script

  1. In VS Code's file list, right-click unit01, choose New File and name it first_sap_api.py.
  2. 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()

Step 4: Run it on sample data

  1. Run:

    python first_sap_api.py --sample

    On macOS or Linux, use python3 if python isn't found.

  2. 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
       }
     ]
   }
  1. 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
  1. Open blocked_orders.json in VS Code. It holds the three blocked sample orders with their items.

  2. 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".

Step 5: Run it against SAP's sandbox

  1. Check that .env in your course folder has a line SAP_API_KEY="...". If not, follow the key steps in the setup topic.

  2. Run, without --sample:

    python first_sap_api.py
  3. 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.
  4. Open blocked_orders.json and check that "source" is "sandbox".

Step 6: Change the question

  1. Near the top of the script, add "OverallSDProcessStatus" to the ORDER_FIELDS list (inside the square brackets, with a comma). Save.
  2. Run python first_sap_api.py --sample. Section A now lists OverallSDProcessStatus too: the field exists in the sample contract.
  3. 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.
  4. Remove "DeliveryBlock" again and save.

What each part of the script does

Part of the script What it does
SERVICE, SANDBOX The service path of the Sales Order API and the sandbox address in front of it
ORDER_FIELDS, ITEM_FIELDS The only fields the script asks for. Names from the API's metadata
LiveSAP Sends real GET requests with your key, a 30-second timeout, and XML or JSON as the accepted format
SampleSAP Answers like the sandbox from made-up data, so you can learn without an account
read_contract Downloads $metadata, finds each entity type, and lists its properties and navigation properties
count_orders Asks for one order with $inlinecount=allpages and reads __count
read_orders Reads pages with $top, $skip, $select and $orderby until a short page or the --max limit
read_items Follows A_SalesOrder('<id>')/to_Item for one order
odata_date Turns /Date(milliseconds)/ into a date like 2026-09-10
is_blocked The working rule: a delivery block or billing block is set
clean_order Builds the tidy record: readable names, Decimal amounts as text, null for empty codes
stop Explains an HTTP error in plain words and stops
main Reads the options, picks sample or sandbox, and runs steps A to E

If something goes wrong

What you see What it means What to do
python is not recognized, or command not found Python isn't installed or the terminal can't find it macOS/Linux: use python3. Windows: repeat the Python step in the setup topic and open a new terminal
ModuleNotFoundError: No module named 'requests' The library isn't installed in the active environment Turn on .venv (Step 2), then run pip install -r requirements.txt from the course folder. --sample works without it
TypeError: unsupported operand type(s) for | Your Python is older than 3.10 Install a current Python, as in the setup topic
SAP_API_KEY is not set The script can't find your key Check .env is in the course folder with SAP_API_KEY="..." on its own line, or run with --sample
Stopped: HTTP 401 The key is missing, wrong or incomplete Copy it again with Show API Key on the API's page and update .env
Could not reach sandbox.api.sap.com Your network or a company proxy blocks the site Try another network, or ask IT to allow sandbox.api.sap.com. --sample still works
Stopped: HTTP 429 Too many calls in a short time Wait a minute and run again. Lower --max while you experiment
Field ... is not in this API's metadata A name in ORDER_FIELDS or ITEM_FIELDS is wrong Copy the exact name from the API Reference on the hub. Names are case-sensitive
KeyError: 'A_SalesOrder' The metadata didn't contain the expected entity type Check SERVICE wasn't changed. With --sample, check you pasted the whole script
ParseError from XML The metadata answer wasn't XML, often an HTML error page from a proxy Open the sandbox address in a browser to see what your network returns
SSL: CERTIFICATE_VERIFY_FAILED on macOS Python's certificates aren't set up Run Install Certificates.command in Applications > Python 3.x

Why the script is built this way

  • Contract first. Checking field names against $metadata turns a class of confusing SAP errors into one clear message.
  • Precise questions. $select keeps 8 of more than 80 header fields. --max caps how much a bug can read.
  • Links on demand. Items are read only for the orders that matter, not for every order.
  • One clean output. Later units read blocked_orders.json and never deal with /Date(...)/ again.
  • Sample and live share code. Only the class that sends requests changes. That is how you prototype before you get access to a customer's system.

The SAP way

The walkthrough used raw HTTP so you could see every part. Here is how the same work is done in SAP projects, as of September 2026.

Finding and trusting an API

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.

Opening an API in a company system

SAP Learning describes the S/4HANA Cloud setup, done by an administrator:

  1. Display Communication Scenarios shows the predefined integration scenarios. The Sales Order API belongs to Sales Order Integration (SAP_COM_0109).
  2. Maintain Communication Users creates the technical user your application signs in with.
  3. Communication Systems registers your application as a communication partner.
  4. 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.

Libraries instead of raw HTTP

  • 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.

OData V2 and V4

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.

Build vs. SAP

Situation Use Why
Learning, a first prototype, a one-off analysis on the sandbox Raw requests, as in this topic You see and control every call
A Python tool that reads several OData V2 services pyodata Metadata-driven, less hand-written URL code
A production app on SAP BTP calling S/4HANA SAP Cloud SDK or CAP with BTP destinations SAP-supported authentication, CSRF handling and typed clients
An integration several systems share SAP Integration Suite Central monitoring, mapping and credential handling
An AI agent that chooses SAP API calls on its own An SAP-endorsed agent architecture SAP's April 2026 API policy points agentic use to endorsed architectures; see Units 9 and 11
Bulk data for analytics or training Not row-by-row API reads Data products and replication fit better; see Unit 7

Production concerns

  • 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.

Pitfalls

  • 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.

  1. Copy first_sap_api.py to first_sap_api_partners.py in unit01.

  2. 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.

  3. 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.

  4. Add a function read_partners(api, order_id) modelled on read_items.

  5. In clean_order, add a "partners" list with the two fields, and pass the partners in from main.

  6. Run python first_sap_api_partners.py --sample, then without --sample if you have a key.

  7. 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.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.

Sources

Sign in to track your progress

We'll email you a one-time sign-in link. No password needed.

or

Tell us a little about you

Optional, every field. It helps us pitch answers to your questions at the right level and decide which topics to write next. It is never shown publicly, and you can change or clear it anytime from the account menu.

SAP areas you work in