Orchestrate

Python and APIs for AI engineers

The small part of Python and HTTP you need to connect SAP data to AI models, learned by calling a practice SAP-shaped API on your own computer.

Updated Sep 29, 2026Foundational 7 minDeep 38 min
Foundational layer · 7 min read

The 60-second version

Most of an AI engineer's code is not "AI". It is plumbing: getting the right business data out of one system, sending it to a model, and putting the answer somewhere a person will use it. That plumbing is built from two things.

  • APIs. An API (application programming interface) is a door one program offers so other programs can ask it for data or ask it to do something. SAP S/4HANA offers APIs for sales orders, business partners, invoices and much more. Every large language model is also offered as an API.
  • Python. A programming language that reads almost like English and has ready-made libraries for calling APIs, handling data and working with AI models. It is the default language of AI work.

Put together: a short Python program asks SAP's API for blocked sales orders, turns the answer into a few numbers, and asks a model's API to explain them. That pattern appears in nearly every topic of this course.

Why it matters to the business

When a vendor or a team says "we'll just connect it through the API", a lot is hidden in that sentence. Leaders who understand the basics ask better questions and spot risks earlier.

Take the running example, blocked sales orders in order-to-cash. An AI assistant that helps credit analysts needs to:

  1. Read blocked orders from S/4HANA through an API, with an identity SAP accepts.
  2. Reduce hundreds of fields to the few that matter, so the model sees what it needs and nothing sensitive it doesn't.
  3. Call a model's API, which costs money per request and can be slow or unavailable.
  4. Handle failures: a wrong key, a network block, SAP being down for maintenance.

Each step has a cost and a risk. Steps 1 and 2 decide what data leaves SAP. Step 3 decides the running cost. Step 4 decides whether the tool is trusted after its first bad morning. None of that is about model choice.

How SAP does it

As of September 2026, based on SAP Learning:

  • SAP Business Accelerator Hub (api.sap.com) is SAP's catalog of APIs for its products, including S/4HANA. It offers sandbox APIs with test data, a Try Out feature to call an API from the browser, and an API key for the sandbox after you log on with an SAP account.
  • OData is the protocol behind many SAP APIs. It lets a caller ask for exactly the rows and columns it needs, using options such as $filter, $select and $top.
  • In a real S/4HANA Cloud system, an API isn't open by default. An administrator activates it with a communication arrangement for the matching communication scenario, and gives the calling system a technical communication user. SAP Learning mentions basic (user name and password) and certificate-based authentication.

This split matters for planning: learning and prototyping can start on the sandbox today, but connecting to your company's system is a configuration and security task with its own lead time.

What a single API call involves

Every API call, to SAP or to a model, has the same five parts. This table translates them into business terms.

Part What it is Business question it raises
Address (URL) Which system and which data Is this the production system or a test system?
Method Read (GET) or change (POST, PATCH, DELETE) Can the AI only read, or can it change SAP data?
Credentials A key, user or certificate that says who is calling Whose identity is used, and what is it allowed to see?
Question (parameters) Which rows and fields Is the AI getting only the data it needs?
Answer (status and data) A status code and the data, usually as JSON What happens when the answer is an error?

Questions to ask

Ask your team, vendor or partner:

  • Which SAP APIs does this solution call, and are they APIs SAP publishes for this purpose?
  • Does it only read, or does it also change data in SAP? Who approved that?
  • Which user or communication user does it call SAP with, and what can that user see?
  • Which fields leave SAP, and where do they go? Does any of it reach an external model provider?
  • What happens when SAP or the model API is slow, down or returns an error?
  • How many model calls does one business transaction make, and what does each cost?

Common misconceptions

  • "An API is a product feature you switch on." In a real system, someone has to activate it, create a technical user and grant permissions. Plan the time.
  • "The sandbox proves it works with our system." The sandbox proves the code works with SAP's API shape and test data. Your system's configuration, data and security are different.
  • "AI engineers need to be expert programmers." They need a solid small core, done carefully: timeouts, error handling, no keys in code. That core is learnable in weeks.
  • "More data makes the AI better." Sending every field costs more, slows answers and leaks data. Ask for the fields you need.

