---
name: Certyo + SAP Integration
version: 1.0.0
description: Generate SAP S/4HANA and Business One to Certyo integration code with field mappings, auth, and working examples
api_base: https://www.certyos.com
auth: X-API-Key header
last_updated: 2026-04-14
---

# Certyo + SAP Integration Skill

This skill enables AI agents to generate production-ready integration code that sends product authenticity and material traceability records from SAP S/4HANA and SAP Business One to the Certyo blockchain anchoring platform. It covers SAP Integration Suite iFlows, SAP CAP handlers, and Python-based OData polling approaches.

## Certyo API Reference

### Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/v1/records` | Ingest a single record (returns 202 Accepted) |
| `POST` | `/api/v1/records/bulk` | Ingest up to 1,000 records in one call |
| `POST` | `/api/v1/verify/record` | Verify a record's blockchain anchoring status |
| `GET` | `/api/v1/records` | Query records by tenant, database, collection, or recordId |

### Authentication

All Certyo API calls require an `X-API-Key` header.

### Record Payload Schema

```json
{
  "tenantId": "string (required)",
  "database": "string (required)",
  "collection": "string (required)",
  "recordId": "string (required)",
  "recordPayload": { "...any JSON (required)" },
  "clientId": "string (optional)",
  "recordVersion": "string (optional, default '1')",
  "operationType": "upsert|insert|update|delete (optional)",
  "sourceTimestamp": "ISO 8601 (optional)",
  "idempotencyKey": "string (optional)"
}
```

### Successful Response (202 Accepted)

```json
{
  "recordId": "string",
  "recordHash": "string (SHA-256)",
  "tenantId": "string",
  "acceptedAt": "ISO 8601",
  "idempotencyReplayed": false
}
```

### Pipeline

Records flow through: Kafka accumulation (1,000 records or 60 seconds) then Merkle tree computation then IPFS pinning then Polygon blockchain anchor. Typical end-to-end anchoring latency is 60-90 seconds.

## SAP Integration Pattern

### Architecture

```
SAP S/4HANA or Business One
  |
  | (Material Document post / Goods Receipt / Delivery)
  v
SAP Integration Suite (Cloud Integration)
  |
  | iFlow: OData poll or event-driven trigger
  v
Groovy / XSLT mapping script
  |
  | HTTP Adapter
  v
Certyo API (POST /api/v1/records)
  |
  | (poll for anchored status)
  v
SAP Integration Suite --> OData PATCH back to SAP custom fields
```

### When to Use This Pattern

- You are running S/4HANA Cloud, S/4HANA On-Premise with BTP connectivity, or SAP Business One.
- You need to anchor goods receipts, material documents, deliveries, batches, or serial number records.
- SAP Integration Suite (Cloud Integration) is the recommended middleware. For on-premise without BTP, use the Python OData poller as a fallback.
- For Business One, use the Service Layer REST API instead of OData.

### Key SAP Movement Types to Operation Type Mapping

| BWART (Movement Type) | Meaning | Certyo `operationType` |
|---|---|---|
| 101 | Goods receipt for purchase order | `insert` |
| 102 | Reversal of goods receipt | `delete` |
| 201 | Goods issue for cost center | `insert` |
| 202 | Reversal of goods issue | `delete` |
| 301 | Transfer posting plant to plant | `update` |
| 601 | Goods issue for delivery | `insert` |
| 602 | Reversal of delivery | `delete` |

## Authentication

### SAP S/4HANA Side (BTP)

Use OAuth 2.0 client credentials or X.509 certificate authentication via SAP Business Technology Platform.

```groovy
// In Integration Suite, credentials are configured as a
// "Deployed Credentials" artifact of type OAuth2ClientCredentials
// Reference by name in the HTTP adapter channel
def sapOAuthCredential = "SAP_S4_OAuth2"
```

For S/4HANA on-premise behind SAP Cloud Connector, use Principal Propagation or Basic Auth with a technical user stored as a Secure Parameter.

### SAP Business One Side

Use session-based authentication via the Service Layer:

```python
import requests

SL_BASE = "https://my-b1-server:50000/b1s/v2"

def get_b1_session():
    resp = requests.post(f"{SL_BASE}/Login", json={
        "CompanyDB": "SBODemoUS",
        "UserName": "manager",
        "Password": "<from-vault>"
    }, verify=False)
    resp.raise_for_status()
    return resp.cookies
```

### Certyo Side

Store the Certyo API key as a Secure Parameter in SAP Integration Suite or in the SAP Credential Store on BTP.

```groovy
// Access Certyo API key from Secure Parameter in iFlow
def certYoApiKey = context.getProperty("CertyoApiKey")
```

## Field Mapping

| SAP Field | Certyo Field | Notes |
|---|---|---|
| `MaterialDocument` + `-` + `MaterialDocumentYear` | `recordId` | Concatenated for uniqueness (e.g., `4900000123-2026`) |
| `BUDAT` (Posting Date) | `sourceTimestamp` | Convert SAP date format to ISO 8601 |
| `"sap"` (constant) | `database` | Identifies the source system |
| `"material-documents"` (constant) | `collection` | Or `"deliveries"`, `"batches"` depending on document type |
| `MATNR` (Material Number) | `recordPayload.materialNumber` | Material master number |
| `CHARG` (Batch Number) | `recordPayload.lotNumber` | Batch/lot for traceability |
| `SERNR` (Serial Number) | `recordPayload.serialNumber` | Individual unit tracking |
| `BWART` (Movement Type) | `operationType` | Map via lookup table (101=insert, 102=delete, etc.) |
| `MENGE` (Quantity) | `recordPayload.quantity` | Document quantity |
| `MEINS` (Unit of Measure) | `recordPayload.unitOfMeasure` | Base UoM |
| `WERKS` (Plant) | `recordPayload.plant` | Receiving or issuing plant |
| `LGORT` (Storage Location) | `recordPayload.storageLocation` | Storage location |
| `LIFNR` (Vendor) | `recordPayload.vendorNumber` | Supplier for goods receipts |
| `EAN11` / GTIN | `recordPayload.gtin` | Global Trade Item Number from material master |
| SAP BTP subaccount tenant | `tenantId` | Your Certyo tenant identifier |

## Code Examples

### Example 1: Groovy Script for SAP Integration Suite iFlow

This Groovy script runs inside an iFlow message-mapping step. It transforms an S/4HANA Material Document OData payload into a Certyo record.

```groovy
import com.sap.gateway.ip.core.customdev.util.Message
import groovy.json.JsonSlurper
import groovy.json.JsonOutput

def Message processData(Message message) {
    def body = message.getBody(String)
    def slurper = new JsonSlurper()
    def matDoc = slurper.parseText(body)

    def tenantId = message.getProperty("CertyoTenantId")

    // Map SAP movement type to Certyo operation type
    def operationTypeMap = [
        "101": "insert",
        "102": "delete",
        "201": "insert",
        "202": "delete",
        "301": "update",
        "601": "insert",
        "602": "delete"
    ]

    def items = matDoc.to_MaterialDocumentItem ?: []
    def records = []

    items.each { item ->
        def movementType = item.GoodsMovementType ?: "101"
        def recordId = "${matDoc.MaterialDocument}-${matDoc.MaterialDocumentYear}-${item.MaterialDocumentItem}"

        def record = [
            tenantId      : tenantId,
            database      : "sap",
            collection    : "material-documents",
            recordId      : recordId,
            operationType : operationTypeMap.getOrDefault(movementType, "upsert"),
            sourceTimestamp: formatSapDate(matDoc.PostingDate),
            idempotencyKey: "${recordId}-v1",
            recordPayload : [
                materialDocument    : matDoc.MaterialDocument,
                materialDocumentYear: matDoc.MaterialDocumentYear,
                materialDocumentItem: item.MaterialDocumentItem,
                materialNumber      : item.Material,
                plant               : item.Plant,
                storageLocation     : item.StorageLocation,
                batch               : item.Batch ?: "",
                serialNumber        : item.SerialNumber ?: "",
                quantity            : item.QuantityInEntryUnit,
                unitOfMeasure       : item.EntryUnit,
                movementType        : movementType,
                vendor              : item.Supplier ?: "",
                purchaseOrder       : item.PurchaseOrder ?: "",
                purchaseOrderItem   : item.PurchaseOrderItem ?: ""
            ]
        ]
        records.add(record)
    }

    // Use bulk endpoint if multiple items, single endpoint otherwise
    if (records.size() > 1) {
        message.setProperty("CertyoEndpoint", "/api/v1/records/bulk")
        message.setBody(JsonOutput.toJson(records))
    } else if (records.size() == 1) {
        message.setProperty("CertyoEndpoint", "/api/v1/records")
        message.setBody(JsonOutput.toJson(records[0]))
    }

    return message
}

def String formatSapDate(String sapDate) {
    if (!sapDate) return new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'")
    // SAP OData returns dates as /Date(timestamp)/ or yyyy-MM-dd
    if (sapDate.contains("/Date(")) {
        def ms = sapDate.replaceAll(/.*Date\((\d+)\).*/, '$1') as Long
        return new Date(ms).format("yyyy-MM-dd'T'HH:mm:ss'Z'")
    }
    return "${sapDate}T00:00:00Z"
}
```

