Calling the SAP Business Partner API from a Kyma Serverless Function via BTP Destination Service

Introduction

One of the most common integration patterns in SAP BTP is connecting a Kyma serverless function to an external SAP API — securely, without hardcoding credentials. In this post I’ll walk through exactly how to do that using the BTP Destination Service, a Kyma ServiceBinding, and the SAP API Business Accelerator Hub sandbox.

By the end you’ll have a working Node.js Kyma function that:

Obtains an OAuth token from XSUAA using client credentialsResolves a named BTP Destination to get the target API URLCalls the SAP Business Partner API (A_BusinessPartner) and returns JSON results

All credentials are stored in Kubernetes secrets — nothing is hardcoded.

Prerequisites

SAP BTP Trial account with a Kyma cluster enabledkubectl configured against your Kyma clusterAn account on api.sap.com with an API key for the sandboxVSCode with the Kubernetes extension (optional but useful)

Step 1: Enable the Serverless Module in Kyma

By default, the Serverless module may not be enabled on your Kyma cluster. Check:

kubectl get crd | grep serverless

If nothing is returned, go to your BTP cockpit, open the Kyma cluster overview, click Modules → Add, and enable Serverless. Wait a few minutes and recheck.

Step 2: Create the BTP Destination Service Instance

We need a Destination Service instance to store and resolve our API destination. Rather than creating it through the BTP cockpit UI (which has limitations on Trial for Kyma runtime), we use the BTP Service Operator directly in Kubernetes.

First verify the BTP Service Operator CRDs are available:

kubectl get crd | grep sap

You should see serviceinstances.services.cloud.sap.com and servicebindings.services.cloud.sap.com.

Create k8s/destination-instance.yaml:

apiVersion: services.cloud.sap.com/v1
kind: ServiceInstance
metadata:
name: destination-service
namespace: default
spec:
serviceOfferingName: destination
servicePlanName: lite

Apply it:

kubectl apply -f k8s/destination-instance.yaml
kubectl get serviceinstances -n default

Wait until the status shows Ready.

Step 3: Create the Service Binding

The binding injects the Destination Service credentials (XSUAA client ID, client secret, token URL, and service URI) into a Kubernetes secret.

Create k8s/destination-binding.yaml:

apiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
name: destination-service-binding
namespace: default
spec:
serviceInstanceName: destination-service

Apply it:

kubectl apply -f k8s/destination-binding.yaml
kubectl get servicebindings -n default

Verify the secret was created and check the keys:

kubectl get secret destination-service-binding -n default -o jsonpath='{.data}’ | python3 -m json.tool

You’ll see keys including clientid, clientsecret, url (XSUAA token endpoint), and uri (Destination Service endpoint).

Step 4: Create the BTP Destination

In BTP cockpit go to Connectivity → Destinations → New Destination → From Scratch and fill in:

Field Value

NameS4H_SANDBOX_BPTypeHTTPURLhttps://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNERAuthenticationNoAuthenticationAdditional Property: APIKeyyour api.sap.com API key

Save it. The API key is stored as a destination property — the function will pass it as a request header.

Step 5: Store the API Key as a Kubernetes Secret

Never put API keys in your function code or YAML. Create a secret:

kubectl create secret generic bp-function-apikey
–from-literal=apikey='<your-api-key>’
-n default

Step 6: Enable Istio Sidecar Injection

The Kyma APIRule requires Istio to be injected into the function pod. Label the namespace:

kubectl label namespace default istio-injection=enabled –overwrite

Step 7: Deploy the Function

Here is the complete k8s/function.yaml. The inline source contains the full Node.js function — no external dependencies required beyond Node built-ins.

apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
name: bp-function
namespace: default
labels:
app: bp-function
spec:
runtime: nodejs20
source:
inline:
dependencies: |
{
“name”: “kyma-bp-function”,
“version”: “1.0.0”,
“description”: “Kyma serverless function to fetch SAP Business Partners via BTP Destination Service”,
“main”: “handler.js”,
“engines”: {
“node”: “>=18”
}
}
source: |
const https = require(“https”);
const http = require(“http”);
const zlib = require(“zlib”);