Key terms

  • API (application programming interface): a door one program offers to others to request data or actions.
  • HTTP: the protocol web browsers and APIs use to send requests and answers.
  • Endpoint: the address of one API resource, such as sales orders.
  • JSON: a plain-text format for data, built from lists and name-value pairs.
  • Status code: a three-digit number in every answer; 200 means OK, 401 means "who are you?", 404 means "not found".
  • API key: a secret string that identifies the caller.
  • OData: the query protocol behind many SAP APIs.
  • Communication arrangement: the S/4HANA Cloud configuration that opens an API to a specific external system.
  • Sandbox: a test system with made-up data, safe for learning.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 1Why is most of an AI engineer's code "plumbing" rather than AI?

    Answer: A. The work is getting the right business data out of a system through its API, sending it to a model's API, and putting the answer where a person will use it. Python and APIs are the building blocks of that plumbing.
  2. 2Of the blocked-orders assistant's steps, which decide what data leaves SAP?

    Answer: D. Read blocked orders through an API, reduce the fields to what matters, call a model's API, and handle failures. Reading and reducing decide what data leaves SAP; the model call decides running cost; failure handling decides whether people trust the tool.
  3. 3What does SAP Business Accelerator Hub give a team that is starting out?

    Answer: C. SAP's catalog of APIs, with documentation, sandbox APIs with test data, a Try Out feature to call an API from the browser, and an API key for the sandbox. Learning and prototyping can start there today.
  4. 4What has to happen before a team can call your company's S/4HANA Cloud system?

    Answer: B. An administrator activates the API with a communication arrangement for its communication scenario and gives the calling system a technical communication user. It is a configuration and security task with its own lead time.
  5. 5Which business question does an API call's method (GET, POST, PATCH, DELETE) raise?

    Answer: A. The method says whether a call reads (GET) or changes data (POST, PATCH, DELETE). The other questions come from the other parts of a call: the address (production or test), the credentials (whose identity) and the answer (what happens on an error).
  6. 6Why is "more data makes the AI better" a misconception?

    Answer: D. Sending every field costs more, slows answers and leaks data. Asking only for the fields you need is cheaper, faster and safer.
  7. 7A vendor says "we'll just connect it through the API". Which question matters most for data protection?

    Answer: C. Which fields leave SAP, and where they go, decides what data could reach an external model provider. Also ask which APIs it calls, whether it can change data, which user it calls SAP with and what happens on errors.
Deep layer · 38 min read

Mental model: a question in a letter, an answer in a letter

An API call is an exchange of two letters.

Your program writes a request: an address, a verb ("get me", "create this"), some envelope notes (headers, such as your key) and the question itself (parameters, or a body of data). The server writes back a response: a three-digit status code that says how it went, some envelope notes, and the answer, usually as JSON.

The second half of the idea is what makes Python a good fit: JSON turns into Python's own building blocks. A JSON object becomes a Python dictionary, a JSON array becomes a list, and so on. Once an API answer is in your program, you are just working with dictionaries and lists.

sequenceDiagram
  participant P as Your Python program
  participant S as SAP API
  participant M as Model API
  P->>S: GET sales orders, key, filter
  S-->>P: 200 OK, JSON records
  P->>P: Reduce records to a few numbers
  P->>M: POST a prompt with those numbers
  M-->>P: 200 OK, JSON with the model's text

That diagram is the skeleton of the blocked-orders assistant from What an SAP FDE does, and of most things you build in this course. The model call is also an API call. Learn the pattern once and you can call SAP, a model provider, a vector database or a ticketing system the same way.

How it works

The anatomy of a request

Here is one real-shaped request to SAP's Sales Order API, taken apart:

GET https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=2&$select=SalesOrder,SoldToParty
APIKey: <your key>
Accept: application/json
Part In the example What it does
Method GET Read. POST creates, PATCH changes, DELETE removes
Host sandbox.api.sap.com Which server
Path /s4hanacloud/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder Which service (API_SALES_ORDER_SRV) and which set of records (A_SalesOrder)
Query parameters $top=2&$select=SalesOrder,SoldToParty The question: how many rows, which fields. They come after ?, joined by &
Headers APIKey, Accept Envelope notes: who you are, which format you want back
Body none for GET The data you send when you create or change something

In Python with the requests library, the same request is one line. You pass the parameters and headers as dictionaries and requests builds the address for you:

# Sketch: shows the shape of the call. The runnable version is in "Build it yourself".
resp = requests.get(url, params={"$top": "2"}, headers={"APIKey": key}, timeout=30)

Always pass timeout. The Requests documentation warns that without it your program can hang indefinitely; nearly all production code should set it.

The anatomy of a response

Every response starts with a status code. The first digit tells you the class, per MDN: 1xx informational, 2xx success, 3xx redirection, 4xx client error (your request was wrong), 5xx server error (the server failed).

Code Name What it usually means for you
200 OK It worked; the answer is in the body
201 Created A create request worked
400 Bad Request The server couldn't understand your question: check parameters and syntax
401 Unauthorized Missing or wrong credentials
403 Forbidden The server knows who you are, but you aren't allowed
404 Not Found Wrong address, or the record doesn't exist
429 Too Many Requests You hit a rate limit: wait and slow down
500 Internal Server Error The server failed; not your fault, but you must handle it
503 Service Unavailable Down or overloaded; try again later

From JSON to Python

The Python documentation's conversion table is short enough to learn by heart:

JSON Python
object {...} dict
array [...] list
string "..." str
number int or float
true / false True / False
null None

With requests, resp.json() does the conversion. SAP's OData V2 APIs wrap the list of records in an outer object: the records sit under d, then results. So the list of orders is resp.json()["d"]["results"].

One trap: in the sales order data used in this course, amounts such as TotalNetAmount arrive as text ("18250.00"), not numbers. Convert with float(...) before you add them up. For money in production code, Python's decimal module avoids rounding surprises.

