Calling the SAP Business Partner API from Kyma without Serverless

Introduction

My previous post walked through calling the SAP Business Partner API from a Kyma Serverless Function. This post covers what happens when that approach hits real-world constraints — and how to adapt.

The two problems I encountered on a managed Kyma cluster:

The Serverless module wasn’t installed — kind: Function doesn’t exist as a resource type, so the function YAML simply won’t applyNo permission to create BTP Destinations via the cockpit — the New Destination button was greyed out

Both are common in corporate and training environments where you don’t own the cluster or subaccount. Here’s how to work around both.

By the end you’ll have the same result as the original post — a live endpoint returning SAP Business Partner JSON — but deployed as a standard Kubernetes Deployment instead of a Kyma Function.

What Changed vs the Original Approach

Original This Post

kind: Function (Kyma Serverless)kind: Deployment (standard Kubernetes)Code inline in YAMLCode in a Docker containerNo registry neededImage pushed to GitHub Container RegistryDestination created in BTP cockpitDestination created via Destination Service REST API

The call chain is identical — your container still calls XSUAA for a token, resolves the destination, then calls the SAP API sandbox. The difference is purely in how the code is packaged and deployed.

Prerequisites

kubectl configured against a Kyma clusterDocker installed locallyA GitHub account (for the container registry)An account on api.sap.com with an API key

Step 1: Check What’s Actually Available

Before spending time on a Kyma Function approach, verify whether Serverless is installed:

kubectl api-resources | grep serverless

If nothing is returned, the Serverless module isn’t enabled. You’ll need either cluster admin access to enable it, or the approach in this post.

Also verify the BTP Service Operator is available (needed for ServiceInstance and ServiceBinding):

kubectl api-resources | grep services.cloud.sap.com

You should see serviceinstances and servicebindings. If these are missing, stop here — the BTP Service Operator isn’t installed and none of this will work.

Step 2: Create the Destination Service Instance and Binding

This is identical to the original post. Apply these to your namespace:

k8s/destination-instance.yaml

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

k8s/destination-binding.yaml

apiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
name: destination-service-binding
namespace: dev-space-neil
spec:
serviceInstanceName: destination-servicekubectl apply -f k8s/destination-instance.yaml
kubectl apply -f k8s/destination-binding.yaml

Wait until both show Ready: True:

kubectl get serviceinstance,servicebinding -n dev-space-neil

The binding creates a Kubernetes secret called destination-service-binding containing clientid, clientsecret, url (XSUAA token endpoint), and uri (Destination Service endpoint). Your container will read these as environment variables.

Step 3: Create the BTP Destination via REST API

If you have access to the BTP cockpit, create the destination there as described in the original post. If not, use the Destination Service REST API directly — the service binding credentials you just created have enough access to create instance-level destinations.

Get a token and POST the destination:

TOKEN=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.clientid}’ | base64 -d)
SECRET=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.clientsecret}’ | base64 -d)
XSUAA=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.url}’ | base64 -d)
URI=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.uri}’ | base64 -d)

ACCESS_TOKEN=$(curl -s -X POST “$XSUAA/oauth/token”
-u “$TOKEN:$SECRET”
-d “grant_type=client_credentials”
| python3 -c “import sys,json; print(json.load(sys.stdin)[‘access_token’])”)

curl -X POST “$URI/destination-configuration/v1/instanceDestinations”
-H “Authorization: Bearer $ACCESS_TOKEN”
-H “Content-Type: application/json”
-d ‘{
“Name”: “S4H_SANDBOX_BP”,
“Type”: “HTTP”,
“URL”: “https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER”,
“Authentication”: “NoAuthentication”,
“ProxyType”: “Internet”,
“APIKey”: “<your-api.sap.com-key>”
}’

The distinction between subaccountDestinations and instanceDestinations matters here. Subaccount destinations are managed at the BTP subaccount level (requires admin access). Instance destinations are scoped to the service instance and can be created by the binding credentials — which is what we have.

Step 4: Write the Handler and HTTP Server

