SAP Insiders
Articles/Integration/From contract to call — shipping a Planned Order integration with Postman
Integration

From contract to call — shipping a Planned Order integration with Postman

Vajra's MES needs to push planned orders into S/4HANA Cloud. One afternoon, three phases: read the API contract on Business Accelerator Hub, build the requests in Postman, automate auth + ETag + tests. Real OData V4 calls, real headers, real failures we caught before production.

From contract to call — dark developer notebook hero showing a mock Postman collection screen with the POST request to the SAP S/4HANA Cloud Planned Order OData V4 endpoint, headers Authorization Bearer access_token, x-csrf-token, Content-Type application/json, and a JSON body with Material SFG-004 MRPArea 1110 TotalQuantity 120 PlndOrderPlannedEndDate 2026-07-04 PlannedOrderProfile LA; footer reads 2 hours 18 requests 1 mock 1 monitor

Vajra Precision Tools, Pune. Their CNC machining line runs two shifts. Every morning, the production planner finalises the shop-floor schedule in the MES — which operations run on which machines, in which slots, with which materials. Until three months ago, that schedule had to be re-keyed into S/4HANA Cloud manually: MD11 for each planned order, one at a time. Forty-five minutes every morning, three data-entry errors per week on average.

The fix: MES emits a planned-order.create event for each confirmed slot. A lightweight middleware picks it up and POSTs to S/4HANA Cloud. The planner stops touching S/4 for this step entirely.

Here is how that integration was built — from reading the API contract to a Postman collection that tests itself.

The brief, in five lines

  • Trigger: MES confirms a production slot and emits planned-order.create.
  • Outcome: a planned order exists in S/4HANA Cloud for plant 1110, with the correct production version, quantity, and schedule dates.
  • Volume: ~80 events/day, bursts to 30/minute at shift-start.
  • Window: 30-second retry budget; after that the MES queues for the next batch.
  • Auth: existing communication user, OAuth 2 client-credentials.

That is the entire design brief. Everything else came from the API contract.

Design phase — read the contract before you open Postman

The single most useful 20 minutes you'll spend on any S/4HANA Cloud integration: open the API on Business Accelerator Hub, read the entities, read the operations, read the constraints. Three questions to answer before you write a single URL:

  1. Which entity holds what you want to write? For us: PlannedOrderHeader (the production order proposal) and PlannedOrderComponent (the components, used when MES knows the exact BOM without triggering a BOM explosion).
  2. What's mandatory in the request body? For "Create without account assignment" — Material, MRPArea, TotalQuantity, and at least one of PlndOrderPlannedStartDate / PlndOrderPlannedEndDate / PlannedOrderOpeningDate.
  3. What do the constraint warnings actually cost you? Two that matter for a manufacturing MES: Variant Configuration is not supported and automatic integration to PP/DS is not enabled. Either of those would have killed scope in week two.

The endpoint pattern:

<host>/sap/opu/odata4/sap/api_plannedorder/srvd_a2x/sap/plannedorder/0001/PlannedOrderHeader

Bookmark it. You will type variations of this URL forty times.

Two ceremonies before the real call

Sequence diagram showing three actors as columns — Vajra MES on the left, S/4HANA Public Cloud in the centre, Postman test rig on the right; four messages going down the page; first POST to OAuth token endpoint with client_credentials returning a 200 with access_token; second a GET to the metadata endpoint with x-csrf-token fetch header returning 200 with x-csrf-token A1B2; third a POST to PlannedOrderHeader with body returning 201 Created with PlannedOrder 1428683; fourth a dashed line from the cloud to Postman noting that the post-script test asserts status 201 and captures the order id; footer note clarifies that step 3 is the only thing the business sees while steps 1 and 2 are the price of admission so automate them in your pre-request script and stop thinking about them.

Two things you do on every S/4HANA Cloud OData write — but they are not the same kind of thing:

OAuth 2 token — authentication layer. Client-credentials flow against the tenant's token endpoint. Returns an access_token with TTL 3599 seconds. This proves who you are. In a production runtime like SAP Cloud Integration, the platform manages the token lifecycle for you — it fetches, caches, and silently refreshes on expiry. In Postman you handle it manually in a pre-request script. Failure here returns 401 Unauthorized.

CSRF token — session integrity guard. S/4HANA's OData layer requires an x-csrf-token header on every POST, PATCH, and DELETE. This is not about identity — it guards against cross-site request forgery by proving the request came through an active, legitimate OData session. You fetch it with a GET to any safe endpoint carrying x-csrf-token: fetch; the response header contains the real token, and you reuse it for the life of that session. It does not rotate per request. It expires when the session ends — which is a different clock to the OAuth TTL. Failure here returns 403 Forbidden.