Asking good questions with OData

OData lets you shape the answer in the request instead of downloading everything and throwing most of it away. SAP Learning's OData course lists the main query options:

Option What it does Example
$select Only these fields $select=SalesOrder,SoldToParty
$filter Only rows that match $filter=SoldToParty eq 'CUST-A'
$top At most this many rows $top=50
$skip Skip this many rows first, for paging $top=50&$skip=50 for the second page
$orderby Sort $orderby=TotalNetAmount desc
$expand Include related records in one call Items with their order
$inlinecount (V2), $count (V4) Include the total number of matches $inlinecount=allpages
$format Answer format $format=json

For AI work, $select matters most. Every field you don't request is a field that can't leak into a prompt, can't cost tokens and can't confuse the model.

The Python you need

AI engineering uses a small core of Python again and again. The script below shows all of it on one sales order. You will run it in Step 2 of the walkthrough; read it now to see the shapes.

Idea Why an AI engineer needs it
Variables and types Know whether "18250.00" is text or a number
f-strings Build messages and prompts from data
Dictionaries One API record is a dictionary
Lists An API answer is a list of records
Loops and if Go through records, keep the ones that matter
List comprehensions Filter and reshape records in one line
Functions Name a rule once (for example "is this order blocked?") and test it
json.loads / json.dumps Read API text into Python; write Python back as JSON
try / except Handle missing fields and failed calls without crashing

Build it yourself: an API lab on your own computer

You will run two small programs. The first shows the Python core from the table above on one sales order. The second, the API lab, starts a small practice API on your own computer that behaves like SAP's Sales Order API. It then calls that API six times to show a missing key, a good call, $select, $filter, a wrong address and turning records into numbers. The same client code can then point at SAP's real sandbox and, optionally, at a language model.

flowchart LR
  L["api_lab.py: client"] -->|GET with key| P["Practice API on your computer"]
  L -.->|"--sap"| S["SAP sandbox"]
  L -.->|"--llm"| M["Model API"]
  P -->|JSON| L
  L --> O["Status codes and a summary"]

The practice API needs no account and no internet connection, so every reader can see every result, even behind a company firewall.

Before you start: complete Set up your computer for this course. It installs Python, VS Code and Git, creates your orchestrate-course folder with its .venv virtual environment, installs requests, anthropic and python-dotenv, and stores your keys in .env. This walkthrough doesn't repeat those steps.

What you need

  • The course setup above. Nothing else for Steps 1 to 5; they are free and work offline.
  • For optional Step 6: your free SAP Business Accelerator Hub key in .env as SAP_API_KEY.
  • For optional Step 7: a Claude API key in .env as ANTHROPIC_API_KEY. Each run makes one small request, which costs a small per-request charge.
  • About an hour the first time.

Step 1: 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 the 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. If it doesn't exist yet, create it first with mkdir unit01:

    cd unit01

Step 2: Run the Python core script

  1. In VS Code's file list, right-click unit01, choose New File and name it python_core.py.
  2. Paste the script below and save with Ctrl+S (Windows, Linux) or Cmd+S (macOS).
"""The part of Python an AI engineer uses every day, on one sales order.

Run it:  python python_core.py
"""
import json

# 1. Values and variables: a name that points at a value.
order_id = "9000001"               # text (str)
amount = 18250.00                  # a number with decimals (float)
is_blocked = True                  # True or False (bool)
print(type(order_id), type(amount), type(is_blocked))

# 2. f-strings: put values inside text.
print(f"Order {order_id} is worth {amount:,.2f} USD")

# 3. Dictionaries: named fields, like one record from an API.
order = {"SalesOrder": "9000001", "SoldToParty": "CUST-A", "DeliveryBlockReason": "01"}
print(order["SoldToParty"])                    # read a field that must exist
print(order.get("HeaderBillingBlockReason"))   # .get returns None if the field is missing

# 4. Lists: many values in order, like the records an API returns.
orders = [order, {"SalesOrder": "9000002", "SoldToParty": "CUST-B", "DeliveryBlockReason": ""}]
print(len(orders), "orders")

# 5. Loops and if: do something for each item, but only when a condition holds.
for o in orders:
    if o["DeliveryBlockReason"]:               # empty text counts as False
        print("blocked:", o["SalesOrder"])

# 6. List comprehension: build a new list from an old one in one line.
blocked = [o["SalesOrder"] for o in orders if o["DeliveryBlockReason"]]
print(blocked)


# 7. Functions: name a piece of logic so you can reuse and test it.
def is_order_blocked(o: dict) -> bool:
    return bool(o.get("DeliveryBlockReason") or o.get("HeaderBillingBlockReason"))


print([is_order_blocked(o) for o in orders])

# 8. JSON: the text format APIs send. json.loads turns text into Python; json.dumps goes back.
text = '{"d": {"results": [{"SalesOrder": "9000003", "TotalNetAmount": "5100.00"}]}}'
data = json.loads(text)
first = data["d"]["results"][0]
print(float(first["TotalNetAmount"]) * 2)      # amounts often arrive as text: convert first
print(json.dumps(first, indent=2))

