Trigger workflow instances from CAP AFTER handlers, receive approval callbacks, and query instance status.
Business processes rarely fit inside a single synchronous request — approvals, multi-step sign-offs, SLA escalations all need orchestration. SAP Build Process Automation provides a low-code workflow engine on BTP. Connecting it to your CAP backend lets you trigger approvals from data changes, receive decisions back, and audit every step without building a custom state machine.
SBPA is the successor to SAP Workflow Management. It provides:
| Component | Role |
|---|---|
| Process Editor | Low-code drag-and-drop flow designer |
| Workflow Runtime | Executes instances; stores audit trail |
| My Inbox (Fiori) | Task UI for human approvers |
| Rest API | Trigger instances, fetch status, send decisions |
| BTP Service | workflow service plan on Cloud Foundry |
The key resource is a Process Definition (like a class) from which you create Instances at runtime.
# Create a Workflow service instance (standard plan)
cf create-service workflow standard my-workflow
# Create a service key for local testing
cf create-service-key my-workflow my-workflow-key
# View credentials
cf service-key my-workflow my-workflow-key
The service key contains:
{
"endpoints": {
"workflow_rest_url": "https://api.workflow.cfapps.eu10.hana.ondemand.com/workflow/rest/v1"
},
"uaa": {
"clientid": "sb-my-workflow!t1234",
"clientsecret": "...",
"url": "https://my-subaccount.authentication.eu10.hana.ondemand.com"
}
}
Add to cds.requires in .cdsrc.json:
{
"requires": {
"WorkflowAPI": {
"kind": "rest",
"credentials": {
"destination": "MY_WORKFLOW_DESTINATION"
}
}
}
}
Or bind the service key directly for local development:
cds bind workflow --to my-workflow:my-workflow-key
// srv/purchase-order-service.js
const cds = require('@sap/cds');
module.exports = class PurchaseOrderService extends cds.ApplicationService {
async init() {
const { PurchaseOrders } = this.entities;
this.after('CREATE', PurchaseOrders, async (po, req) => {
if (po.totalAmount > 10000) {
await this._triggerApprovalWorkflow(po, req);
}
});
await super.init();
}
async _triggerApprovalWorkflow(po, req) {
const workflow = await cds.connect.to('WorkflowAPI');
const instance = await workflow.post('/v1/workflow-instances', {
definitionId: 'po-approval-process', // process definition ID in SBPA
context: {
purchaseOrderId: po.ID,
amount: po.totalAmount,
requestedBy: req.user.id,
requestedByName: req.user.name,
currency: po.currency
}
});
// Persist the workflow instance ID so we can query status later
const db = await cds.connect.to('db');
await db.update(PurchaseOrders, po.ID)
.with({ workflowInstanceId: instance.id, status: 'PendingApproval' });
}
};
SBPA can call a webhook (your CAP endpoint) when the workflow completes. Define an OData action on your service:
// srv/purchase-order-service.cds
service PurchaseOrderService {
entity PurchaseOrders as projection on db.PurchaseOrders;
// Called by SBPA when approval workflow completes
action workflowCallback(
instanceId : String,
decision : String, // 'Approved' | 'Rejected'
comment : String,
approvedBy : String
) returns String;
}
this.on('workflowCallback', async req => {
const { instanceId, decision, comment, approvedBy } = req.data;
const db = await cds.connect.to('db');
const po = await db.read(PurchaseOrders).where({ workflowInstanceId: instanceId });
if (!po) return req.reject(404, `PO not found for workflow instance ${instanceId}`);
if (decision === 'Approved') {
await db.update(PurchaseOrders, po.ID)
.with({ status: 'Approved', approvedBy, approvedAt: new Date() });
await this._sendToBackend(po);
} else {
await db.update(PurchaseOrders, po.ID)
.with({ status: 'Rejected', rejectionComment: comment });
}
return 'Callback processed';
});
Secure the callback endpoint — SBPA will include an OAuth token from its own XSUAA tenant:
@requires: 'WorkflowSystem' // a dedicated scope for the SBPA service principal
action workflowCallback(...) returns String;
async getApprovalStatus(req) {
const { poId } = req.data;
const db = await cds.connect.to('db');
const workflow = await cds.connect.to('WorkflowAPI');
const po = await db.read(PurchaseOrders, poId);
if (!po.workflowInstanceId) return { status: 'NoWorkflow' };
const instance = await workflow.get(
`/v1/workflow-instances/${po.workflowInstanceId}`
);
return {
status: instance.status, // RUNNING | COMPLETED | ERRONEOUS
startedAt: instance.startedAt,
subject: instance.subject
};
}
Design defensive workflows:
- Set task deadlines in the Process Editor (e.g., 48 h) with auto-escalation to a manager group
- Use a boundary error event on each task to catch technical failures
- Log the SBPA instance ID immediately so you can query /v1/workflow-instances/{id}/execution-logs
// Poll for stuck instances (run as a scheduled job)
const stuck = await workflow.get('/v1/workflow-instances?status=RUNNING&startedBefore=2d');
for (const inst of stuck.value) {
logger.warn(`Workflow ${inst.id} has been running for >2 days`);
}
| Mistake | Fix |
|---|---|
| Storing workflow credentials in code | Use BTP Destination Service |
Calling SBPA synchronously in a CREATE handler |
Move to AFTER to avoid blocking the DB commit |
| Not persisting the instance ID | You lose the ability to correlate callbacks |
| Hardcoding process definition ID | Put it in an environment variable / feature flag |
| Forgetting BAPI_TRANSACTION_COMMIT equivalent | Commit your CAP changes before calling SBPA |
You can trigger a workflow instance from a CAP AFTER CREATE handler, store the returned instance ID, and handle the approval callback via an @requires-protected OData action. You know how to query instance status and where to look for execution logs when a workflow gets stuck.