n8n Meets SAP — the Bridge over OData and HTTP

n8n Meets SAP — the Bridge over OData and HTTP

Article 10 · Series: Getting Started with n8n

At the end of Article 9 the classification stage is resilient: two AI backends, round-robin, failover, a rule-based emergency exit. What is still missing is the step out of n8n into a foreign system. This article builds it, against the most obvious target for a support pipeline: SAP. The classified ticket is enriched with customer master data from a SAP backend, over OData and HTTP, with the same graceful-degradation principle that already carries the AI side.

One note up front, because it frames the whole stage. I am not binding a productive S/4 here, but the public SAP Business Accelerator Hub sandbox, a shared S/4HANA Cloud backend with demo data, reachable with a free SAP ID account. Auth here is a single header. The real OAuth flow against an xsuaa token endpoint I save for Article 13.

The code for this article is on Codeberg, tag v0.10: codeberg.org/rotecodefraktion/n8n-einstieg.

OData is not REST

The endpoint is the OData service API_BUSINESS_PARTNER of the Hub sandbox. A first call shows immediately that SAP OData deviates from what you expect of REST APIs:

curl -H "APIKey: <key>" -H "Accept: application/json" \
  "https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?\$top=2&\$select=BusinessPartner,BusinessPartnerFullName"
{"d":{"results":[
  {"BusinessPartner":"11","BusinessPartnerFullName":"Cust15 Cust15"},
  {"BusinessPartner":"202","BusinessPartnerFullName":"Nue tech inc"}
]}}

Three things stand out. First the protocol: the sandbox serves OData V2, not V4. The payload sits inside a d wrapper, collections additionally in a results array, each entity carries a __metadata block. Treat the response like a flat REST answer and you grab into thin air. Second the auth: a static APIKey header, which you fetch at api.sap.com per service via “Show API Key”. Third the query language: $select for field selection, $filter for conditions, $inlinecount=allpages for the total count. The last returns the hit count in the field d.__count, here 1262 business partners, the entry point to any pagination.

The most important difference, though, shows in the error case. SAP answers an unknown entity not with an RFC problem document but with its own gateway envelope:

{"error":{"code":"/IWBEP/CM_MGW_RT/020",
  "message":{"lang":"en","value":"Resource not found for segment 'A_BusinessPartnerType'"},
  "innererror":{"transactionid":"AB004E5DA0010B6C2F605A449D13608D",
    "timestamp":"20260612...","Error_Resolution":{"SAP_Note":"See SAP Note 1797736 ..."}}}}

That transactionid is not decoration. It is the key with which a SAP administrator finds the error again in /IWFND/ERROR_LOG on the gateway. That is exactly why you want it in your own log later, not just a generic “Request failed”.

The bridge as a sub-workflow

The binding itself is its own sub-workflow, v0.10 SAP OData Bridge, which the dispatcher from Article 9 calls at the enrichment step. This encapsulation is the same as for the classifiers: a clearly bounded piece of logic, testable in isolation, exportable on its own.

The v0.10 SAP OData Bridge sub-workflow on the n8n canvas: Ticket Input, the HTTP node Lookup Business Partner with success and error outputs, Enrich Ticket and Mark Customer Unknown respectively, both converging on Enriched Ticket

The incoming ticket carries a customerId, a BusinessPartner ID. The core of the bridge is an HTTP Request node that addresses the entity directly by its key:

GET A_BusinessPartner('{{ $json.customerId }}')
    ?$select=BusinessPartner,BusinessPartnerFullName,BusinessPartnerCategory,BusinessPartnerGrouping
    &$format=json

The APIKey header comes from a Header Auth credential, so the key does not sit in the node and does not travel into the export. The decisive setting is On Error: Continue (using error output). With it the node gets two outputs, and an unknown customer does not abort the pipeline:

  • Success leads into a Code node Enrich Ticket that merges the master data into the ticket and sets customerLookup: "ok".
  • Error leads into Mark Customer Unknown, which sets customerLookup: "unknown" and lets the ticket flow on anyway.

This is the same pattern as the AI failover from Article 9: an outage degrades quality, it does not drop the ticket. A ticket without a customer assignment is still a routed ticket.

The Lookup Business Partner HTTP Request node in detail: GET against the OData service with the customerId expression in the entity key, header auth via the SAP Hub Sandbox APIKey credential, $select and $format as query parameters, on the right the note that execution continues even if the node fails

While building, a quirk of n8n surfaced that you have to know. The reference to the trigger via $('Ticket Input').item breaks on the error branch: across a node’s error output the paired-item link, with which n8n pairs items across nodes, is lost. On the success branch it holds, on the error branch it does not. The fix is $('Ticket Input').first() instead of .item, unambiguous because the sub-workflow processes exactly one ticket per run. The same change was needed one level up too, in the dispatcher’s Enrich node that pulls customerId and id from the webhook: across the Execute Workflow cascade .item resolves unreliably, .first() deterministically.