# 9. Errors: expect them and say what went wrong instead of crashing.
try:
    print(first["DeliveryBlockReason"])
except KeyError as err:
    print("field not in this record:", err)
  1. Run it:

    python python_core.py

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

  2. You should see exactly this:

<class 'str'> <class 'float'> <class 'bool'>
Order 9000001 is worth 18,250.00 USD
CUST-A
None
2 orders
blocked: 9000001
['9000001']
[True, False]
10200.0
{
  "SalesOrder": "9000003",
  "TotalNetAmount": "5100.00"
}
field not in this record: 'DeliveryBlockReason'
  1. Match each output line to the numbered comment in the script. Then change something and run again: set "DeliveryBlockReason": "01" on the second order and watch lines 5 to 7 change.

Step 3: Save the API lab script

  1. Create another new file in unit01 named api_lab.py.
  2. Paste the whole script below and save.
"""API lab: learn how Python talks to web APIs, using a small SAP-shaped practice API.

How to run (from the folder that holds this file):
  python api_lab.py          start a practice API on your own computer and run six experiments
  python api_lab.py --sap    run the same client against SAP's sandbox (needs SAP_API_KEY in .env)
  python api_lab.py --llm    also ask a language model to summarize the result (needs ANTHROPIC_API_KEY)

The practice API only exists while the script runs. Its data is made up.
"""
import json
import os
import sys
import threading
from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlencode, urlparse

import requests

try:  # read keys from a .env file if you set one up (see "Set up your computer")
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

PORT = 8765                      # the local "door number" of the practice API
PRACTICE_KEY = "course-demo-key"  # the practice API's key; it protects nothing real
SERVICE = "/sap/opu/odata/sap/API_SALES_ORDER_SRV"
SAP_SANDBOX = "https://sandbox.api.sap.com/s4hanacloud" + SERVICE

# Made-up sales orders, shaped like SAP's Sales Order API. Amounts are text, as in OData V2.
ORDERS = [
    {"SalesOrder": "9000001", "SoldToParty": "CUST-A", "TotalNetAmount": "18250.00",
     "TransactionCurrency": "USD", "DeliveryBlockReason": "01", "HeaderBillingBlockReason": ""},
    {"SalesOrder": "9000002", "SoldToParty": "CUST-B", "TotalNetAmount": "940.00",
     "TransactionCurrency": "USD", "DeliveryBlockReason": "", "HeaderBillingBlockReason": "02"},
    {"SalesOrder": "9000003", "SoldToParty": "CUST-C", "TotalNetAmount": "5100.00",
     "TransactionCurrency": "USD", "DeliveryBlockReason": "", "HeaderBillingBlockReason": ""},
    {"SalesOrder": "9000004", "SoldToParty": "CUST-A", "TotalNetAmount": "7300.00",
     "TransactionCurrency": "USD", "DeliveryBlockReason": "01", "HeaderBillingBlockReason": ""},
    {"SalesOrder": "9000005", "SoldToParty": "CUST-D", "TotalNetAmount": "12000.00",
     "TransactionCurrency": "USD", "DeliveryBlockReason": "", "HeaderBillingBlockReason": ""},
]


# ---------------------------------------------------------------- the practice API (server)

class PracticeAPI(BaseHTTPRequestHandler):
    """Answers GET requests the way a simple OData V2 service would."""

    def do_GET(self):
        url = urlparse(self.path)
        query = {k: v[0] for k, v in parse_qs(url.query).items()}
        if self.headers.get("APIKey") != PRACTICE_KEY:
            return self.reply(401, {"error": {"message": "Missing or wrong API key"}})
        if url.path != SERVICE + "/A_SalesOrder":
            return self.reply(404, {"error": {"message": f"No such resource: {url.path}"}})
        rows = ORDERS
        if "$filter" in query:  # supports one condition: Field eq 'value' or Field ne 'value'
            try:
                field, op, value = query["$filter"].split(" ", 2)
                value = value.strip("'")
                assert op in ("eq", "ne") and field in ORDERS[0]
            except (ValueError, AssertionError):
                return self.reply(400, {"error": {"message": "Filter must look like: Field eq 'value'"}})
            rows = [r for r in rows if (r[field] == value) == (op == "eq")]
        skip, top = int(query.get("$skip", 0)), int(query.get("$top", len(rows)))
        rows = rows[skip:skip + top]
        if "$select" in query:
            fields = query["$select"].split(",")
            rows = [{f: r[f] for f in fields if f in r} for r in rows]
        self.reply(200, {"d": {"results": rows}})

    def reply(self, status: int, body: dict) -> None:
        data = json.dumps(body).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):  # keep the terminal quiet
        pass