### Example 2: JavaScript CAP Handler (SAP Cloud Application Programming Model)

This handler runs in a CAP-based extension app on SAP BTP and intercepts material document creation events.

```javascript
const cds = require("@sap/cds");
const { default: axios } = require("axios");

const CERTYO_BASE = "https://www.certyos.com";
const OPERATION_MAP = {
  "101": "insert",
  "102": "delete",
  "201": "insert",
  "202": "delete",
  "301": "update",
  "601": "insert",
  "602": "delete",
};

module.exports = (srv) => {
  srv.on("MaterialDocumentCreated", async (msg) => {
    const { data } = msg;
    const certYoApiKey = process.env.CERTYO_API_KEY;
    const certYoTenantId = process.env.CERTYO_TENANT_ID;

    if (!certYoApiKey || !certYoTenantId) {
      console.error("Certyo credentials not configured.");
      return;
    }

    const items = data.to_MaterialDocumentItem || [];
    const records = items.map((item) => {
      const recordId = `${data.MaterialDocument}-${data.MaterialDocumentYear}-${item.MaterialDocumentItem}`;
      return {
        tenantId: certYoTenantId,
        database: "sap",
        collection: "material-documents",
        recordId,
        operationType: OPERATION_MAP[item.GoodsMovementType] || "upsert",
        sourceTimestamp: data.PostingDate
          ? new Date(data.PostingDate).toISOString()
          : new Date().toISOString(),
        idempotencyKey: `${recordId}-v1`,
        recordPayload: {
          materialDocument: data.MaterialDocument,
          materialDocumentYear: data.MaterialDocumentYear,
          materialNumber: item.Material,
          plant: item.Plant,
          storageLocation: item.StorageLocation,
          batch: item.Batch || "",
          serialNumber: item.SerialNumber || "",
          quantity: parseFloat(item.QuantityInEntryUnit),
          unitOfMeasure: item.EntryUnit,
          movementType: item.GoodsMovementType,
          vendor: item.Supplier || "",
        },
      };
    });

    try {
      if (records.length === 0) return;

      const endpoint =
        records.length > 1 ? "/api/v1/records/bulk" : "/api/v1/records";
      const payload = records.length > 1 ? records : records[0];

      const response = await axios.post(`${CERTYO_BASE}${endpoint}`, payload, {
        headers: {
          "Content-Type": "application/json",
          "X-API-Key": certYoApiKey,
        },
        timeout: 10000,
      });

      console.log(
        `Certyo ingested ${records.length} record(s). Status: ${response.status}`
      );
    } catch (err) {
      console.error(
        "Certyo ingestion failed:",
        err.response?.status,
        err.response?.data || err.message
      );
      // Do not throw - allow SAP transaction to complete.
      // Failed records will be retried via scheduled reconciliation.
    }
  });
};
```

### Example 3: Python OData Poller for S/4HANA or Business One

A standalone Python script that polls SAP for new material documents and sends them to Certyo. Suitable for on-premise S/4HANA without BTP or for SAP Business One environments.

