63 · SAP Build Process Automation

Trigger workflow instances from CAP AFTER handlers, receive approval callbacks, and query instance status.

Episode 63 — SAP Build Process Automation

What you'll learn

Why this matters

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.

How SAP Build Process Automation works

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.

Setting up the service binding

# 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

Triggering a workflow instance from CAP

// 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' });
  }
};

Receiving the approval callback

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;

Querying workflow instance status

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
  };
}

Handling errors and escalations

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`);
}

Common mistakes

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

Checkpoint ✓

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.

Sign in for progress tracking & Q&A →