def start_practice_api() -> str:
    """Start the practice API in the background and return its address."""
    try:
        server = ThreadingHTTPServer(("127.0.0.1", PORT), PracticeAPI)
    except OSError:
        sys.exit(f"Port {PORT} is busy. Change PORT near the top of the script, save and run again.")
    threading.Thread(target=server.serve_forever, daemon=True).start()
    return f"http://127.0.0.1:{PORT}{SERVICE}"


# ---------------------------------------------------------------- the client (what you reuse)

def get_json(url: str, key: str | None, params: dict | None = None) -> tuple[int, dict]:
    """Send one GET request and return (status code, JSON body). Never hangs, never crashes."""
    headers = {"Accept": "application/json"}
    if key:
        headers["APIKey"] = key
    try:
        query = urlencode(params or {}, quote_via=quote, safe="$,")  # spaces become %20
        resp = requests.get(url, headers=headers, params=query, timeout=30)
    except requests.exceptions.Timeout:
        return 0, {"error": "The server did not answer within 30 seconds."}
    except requests.exceptions.ConnectionError:
        return 0, {"error": f"Could not connect to {urlparse(url).netloc}. Network or proxy?"}
    print(f"   GET {resp.url}")
    try:
        body = resp.json()
    except ValueError:  # the answer was not JSON, for example an HTML error page
        body = {"error": "Response was not JSON", "text": resp.text[:200]}
    return resp.status_code, body


MEANING = {  # what the common status codes mean, in plain words
    200: "OK", 400: "Bad Request: the server didn't understand the question",
    401: "Unauthorized: missing or wrong key", 403: "Forbidden: known key, but not allowed",
    404: "Not Found: wrong address", 429: "Too Many Requests: slow down",
    500: "Internal Server Error: the server failed", 503: "Service Unavailable: try later",
}


def meaning(status: int) -> str:
    return f"{status} {MEANING.get(status, 'see the list of status codes')}"


def records(body: dict) -> list:
    """OData V2 puts the list of records under d -> results."""
    return body.get("d", {}).get("results", [])


def summarize(orders: list) -> dict:
    """Turn raw records into the numbers a person cares about."""
    blocked = [o for o in orders if o.get("DeliveryBlockReason") or o.get("HeaderBillingBlockReason")]
    return {
        "orders_read": len(orders),
        "blocked": len(blocked),
        "blocked_value": round(sum(float(o.get("TotalNetAmount") or 0) for o in blocked), 2),
        "blocked_by_customer": dict(Counter(o.get("SoldToParty") for o in blocked)),
    }


def ask_llm(summary: dict) -> str:
    """Ask a language model for a two-sentence summary. Replace this function for another provider."""
    import anthropic  # imported here so the rest runs without it

    client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment
    message = client.messages.create(
        model=os.environ.get("LLM_MODEL", "claude-opus-5-5"),
        max_tokens=300,
        messages=[{"role": "user", "content":
                   "In two sentences for a credit manager, summarize these blocked sales order "
                   "figures. Use only these numbers; don't guess what block codes mean.\n"
                   + json.dumps(summary)}],
    )
    return "".join(block.text for block in message.content if block.type == "text")


# ---------------------------------------------------------------- the experiments

def main() -> None:
    args = sys.argv[1:]
    if "--sap" in args:
        base, key = SAP_SANDBOX, os.environ.get("SAP_API_KEY")
        if not key:
            sys.exit("SAP_API_KEY is not set. Add it to .env (see Set up your computer).")
        print("Target: SAP Business Accelerator Hub sandbox\n")
    else:
        base, key = start_practice_api(), PRACTICE_KEY
        print(f"Target: practice API running on your computer at {base}\n")
    orders_url = base + "/A_SalesOrder"

    print("1. Call without a key")
    status, body = get_json(orders_url, None, {"$top": "2"})
    if status == 0:
        sys.exit(f"   -> no answer: {body['error']}")
    print(f"   -> {meaning(status)}\n")

    print("2. Call with the key")
    status, body = get_json(orders_url, key, {"$top": "2", "$format": "json"})
    print(f"   -> {meaning(status)}, {len(records(body))} records. First one:")
    print("   " + json.dumps(records(body)[:1], indent=2).replace("\n", "\n   ") + "\n")

    print("3. Ask for fewer fields with $select")
    status, body = get_json(orders_url, key, {"$top": "3", "$select": "SalesOrder,SoldToParty",
                                              "$format": "json"})
    print(f"   -> {meaning(status)}: {records(body)}\n")

    print("4. Ask for fewer rows with $filter")
    status, body = get_json(orders_url, key, {"$top": "50", "$filter": "DeliveryBlockReason ne ''",
                                              "$format": "json"})
    print(f"   -> {meaning(status)}: {len(records(body))} orders with a delivery block\n")

    print("5. Ask for something that doesn't exist")
    status, body = get_json(base + "/A_SalesOrderTypo", key, {"$top": "1"})
    print(f"   -> {meaning(status)}\n")

    print("6. Turn records into numbers")
    status, body = get_json(orders_url, key, {"$top": "50", "$format": "json"})
    if status != 200:
        sys.exit(f"   -> {meaning(status)}: {body}. Stopping here.")
    summary = summarize(records(body))
    print("   " + json.dumps(summary, indent=2).replace("\n", "\n   "))

    if "--llm" in args:
        print("\n7. Ask a language model to put it in words")
        print("   " + ask_llm(summary))