```python
"""
SAP to Certyo OData Poller
Polls SAP for new material documents and sends them to Certyo.
Designed for on-premise S/4HANA or Business One Service Layer.
"""

import os
import time
import logging
from datetime import datetime, timedelta, timezone

import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

# --- Configuration (externalize to env vars or vault) ---
SAP_BASE_URL = os.environ["SAP_BASE_URL"]  # e.g., https://s4hana.company.com/sap/opu/odata/sap
SAP_USER = os.environ["SAP_USER"]
SAP_PASSWORD = os.environ["SAP_PASSWORD"]
CERTYO_BASE_URL = "https://www.certyos.com"
CERTYO_API_KEY = os.environ["CERTYO_API_KEY"]
CERTYO_TENANT_ID = os.environ["CERTYO_TENANT_ID"]
POLL_INTERVAL_SECONDS = int(os.environ.get("POLL_INTERVAL_SECONDS", "60"))
SAP_SYSTEM_TYPE = os.environ.get("SAP_SYSTEM_TYPE", "S4HANA")  # S4HANA or B1

OPERATION_MAP = {
    "101": "insert",
    "102": "delete",
    "201": "insert",
    "202": "delete",
    "301": "update",
    "601": "insert",
    "602": "delete",
}


def fetch_csrf_token(session: requests.Session) -> str:
    """Fetch CSRF token required for SAP OData write operations."""
    resp = session.get(
        f"{SAP_BASE_URL}/API_MATERIAL_DOCUMENT_SRV",
        headers={"X-CSRF-Token": "Fetch"},
        auth=HTTPBasicAuth(SAP_USER, SAP_PASSWORD),
    )
    resp.raise_for_status()
    return resp.headers.get("X-CSRF-Token", "")


def poll_material_documents(session: requests.Session, since: datetime) -> list:
    """Poll SAP for material documents created after the given timestamp."""
    since_str = since.strftime("%Y-%m-%dT%H:%M:%S")

    if SAP_SYSTEM_TYPE == "B1":
        # Business One Service Layer
        url = (
            f"{SAP_BASE_URL}/InventoryGenEntries"
            f"?$filter=CreateDate ge '{since.strftime('%Y-%m-%d')}'"
            f"&$orderby=CreateDate asc"
            f"&$top=100"
        )
    else:
        # S/4HANA OData
        url = (
            f"{SAP_BASE_URL}/API_MATERIAL_DOCUMENT_SRV/A_MaterialDocumentHeader"
            f"?$filter=CreationDate ge datetime'{since_str}'"
            f"&$expand=to_MaterialDocumentItem"
            f"&$orderby=CreationDate asc"
            f"&$top=100"
            f"&$format=json"
        )

    resp = session.get(url, auth=HTTPBasicAuth(SAP_USER, SAP_PASSWORD))
    resp.raise_for_status()
    data = resp.json()

    if SAP_SYSTEM_TYPE == "B1":
        return data.get("value", [])
    else:
        return data.get("d", {}).get("results", [])


def transform_s4_to_certyo(doc: dict) -> list:
    """Transform an S/4HANA material document into Certyo records."""
    items = doc.get("to_MaterialDocumentItem", {}).get("results", [])
    records = []

    for item in items:
        movement_type = item.get("GoodsMovementType", "101")
        record_id = (
            f"{doc['MaterialDocument']}-{doc['MaterialDocumentYear']}"
            f"-{item['MaterialDocumentItem']}"
        )

        posting_date_raw = doc.get("PostingDate", "")
        if "/Date(" in posting_date_raw:
            ms = int(posting_date_raw.split("(")[1].split(")")[0])
            posting_date = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
        else:
            posting_date = datetime.now(timezone.utc).isoformat()

        records.append({
            "tenantId": CERTYO_TENANT_ID,
            "database": "sap",
            "collection": "material-documents",
            "recordId": record_id,
            "operationType": OPERATION_MAP.get(movement_type, "upsert"),
            "sourceTimestamp": posting_date,
            "idempotencyKey": f"{record_id}-v1",
            "recordPayload": {
                "materialDocument": doc["MaterialDocument"],
                "materialDocumentYear": doc["MaterialDocumentYear"],
                "materialNumber": item.get("Material", ""),
                "plant": item.get("Plant", ""),
                "storageLocation": item.get("StorageLocation", ""),
                "batch": item.get("Batch", ""),
                "serialNumber": item.get("SerialNumber", ""),
                "quantity": float(item.get("QuantityInEntryUnit", 0)),
                "unitOfMeasure": item.get("EntryUnit", ""),
                "movementType": movement_type,
                "vendor": item.get("Supplier", ""),
            },
        })

    return records


def transform_b1_to_certyo(doc: dict) -> list:
    """Transform a Business One inventory document into Certyo records."""
    lines = doc.get("DocumentLines", [])
    records = []

    for line in lines:
        record_id = f"{doc['DocNum']}-{line['LineNum']}"
        records.append({
            "tenantId": CERTYO_TENANT_ID,
            "database": "sap-business-one",
            "collection": "inventory-transactions",
            "recordId": record_id,
            "operationType": "insert",
            "sourceTimestamp": f"{doc.get('CreateDate', '')}T00:00:00Z",
            "idempotencyKey": f"{record_id}-v1",
            "recordPayload": {
                "docNum": doc["DocNum"],
                "itemCode": line.get("ItemCode", ""),
                "itemDescription": line.get("ItemDescription", ""),
                "quantity": float(line.get("Quantity", 0)),
                "warehouse": line.get("WarehouseCode", ""),
                "batchNumber": line.get("BatchNumber", ""),
                "serialNumber": line.get("SerialNumber", ""),
            },
        })

    return records


def send_to_certyo(records: list) -> None:
    """Send records to Certyo, using bulk endpoint when appropriate."""
    if not records:
        return

    headers = {
        "Content-Type": "application/json",
        "X-API-Key": CERTYO_API_KEY,
    }

    if len(records) == 1:
        resp = requests.post(
            f"{CERTYO_BASE_URL}/api/v1/records",
            json=records[0],
            headers=headers,
            timeout=10,
        )
        resp.raise_for_status()
        logger.info("Ingested 1 record: %s", resp.json().get("recordId"))
    else:
        # Chunk into batches of 1000 for the bulk endpoint
        for i in range(0, len(records), 1000):
            batch = records[i : i + 1000]
            resp = requests.post(
                f"{CERTYO_BASE_URL}/api/v1/records/bulk",
                json=batch,
                headers=headers,
                timeout=30,
            )
            resp.raise_for_status()
            logger.info("Bulk ingested %d records.", len(batch))


def write_back_to_sap(session: requests.Session, record_id: str, record_hash: str) -> None:
    """Write Certyo hash back to SAP custom field via OData PATCH (S/4HANA)."""
    csrf_token = fetch_csrf_token(session)

    parts = record_id.split("-")
    mat_doc = parts[0]
    mat_doc_year = parts[1]

    url = (
        f"{SAP_BASE_URL}/API_MATERIAL_DOCUMENT_SRV"
        f"/A_MaterialDocumentHeader(MaterialDocument='{mat_doc}',"
        f"MaterialDocumentYear='{mat_doc_year}')"
    )

    resp = session.patch(
        url,
        json={
            "YY1_CertyoHash_MDH": record_hash,
            "YY1_CertyoStatus_MDH": "Anchored",
        },
        headers={
            "X-CSRF-Token": csrf_token,
            "Content-Type": "application/json",
            "If-Match": "*",
        },
        auth=HTTPBasicAuth(SAP_USER, SAP_PASSWORD),
    )
    resp.raise_for_status()
    logger.info("Wrote anchor status back to SAP for %s.", record_id)


def main():
    session = requests.Session()
    last_poll = datetime.now(timezone.utc) - timedelta(minutes=5)

    logger.info("Starting SAP-Certyo poller (system: %s, interval: %ds)", SAP_SYSTEM_TYPE, POLL_INTERVAL_SECONDS)

    while True:
        try:
            docs = poll_material_documents(session, last_poll)
            logger.info("Polled %d documents since %s.", len(docs), last_poll.isoformat())

            all_records = []
            for doc in docs:
                if SAP_SYSTEM_TYPE == "B1":
                    all_records.extend(transform_b1_to_certyo(doc))
                else:
                    all_records.extend(transform_s4_to_certyo(doc))

            send_to_certyo(all_records)
            last_poll = datetime.now(timezone.utc)

        except Exception:
            logger.exception("Poll cycle failed. Will retry next interval.")

        time.sleep(POLL_INTERVAL_SECONDS)


if __name__ == "__main__":
    main()
```