The business logic in handler.js is unchanged from the original post — get a token, resolve the destination, call the API. The only difference is gzip handling (the SAP API sandbox always compresses responses) and reading the API key from the destination properties rather than a separate Kubernetes secret.

handler.js

const https = require(“https”);
const http = require(“http”);
const zlib = require(“zlib”);

/**
* Gets an OAuth2 access token from XSUAA using client credentials flow.
* Credentials are injected as env vars from the destination-service-binding secret.
*/
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`);

return new Promise((resolve, reject) => {
const options = {
hostname: tokenUrl.hostname,
path: `${tokenUrl.pathname}?grant_type=client_credentials`,
method: “POST”,
headers: {
Authorization: `Basic ${credentials}`, // Base64-encoded clientid:clientsecret
“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(`Token parse failed: ${data}`)); }
});
});
req.on(“error”, reject);
req.end();
});
}

/**
* Resolves a named BTP Destination via the Destination Service REST API.
* Returns the full destination object including destinationConfiguration,
* which contains the target URL and any additional properties (e.g. APIKey).
*/
async function getDestination(name, token) {
const destUrl = new URL(
`${process.env.DESTINATION_URI}/destination-configuration/v1/destinations/${name}`
);
return new Promise((resolve, reject) => {
const options = {
hostname: destUrl.hostname,
path: destUrl.pathname,
method: “GET”,
headers: { Authorization: `Bearer ${token}` }, // Token obtained from getDestinationToken()
};
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(`Destination parse failed: ${data}`)); }
});
});
req.on(“error”, reject);
req.end();
});
}

/**
* Calls the SAP Business Partner API via the resolved destination.
* The SAP API Hub sandbox always returns gzip-compressed responses,
* so we explicitly handle gzip/deflate decompression via Node’s zlib module.
*/
async function fetchBusinessPartners(destination) {
const config = destination.destinationConfiguration;
const targetUrl = new URL(`${config.URL}/A_BusinessPartner?$format=json&$top=20`);
// Use http or https depending on the destination URL scheme
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: config.APIKey, // API key stored as a destination property, not a separate secret
Accept: “application/json”,
},
};
const req = protocol.request(options, (res) => {
const chunks = [];
const encoding = res.headers[“content-encoding”];
// Pipe through the appropriate decompressor based on Content-Encoding header
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();
});
}

/**
* Main entry point — orchestrates the full call chain:
* 1. Get XSUAA token
* 2. Resolve the S4H_SANDBOX_BP destination
* 3. Fetch Business Partners from the SAP API sandbox
* Returns a response object compatible with both Kyma Functions and the HTTP server wrapper.
*/
module.exports = {
main: async function (event, context) {
try {
const token = await getDestinationToken();
const destination = await getDestination(“S4H_SANDBOX_BP”, 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 },
};
}
},
};

Since this is a container rather than a Kyma Function, we need a simple HTTP server to drive it. server.js wraps the handler:

server.js

const http = require(“http”);
const handler = require(“./handler”);

const PORT = process.env.PORT || 8080; // Default to 8080; override via env var if needed

/**
* Minimal HTTP server that wraps handler.js for container deployment.
* Kyma Functions have their own runtime that calls handler.main() directly —
* this server provides the equivalent entry point when running as a standard Deployment.
* Every request regardless of path/method triggers the Business Partner fetch.
*/
const server = http.createServer(async (req, res) => {
try {
const result = await handler.main({}, {});
// handler.main() may return body as object or string — normalise to string
const body = typeof result.body === “string”
? result.body
: JSON.stringify(result.body);
res.writeHead(result.statusCode || 200, {
“Content-Type”: “application/json”,
…result.headers,
});
res.end(body);
} catch (err) {
res.writeHead(500, { “Content-Type”: “application/json” });
res.end(JSON.stringify({ error: err.message }));
}
});

server.listen(PORT, () => console.log(`Listening on port ${PORT}`));

Step 5: Build and Push a Multi-Platform Container Image

This step doesn’t exist in the original Kyma Function approach — the serverless runtime handles that for you. Here you need to build a Docker image and push it to a registry the cluster can reach.

Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install –omit=dev
COPY handler.js server.js ./
EXPOSE 8080
CMD [“node”, “server.js”]

One important detail: if you’re on an Apple Silicon Mac, Docker builds linux/arm64 by default. Most cloud Kubernetes clusters run linux/amd64. Build for both to avoid an ImagePullBackOff with the error no match for platform in manifest:

# Authenticate with GitHub Container Registry
echo $(gh auth token) | docker login ghcr.io -u <your-github-username> –password-stdin

# Build and push for both platforms
docker buildx build
–platform linux/amd64,linux/arm64
-t ghcr.io/<your-github-username>/bp-function:latest
–push .

Make the package public in GitHub (your profile → Packages → bp-function → Package settings → Change visibility → Public) so the cluster can pull it without credentials.

Step 6: Deploy to Kyma

k8s/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
name: bp-function
namespace: dev-space-neil
labels:
app: bp-function
spec:
replicas: 1
selector:
matchLabels:
app: bp-function
template:
metadata:
labels:
app: bp-function
spec:
containers:
– name: bp-function
image: ghcr.io/<your-github-username>/bp-function:latest
ports:
– containerPort: 8080
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
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi

apiVersion: v1
kind: Service
metadata:
name: bp-function
namespace: dev-space-neil
spec:
selector:
app: bp-function
ports:
– port: 80
targetPort: 8080

k8s/apirule.yaml

Use the full cluster domain rather than a short hostname. Find yours with:

kubectl get configmap shoot-info -n kube-system -o jsonpath='{.data.domain}’apiVersion: gateway.kyma-project.io/v2
kind: APIRule
metadata:
name: bp-function
namespace: dev-space-neil
spec:
hosts:
– bp-function.<your-cluster-domain>.kyma.ondemand.com
service:
name: bp-function
port: 80
gateway: kyma-system/kyma-gateway
rules:
– path: /*
methods:
– GET
noAuth: true

Apply both:

kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/apirule.yaml

Check the pod is running:

kubectl get pods -n dev-space-neil -l app=bp-function

Step 7: Test

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

You should see Business Partner records:

{
“d”: {
“results”: [
{
“BusinessPartner”: “11”,
“BusinessPartnerFullName”: “Cust15 Cust15”,

}
]
}
}

How It Works

The call chain is the same as the original Kyma Function approach:

Internet -> APIRule (Istio) -> Service -> Pod
-> XSUAA (get OAuth token)
-> Destination Service (resolve S4H_SANDBOX_BP)
-> SAP API Hub sandbox (fetch Business Partners)
-> JSON response

The difference is that your code runs in a container you built and pushed, rather than in the Kyma serverless runtime. The BTP Service Operator still injects the Destination Service credentials into the pod as environment variables via the ServiceBinding secret — that part is identical.

Key Points

The Serverless module is optional. The BTP Service Operator, APIRule, and Istio are the parts that matter for this integration pattern. If Serverless isn’t enabled, a standard Deployment works just as well.

Instance destinations vs subaccount destinations. If you can’t create destinations in the BTP cockpit, use POST /destination-configuration/v1/instanceDestinations. These are scoped to the service instance and can be created using the binding credentials. They’re resolved at runtime just like subaccount destinations.

Multi-platform builds matter. Apple Silicon Macs build linux/arm64 by default. Use docker buildx build –platform linux/amd64,linux/arm64 to produce a manifest list that works on both architectures — avoiding the cryptic no match for platform in manifest error.

Use the full APIRule hostname. In Kyma API Gateway v2, short hostnames in the hosts field expand to include the namespace (bp-function.dev-space-neil.<cluster-domain>). This may not match your cluster’s wildcard DNS. Use the full explicit hostname (bp-function.<cluster-domain>) to be safe — check what format existing APIRules in your cluster use.

Gzip is not optional. The SAP API Business Hub sandbox always returns gzip-compressed responses regardless of what you put in Accept-Encoding. Handle it explicitly with Node’s built-in zlib module.

 

​ IntroductionMy previous post walked through calling the SAP Business Partner API from a Kyma Serverless Function. This post covers what happens when that approach hits real-world constraints — and how to adapt.The two problems I encountered on a managed Kyma cluster:The Serverless module wasn’t installed — kind: Function doesn’t exist as a resource type, so the function YAML simply won’t applyNo permission to create BTP Destinations via the cockpit — the New Destination button was greyed outBoth are common in corporate and training environments where you don’t own the cluster or subaccount. Here’s how to work around both.By the end you’ll have the same result as the original post — a live endpoint returning SAP Business Partner JSON — but deployed as a standard Kubernetes Deployment instead of a Kyma Function.What Changed vs the Original ApproachOriginal This Postkind: Function (Kyma Serverless)kind: Deployment (standard Kubernetes)Code inline in YAMLCode in a Docker containerNo registry neededImage pushed to GitHub Container RegistryDestination created in BTP cockpitDestination created via Destination Service REST APIThe call chain is identical — your container still calls XSUAA for a token, resolves the destination, then calls the SAP API sandbox. The difference is purely in how the code is packaged and deployed.Prerequisiteskubectl configured against a Kyma clusterDocker installed locallyA GitHub account (for the container registry)An account on api.sap.com with an API keyStep 1: Check What’s Actually AvailableBefore spending time on a Kyma Function approach, verify whether Serverless is installed:kubectl api-resources | grep serverlessIf nothing is returned, the Serverless module isn’t enabled. You’ll need either cluster admin access to enable it, or the approach in this post.Also verify the BTP Service Operator is available (needed for ServiceInstance and ServiceBinding):kubectl api-resources | grep services.cloud.sap.comYou should see serviceinstances and servicebindings. If these are missing, stop here — the BTP Service Operator isn’t installed and none of this will work.Step 2: Create the Destination Service Instance and BindingThis is identical to the original post. Apply these to your namespace:k8s/destination-instance.yamlapiVersion: services.cloud.sap.com/v1
kind: ServiceInstance
metadata:
name: destination-service
namespace: dev-space-neil
spec:
serviceOfferingName: destination
servicePlanName: litek8s/destination-binding.yamlapiVersion: services.cloud.sap.com/v1
kind: ServiceBinding
metadata:
name: destination-service-binding
namespace: dev-space-neil
spec:
serviceInstanceName: destination-servicekubectl apply -f k8s/destination-instance.yaml
kubectl apply -f k8s/destination-binding.yamlWait until both show Ready: True:kubectl get serviceinstance,servicebinding -n dev-space-neilThe binding creates a Kubernetes secret called destination-service-binding containing clientid, clientsecret, url (XSUAA token endpoint), and uri (Destination Service endpoint). Your container will read these as environment variables.Step 3: Create the BTP Destination via REST APIIf you have access to the BTP cockpit, create the destination there as described in the original post. If not, use the Destination Service REST API directly — the service binding credentials you just created have enough access to create instance-level destinations.Get a token and POST the destination:TOKEN=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.clientid}’ | base64 -d)
SECRET=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.clientsecret}’ | base64 -d)
XSUAA=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.url}’ | base64 -d)
URI=$(kubectl get secret destination-service-binding -n dev-space-neil
-o jsonpath='{.data.uri}’ | base64 -d)

ACCESS_TOKEN=$(curl -s -X POST “$XSUAA/oauth/token”
-u “$TOKEN:$SECRET”
-d “grant_type=client_credentials”
| python3 -c “import sys,json; print(json.load(sys.stdin)[‘access_token’])”)

curl -X POST “$URI/destination-configuration/v1/instanceDestinations”
-H “Authorization: Bearer $ACCESS_TOKEN”
-H “Content-Type: application/json”
-d ‘{
“Name”: “S4H_SANDBOX_BP”,
“Type”: “HTTP”,
“URL”: “https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER”,
“Authentication”: “NoAuthentication”,
“ProxyType”: “Internet”,
“APIKey”: “<your-api.sap.com-key>”
}’The distinction between subaccountDestinations and instanceDestinations matters here. Subaccount destinations are managed at the BTP subaccount level (requires admin access). Instance destinations are scoped to the service instance and can be created by the binding credentials — which is what we have.Step 4: Write the Handler and HTTP ServerThe business logic in handler.js is unchanged from the original post — get a token, resolve the destination, call the API. The only difference is gzip handling (the SAP API sandbox always compresses responses) and reading the API key from the destination properties rather than a separate Kubernetes secret.handler.jsconst https = require(“https”);
const http = require(“http”);
const zlib = require(“zlib”);

/**
* Gets an OAuth2 access token from XSUAA using client credentials flow.
* Credentials are injected as env vars from the destination-service-binding secret.
*/
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`);