if __name__ == "__main__":
    main()

Step 4: Run the six experiments

  1. Run:

    python api_lab.py
  2. You should see this (the practice data is fixed, so yours will match):

Target: practice API running on your computer at http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV

1. Call without a key
   GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=2
   -> 401 Unauthorized: missing or wrong key

2. Call with the key
   GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=2&$format=json
   -> 200 OK, 2 records. First one:
   [
     {
       "SalesOrder": "9000001",
       "SoldToParty": "CUST-A",
       "TotalNetAmount": "18250.00",
       "TransactionCurrency": "USD",
       "DeliveryBlockReason": "01",
       "HeaderBillingBlockReason": ""
     }
   ]

3. Ask for fewer fields with $select
   GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=3&$select=SalesOrder,SoldToParty&$format=json
   -> 200 OK: [{'SalesOrder': '9000001', 'SoldToParty': 'CUST-A'}, {'SalesOrder': '9000002', 'SoldToParty': 'CUST-B'}, {'SalesOrder': '9000003', 'SoldToParty': 'CUST-C'}]

4. Ask for fewer rows with $filter
   GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=50&$filter=DeliveryBlockReason%20ne%20%27%27&$format=json
   -> 200 OK: 2 orders with a delivery block

5. Ask for something that doesn't exist
   GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrderTypo?$top=1
   -> 404 Not Found: wrong address

6. Turn records into numbers
   GET http://127.0.0.1:8765/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder?$top=50&$format=json
   {
     "orders_read": 5,
     "blocked": 3,
     "blocked_value": 26490.0,
     "blocked_by_customer": {
       "CUST-A": 2,
       "CUST-B": 1
     }
   }
  1. Read the results in order. Each experiment teaches one thing:
Experiment What you see What it teaches
1. Without a key 401 Unauthorized The server checks the APIKey header before anything else
2. With the key 200 OK and a record The answer is JSON; OData V2 puts records under d, results
3. $select Only two fields per record Ask only for what you need
4. $filter Only orders with a delivery block Let the server do the filtering. Notice the space became %20 in the address
5. Wrong address 404 Not Found A typo in the path is a different error from a wrong key
6. Summary Counts and a total Records become the few numbers a person or a model needs

When the script ends, the practice API stops too. It only exists while the script runs.

Step 5: Change the question

  1. In api_lab.py, find experiment 4 and change the filter from "DeliveryBlockReason ne ''" to "SoldToParty eq 'CUST-A'". Change the printed text after it to orders for CUST-A. Save.
  2. Run python api_lab.py again. Experiment 4 should now report 2 orders for CUST-A.
  3. Now break it on purpose: change the filter to "SoldToParty equals 'CUST-A'" and run again. Experiment 4 now shows 400 Bad Request and 0 orders, because the practice API only understands eq and ne. That is what a malformed question looks like.
  4. Put the filter back to "DeliveryBlockReason ne ''" and the text back to orders with a delivery block, and save.

Step 6 (optional): Point the same client at SAP's sandbox

  1. Check that .env in your course folder has a line SAP_API_KEY="..." with your key from api.sap.com (Show API Key on an API page). The script finds .env in the folder above unit01 on its own.

  2. Run:

    python api_lab.py --sap
  3. You should see Target: SAP Business Accelerator Hub sandbox and the same six experiments, now with SAP's addresses and SAP's demo orders. Experiment 1 should show 401. Experiment 2 onwards should show 200 OK. The records have the same field names as the practice API, because the practice API copies the shape of SAP's.

  4. Some results may differ, and that is valid, not an error: experiment 4 may find 0 orders with a delivery block if the demo data has none. Experiment 5 may return a different 4xx code, because each server words its errors in its own way. The sandbox is shared and its data changes.

Step 7 (optional): Ask a model to put it in words

  1. If you haven't yet, create a key in the Claude Console and add it to .env as ANTHROPIC_API_KEY="...", as described in the setup topic. Using another provider? Replace only the ask_llm function.

  2. Run:

    python api_lab.py --llm

    Add --sap as well to summarize the sandbox data instead of the practice data.

  3. After experiment 6 you get a seventh block with two sentences from the model. The wording varies from run to run. For the practice data it should mention 3 blocked orders worth 26,490 and that CUST-A has two of them.

Notice what the model receives: four numbers and a customer count, not the raw orders. Reducing data before a model call is a habit you will use in every later unit.

What each part of the script does