async function getDestinationToken() {
const credentials = Buffer.from(
`${process.env.CLIENTID}:${process.env.CLIENTSECRET}`
).toString(“base64”);
const tokenUrl = new URL(`${process.env.XSUAA_URL}/oauth/token`);
const body = “grant_type=client_credentials”;

return new Promise((resolve, reject) => {
const options = {
hostname: tokenUrl.hostname,
path: `${tokenUrl.pathname}?${body}`,
method: “POST”,
headers: {
Authorization: `Basic ${credentials}`,
“Content-Type”: “application/x-www-form-urlencoded”,
},
};
const req = https.request(options, (res) => {
let data = “”;
res.on(“data”, (chunk) => (data += chunk));
res.on(“end”, () => {
try {
resolve(JSON.parse(data).access_token);
} catch (e) {
reject(new Error(`Failed to parse token response: ${data}`));
}
});
});
req.on(“error”, reject);
req.end();
});
}

async function getDestination(destinationName, token) {
const destUrl = new URL(
`${process.env.DESTINATION_URI}/destination-configuration/v1/destinations/${destinationName}`
);
return new Promise((resolve, reject) => {
const options = {
hostname: destUrl.hostname,
path: destUrl.pathname,
method: “GET”,
headers: {
Authorization: `Bearer ${token}`,
},
};
const req = https.request(options, (res) => {
let data = “”;
res.on(“data”, (chunk) => (data += chunk));
res.on(“end”, () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Failed to parse destination response: ${data}`));
}
});
});
req.on(“error”, reject);
req.end();
});
}

async function fetchBusinessPartners(destination) {
const destUrl = destination.destinationConfiguration;
const targetUrl = new URL(`${destUrl.URL}/A_BusinessPartner?$format=json&$top=20`);
const protocol = targetUrl.protocol === “https:” ? https : http;
return new Promise((resolve, reject) => {
const options = {
hostname: targetUrl.hostname,
path: `${targetUrl.pathname}${targetUrl.search}`,
method: “GET”,
headers: {
APIKey: process.env.API_KEY,
Accept: “application/json”,
},
};
const req = protocol.request(options, (res) => {
const chunks = [];
const encoding = res.headers[“content-encoding”];
const stream = encoding === “gzip” ? res.pipe(zlib.createGunzip())
: encoding === “deflate” ? res.pipe(zlib.createInflate())
: res;
stream.on(“data”, (chunk) => chunks.push(chunk));
stream.on(“end”, () => {
const raw = Buffer.concat(chunks).toString(“utf8”);
try {
resolve({ statusCode: res.statusCode, body: JSON.parse(raw) });
} catch (e) {
resolve({ statusCode: res.statusCode, body: raw });
}
});
stream.on(“error”, reject);
});
req.on(“error”, reject);
req.end();
});
}

module.exports = {
main: async function (event, context) {
const DESTINATION_NAME = “S4H_SANDBOX_BP”;
try {
const token = await getDestinationToken();
const destination = await getDestination(DESTINATION_NAME, token);
const result = await fetchBusinessPartners(destination);
return {
statusCode: result.statusCode,
headers: { “Content-Type”: “application/json” },
body: result.body,
};
} catch (err) {
console.error(“Error:”, err.message);
return {
statusCode: 500,
headers: { “Content-Type”: “application/json” },
body: { error: err.message },
};
}
},
};
resourceConfiguration:
function:
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi
env:
– name: CLIENTID
valueFrom:
secretKeyRef:
name: destination-service-binding
key: clientid
– name: CLIENTSECRET
valueFrom:
secretKeyRef:
name: destination-service-binding
key: clientsecret
– name: XSUAA_URL
valueFrom:
secretKeyRef:
name: destination-service-binding
key: url
– name: DESTINATION_URI
valueFrom:
secretKeyRef:
name: destination-service-binding
key: uri
– name: API_KEY
valueFrom:
secretKeyRef:
name: bp-function-apikey
key: apikey

Apply it:

kubectl apply -f k8s/function.yaml
kubectl get functions -n default

Wait for RUNNING: True.

Step 8: Expose the Function with an APIRule

Create k8s/apirule.yaml using the v2 API (v1beta1 is deprecated):

apiVersion: gateway.kyma-project.io/v2
kind: APIRule
metadata:
name: bp-function
namespace: default
spec:
gateway: kyma-system/kyma-gateway
hosts:
– bp-function.<your-cluster-domain>.kyma.ondemand.com
rules:
– methods:
– GET
noAuth: true
path: /*
service:
name: bp-function
port: 80

Get your cluster domain:

kubectl get gateway -n kyma-system kyma-gateway -o jsonpath='{.spec.servers[0].hosts[0]}’

Apply and check status:

kubectl apply -f k8s/apirule.yaml
kubectl get apirule bp-function -n default -o jsonpath='{.status.state}’

Step 9: Test It

curl https://bp-function.<your-cluster-domain>.kyma.ondemand.com

You should get a JSON response containing Business Partner records from the SAP sandbox:

{
“statusCode”: 200,
“body”: {
“d”: {
“results”: [
{
“BusinessPartner”: “1000037”,
“BusinessPartnerFullName”: “…”,

}
]
}
}
}

How It Works

The flow is:

The function starts and reads XSUAA credentials from the Kubernetes secret injected by the BTP Service OperatorIt calls the XSUAA token endpoint with client credentials to get a bearer tokenIt uses that token to call the BTP Destination Service and resolve the S4H_SANDBOX_BP destination — getting back the target URL and any stored propertiesIt calls the SAP API sandbox using the resolved URL, passing the API key as a headerThe response is gzip-decoded (the sandbox always returns gzip) and returned as JSON

A Note on Gzip

The SAP API Hub sandbox always returns gzip-compressed responses. Node’s https module does not automatically decompress these. The function handles this explicitly using Node’s built-in zlib module, detecting the content-encoding response header and piping through zlib.createGunzip() or zlib.createInflate() as appropriate.

Key Points

No external npm packages — the function uses only Node.js built-ins (https, http, zlib), keeping the deployment simple and fast.

Credentials never touch code or git — XSUAA credentials come from the BTP Service Operator binding secret, the API key from a manually created Kubernetes secret. Neither appears in any YAML committed to source control.

Destination Service as the integration layer — by resolving the destination at runtime rather than hardcoding the URL, you can swap the backend (sandbox vs. production, different regions) just by changing the BTP destination configuration, with no code changes.

Istio sidecar is required — the Kyma APIRule validation requires the function pod to have the Istio sidecar injected. Make sure you label the namespace with istio-injection=enabled before deploying the function.

What’s Next

From here you could extend this pattern to:

Query specific Business Partners by keyPOST data back to the APIChain multiple API calls within a single functionTrigger the function from a Kyma event rather than an HTTP callDeploy to a production BTP account pointing at a real S/4HANA system rather than the sandbox

The same pattern — BTP Destination Service + Kyma ServiceBinding + serverless function — works for any SAP API on the Business Accelerator Hub, and for non-SAP APIs too.

 

​ IntroductionOne of the most common integration patterns in SAP BTP is connecting a Kyma serverless function to an external SAP API — securely, without hardcoding credentials. In this post I’ll walk through exactly how to do that using the BTP Destination Service, a Kyma ServiceBinding, and the SAP API Business Accelerator Hub sandbox.By the end you’ll have a working Node.js Kyma function that:Obtains an OAuth token from XSUAA using client credentialsResolves a named BTP Destination to get the target API URLCalls the SAP Business Partner API (A_BusinessPartner) and returns JSON resultsAll credentials are stored in Kubernetes secrets — nothing is hardcoded.PrerequisitesSAP BTP Trial account with a Kyma cluster enabledkubectl configured against your Kyma clusterAn account on api.sap.com with an API key for the sandboxVSCode with the Kubernetes extension (optional but useful)Step 1: Enable the Serverless Module in KymaBy default, the Serverless module may not be enabled on your Kyma cluster. Check:kubectl get crd | grep serverlessIf nothing is returned, go to your BTP cockpit, open the Kyma cluster overview, click Modules → Add, and enable Serverless. Wait a few minutes and recheck.Step 2: Create the BTP Destination Service InstanceWe need a Destination Service instance to store and resolve our API destination. Rather than creating it through the BTP cockpit UI (which has limitations on Trial for Kyma runtime), we use the BTP Service Operator directly in Kubernetes.First verify the BTP Service Operator CRDs are available:kubectl get crd | grep sapYou should see serviceinstances.services.cloud.sap.com and servicebindings.services.cloud.sap.com.Create k8s/destination-instance.yaml:apiVersion: services.cloud.sap.com/v1
kind: ServiceInstance
metadata:
name: destination-service
namespace: default
spec:
serviceOfferingName: destination
servicePlanName: liteApply it:kubectl apply -f k8s/destination-instance.yaml
kubectl get serviceinstances -n defaultWait until the status shows Ready.Step 3: Create the Service BindingThe binding injects the Destination Service credentials (XSUAA client ID, client secret, token URL, and service URI) into a Kubernetes secret.Create k8s/destination-binding.yaml:apiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
name: destination-service-binding
namespace: default
spec:
serviceInstanceName: destination-serviceApply it:kubectl apply -f k8s/destination-binding.yaml
kubectl get servicebindings -n defaultVerify the secret was created and check the keys:kubectl get secret destination-service-binding -n default -o jsonpath='{.data}’ | python3 -m json.toolYou’ll see keys including clientid, clientsecret, url (XSUAA token endpoint), and uri (Destination Service endpoint).Step 4: Create the BTP DestinationIn BTP cockpit go to Connectivity → Destinations → New Destination → From Scratch and fill in:Field ValueNameS4H_SANDBOX_BPTypeHTTPURLhttps://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNERAuthenticationNoAuthenticationAdditional Property: APIKeyyour api.sap.com API keySave it. The API key is stored as a destination property — the function will pass it as a request header.Step 5: Store the API Key as a Kubernetes SecretNever put API keys in your function code or YAML. Create a secret:kubectl create secret generic bp-function-apikey
–from-literal=apikey='<your-api-key>’
-n defaultStep 6: Enable Istio Sidecar InjectionThe Kyma APIRule requires Istio to be injected into the function pod. Label the namespace:kubectl label namespace default istio-injection=enabled –overwriteStep 7: Deploy the FunctionHere is the complete k8s/function.yaml. The inline source contains the full Node.js function — no external dependencies required beyond Node built-ins.apiVersion: serverless.kyma-project.io/v1alpha2
kind: Function
metadata:
name: bp-function
namespace: default
labels:
app: bp-function
spec:
runtime: nodejs20
source:
inline:
dependencies: |
{
“name”: “kyma-bp-function”,
“version”: “1.0.0”,
“description”: “Kyma serverless function to fetch SAP Business Partners via BTP Destination Service”,
“main”: “handler.js”,
“engines”: {
“node”: “>=18”
}
}
source: |
const https = require(“https”);
const http = require(“http”);
const zlib = require(“zlib”);

async function getDestinationToken() {
const credentials = Buffer.from(
`${process.env.CLIENTID}:${process.env.CLIENTSECRET}`
).toString(“base64”);
const tokenUrl = new URL(`${process.env.XSUAA_URL}/oauth/token`);
const body = “grant_type=client_credentials”;

return new Promise((resolve, reject) => {
const options = {
hostname: tokenUrl.hostname,
path: `${tokenUrl.pathname}?${body}`,
method: “POST”,
headers: {
Authorization: `Basic ${credentials}`,
“Content-Type”: “application/x-www-form-urlencoded”,
},
};
const req = https.request(options, (res) => {
let data = “”;
res.on(“data”, (chunk) => (data += chunk));
res.on(“end”, () => {
try {
resolve(JSON.parse(data).access_token);
} catch (e) {
reject(new Error(`Failed to parse token response: ${data}`));
}
});
});
req.on(“error”, reject);
req.end();
});
}

async function getDestination(destinationName, token) {
const destUrl = new URL(
`${process.env.DESTINATION_URI}/destination-configuration/v1/destinations/${destinationName}`
);
return new Promise((resolve, reject) => {
const options = {
hostname: destUrl.hostname,
path: destUrl.pathname,
method: “GET”,
headers: {
Authorization: `Bearer ${token}`,
},
};
const req = https.request(options, (res) => {
let data = “”;
res.on(“data”, (chunk) => (data += chunk));
res.on(“end”, () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Failed to parse destination response: ${data}`));
}
});
});
req.on(“error”, reject);
req.end();
});
}