return new Promise((resolve, reject) => {
const options = {
hostname: tokenUrl.hostname,
path: `${tokenUrl.pathname}?grant_type=client_credentials`,
method: “POST”,
headers: {
Authorization: `Basic ${credentials}`, // Base64-encoded clientid:clientsecret
“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(`Token parse failed: ${data}`)); }
});
});
req.on(“error”, reject);
req.end();
});
}

/**
* Resolves a named BTP Destination via the Destination Service REST API.
* Returns the full destination object including destinationConfiguration,
* which contains the target URL and any additional properties (e.g. APIKey).
*/
async function getDestination(name, token) {
const destUrl = new URL(
`${process.env.DESTINATION_URI}/destination-configuration/v1/destinations/${name}`
);
return new Promise((resolve, reject) => {
const options = {
hostname: destUrl.hostname,
path: destUrl.pathname,
method: “GET”,
headers: { Authorization: `Bearer ${token}` }, // Token obtained from getDestinationToken()
};
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(`Destination parse failed: ${data}`)); }
});
});
req.on(“error”, reject);
req.end();
});
}

/**
* Calls the SAP Business Partner API via the resolved destination.
* The SAP API Hub sandbox always returns gzip-compressed responses,
* so we explicitly handle gzip/deflate decompression via Node’s zlib module.
*/
async function fetchBusinessPartners(destination) {
const config = destination.destinationConfiguration;
const targetUrl = new URL(`${config.URL}/A_BusinessPartner?$format=json&$top=20`);
// Use http or https depending on the destination URL scheme
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: config.APIKey, // API key stored as a destination property, not a separate secret
Accept: “application/json”,
},
};
const req = protocol.request(options, (res) => {
const chunks = [];
const encoding = res.headers[“content-encoding”];
// Pipe through the appropriate decompressor based on Content-Encoding header
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();
});
}