Part of the script What it does
PORT, PRACTICE_KEY, SERVICE, SAP_SANDBOX Settings: the practice door number and key, the service path, and SAP's sandbox address
ORDERS Five made-up sales orders shaped like SAP's
PracticeAPI.do_GET The practice server: checks the key (401), checks the address (404), applies $filter (400 if malformed), $skip, $top and $select, and answers in OData V2 shape
reply Writes the status code, headers and JSON body of an answer
start_practice_api Starts the practice server in the background, on your computer only
get_json The client you reuse: builds the address, sends the key, always uses a timeout, and turns network failures into a clear message instead of a crash
MEANING, meaning Plain-language names for common status codes
records Takes the list of records out of the d, results wrapper
summarize Counts blocked orders, adds up their value and groups them by customer
ask_llm Only with --llm: sends the summary to a model and returns its text
main Chooses the target (practice or --sap) and runs the experiments in order

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 Step 1 of 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 1), then run pip install -r requirements.txt from the course folder
TypeError: unsupported operand type(s) for | Your Python is older than 3.10 Install a current Python, as in the setup topic
Port 8765 is busy Another program, or an earlier run that is still open, uses that door Close other terminals running the script, or change PORT to another number such as 8766
Windows asks whether Python may use the network Windows Firewall noticed the practice server It only listens on your own computer; you can choose to deny outside access and the lab still works
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
Experiment 2 shows 401 with --sap The key is wrong or incomplete Copy it again with Show API Key on api.sap.com and update .env
no answer: Could not connect to sandbox.api.sap.com Your network or a company proxy blocks it Try a home network, or ask IT to allow sandbox.api.sap.com; Steps 1 to 5 still work
An error mentioning authentication or the API key with --llm The model key is missing or wrong Check ANTHROPIC_API_KEY in .env
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

  • One function does every call. Timeouts, headers and error handling live in get_json. When you add retries or logging later, you change one place.
  • Errors become messages, not crashes. A beginner's first SAP call often fails for network reasons. The script says which kind of failure it was.
  • The practice API mirrors SAP's shape. Code you write against it works against the sandbox with a new address and key. That is how FDEs prototype before they get access to a customer's system.
  • The model gets numbers, not records. Less data in the prompt means lower cost, less leakage and fewer ways for the model to go wrong.

The SAP way

The API lab taught the general mechanics. Here is how they map to SAP, as of September 2026.

Finding an API: SAP Business Accelerator Hub

SAP Business Accelerator Hub at api.sap.com is where SAP publishes APIs for its products, including S/4HANA. SAP Learning describes three things it gives a learner:

  • Documentation of each API: its entities, fields and parameters.
  • Sandbox APIs with test data, so you can call a real SAP API shape without owning a system.
  • Try Out, to call an API from the browser, and Show API Key, to get the sandbox key your code sends in the APIKey header.

Start every SAP integration here. The field names, entity sets and parameters you use in code should come from the API's page on the hub, not from memory or a blog post.

Calling a real system: communication arrangements

A sandbox key doesn't open a company's S/4HANA Cloud system. SAP Learning's integration course describes how an administrator exposes an API there:

  1. Every API belongs to one or more communication scenarios, which bundle the inbound and outbound pieces for an integration.
  2. The administrator creates a communication user, a technical user for the calling system.
  3. They create a communication system, representing your application.
  4. They create and activate a communication arrangement for the scenario, linking the system and user.

SAP Learning names basic authentication (user name and password) and certificate-based authentication for these users. Other set-ups, such as calling through SAP BTP destinations, come up in later units.

OData versions

SAP APIs come in OData V2 and OData V4 flavors, and the JSON looks different. V2 wraps records in d and results, as in this lab. V4 puts them in a value array, and the count option is $count instead of V2's $inlinecount. Check the version on the API's page before you write the code that reads the answer.

The model side

A model provider's API follows the same rules: an address, a key in a header, a JSON request, a status code and a JSON answer. The Anthropic Python SDK used in Step 7 hides the HTTP details: client.messages.create(...) sends one request and reads the key from ANTHROPIC_API_KEY. SAP's own route to models, the generative AI hub, is covered in Unit 5.

Build vs. SAP

Situation Use Why
Learning, prototypes, a few scripted calls requests directly, as in this topic You see every part of the call; nothing hidden
A model provider's API The provider's SDK (such as anthropic) Handles authentication, retries and response parsing for you
Production apps on SAP BTP calling S/4HANA SAP's SDKs and BTP connectivity services Built for SAP authentication, destinations and typed clients; covered from Unit 5 on
Reading many records for analytics Not row-by-row API calls Bulk extraction and data products are better fits; see the data topics in Unit 7
An integration many systems share SAP Integration Suite Central monitoring and mapping instead of point-to-point scripts

The pattern: raw HTTP to learn and prototype, an SDK when one exists for the target, a platform service when the integration outlives the prototype.