## Verification and Write-back

After Certyo anchors records (typically 60-90 seconds), verify and write the result back to SAP custom fields.

### S/4HANA Write-back via OData PATCH

SAP OData PATCH requests require a valid CSRF token. Always fetch the token immediately before the PATCH call.

```python
def verify_and_write_back(session: requests.Session) -> None:
    """Poll Certyo for anchored records and write hashes back to SAP."""
    headers = {"X-API-Key": CERTYO_API_KEY}

    # Query Certyo for anchored SAP records
    resp = requests.get(
        f"{CERTYO_BASE_URL}/api/v1/records",
        params={"database": "sap", "collection": "material-documents"},
        headers=headers,
        timeout=10,
    )
    resp.raise_for_status()
    records = resp.json()

    for record in records:
        if record.get("status") != "Anchored":
            continue

        # Verify on-chain
        verify_resp = requests.post(
            f"{CERTYO_BASE_URL}/api/v1/verify/record",
            json={
                "tenantId": record["tenantId"],
                "database": record["database"],
                "collection": record["collection"],
                "recordId": record["recordId"],
            },
            headers=headers,
            timeout=10,
        )

        if verify_resp.status_code == 200:
            write_back_to_sap(session, record["recordId"], record["recordHash"])
```

### Integration Suite iFlow Write-back

