Workflows API
The Workflows API enables automation of business processes with visual workflows, multi-step approvals, timer events, parallel branches, retry of failed steps, pausing and 7 integrated actions (email, webhooks, tickets, etc.). Templates support versioning with rollback and publishing.
Endpoints Overview
Workflow Instances
| Method | Endpoint | Description |
|---|---|---|
GET | /api/workflow/instances | List all instances (with filtering) |
GET | /api/workflow/instances/:id | Get single instance (with steps & activities) |
GET | /api/workflow/instances/stats | Counters within your visibility scope + your open tasks |
POST | /api/workflow/instances/:id/steps/:stepId/complete | Complete step (approval steps go through /approve) |
POST | /api/workflow/instances/:id/steps/:stepId/approve | Record approval decision |
POST | /api/workflow/instances/:id/cancel | Cancel workflow (202, no body) |
POST | /api/workflow/instances/:id/pause | Pause workflow; no steps run until it is resumed (202, no body) |
POST | /api/workflow/instances/:id/resume | Resume workflow (202, no body) |
POST | /api/workflow/instances/:id/steps/:stepId/retry | Retry failed step |
POST | /api/workflow/instances/:id/steps/:stepId/reassign | Reassign step to another user |
Workflows are started via one of the two start paths — users through POST /api/workflow/catalog/:templateId/start, external systems by API key through POST /api/workflow/api/trigger/:templateId. Both answer 202 Accepted; the workflow engine executes asynchronously. Same for cancel/pause/resume: 202 with no body.
Templates (CRUD & Publishing)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/workflow/templates | List all templates (filters + pagination; ?includeDeleted=true shows the trash) |
GET | /api/workflow/templates/statistics | Counters across all templates: total, published, draft, archived, inactive (?includeDeleted) |
GET | /api/workflow/templates/categories | Distinct categories across all templates ({data: string[]}) — source for the category filters |
GET | /api/workflow/templates/:id | Get single template with steps |
POST | /api/workflow/templates | Create template (drag-&-drop editor) |
PUT | /api/workflow/templates/:id | Update template |
DELETE | /api/workflow/templates/:id | Delete template (soft-delete) |
POST | /api/workflow/templates/:id/restore | Restore template (requires restoreTemplates AND viewDeletedTemplates) |
POST | /api/workflow/templates/:id/publish | Publish template |
POST | /api/workflow/templates/:id/unpublish | Unpublish template |
PUT | /api/workflow/templates/:id/permissions | Set template permissions |
Versioning
| Method | Endpoint | Description |
|---|---|---|
POST | /api/workflow/templates/:id/versions | Create new version (semver, changelog) |
POST | /api/workflow/templates/:name/rollback | Rollback to a previous version |
POST | /api/workflow/templates/:id/publish · /unpublish | Publish / unpublish |
My Tasks
A user's open workflow steps are returned by
GET /api/my-tasks?types=WORKFLOW_STEP. Instance statistics are served byGET /api/workflow/instances/stats.
Activity & Audit
| Method | Endpoint | Description |
|---|---|---|
GET | /api/workflow/activity/history | Global workflow history |
GET | /api/workflow/activity/statistics | Workflow statistics |
| Activities of a single instance come embedded from GET /api/workflow/instances/:id. | ||
Workflow Catalog
| Method | Endpoint | Description |
|---|---|---|
GET | /api/workflow/catalog/list | Startable workflows: flat list ({data, pagination}, search & category filter) |
POST | /api/workflow/catalog/:templateId/start | Start workflow from catalog (202) |
API Trigger (External Systems)
| Method | Endpoint | Description |
|---|---|---|
POST | /api/workflow/api/trigger/:templateId | Trigger workflow via API key |
Workflow Concepts
Templates vs. Instances
WorkflowTemplate: ├─ Blueprint/Definition (reusable) ├─ Contains step definitions (array of nodes) ├─ Versioned (v1, v2, v3...) └─ Can be published/unpublished WorkflowInstance: ├─ Concrete execution of a template ├─ Has own runtime data (data, variables) ├─ Creates StepExecutions (one per step) ├─ Status: DRAFT → RUNNING → COMPLETED/FAILED/CANCELLED └─ Has activities (audit trail)
Workflow Status
| Status | Description |
|---|---|
DRAFT | Created but not started yet |
RUNNING | Actively running, steps being executed |
PAUSED | Temporarily paused, can be resumed |
COMPLETED | Successfully completed |
CANCELLED | Cancelled by user |
FAILED | Failed (e.g., approval rejected without escalation) |
Step Status
| Status | Description |
|---|---|
PENDING | Not yet reached |
ASSIGNED | Assigned, waiting for user action |
IN_PROGRESS | Currently being processed |
COMPLETED | Completed |
REJECTED | Rejected (approval only) |
FAILED | Failed (e.g., webhook error) |
SKIPPED | Skipped (e.g., unchosen conditional branch) |
8 Node Types (Step Types)
1. MANUAL_TASK
Manual task assigned to a user or group. User must manually complete the task.
Properties: • Assignment: User, group or role-based • Deadlines: Optional (e.g., "in 24 hours") • Notifications: Automatic via unified system • Instructions: Instructions for the user Flow: 1. Step is set to ASSIGNED 2. User receives notification 3. User completes task (POST .../complete) 4. Step changes to COMPLETED 5. Next step is triggered
2. APPROVAL
Approval step with auto-approve/reject logic, escalation support and manual decision.
Features: • Auto-Approve: Field-based (e.g., "if amount < 5000") • Auto-Reject: Has higher priority than auto-approve • Escalation: On any rejection to the configured step (escalationStepId) • Manual Approval: If no auto-rule applies • failOnReject: Workflow FAILED on rejection — escalation takes precedence Flow (Auto-Reject): 1. Check auto-reject condition (FIRST priority) 2. If TRUE: Reject + escalation (if configured) 3. If escalation missing: Workflow FAILED Flow (Auto-Approve): 1. Check auto-reject condition (FALSE) 2. Check auto-approve condition (SECOND priority) 3. If TRUE: Immediately APPROVED + next step Flow (Manual): 1. No auto-rule applies 2. Step is set to ASSIGNED 3. Approver receives notification 4. Approver decides (POST .../approve) 5. On APPROVED: Next step 6. On REJECTED: Escalation or Workflow FAILED
3. AUTOMATED_ACTION
Automated action - executes one of 7 integrated actions.
7 Action Types:
1. send_email = Send email (via notification worker)
2. webhook = HTTP request (POST/GET/PUT), SSRF-protected
3. create_ticket = Create new ticket
4. update_ticket = Update ticket (status, priority, assignee)
5. add_ticket_comment = Add comment to ticket
6. update_field = Update workflow field (data)
7. assign_ticket = Auto-assign ticket (via assignment engine)
Security:
• SSRF-Protection: Webhooks block private IPs (127.0.0.1, 10.x, 192.168.x)
• Timeout: Default 10s, configurable up to 60s
• Redirects: Disabled (redirect: "error")
• Variable Replacement: {{"{{"}}field{{"}}"}} replaced by workflow.data.field
Flow:
1. Step changes to IN_PROGRESS
2. Action is executed (with timeout protection)
3. On success: COMPLETED + outputData saved
4. On error: FAILED + errorMessage
5. Next step is triggered (only on success)
4. NOTIFICATION
Sends notifications to users or groups (via unified notification system).
Recipient Types:
• userIds: Array of user IDs
• groupId: All members of a group
• roleName: All users with this role
• notifyInitiator: Notify workflow initiator
• recipientField: Read dynamically from workflow.data
Notification Types:
• task: General task notification (default)
• reminder: Reminder notification (e.g., "deadline in 2h")
• escalation: Escalation notification
Flow:
1. Recipients are resolved (deduplicated)
2. For each recipient: Notification via unified system
3. Step completes immediately (no waiting)
4. outputData: { notificationsSent: X, totalRecipients: Y }
5. PARALLEL_GATEWAY
Starts multiple workflow branches in parallel. All branches execute simultaneously.
Properties: • nextSteps: Array of step IDs (parallel branches) • No synchronization at end (no "join") • Workflow is COMPLETED when ALL branches are completed Flow: 1. Gateway step changes to COMPLETED 2. WorkflowEngine starts ALL nextSteps simultaneously 3. Each branch runs independently 4. Workflow status stays RUNNING until all branches done Example: Gateway → [Branch A: Legal Approval, Branch B: Finance Approval] Both approvals run in parallel, independently of each other
6. CONDITIONAL_BRANCH
Conditional branching - selects one of multiple paths based on conditions.
18 supported operators:
Equality: equals, notEquals
Numeric: greaterThan, lessThan, greaterOrEqual, lessOrEqual, between
String: contains, notContains, startsWith, endsWith, matches (regex)
List: in, notIn
Empty/null: isNull, isNotNull, isEmpty, isNotEmpty
in and notIn expect a non-empty value list, between exactly two bounds;the four empty/null operators do not read value. With valueType (string,number, boolean) both sides are converted before comparing — without itequals/notEquals compare strictly ("5" is not 5).
Complex Conditions (Nested):
{
"type": "AND",
"conditions": [
{ "type": "field", "field": "trigger.priority", "operator": "equals", "value": "HIGH" },
{ "type": "field", "field": "trigger.amount", "operator": "greaterThan", "value": 10000 }
]
}
Flow:
1. Conditions are evaluated in order
2. First match = Selected branch
3. No match = Default branch (if configured)
4. No match + no default = FAILED
5. Selected branch is triggered (overrideNextSteps)
Security:
• Max Recursion Depth: 10 (prevents stack overflow)
• Max Field Depth: 10 (prevents DoS via "a.b.c.d.e...")
• Dangerous keys blocked: __proto__, constructor, prototype
• Regex limit: 500 characters
7. DATA_COLLECTION
Form step - presents a form and collects user input.
Properties:
• formSchema: JSON schema for form fields
• prefillFields: Prefill fields from workflow.data
• Validierung: server-side on completion, against the formSchema
Flow:
1. Step is set to ASSIGNED
2. User receives notification
3. User fills out form
4. POST .../complete with data
5. Backend validates data against formSchema
6. On success: the validated values land in data.stepOutputs[stepName]
7. Step COMPLETED + next step
Example formSchema:
{
"requestReason": {
"type": "textarea",
"label": "Reason",
"required": true,
"minLength": 50
},
"urgency": {
"type": "select",
"label": "Urgency",
"options": ["LOW", "MEDIUM", "HIGH"]
}
}
8. TIMER_EVENT
Timer step - delays the workflow by a fixed time or waits until a specific time.
3 Timer Types (timerType):
1. duration = Relative delay from the start of the step (e.g., "3 hours")
• days: 0
• hours: 3
• minutes: 30
• seconds: 0
2. datetime = Absolute point in time, ISO-8601 with time zone (Z or ±HH:MM)
• datetime: "2026-02-01T09:00:00Z"
3. expression = Point in time taken from the workflow data; the resolved value must carry a time zone
• expression: "{{trigger.dueDate}}"
Flow:
1. Step changes to IN_PROGRESS
2. TimerJob is created (persistent in DB)
3. Step waits (shouldComplete = false)
4. TimerChecker service checks every 30s for due timers
5. When executeAt reached: Step is set to COMPLETED
6. Next step is triggered
Special Case:
• Delay = 0 or timestamp already past → Immediate completion
• No CRON support (use the CronJob system for that)
API Examples
Start Workflow
POST /api/workflow/catalog/:templateId/start
{
"data": {
"requestType": "Hardware",
"amount": 12500,
"justification": "New laptops for development team"
}
}
data is validated against the template's triggerSchema (required fields → 400 FORM_VALIDATION_FAILED) and lands on the instance under data.trigger. The template start permission, the per-user concurrent instance limit and the workflow data limits are enforced as well.
Response (202 Accepted)
{
"message": "Workflow started successfully",
"templateId": "clx...",
"templateName": "Hardware Purchase Request"
}
The start runs asynchronously through the workflow engine — the response therefore carries no instance ID yet. The resulting instance shows up in GET /api/workflow/instances.
Complete Manual Task
POST /api/workflow/instances/:instanceId/steps/:stepId/complete
{
"outcome": "COMPLETED",
"data": {
"budgetLineItem": "IT-Equipment-2026",
"approvalCode": "FIN-2026-045"
}
}
outcome ∈ APPROVED | REJECTED | COMPLETED | FAILED (optional). data carries the step input — for DATA_COLLECTION the form fields, validated against the formSchema. Response: the completed step execution ({id, status, completedAt}).
An APPROVAL step is decided through /approve, not through this endpoint (400 APPROVAL_STEP_REQUIRES_DECISION). That way every approval decision takes the same path — same permission check, same comment requirement, same rejection handling.
Approval Decision
POST /api/workflow/instances/:instanceId/steps/:stepId/approve
{
"decision": "APPROVED",
"comment": "Approved based on business justification and available budget"
}
// OR
{
"decision": "REJECTED",
"comment": "Budget exceeded for Q1, resubmit in Q2"
}
decision is exactly APPROVED or REJECTED (400 INVALID_APPROVAL_DECISION). Beyond the assignment, the decider needs the workflows.completeSteps permission. If the step sets requireRejectionComment, a rejection requires a comment (400 REJECTION_COMMENT_REQUIRED). Response: {decision, complete} — complete=false means further approvals are still pending. The comment — on approval as well as on rejection — lands in the instance under data.stepOutputs[step name].comments and is visible there in the overview and the activity trail.
Trigger Workflow via API Key (External Systems)
POST /api/workflow/api/trigger/:templateId
X-API-Key: your-api-key-here
{
"data": {
"externalSystemId": "SAP-12345",
"purchaseOrderNumber": "PO-2026-1234",
"amount": 25000,
"vendor": "Dell Technologies"
},
"metadata": {
"source": "SAP",
"correlationId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-27T15:30:00Z"
}
}
- Auth: X-API-Key header. The API key's role needs the workflows.startWorkflow permission AND — if the template carries start permissions — must be listed in its allowedRoles (allowedUsers only applies to user starts, not to API keys; if both lists are empty the global permission is enough).
- Body:
data(optional, validated against the template's triggerSchema, size-capped; lands on the instance underdata.trigger) ·metadata(optional: source, correlationId (UUID), timestamp) - Response:
202 Accepted— the instance is created and processed asynchronously by the workflow engine. - The template must carry triggerType API — with any other value the route answers 400 INVALID_TRIGGER_TYPE. The field has exactly two values: MANUAL (started from the catalog) and API (started by an external system); any other value is rejected with 400 when saving the template.
Get Workflow Catalog
GET /api/workflow/catalog/list?category=Procurement&search=hardware&page=1&limit=20
Response
{
"data": [
{
"id": "clx...",
"name": "Hardware Purchase Request",
"description": "Multi-step approval for hardware purchases > €5,000",
"category": "Procurement",
"version": 3,
"isActive": true,
"isPublished": true,
"triggerType": "MANUAL",
"triggerSchema": { "fields": [ "..." ] }
}
],
"pagination": { "page": 1, "limit": 20, "total": 7, "totalPages": 1, "hasMore": false }
}
The catalog is the end-user surface: it requires workflows.startWorkflow and only returns published, active templates the caller may actually start (allowedRoles/allowedUsers). Template MANAGEMENT (GET /api/workflow/templates with the full step definitions) requires workflows.viewTemplates instead.
Get Instance with Details
GET /api/workflow/instances/:id
Response (Full Instance)
{
"id": "clx...",
"templateId": "clx...",
"status": "RUNNING",
"priority": "HIGH",
"currentSteps": ["step-3"],
"completedSteps": {
"step-1": { "status": "COMPLETED", "outcome": "COMPLETED", "startedAt": "2026-01-27T15:30:00Z", "completedAt": "2026-01-27T15:35:00Z", "errorMessage": null },
"step-2": { "status": "COMPLETED", "outcome": "APPROVED", "startedAt": "2026-01-27T15:35:00Z", "completedAt": "2026-01-27T16:20:00Z", "errorMessage": null }
},
"data": {
"trigger": { "requestType": "Hardware", "amount": 12500 },
"variables": { "budgetLineItem": "IT-Equipment-2026" },
"stepOutputs": {
"Submit Request": { "stepType": "DATA_COLLECTION", "budgetLineItem": "IT-Equipment-2026" },
"Finance Approval": { "stepType": "APPROVAL", "outcome": "APPROVED", "autoApproved": true }
}
},
"stepDefinitions": [ "... snapshot of the steps at start ..." ],
"templateVersion": 3,
"template": {
"id": "clx...",
"name": "Hardware Purchase Request",
"category": "Procurement"
},
"stepExecutions": [
{
"id": "clx...",
"stepId": "step-1",
"stepName": "Submit Request",
"stepType": "DATA_COLLECTION",
"status": "COMPLETED",
"assignedToId": "clx...",
"completedAt": "2026-01-27T15:35:00Z",
"outputData": {
"budgetLineItem": "IT-Equipment-2026"
}
},
{
"id": "clx...",
"stepId": "step-2",
"stepName": "Finance Approval",
"stepType": "APPROVAL",
"status": "COMPLETED",
"outcome": "APPROVED",
"assignedToId": "clx...",
"completedAt": "2026-01-27T16:20:00Z",
"outputData": {
"autoApproved": true,
"reason": "Amount below auto-approval threshold"
}
},
{
"id": "clx...",
"stepId": "step-3",
"stepName": "Manager Approval",
"stepType": "APPROVAL",
"status": "ASSIGNED",
"assignedToId": "clx...",
"assignedAt": "2026-01-27T16:20:00Z"
}
],
"activities": [
{
"id": "clx...",
"action": "WORKFLOW_STARTED",
"timestamp": "2026-01-27T15:30:00Z",
"user": {
"id": "clx...",
"name": "John Doe",
"email": "john@example.com"
}
},
{
"id": "clx...",
"action": "STEP_COMPLETED",
"timestamp": "2026-01-27T15:35:00Z",
"details": {
"stepName": "Submit Request",
"outcome": "COMPLETED"
}
},
{
"id": "clx...",
"action": "STEP_AUTO_APPROVED",
"timestamp": "2026-01-27T16:20:00Z",
"details": {
"stepName": "Finance Approval",
"reason": "Amount below auto-approval threshold"
}
}
],
"initiator": {
"id": "clx...",
"name": "John Doe",
"email": "john@example.com"
},
"startedAt": "2026-01-27T15:30:00Z",
"createdAt": "2026-01-27T15:30:00Z",
"updatedAt": "2026-01-27T16:20:00Z"
}
Running Workflow Data Model
WorkflowInstance.data has exactly three areas: trigger, variables and stepOutputs. stepOutputs is keyed by step name, which is why step names must be unique per template. Each entry carries stepType plus the outputs collected in that step (e.g. comments for MANUAL_TASK and for the decision on an APPROVAL step, form fields for DATA_COLLECTION, a checklist object for checklists).
// WorkflowInstance.data
{
"trigger": { /* Trigger/start data from triggerSchema */ },
"variables": { /* Workflow variables */ },
"stepOutputs": {
"Step 1": { "stepType": "MANUAL_TASK", "comments": "Ok cool" },
"Step 2": { "stepType": "DATA_COLLECTION", "roomNumber": "option4" },
"Finance Approval": { "stepType": "APPROVAL", "comments": "Budget confirmed" },
"Step 3": {
"stepType": "MANUAL_TASK",
"comments": "Rejected",
"checklist": { "items": ["..."], "checked": ["..."], "totalCount": 5, "completedCount": 5 }
}
}
}
Additionally on the instance: currentSteps[] (active step IDs), completedSteps (map step ID → {status, outcome, startedAt, completedAt, errorMessage}), plus stepDefinitions (snapshot of the step definitions at start) + templateVersion — so template changes do not break running instances. The embedded template relation contains only id, name and category; the step structure of the run is in stepDefinitions.
Templating/paths reference this model: {{ trigger.x }}, {{ variables.y }}, {{ stepOutputs["Step Name"].field }}
(dot or bracket notation).
StepExecution
Per step (unique per instance, [workflowInstanceId, stepId]) there is one StepExecution:
- status: PENDING, ASSIGNED, IN_PROGRESS, COMPLETED, REJECTED, FAILED, SKIPPED · outcome: APPROVED, REJECTED, COMPLETED, FAILED
- assignmentType: user, group, system, initiator, initiator_manager, previous_step_user, dynamic (+ assignedToId / assignedRole / assignedGroup)
- inputData, outputData, formData, formSchema (for DATA_COLLECTION), validationErrors, retryCount, comments/rejectionReason
- On completion, outputData is also mirrored to data.stepOutputs[stepName] (audit trail).
Workflow Engine (Container)
Execution is handled by a dedicated container (workflow-engine). Externally it exposes only /health; it reads and writes all data through the internal backend API: it reads running and overdue instances and steps, creates the follow-up steps, completes steps or marks them as failed, merges variables and processes timers and approvals. Each step type has its own executor (automated, approval, manual, notification, gateway, dataCollection, timer).
- Timers – checks every 30 seconds which TIMER_EVENT steps have reached their point in time (executeAt)
- SLA monitoring – overdue steps/instances, escalation
- Assignment – dynamic assignment (user/role/group, templating against trigger/variables/stepOutputs)
- Auto-Approval – auto-approve/reject by condition
Advanced Features
Auto-Approval Logic
Approval steps can be automatically approved/rejected based on conditions:
{
"type": "APPROVAL",
"name": "Finance Approval",
"config": {
"autoApproveConditions": [
{
"type": "field",
"field": "trigger.amount",
"operator": "lessThan",
"value": 5000
}
],
"autoRejectConditions": [
{
"type": "field",
"field": "trigger.priority",
"operator": "in",
"value": ["URGENT", "HIGH"]
}
],
"autoRejectLogic": "OR",
"failOnReject": true,
"escalationStepId": "step-escalation"
}
}
Besides type: "field", auto-decisions also support type: "permission" and type: "role"; checkPermissionsFor selects who is checked — the initiator (default) or the step assignee. Several conditions are combined by autoApproveLogic with AND (default) or OR, and by autoRejectLogic with OR (default) or AND — so a single reason is enough to reject.
Conditions must be evaluable: When a template is saved, the API checks every auto-decision condition and every path condition of a CONDITIONAL_BRANCH. Incomplete conditions are rejected with 400 INVALID_STEP_CONDITIONS; details.conditionErrors names, per row, the step, the surface (autoApprove, autoReject, branchPath), the row number and the reason — missing field, missing or unknown operator, missing permission or role, missing value list for in/notIn, incomplete bounds for between, an empty AND/OR group or nesting deeper than ten levels.
Why the strictness: an incomplete condition always yields the same result at runtime. It therefore counts as not fulfilled — an auto-approval will not apply, a branch path will not be chosen. What cannot take effect should not be stored in the first place.
Webhook with SSRF Protection
{
"type": "AUTOMATED_ACTION",
"name": "Notify External System",
"config": {
"actionType": "webhook",
"actionConfig": {
"url": "https://external-system.com/api/webhook",
"method": "POST",
"headers": {
"Authorization": "Bearer {{"{{"}}apiToken{{"}}"}}",
"X-Event-Type": "workflow.completed"
},
"body": {
"workflowId": "{{"{{"}}workflowId{{"}}"}}",
"status": "{{"{{"}}status{{"}}"}}",
"completedAt": "{{"{{"}}completedAt{{"}}"}}"
},
"timeout": 15000
}
}
}
Note: Webhook URLs are validated:
- Private IPs blocked (127.0.0.1, 10.x, 192.168.x)
- DNS resolution check against SSRF
- Redirects disabled
- Timeout protection (default: 10s, max: 60s)
Parallel Approval
{
"type": "PARALLEL_GATEWAY",
"name": "Multi-Department Approval",
"nextSteps": [
"step-legal-approval",
"step-finance-approval",
"step-it-approval"
]
}
// All 3 approvals run in parallel
// Workflow is COMPLETED when ALL 3 are approved
Conditional Routing
{
"type": "CONDITIONAL_BRANCH",
"name": "Route by Amount",
"config": {
"paths": [
{
"id": "high-value",
"name": "CFO",
"condition": {
"type": "field",
"field": "trigger.amount",
"operator": "greaterThan",
"value": 50000
},
"nextSteps": ["step-cfo-approval"]
},
{
"id": "medium-value",
"name": "Manager",
"condition": {
"type": "field",
"field": "trigger.amount",
"operator": "between",
"value": [10000, 50000]
},
"nextSteps": ["step-manager-approval"]
},
{
"id": "rest",
"name": "Default",
"isDefault": true,
"nextSteps": ["step-auto-approve"]
}
]
}
}
Paths are evaluated in order, first match wins; paths marked isDefault only apply when no condition matched. A default path needs no condition of its own — it is the only path that may omit one. If nothing matches and there is no default path, the step fails.
Timer with Notification
{
"steps": [
{
"id": "step-1",
"type": "APPROVAL",
"name": "Approval Required",
"nextSteps": ["step-2"]
},
{
"id": "step-2",
"type": "TIMER_EVENT",
"name": "Wait 24 Hours",
"config": {
"timerType": "duration",
"hours": 24
},
"nextSteps": ["step-3"]
},
{
"id": "step-3",
"type": "NOTIFICATION",
"name": "Reminder",
"config": {
"notifyInitiator": true,
"message": "Your request has been pending for 24 hours"
},
"nextSteps": ["step-4"]
}
]
}
Filtering & Pagination
Filter Instances
GET /api/workflow/instances?status=RUNNING&priority=HIGH&templateId=clx...&search=purchase&page=1&limit=20&sortBy=startedAt&sortOrder=desc
| Parameter | Description |
|---|---|
status | DRAFT, RUNNING, PAUSED, COMPLETED, CANCELLED, FAILED |
priority | LOW, MEDIUM, HIGH, URGENT |
templateId | Filter by template |
initiatorId | Filter by initiator |
category | Filter by template category |
search | Search in template names |
page | Page number (default: 1) |
limit | Items per page (default: 50, max: 100) |
sortBy | startedAt, status or templateName (default: startedAt) |
sortOrder | asc or desc (default: desc) |
Permissions
| Permission | Description |
|---|---|
workflows.startWorkflow | See the catalog and start workflows (end-user surface; also for API-key triggers) |
workflows.viewOwnInstances | View own workflow instances |
workflows.viewAllInstances | View all workflow instances |
workflows.cancelInstances | Cancel/pause/resume instances and retry failed steps |
workflows.completeSteps | Complete assigned steps and record approval decisions |
workflows.reassignSteps | Reassign steps to another user |
workflows.assignable | Can be assigned workflow steps |
workflows.viewTemplates | Template MANAGEMENT: view the template list, statistics and definitions |
workflows.createTemplates | Create templates |
workflows.editTemplates | Edit templates, create versions and set start permissions |
workflows.publishTemplates | Publish/unpublish templates |
workflows.deleteTemplates | Delete templates (soft delete) |
workflows.viewDeletedTemplates | See the trash (?includeDeleted on list and statistics) |
workflows.restoreTemplates | Restore deleted templates — together with viewDeletedTemplates |
Usage and management have separate permissions: startWorkflow covers the catalog, starting and your own tasks, viewOwn/viewAllInstances the running instances — the complete template definitions (including the webhook configuration of AUTOMATED_ACTION steps) are visible only with viewTemplates.
Two rights additionally apply only to visible instances: cancel, pause, resume and retry only affect instances the caller may see (as initiator or with viewAllInstances). Reassign checks the same on top of the permission.
Error Handling
Common Errors
| Error Code | HTTP Status | Description |
|---|---|---|
WORKFLOW_TEMPLATE_NOT_FOUND | 404 | Template ID does not exist |
TEMPLATE_INACTIVE | 403 | Template is not active |
TEMPLATE_NOT_PUBLISHED | 403 | Template not published |
TEMPLATE_ARCHIVED | 403 | Template is archived |
CANNOT_EDIT_PUBLISHED_TEMPLATE | 400 | Published templates are locked — create a new version instead (exception: isActive and the start permissions) |
INVALID_PERMISSIONS_CONFIG | 400 | Invalid start permissions; details.validationErrors carries a code + params per row |
INVALID_STEP_CONDITIONS | 400 | Incomplete condition in a step; details.conditionErrors names step, surface and reason per row |
ESCALATION_STEP_NOT_FOUND | 400 | escalationStepId points to a step the template does not contain |
ESCALATION_CYCLE_DETECTED | 400 | The escalation chain forms a loop; details.path shows the cycle |
WORKFLOW_INSTANCE_NOT_FOUND | 404 | Instance ID does not exist |
STEP_NOT_ASSIGNED | 403 | Step not assigned to you |
STEP_ALREADY_COMPLETED | 409 | Step already completed |
STEP_INVALID_STATUS | 400 | Step is not ASSIGNED/IN_PROGRESS |
WORKFLOW_NOT_RUNNING | 400 | Workflow is not RUNNING |
FORM_VALIDATION_FAILED | 400 | Form data invalid |
APPROVAL_STEP_REQUIRES_DECISION | 400 | Approval step addressed via /complete instead of /approve |
INVALID_APPROVAL_DECISION | 400 | decision is not APPROVED/REJECTED |
REJECTION_COMMENT_REQUIRED | 400 | Rejection without a comment although the step requires one |
WORKFLOW_DATA_LIMIT_EXCEEDED | 400 | Workflow data > 1MB |
CONCURRENT_MODIFICATION | 409 | Concurrent modification detected (optimistic locking) |
Error Example
{
"error": "Step not assigned to you",
"errorCode": "STEP_NOT_ASSIGNED",
"message": "You cannot complete this step as it is assigned to user clx...",
"statusCode": 403
}
Best Practices
💡 Tips
1. Workflow Design
- • Use PARALLEL_GATEWAY for independent approvals (faster)
- • Set auto-approve for low-risk approvals (< €5k)
- • Use CONDITIONAL_BRANCH instead of multiple workflows
- • Always configure escalation (prevents FAILED status)
2. Performance
- • Keep workflow.data < 500KB (limit: 1MB)
- • Use webhook timeouts (default: 10s, max: 60s)
- • Avoid deeply nested conditions (max: 10 levels)
3. Security
- • Never store secrets in workflow data (trigger, variables, stepOutputs); webhook credentials belong in the step configuration of the template, which is visible only with workflows.viewTemplates
- • Validate webhook URLs (SSRF protection active)
- • Rotate API keys for external triggers (every 90 days)
- • Check permissions before template publish
4. Monitoring
- • Use activity log for audit trail
- • Monitor for FAILED/PAUSED workflows
- • SLA monitoring for time-critical workflows
Technical Details
Architecture
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React) │
│ • Visual Workflow Designer (React Flow) │
│ • Instance Monitoring Dashboard │
│ • Step Action UI (Complete, Approve, Form) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ BACKEND API (Node.js) │
│ • /api/workflow/instances (CRUD) │
│ • /api/workflow/catalog (Browse & Start) │
│ • /api/workflow/api/trigger (External Systems) │
│ • Validation (Zod), RBAC, Activity Logging │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ WORKFLOW-ENGINE (Separate Container) │
│ • WorkflowEngine: State Machine, Step Orchestration │
│ • StepExecutorFactory: Routes to correct Executor │
│ • 8 Step Executors (Manual, Approval, Automated, etc.) │
│ • ConditionEvaluator: 18 Operators │
│ • AutoApprovalService: Field/Permission/Role Logic │
│ • TimerChecker: Polls Timer Jobs every 30s │
│ • Redis PubSub: workflow:step:complete Events │
└─────────────────────────────────────────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ NOTIFICATION │ │ JOB-WORKER │
│ WORKER │ │ (CronJobs) │
│ │ │ │
│ • Email │ │ • Timer Jobs │
│ • In-App │ │ • Scheduled │
└─────────────────┘ └─────────────────┘
Versioning
Workflows support versioning at template level.
Note: Running instances always use the template version they were started with. Template changes only affect new instances.
Attachments
Workflows use the Unified Attachment System for approval documents, supporting documents, etc.:
# Upload file to workflow
POST /api/attachments/WORKFLOW/:workflowId
# All attachments of a workflow
GET /api/attachments/WORKFLOW/:workflowId
Details: See Attachments & File Settings API for zero-trust virus scan, file settings and retention policies.