Production concerns

  • Timeouts on every call. A call without a timeout can hang your program. The Requests documentation says nearly all production code should set one.
  • Retries with care. Retry on 429, 503 and network errors, with a growing wait between tries. Don't retry on 400, 401, 403 or 404: the answer won't change. Never blindly retry a create request, or you may create the record twice.
  • Paging. Real systems hold more records than one call returns. Loop with $top and $skip until a page comes back short, and set an upper limit so a bug can't read a million records.
  • Least privilege. The communication user an AI tool uses should see only what the tool needs. Read-only unless changes are part of the design and approved.
  • Data minimization. Use $select. Strip or mask personal data before it reaches a prompt. Unit 11 covers this in depth.
  • Secrets. Keys in .env locally; a secrets store or SAP BTP service bindings in production. Never in code, logs or screenshots.
  • Logging. Log the address, status code and timing of each call, not the full response body, which may hold personal data.
  • Cost. Every model call costs money. Count model calls per business transaction before you scale.
  • Clean core. Use the APIs SAP publishes for integration rather than reading SAP tables directly. Published APIs survive upgrades; table layouts are not a contract.

Pitfalls

  • Forgetting timeout. The script works on your laptop, then hangs in production on the first slow day.
  • Treating every error the same. 401 needs a new key; 404 needs a new address; 503 needs patience. A single "API error" message wastes hours.
  • Adding amounts as text. "940.00" + "5100.00" is "940.005100.00", not a number. Convert first.
  • Reading V4 answers with V2 code. ["d"]["results"] fails on V4, where records are under value.
  • Downloading everything, filtering in Python. Slow, expensive and a data-protection risk. Filter and select on the server.
  • Keys in code. The fastest way to leak one is to paste it into a script "just for a test" and push it.
  • Guessing field names. DeliveryBlockReason, not DeliveryBlock. Copy names from the API's page on the Business Accelerator Hub.

Exercise: save a blocked-orders summary as JSON

Extend the API lab so its result can feed later work.

  1. Copy api_lab.py to a new file blocked_summary.py in unit01.

  2. At the end of main(), after the summary is printed, add these two lines (inside main, with the same indentation as the print above them):

        with open("blocked_summary.json", "w", encoding="utf-8") as f:
            json.dump(summary, f, indent=2)
  3. Run python blocked_summary.py. Check that blocked_summary.json appears in unit01 and holds the same numbers as experiment 6.

  4. Add "blocked_value_by_customer" to summarize: a dictionary from customer to the total value of their blocked orders. Hint: start with an empty dictionary and add each blocked order's float(TotalNetAmount) to its customer's entry with .get(customer, 0).

  5. If you have a sandbox key, run python blocked_summary.py --sap and compare the file with the practice result.

  6. Save your work in Git from the course folder:

    git add unit01/python_core.py unit01/api_lab.py unit01/blocked_summary.py
    git commit -m "Add API lab and blocked-orders summary"

Done when: blocked_summary.json holds orders_read, blocked, blocked_value, blocked_by_customer and your new blocked_value_by_customer, and git log shows the commit. For the practice data, blocked_value_by_customer should be {"CUST-A": 25550.0, "CUST-B": 940.0}. Later in Unit 1 you call SAP's API in more depth, and this summary shape is the input your later triage and evaluation work starts from.

Check yourself

Pick one answer for each question. The explanation appears after you choose.
  1. 1Which part of an HTTP request carries the API key?

    Answer: B. Method, host, path, query parameters, headers and, for creates and changes, a body. The key travels in a header; for the SAP sandbox that header is APIKey.
  2. 2Experiment 1 returns 401 and experiment 5 returns 404. What do you fix?

    Answer: A. 401 Unauthorized means missing or wrong credentials: fix the key. 404 Not Found means a wrong address or a record that doesn't exist: fix the path. Different errors need different fixes, so never report them all as "API error".
  3. 3Why pass timeout on every call, and what should your program do when a call times out?

    Answer: D. Without a timeout a call can hang your program indefinitely. On a timeout, report which call failed, and retry only with a growing wait if the call is safe to repeat.
  4. 4A colleague sends the model every field of 500 sales orders. Name two OData options that would help, and two reasons to use them.

    Answer: C. $select for only the needed fields and $filter for only the relevant orders, such as blocked ones. They cut cost and tokens, and they keep data that isn't needed out of the prompt.
  5. 5What does resp.json()["d"]["results"] return for an OData V2 answer, and what trap waits in the amounts?

    Answer: B. The list of records, each a Python dictionary. Amounts such as TotalNetAmount arrive as text, like "18250.00", so convert them before adding up; use decimal for money in production.
  6. 6Which errors should your code retry?

    Answer: A. Retry 429, 503 and network errors with a growing wait. Don't retry 400, 401, 403 or 404, because the answer won't change. Never blindly retry a create request, or you may create the record twice.
  7. 7What must an administrator set up before your code can call a company's S/4HANA Cloud system rather than the sandbox?

    Answer: D. A communication user, a communication system for your application, and an active communication arrangement for the API's communication scenario. Your code then sends those credentials instead of the sandbox APIKey.
  8. 8You switch from an OData V2 API to a V4 API and your code fails with KeyError: 'd'. Why?

    Answer: C. V2 wraps records in d and results; V4 puts them in a value array. Check the OData version on the API's hub page before writing the code that reads the answer.

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