In Postman, both live in the pre-request script on the collection root because that is the only place to automate them. In a production iFlow or middleware, you configure them in separate steps with separate error handling — a 401 triggers an OAuth retry on a different endpoint; a 403 triggers a new CSRF fetch on the same OData service. They fail differently, they recover differently, and conflating them costs you debugging time.

What happens inside SAP when you POST

Most Postman tutorials stop at the HTTP boundary. But if something fails — a 400, a silent error in sap-messages, a slow response — you need to know which layer to blame. Here are the four layers your POST travels through before a planned order number comes back.

Architecture diagram showing four columns — Postman on the left sending a CSRF fetch and a POST with full JSON body; SAP Gateway in the middle-left handling ICF service path, OAuth token check, CSRF validation, and OData body parsing; Planned Order OData API in the middle-right running business validation (material, MRP type, production version, dates), PP scheduling and capacity load, availability check, and appending sap-messages warnings; SAP S/4HANA on the right writing to PLAF table (PLNUM MATNR WERKS MENGE PSTTR PEDTR DISPO VERID LGORT FXPLN FIXKZ), RESB for component reservations, KBED for capacity requirements, and returning PlannedOrder number 1428683 in the 201 response; a dashed green line shows the response travelling back through all layers to Postman; five-step flow summary bar at the bottom.

Layer 1 — SAP Gateway. Your request hits the ICF service at sap/opu/odata4/sap/api_plannedorder/srvd_a2x/…. Gateway checks the Bearer token (OAuth validation), checks the x-csrf-token header (fails fast with 403 if missing or stale), then parses the JSON body into a typed OData entity. This is the layer that returns 401 and 403. It knows nothing about production planning.

Layer 2 — Planned Order OData API. The typed entity reaches the API handler, which runs four checks before touching any data:

  • Does the material exist in plant 1110 with an MRP type that supports planned orders?
  • Is the specified ProductionVersion active and assigned to this plant/material combination?
  • Are the planned dates within the material's planning horizon?
  • Does the MaterialProcurementCategory match what the material master allows?

A failure here returns 400 Bad Request with a meaningful message in the body — read it. A warning returns 201 Created with an error-severity entry in the sap-messages response header — which is why you must parse that header on every response.

Layer 3 — PP business logic. If validation passes, the API calls the PP scheduling engine. Dates are confirmed via backward/forward scheduling against the routing. Capacity requirements are written to KBED. If PlannedOrderBOMIsFixed is not set, BOM explosion runs and populates component reservations in RESB. If you send your own components (the deep-body pattern), explosion is skipped and your components land in RESB directly.

Layer 4 — PLAF table. The planned order is written to PLAF (the primary planned order table in S/4HANA). Key fields: PLNUM (the generated order number), MATNR, WERKS, MENGE (quantity), PSTTR/PEDTR (start/end dates), DISPO (MRP controller), VERID (production version), LGORT (storage location), FXPLN (firm flag from PlannedOrderIsFirm), FIXKZ (BOM fixed flag). The PLNUM value is what comes back as PlannedOrder in your 201 response — store it.

Practical implication. If the MES retries on a timeout and the first POST actually wrote to PLAF, the retry creates a second PLAF record — a genuine duplicate planned order. The API has no built-in idempotency check. See the "Best in principle" section at the bottom for the external number assignment fix.

Develop phase — the full JSON body

A minimum-body request creates a valid planned order. A full-body request creates the right one. Here is what Vajra's MES actually sends:

{
  "Material":                    "SFG-004",
  "MRPArea":                     "1110",
  "ProductionPlant":             "1110",
  "MRPController":               "ANI",
  "TotalQuantity":               120,
  "BaseUnit":                    "PC",
  "PlndOrderPlannedStartDate":   "2026-07-01",
  "PlndOrderPlannedEndDate":     "2026-07-04",
  "PlannedOrderProfile":         "LA",
  "MaterialProcurementCategory": "E",
  "PlannedOrderIsFirm":          "X",
  "PlannedOrderBOMIsFixed":      "X",
  "ProductionVersion":           "0001",
  "StorageLocation":             "1110"
}

What each field is doing and why it matters:

FieldValueWhy it's here
MaterialSFG-004Bearing pre-assembly — the output of this production slot
MRPArea1110Plant 1110; required, must match ProductionPlant for in-house orders
ProductionPlant1110Explicit — never rely on the MRP area default when both are known
MRPControllerANIAnitha's controller code; without it, the order lands in the "unassigned" worklist
TotalQuantity120Units the slot is scheduled to produce
BaseUnitPCPieces; matches the material master unit
PlndOrderPlannedStartDate2026-07-01When the machine slot starts — MES knows this exactly
PlndOrderPlannedEndDate2026-07-04Confirmed end; capacity scheduling uses both
PlannedOrderProfileLAIn-house production profile; determines order type and scheduling rule
MaterialProcurementCategoryEE = in-house production. If you omit this on a material that also has external procurement, the API can default to F and create a purchase requisition instead of a production order proposal
PlannedOrderIsFirmXThe MES has committed this slot to a machine. MRP must not reschedule it
PlannedOrderBOMIsFixedXMES will push its own components (see next section); block automatic BOM re-explosion
ProductionVersion0001Which routing and BOM combination to use — critical for plants with multiple production routes
StorageLocation1110Where the finished goods land after production confirmation

PlannedOrderIsFirm and PlannedOrderBOMIsFixed are the two fields most integrations skip on day one and regret on day 30. If MRP runs after the MES pushes an order without firming, MRP will reschedule it. The MES and S/4 then disagree about when things are happening. Firm from the start.

Create with components — skipping BOM explosion

Vajra's MES has the BOM from its own data model. For SFG-004 Bearing Pre-assembly, the MES knows exactly: 120 bearing races (RM-B205) and 240 grease inserts (RM-G103). No need for S/4 to explode the BOM — push the components directly.

{
  "Material":                "SFG-004",
  "MRPArea":                 "1110",
  "ProductionPlant":         "1110",
  "MRPController":           "ANI",
  "TotalQuantity":           120,
  "BaseUnit":                "PC",
  "PlndOrderPlannedStartDate": "2026-07-01",
  "PlndOrderPlannedEndDate": "2026-07-04",
  "PlannedOrderProfile":     "LA",
  "MaterialProcurementCategory": "E",
  "PlannedOrderIsFirm":      "X",
  "PlannedOrderBOMIsFixed":  "X",
  "ProductionVersion":       "0001",
  "StorageLocation":         "1110",
  "_PlannedOrderComponent": [
    {
      "Material":                       "RM-B205",
      "ComponentQuantity":              120,
      "ComponentUnit":                  "PC",
      "ItemNumber":                     "0010",
      "StorageLocation":                "1110",
      "MaterialComponentIsPhantomItem": false
    },
    {
      "Material":                       "RM-G103",
      "ComponentQuantity":              240,
      "ComponentUnit":                  "PC",
      "ItemNumber":                     "0020",
      "StorageLocation":                "1110",
      "MaterialComponentIsPhantomItem": false
    }
  ]
}

The _PlannedOrderComponent navigation property is a deep-body insert. One POST creates both the header and the components atomically. If the component array is malformed, the entire request fails — no partial creates.

Two things that will bite you once: ItemNumber must be a four-character string ("0010", not 10), and MaterialComponentIsPhantomItem must be false for real stockable components — not omitted, not null.

Collection structure

Vajra MES → S/4HANA
├── _setup
│   ├── 1. Get access token            (POST oauth/token)
│   └── 2. Fetch CSRF                  (GET $metadata, x-csrf-token: fetch)
├── Planned Order
│   ├── GET by id                      (GET PlannedOrderHeader('{{id}}'))
│   ├── Create — no components         (POST PlannedOrderHeader, full body)
│   ├── Create — with components       (POST PlannedOrderHeader, deep body)
│   ├── Change quantity                (PATCH PlannedOrderHeader('{{id}}'))
│   └── Delete                         (DELETE PlannedOrderHeader('{{id}}'))
└── _mocks
    └── 201 Created · planned order    (Postman mock URL)

The _setup folder exists only to document the auth chain. In production the pre-request script on the collection root handles it automatically.

Capture the created order number in the test script so the next request (PATCH, DELETE) can consume it:

pm.test("201 Created", () => pm.response.to.have.status(201));

const order = pm.response.json().PlannedOrder;
pm.collectionVariables.set("last_planned_order", order);

pm.test("PlannedOrder is numeric and non-zero", () => {
  pm.expect(Number(order)).to.be.greaterThan(0);
});

ETags — the change request that bites once

The API uses ETags for optimistic concurrency. You cannot PATCH without telling the server which version you're updating. The version comes from an If-Match header:

GET …/PlannedOrderHeader('1428683')
→ 200 OK
  ETag: W/"SADL-020260310080153C~20260310080153"
PATCH …/PlannedOrderHeader('1428683')
If-Match: W/"SADL-020260310080153C~20260310080153"
Content-Type: application/json

{ "TotalQuantity": 144 }

Without If-Match412 Precondition Required. With a stale If-Match412 Precondition Failed. The second one is your friend — it means somebody changed the record since you read it; refetch and retry. Do not suppress it.

In the test script, capture the ETag from every GET:

const etag = pm.response.headers.get("ETag");
pm.collectionVariables.set("po_etag", etag);