/**
* Main entry point — orchestrates the full call chain:
* 1. Get XSUAA token
* 2. Resolve the S4H_SANDBOX_BP destination
* 3. Fetch Business Partners from the SAP API sandbox
* Returns a response object compatible with both Kyma Functions and the HTTP server wrapper.
*/
module.exports = {
main: async function (event, context) {
try {
const token = await getDestinationToken();
const destination = await getDestination(“S4H_SANDBOX_BP”, 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 },
};
}
},
};Since this is a container rather than a Kyma Function, we need a simple HTTP server to drive it. server.js wraps the handler:server.jsconst http = require(“http”);
const handler = require(“./handler”);

const PORT = process.env.PORT || 8080; // Default to 8080; override via env var if needed

/**
* Minimal HTTP server that wraps handler.js for container deployment.
* Kyma Functions have their own runtime that calls handler.main() directly —
* this server provides the equivalent entry point when running as a standard Deployment.
* Every request regardless of path/method triggers the Business Partner fetch.
*/
const server = http.createServer(async (req, res) => {
try {
const result = await handler.main({}, {});
// handler.main() may return body as object or string — normalise to string
const body = typeof result.body === “string”
? result.body
: JSON.stringify(result.body);
res.writeHead(result.statusCode || 200, {
“Content-Type”: “application/json”,
…result.headers,
});
res.end(body);
} catch (err) {
res.writeHead(500, { “Content-Type”: “application/json” });
res.end(JSON.stringify({ error: err.message }));
}
});