async function fetchBusinessPartners(destination) {
const destUrl = destination.destinationConfiguration;
const targetUrl = new URL(`${destUrl.URL}/A_BusinessPartner?$format=json&$top=20`);
const protocol = targetUrl.protocol === “https:” ? https : http;
return new Promise((resolve, reject) => {
const options = {
hostname: targetUrl.hostname,
path: `${targetUrl.pathname}${targetUrl.search}`,
method: “GET”,
headers: {
APIKey: process.env.API_KEY,
Accept: “application/json”,
},
};
const req = protocol.request(options, (res) => {
const chunks = [];
const encoding = res.headers[“content-encoding”];
const stream = encoding === “gzip” ? res.pipe(zlib.createGunzip())
: encoding === “deflate” ? res.pipe(zlib.createInflate())
: res;
stream.on(“data”, (chunk) => chunks.push(chunk));
stream.on(“end”, () => {
const raw = Buffer.concat(chunks).toString(“utf8”);
try {
resolve({ statusCode: res.statusCode, body: JSON.parse(raw) });
} catch (e) {
resolve({ statusCode: res.statusCode, body: raw });
}
});
stream.on(“error”, reject);
});
req.on(“error”, reject);
req.end();
});
}

module.exports = {
main: async function (event, context) {
const DESTINATION_NAME = “S4H_SANDBOX_BP”;
try {
const token = await getDestinationToken();
const destination = await getDestination(DESTINATION_NAME, token);
const result = await fetchBusinessPartners(destination);
return {
statusCode: result.statusCode,
headers: { “Content-Type”: “application/json” },
body: result.body,
};
} catch (err) {
console.error(“Error:”, err.message);
return {
statusCode: 500,
headers: { “Content-Type”: “application/json” },
body: { error: err.message },
};
}
},
};
resourceConfiguration:
function:
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi
env:
– name: CLIENTID
valueFrom:
secretKeyRef:
name: destination-service-binding
key: clientid
– name: CLIENTSECRET
valueFrom:
secretKeyRef:
name: destination-service-binding
key: clientsecret
– name: XSUAA_URL
valueFrom:
secretKeyRef:
name: destination-service-binding
key: url
– name: DESTINATION_URI
valueFrom:
secretKeyRef:
name: destination-service-binding
key: uri
– name: API_KEY
valueFrom:
secretKeyRef:
name: bp-function-apikey
key: apikeyApply it:kubectl apply -f k8s/function.yaml
kubectl get functions -n defaultWait for RUNNING: True.Step 8: Expose the Function with an APIRuleCreate k8s/apirule.yaml using the v2 API (v1beta1 is deprecated):apiVersion: gateway.kyma-project.io/v2
kind: APIRule
metadata:
name: bp-function
namespace: default
spec:
gateway: kyma-system/kyma-gateway
hosts:
– bp-function.<your-cluster-domain>.kyma.ondemand.com
rules:
– methods:
– GET
noAuth: true
path: /*
service:
name: bp-function
port: 80Get your cluster domain:kubectl get gateway -n kyma-system kyma-gateway -o jsonpath='{.spec.servers[0].hosts[0]}’Apply and check status:kubectl apply -f k8s/apirule.yaml
kubectl get apirule bp-function -n default -o jsonpath='{.status.state}’Step 9: Test Itcurl https://bp-function.<your-cluster-domain>.kyma.ondemand.comYou should get a JSON response containing Business Partner records from the SAP sandbox:{
“statusCode”: 200,
“body”: {
“d”: {
“results”: [
{
“BusinessPartner”: “1000037”,
“BusinessPartnerFullName”: “…”,

}
]
}
}
}How It WorksThe flow is:The function starts and reads XSUAA credentials from the Kubernetes secret injected by the BTP Service OperatorIt calls the XSUAA token endpoint with client credentials to get a bearer tokenIt uses that token to call the BTP Destination Service and resolve the S4H_SANDBOX_BP destination — getting back the target URL and any stored propertiesIt calls the SAP API sandbox using the resolved URL, passing the API key as a headerThe response is gzip-decoded (the sandbox always returns gzip) and returned as JSONA Note on GzipThe SAP API Hub sandbox always returns gzip-compressed responses. Node’s https module does not automatically decompress these. The function handles this explicitly using Node’s built-in zlib module, detecting the content-encoding response header and piping through zlib.createGunzip() or zlib.createInflate() as appropriate.Key PointsNo external npm packages — the function uses only Node.js built-ins (https, http, zlib), keeping the deployment simple and fast.Credentials never touch code or git — XSUAA credentials come from the BTP Service Operator binding secret, the API key from a manually created Kubernetes secret. Neither appears in any YAML committed to source control.Destination Service as the integration layer — by resolving the destination at runtime rather than hardcoding the URL, you can swap the backend (sandbox vs. production, different regions) just by changing the BTP destination configuration, with no code changes.Istio sidecar is required — the Kyma APIRule validation requires the function pod to have the Istio sidecar injected. Make sure you label the namespace with istio-injection=enabled before deploying the function.What’s NextFrom here you could extend this pattern to:Query specific Business Partners by keyPOST data back to the APIChain multiple API calls within a single functionTrigger the function from a Kyma event rather than an HTTP callDeploy to a production BTP account pointing at a real S/4HANA system rather than the sandboxThe same pattern — BTP Destination Service + Kyma ServiceBinding + serverless function — works for any SAP API on the Business Accelerator Hub, and for non-SAP APIs too.   Read More Technology Blog Posts by Members articles 

#SAP

#SAPTechnologyblog

You May Also Like

More From Author