Test phase — environments, assertions, mocks, monitors

Environments. One per system: dev, qa, prod. Each carries s4_host, client_id, client_secret, token_url. Switch in the top-right dropdown. Never put a credential in the request body or a collection variable that gets exported.

Three assertions on every request, minimum:

pm.test("status is 2xx",    () => pm.expect(pm.response.code).to.be.within(200, 299));
pm.test("response is JSON", () => pm.response.to.be.json);
pm.test("under 1500 ms",    () => pm.expect(pm.response.responseTime).to.be.below(1500));

The 1500 ms test caught a real problem: a specific Material value triggered a five-second response because of a missing planning version. No error — just silence and a very slow call.

Mocks. Publish a Postman mock URL that returns canned 201 responses. Point the MES at the mock during week one. The MES team integrated without touching real S/4. When we cut over, only the base URL changed. Three lines in their config.

Monitors. A monitor running the full create flow against dev every 15 minutes tells you the end-to-end path is healthy — not just that the GET endpoint responds, but that auth, CSRF, the POST body, and the ETag PATCH all work together.

Three failure modes we caught before production

1. Token expiry. First script fetched the token once and reused it forever. After 3,599 seconds, every request returned 401 Unauthorized. Fix: check token age in the pre-request script, refetch if older than 50 minutes.

const fetched = pm.collectionVariables.get("token_fetched_at");
const now = Date.now();
if (!fetched || now - Number(fetched) > 50 * 60 * 1000) {
  // …request a new token, store it…
  pm.collectionVariables.set("token_fetched_at", String(now));
}

2. CSRF expiry in long-running monitors. The CSRF token expires with the session. In a monitor that runs auth → CSRF → create → patch → delete, the CSRF was stale by step 4. Fix: fetch CSRF just before each write, not once at collection root.

3. sap-messages carrying errors inside a 200 or 201. When a request succeeds it can still return a sap-messages header with error-severity entries. "Material not maintained for plant" comes back dressed as a 201 Created. Parse it explicitly:

const raw = pm.response.headers.get("sap-messages");
if (raw) {
  const messages = JSON.parse(raw);
  const errors = messages.filter(m => m.severity === "error");
  pm.test("no sap-messages errors", () => pm.expect(errors).to.have.length(0));
}

This one test has caught three silent failures in production that would otherwise have required a planner to manually reconcile the MES against S/4.

Best in principle

If you are building a production integration (not just a Postman demo), five things that pay for themselves:

Idempotency via external number assignment. OData V4 here has no idempotency token. If MES retries on a network blip, you get duplicate planned orders — both valid, neither flagged. Fix: configure external number ranges on the planned order number object, have MES supply its own reference number in the POST. The second attempt hits a conflict and returns a key-duplicate error instead of silently creating a second order.

Read before you write. Never PATCH without a preceding GET. The GET gives you the ETag, confirms the current state, and lets you short-circuit if the first attempt already succeeded (check if the MES's values are already there before POSTing a change).

Retry strategy. Retry on 503 Service Unavailable — transient. Retry on network timeout. Do not retry on 412 Precondition Failed without first refetching the ETag. Never retry 400 Bad Request — that is a bug in your message body, not a transient failure.

Secrets in environments only. client_id, client_secret, token_url: Postman environment variables. In CI/CD, pull from a secrets manager and inject at runtime. A Postman collection export should contain zero credentials. If someone exports the collection for a demo, it should not also export the production secret.

Monitor the full path, not just the GET. A health check that pings GET PlannedOrderHeader('1428683') every 15 minutes tells you the API responds. It does not tell you that the token flow, the CSRF fetch, and the POST body are working. Run the full create → PATCH → DELETE flow in the monitor against a stable test material. That is the signal worth alerting on.

What stayed unsolved

  • Conversion to production orders is not supported by this API. The MES wanted it in scope. The constraint section of the API documentation said no on line one. Build that path separately against the Production Order API.
  • No automatic PP/DS integration. If Vajra activates PP/DS later, planned orders pushed through this API won't be visible to PP/DS as plannable. Acknowledged in the brief; re-evaluate in 18 months.
  • PlannedOrderIsFirm is a flag, not a lock. A planner with the right authorisation can unset it in S/4HANA. The MES has no way to know. If the line schedule changes in S/4 after the MES has committed the slot, you get a conflict. The right answer is a webhook or polling job from S/4 back to MES — out of scope for this phase.

Source: SAP Help Portal — "APIs for Manufacturing" (S/4HANA Cloud Public Edition 2602, Planned Order OData V4 service). Endpoint paths, mandatory fields, ETag rules and sample message codes are from the official documentation. © SAP SE. Article narrative and worked examples by SAP Insiders.