Recovering the SAP error

The most interesting part is the error path, and that because of a pitfall you only notice in the log. Assemble the marker naively and read the SAP code from the error object, and what you get is not the SAP code but ERR_BAD_REQUEST. In the error output n8n passes through the transport-level Axios error, not the SAP envelope.

The envelope is there nonetheless, just buried. It sits inside $json.error.message as a status-prefixed, double JSON-encoded string:

404 - "{\"error\":{\"code\":\"/IWBEP/CM_MGW_RT/020\", ... }}"

To extract the SAP code, the message and the transactionid, I cut off the 404 - and parse the rest twice, first the JSON string, then the envelope:

function parseSapError(errObj) {
  const out = { httpStatus: null, sapCode: null, sapMessage: null, transactionId: null };
  if (!errObj) return out;
  const msg = typeof errObj === 'string' ? errObj : (errObj.message ?? '');
  const mStatus = msg.match(/(\d{3})/);
  if (mStatus) out.httpStatus = Number(mStatus[1]);
  const dash = msg.indexOf(' - ');
  let env = null;
  if (dash >= 0) {
    const payload = msg.slice(dash + 3).trim();
    try {
      const body = payload.startsWith('"') ? JSON.parse(payload) : payload;
      env = JSON.parse(body);
    } catch (e) { env = null; }
  }
  if (env && env.error) {
    out.sapCode = env.error.code ?? null;
    out.sapMessage = env.error.message?.value ?? null;
    out.transactionId = env.error.innererror?.transactionid ?? null;
  }
  return out;
}

With it the log carries what error analysis actually needs:

{"marker":"n8n-sap-lookup-failed","ticketId":"SAP-ERR4","customerId":"99999999",
 "httpStatus":404,"sapCode":"/IWBEP/CM_MGW_RT/020",
 "sapMessage":"Resource not found for segment 'A_BusinessPartnerType'",
 "transactionId":"AB004E5DA0010B6C2F605A449D13608D"}

The same sapCode is additionally written onto the ticket as customerLookupError. Skip this and settle for ERR_BAD_REQUEST, and you throw away the very thread by which you would untangle the error in the SAP backend.

On testing: while building this stage, two already documented stumbling blocks struck again. Duplicating the dispatcher produced the same random webhook path and dual-active collision as in Article 9. And a change to the sub-workflow only takes effect in a production run after an explicit publish, the n8n 2.0 model from Article 8.

n8n and AIF do not belong in the same bucket

One confusion is worth clearing up, because it is common in SAP contexts. n8n does not replace the Application Interface Framework. AIF monitors messages inside the SAP world, offers business-level recovery and a compliance-grade error monitor. n8n orchestrates before and after: it receives the ticket, classifies, enriches over OData, routes. When an OData call hits an interface that AIF guards, the retry inside SAP belongs to AIF, the cross-system flow to n8n. These are complementary layers, not competing ones. Position n8n as an AIF replacement and you set an orchestration layer against a recovery layer that have different jobs.

Versioning workflows in Git

A workflow that binds a foreign system belongs in version control, not just in the n8n editor. For that the repo holds scripts/export-workflows.sh: it pulls all workflows over the n8n REST API, keeps only the portable fields and passes them through strip-workflow.py, which removes instance-specific data, credential IDs, webhookId, pinData, versionId.

N8N_API_KEY=... ./scripts/export-workflows.sh
git diff workflows/

Credentials are referenced by name only. On import, n8n rebinds them to the local credential of the same name. So the workflow sits traceably in Git, and no secret, neither the API key nor a token, ever travels into the repository.

Joule Studio as an outlook, not a promise

SAP has announced for 2026 a tighter integration of n8n into its own tooling world, under the heading Joule Studio. What that looks like in the end, and from when it is stably available, is not settled at the time of this article. The path I show here is the stable one: HTTP and OData have been there for years and do not change overnight. An embedded Joule Studio may complement this later, but you do not need it to replace the OData path.

Transition to Article 11

The pipeline now speaks SAP, classifies resiliently and enriches. What is still missing is the step from a working demo setup to production operation. In the current setup n8n runs behind a reverse proxy, the app port is not open to the outside, the webhook reachable only over HTTPS, and executions run in the main process. Real throughput and resilience need more. Article 11 takes on queue mode, the separation of editor, webhook intake and worker processes, and a production checklist.

Start the model backends

The bridge workflow reads the ticket category from the classifier, so the model backends have to run: Ollama on :11434 and the Hummingbird MLX gateway on :8080, both reachable from the n8n container via host.docker.internal.

# Ollama
ollama serve

# Hummingbird MLX gateway (see Article 6)
./hummingbird-mlx serve --port 8080

Anyone not running the MLX gateway gets by with a single Ollama backend; the round-robin stage from Article 9 then falls back to one backend. The SAP bridge itself needs none of the models, only an APIKey for the Hub sandbox.