s4hana-create-product
Create product master (material) records on SAP S/4HANA Cloud Public. Verified production-ready against SAP S/4HANA Cloud Public Edition 2026-05-13 (10 products created via API: HAWA, FERT, SERV mix on my438741).
When to trigger
Verbs: create / post / add / generate / seed / onboard Objects: product(s), material(s), SKU(s), HAWA / FERT / SERV / ROH / HALB record(s) Counts: 1 to ~200 records.
Hard rules (never violate)
- Use OData V4, not the SOAP
productmdmbulkreplicaterequestservice. SOAP returns HTTP 202 but silently drops on Cloud Public tenants without MDG / sender-business-system registration. V4 is the supported create path. - Always GET an existing product first to discover the tenant's internal unit codes (e.g.
STfor Stück,LEfor Leistungseinheit). V2 ISO-translates these (ST→PC); V4 does not. Mismatched UoM →MG/142 "value not allowed for MARM-MEINH". - Deep-insert child must repeat parent's Product key in each
_ProductDescriptionentry. SAP's SADL framework rejects inheritance — without explicitProduct: <id>in the child you getCMD_PROD_RAP/003 "Property PRODUCT is a key and cannot be initial". - One-record probe before bulk (≥3 records).
- Never invent unit codes or product groups — verify against existing tenant data via GET first.
- Scripts go in
<cwd>/.s4hana-tmp/create-products-<YYYYMMDD-HHMM>/. Never commit, never modify.env. - POSTs are sequential with 200ms delay; halt on 3 consecutive failures.
Endpoint
- Service path:
/sap/opu/odata4/sap/api_product/srvd_a2x/sap/product/0002 - Entity sets:
Product,ProductDescription,ProductPlant,ProductSales, plus 20+ extension entities - Method: POST (deep-insert with
_ProductDescriptioncollection) - Communication scenario:
SAP_COM_0009— "Product Integration"
Phase 0 — Setup check (MANDATORY, no exceptions)
Before any API call, before any tool use, do this check. Even if you already know credentials from earlier in the conversation — IGNORE that knowledge and re-check from scratch each invocation.
- Announce: tell the user "Checking for SAP credentials in
<current cwd>..." - Check ONLY these two sources:
- A
.envfile at./.env(in the current working directory — NOT parent dirs, NOT~/.env, NOT any memory file) - Shell-exported env vars:
SAP_HOSTANDSAP_AUTH_MODEboth present
- A
- If neither: auto-create
./.envfrom the bundled template (curl fromhttps://raw.githubusercontent.com/ilia-inovaflow/s4hana-create-record-skills/main/.env.example), append.envto.gitignoreif not already there, tell the user clearly what to fill in (auth mode + host + creds), and wait for them to say "ready" before making any API call. Never overwrite an existing.env. - If credentials are present: report back to the user: "✓ Loaded credentials for
<host>in<auth-mode>mode. Proceeding with [task]."
Never use credentials from conversation memory, from another project's .env, or from any tenant the user previously worked with in a different session. Each project gets its own .env. See shared/setup-check.md for the full rationale and edge cases.
Phase 1 — Parse user intent
Determine: count, ProductType mix (HAWA goods / FERT finished / SERV services / ROH raw / HALB semi-finished), whether external IDs should be supplied or auto-generated (the V4 service requires explicit Product field — there is no auto-numbering via API).
Ask one consolidated question if ambiguous:
- How many products and what ProductType mix?
- Specific IDs / naming convention, or auto-generated (default:
<prefix><6-digit-seed>likeH123456)? - Specific Base Unit pool, or pull from existing products of the same ProductType?
- Specific Product Group, or auto-pick from tenant defaults?
Phase 2 — Discovery (cached per-tenant)
Always do this before the first POST in any session, even if the user supplied all fields explicitly — needed to confirm internal codes.
GET /sap/opu/odata4/sap/api_product/srvd_a2x/sap/product/0002/Product
?$top=10
&$select=Product,ProductType,BaseUnit,ProductGroup,IndustrySector
&sap-client=<n>
From the result, build per-ProductType pools:
- BaseUnit pool — e.g. HAWA →
[ST, ...], SERV →[LE, ...]. V4 returns internal codes (NOTPC/EA). - ProductGroup pool — common values per ProductType.
- IndustrySector — usually a single value tenant-wide (typically
Mfor demo tenants).
Cache for the session. If empty (fresh tenant), use these conservative defaults: HAWA/FERT/ROH/HALB → BaseUnit ST, ProductGroup A001, IndustrySector M. SERV → BaseUnit LE, ProductGroup A001, IndustrySector M.
Phase 3 — Build payload
Per record:
{
"Product": "<external-id, ≤18 chars uppercase>",
"ProductType": "<HAWA|FERT|SERV|ROH|HALB>",
"BaseUnit": "<internal-code from Phase 2>",
"ProductGroup": "<from tenant pool>",
"IndustrySector": "<usually M>",
"_ProductDescription": [
{ "Product": "<SAME external-id>", "Language": "EN", "ProductDescription": "<≤40 chars>" }
]
}
ID generation: <type-prefix><6-digit-seed> (e.g. H for HAWA, F for FERT, S for SERV) keeps batches scannable.
Phase 4 — CSRF + POST
GET <base>/?sap-client=<n>withX-CSRF-Token: Fetch— capture token + cookies.POST <base>/Product?sap-client=<n>with the deep-insert payload above. Headers:Authorization,Content-Type: application/json,Accept: application/json,X-CSRF-Token: <token>,Cookie: <captured>.- Reuse token across batch; refresh on
403 CSRF token validation failed.
Expected success: HTTP 201 with full product entity in response body (including auto-populated CreationDate, CreatedByUser, BaseISOUnit, etc.).
Phase 5 — Verify
After each POST: extract Product from the 201 response body and append {ok, productID, productType, ts, error?} to create-log.jsonl. After bulk completes, do a spot-check with $expand=_ProductDescription:
GET /Product('<id>')?$expand=_ProductDescription&sap-client=<n>
Confirm IsMarkedForDeletion=false and the description was committed.
Known error catalog
| Code | Cause | Fix |
|---|---|---|
CMD_PROD_RAP/003 "Property PRODUCT is a key and cannot be initial" |
Deep-insert child missing parent key | Add "Product": "<same-id>" to each _ProductDescription entry |
MG/142 "Value X not allowed for MARM-MEINH" |
Wrong unit code (used V2/ISO PC instead of V4 internal ST) |
GET existing product to discover the tenant's actual code |
PMD_MSG/032 "Description of product X not maintained" |
Tried to POST Product without _ProductDescription |
Description is mandatory at create — include it in deep-insert |
API_PRD_MSG/009 "Create operation not allowed on entity" |
Using OData V2 API_PRODUCT_SRV instead of V4 |
Switch to V4 path — V2 is read-only for Product header |
M3/098 "Enter a material type" |
Missing ProductType |
Required field, supply HAWA/FERT/etc. |
(none) "Enter an industry sector" |
Missing IndustrySector |
Required even though Fiori's manual dialog doesn't ask for it |
| HTTP 202 + silent drop (SOAP path) | Used productmdmbulkreplicaterequest SOAP service |
Switch to V4 — SOAP needs sender business system registration |
Output structure
<cwd>/.s4hana-tmp/create-products-<YYYYMMDD-HHMM>/
├── tenant-discovery.json # per-ProductType pools (BaseUnit, ProductGroup, IndustrySector)
├── payloads.json
├── create-log.jsonl
├── results.json
└── verify-sample.json
Cross-tenant migration notes
- Source product IDs (MATNR) can be preserved on the target if they're ≤18 chars and the tenant allows external numbering. No internal numbering option exists via API.
- Unit codes do NOT carry across tenants — translate to the target tenant's internal codes via the Phase 2 discovery.
- Plant / sales / valuation / procurement views are SEPARATE entities (
ProductPlant,ProductSales, etc.) on the same V4 service. The header create handled by this skill is sufficient for basic-data lookup but extending to a plant requires a second POST toProductPlant.
Reference files
references/v4-vs-soap.md— why OData V4 and not the SOAP MDM Replicate service (decision matrix + the silent-drop trap)references/field-reference.md— complete Product entity field list with required vs optional, max lengths, deep-insert nav propertiesscripts/bulk-product-poster.mjs— reference implementation