server.listen(PORT, () => console.log(`Listening on port ${PORT}`));Step 5: Build and Push a Multi-Platform Container ImageThis step doesn’t exist in the original Kyma Function approach — the serverless runtime handles that for you. Here you need to build a Docker image and push it to a registry the cluster can reach.DockerfileFROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install –omit=dev
COPY handler.js server.js ./
EXPOSE 8080
CMD [“node”, “server.js”]One important detail: if you’re on an Apple Silicon Mac, Docker builds linux/arm64 by default. Most cloud Kubernetes clusters run linux/amd64. Build for both to avoid an ImagePullBackOff with the error no match for platform in manifest:# Authenticate with GitHub Container Registry
echo $(gh auth token) | docker login ghcr.io -u <your-github-username> –password-stdin

# Build and push for both platforms
docker buildx build
–platform linux/amd64,linux/arm64
-t ghcr.io/<your-github-username>/bp-function:latest
–push .Make the package public in GitHub (your profile → Packages → bp-function → Package settings → Change visibility → Public) so the cluster can pull it without credentials.Step 6: Deploy to Kymak8s/deployment.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: bp-function
namespace: dev-space-neil
labels:
app: bp-function
spec:
replicas: 1
selector:
matchLabels:
app: bp-function
template:
metadata:
labels:
app: bp-function
spec:
containers:
– name: bp-function
image: ghcr.io/<your-github-username>/bp-function:latest
ports:
– containerPort: 8080
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
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi

apiVersion: v1
kind: Service
metadata:
name: bp-function
namespace: dev-space-neil
spec:
selector:
app: bp-function
ports:
– port: 80
targetPort: 8080k8s/apirule.yamlUse the full cluster domain rather than a short hostname. Find yours with:kubectl get configmap shoot-info -n kube-system -o jsonpath='{.data.domain}’apiVersion: gateway.kyma-project.io/v2
kind: APIRule
metadata:
name: bp-function
namespace: dev-space-neil
spec:
hosts:
– bp-function.<your-cluster-domain>.kyma.ondemand.com
service:
name: bp-function
port: 80
gateway: kyma-system/kyma-gateway
rules:
– path: /*
methods:
– GET
noAuth: trueApply both:kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/apirule.yamlCheck the pod is running:kubectl get pods -n dev-space-neil -l app=bp-functionStep 7: Testcurl https://bp-function.<your-cluster-domain>.kyma.ondemand.comYou should see Business Partner records:{
“d”: {
“results”: [
{
“BusinessPartner”: “11”,
“BusinessPartnerFullName”: “Cust15 Cust15”,

}
]
}
}How It WorksThe call chain is the same as the original Kyma Function approach:Internet -> APIRule (Istio) -> Service -> Pod
-> XSUAA (get OAuth token)
-> Destination Service (resolve S4H_SANDBOX_BP)
-> SAP API Hub sandbox (fetch Business Partners)
-> JSON responseThe difference is that your code runs in a container you built and pushed, rather than in the Kyma serverless runtime. The BTP Service Operator still injects the Destination Service credentials into the pod as environment variables via the ServiceBinding secret — that part is identical.Key PointsThe Serverless module is optional. The BTP Service Operator, APIRule, and Istio are the parts that matter for this integration pattern. If Serverless isn’t enabled, a standard Deployment works just as well.Instance destinations vs subaccount destinations. If you can’t create destinations in the BTP cockpit, use POST /destination-configuration/v1/instanceDestinations. These are scoped to the service instance and can be created using the binding credentials. They’re resolved at runtime just like subaccount destinations.Multi-platform builds matter. Apple Silicon Macs build linux/arm64 by default. Use docker buildx build –platform linux/amd64,linux/arm64 to produce a manifest list that works on both architectures — avoiding the cryptic no match for platform in manifest error.Use the full APIRule hostname. In Kyma API Gateway v2, short hostnames in the hosts field expand to include the namespace (bp-function.dev-space-neil.<cluster-domain>). This may not match your cluster’s wildcard DNS. Use the full explicit hostname (bp-function.<cluster-domain>) to be safe — check what format existing APIRules in your cluster use.Gzip is not optional. The SAP API Business Hub sandbox always returns gzip-compressed responses regardless of what you put in Accept-Encoding. Handle it explicitly with Node’s built-in zlib module.   Read More Technology Blog Posts by Members articles 

#SAP

#SAPTechnologyblog

You May Also Like

More From Author