Configure a second iFlow that runs on a scheduled timer (every 2 minutes), queries Certyo for anchored records, and PATCHes the SAP custom fields using an OData receiver adapter with CSRF token handling enabled.

## Code Generation Rules

1. **Use SAP Integration Suite for Cloud deployments.** For S/4HANA Cloud or any BTP-connected system, route all Certyo traffic through Integration Suite iFlows. This provides monitoring, retry, and credential management out of the box.

2. **Externalize all credentials as Secure Parameters.** In Integration Suite, use deployed credential artifacts. In CAP apps, use `@sap/xsenv` to bind to credential store service instances. Never hardcode passwords or API keys.

3. **Handle CSRF tokens on every SAP write-back.** SAP OData services require a fresh CSRF token for PATCH/POST/DELETE. Fetch it with `X-CSRF-Token: Fetch` on a GET request immediately before the write operation. Tokens expire quickly.

4. **Map `BWART` (movement type) to `operationType`.** Use the lookup table: 101/201/601 = `insert`, 102/202/602 = `delete`, 301 = `update`. Default to `upsert` for unmapped types. This is critical for the Certyo audit trail.

5. **Use OData, not RFC or IDoc.** Modern S/4HANA APIs use OData V2/V4. Never generate RFC function module calls or IDoc processing for new integrations. For Business One, use the Service Layer REST API.

6. **Construct `recordId` from document + year + item.** SAP material documents are uniquely identified by the combination of document number, document year, and line item. Concatenate these with hyphens for the Certyo `recordId`.

7. **Always set `idempotencyKey`.** Use the pattern `{recordId}-v{version}`. SAP can trigger the same event multiple times (e.g., on iFlow restart), and idempotency prevents duplicate records in Certyo.

8. **Use the bulk endpoint for batch postings.** When an iFlow processes a collective material document with many line items, or when the Python poller fetches multiple documents, use `POST /api/v1/records/bulk` (up to 1,000 records per call).

9. **Convert SAP date formats to ISO 8601.** SAP OData V2 returns dates as `/Date(milliseconds)/`. SAP OData V4 and Service Layer return `yyyy-MM-dd`. Always normalize to full ISO 8601 with timezone for `sourceTimestamp`.

10. **Keep SAP-side custom fields minimal.** Add only `YY1_CertyoHash` (string, 64 chars) and `YY1_CertyoStatus` (string, 20 chars) as custom fields via SAP Extensibility or Key User Tools. Do not store full Certyo responses in SAP.
