Skip to content
GitHub

REST API

All paths below are relative to the API base URL (https://localhost:7140 in the Aspire dev stack). Endpoints are grouped by controller: /Definitions/* (deploy, disable, enable, list), /Execution/* (start, message, signal, complete-activity, evaluate-conditions), /Instances/* (state) and /UserTasks/*. The old /Workflow/* prefix was removed when the controller was split (see PR #614) and now returns 404.

EndpointMethodBody
/Definitions/deployPOST{"BpmnXml":"<raw BPMN XML string>"}
/Execution/startPOST{"WorkflowId":"process-id"} or {"WorkflowId":"process-id","Variables":{"key":"value"},"BusinessId":"order-123"} — BusinessId is optional (see Business id); Variables is optional; when provided, the variables are merged into the root scope before the workflow starts (required for message event sub-processes that resolve correlation keys from variables at scope entry)
/Execution/messagePOST{"MessageName":"...", "CorrelationKey":"...", "Variables":{}, "BusinessId":"..."} — BusinessId is optional and applies only to instances a message start event creates; a message delivered to a running instance leaves that instance’s business id unchanged
/Execution/signalPOST{"SignalName":"..."}
/Execution/complete-activityPOST{"WorkflowInstanceId":"guid", "ActivityId":"activity-id", "Variables":{}}
/Execution/evaluate-conditionsPOST{"WorkflowId":"process-id", "Variables":{"key":"value"}} — Evaluates all conditional start events (or only those for the given WorkflowId if provided) against the supplied variables. Returns {"StartedInstanceIds":["guid",...], "Errors":["..."]}. Errors is present only when one or more listeners failed during evaluation.
/Instances/{instanceId}/stateGET(none) — Returns the current state snapshot for a specific workflow instance
/UserTasksGET(query string) — Paginated list of pending user tasks. See User Task endpoints.
/UserTasks/{activityInstanceId}GET(none) — Single user-task lookup.
/UserTasks/{activityInstanceId}/claimPOST{"UserId":"alice"}
/UserTasks/{activityInstanceId}/unclaimPOST(empty body)
/UserTasks/{activityInstanceId}/completePOST{"UserId":"alice", "Variables":{"approved":true}}
/UserTasks/{activityInstanceId}/failPOST{"errorMessage":"reason", "errorCode":"400"} — fails the task; routes via Error Boundary Event if one matches.
/UserTasks/{activityInstanceId}/cancelPOST{"reason":"optional"} — cancels the task; no error propagation. Idempotent.
Every request passes the opt-in JWT and rate-limit layers, then an endpoint group hands it to a process-definition, correlation or WorkflowInstance grain, or to the read-side projection. Open interactive diagram ↗

Deploys a BPMN process definition to the engine. The request body contains the raw BPMN XML as a string. On success the engine parses the XML, registers the process definition, and returns the assigned key and version number. If the same process ID is deployed again, the version is incremented automatically.

Why this endpoint exists: Before any workflow instance can be started, its process definition must be deployed. This endpoint is the programmatic entry point for uploading BPMN definitions — the Web UI also uses it internally when you import a .bpmn file.

Request

POST /Definitions/deploy
Content-Type: application/json
{
"BpmnXml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><definitions ...>...</definitions>"
}

Success response (200)

{
"ProcessDefinitionKey": "my-process",
"Version": 1
}

Error response (400) — returned when BpmnXml is empty, is not well-formed XML, or is not a deployable BPMN model (e.g. no <process> element). The message carries the parser diagnostic:

{
"error": "Invalid BPMN: BPMN file must contain a process element"
}

Best-practice example

Terminal window
# Deploy a local BPMN file via curl
BPMN_XML=$(cat my-workflow.bpmn | jq -Rs .)
curl -s -X POST https://localhost:7140/Definitions/deploy \
-H "Content-Type: application/json" \
-d "{\"BpmnXml\": $BPMN_XML}"

This reads the .bpmn file, JSON-escapes it with jq -Rs, and sends it to the deploy endpoint. The response contains the ProcessDefinitionKey you pass to /start to create instances.

Starts a new workflow instance from a deployed process definition. Returns the instance ID which can be used to track state, send messages, or complete activities.

Request

POST /Execution/start
Content-Type: application/json
{
"WorkflowId": "my-process",
"Variables": { "amount": 100 },
"BusinessId": "order-123"
}

Variables is optional. When provided, variables are merged into the root scope before the workflow starts.

BusinessId is optional. It is a business identifier for the instance, such as an order number or a contract id. See Business id.

Success response (200)

{
"WorkflowInstanceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

Error responses

  • 400 — {"error": "WorkflowId is required"}, or an invalid BusinessId (ProblemDetails; empty or whitespace, leading or trailing whitespace, control characters such as CR/LF or tab, or longer than 256 characters)
  • 404 — the process key was never deployed (ProblemDetails, detail: Workflow with id 'my-process' is not registered. …)
  • 409 — the process key is disabled (ProblemDetails, detail: Process 'my-process' is disabled. Enable it before creating new instances.)

A process instance can have a business id: a value your systems already know, such as an order number. Use it to find “the process for order 123” without storing the generated instance GUID. It follows Camunda 8.9+‘s businessId:

  • Set only at start, on POST /Execution/start, on POST /Execution/message when a message start event creates the instance, or in the admin UI’s Business ID for Start field. It can’t be changed afterwards: a message correlated to a running instance does not change it. Instances started by timers, signals, conditional starts or call activities have none.
  • At most 256 characters, and not empty or whitespace. An invalid value returns 400.
  • Stored exactly as sent. Leading or trailing whitespace is rejected with 400 rather than trimmed, so the value you look up is the value you started with.
  • Not unique. Two instances can share a business id. Fleans does not enforce uniqueness, so filter by it and pick the instance you need.
  • Not inherited. A call activity’s child instance does not get the parent’s business id.
  • Visible everywhere: as businessId on GET /Instances/{id}/state and on the instance listings. Find instances by it with the businessId query parameter, for example /Definitions/my-process/instances?businessId=order-123. It matches exactly, so values with , or | work too (URL-encode them); those characters would split a Sieve filters term. A padded, too-long or control-character value returns 400; an empty value is ignored (no filter). The MCP ListInstances tool takes the same businessId argument. It also appears in the admin UI, and custom-task plugins get it as CustomTaskExecutionContext.BusinessId.

Delivers a message to workflow instances waiting for it, correlated by key. Used to trigger intermediate message catch events and message start events.

Request

POST /Execution/message
Content-Type: application/json
{
"MessageName": "payment-received",
"CorrelationKey": "order-123",
"Variables": { "paymentId": "pay-456" }
}

Success response (200)

{
"Delivered": true,
"WorkflowInstanceIds": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"]
}

Error responses

  • 400 — {"error": "MessageName is required"}, or an invalid BusinessId (ProblemDetails)
  • 404 — {"error": "No active subscription found for message 'payment-received' with correlation key 'order-123'"} — no workflow instance is currently waiting for this message/key combination

Broadcasts a signal to all workflow instances listening for it. Unlike messages, signals have no correlation key — every matching listener receives the signal.

Request

POST /Execution/signal
Content-Type: application/json
{
"SignalName": "global-alert"
}

Success response (200)

{
"DeliveredCount": 2,
"WorkflowInstanceIds": [
"3fa85f64-5717-4562-b3fc-2c963f66afa6",
"8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8"
]
}

Error responses

  • 400 — {"error": "SignalName is required"}
  • 404 — {"error": "No active subscription found for signal 'global-alert'"} — no workflow instance is currently listening for this signal

Completes a manual activity (e.g., a task waiting for external input) on a running workflow instance, optionally passing output variables.

Request

POST /Execution/complete-activity
Content-Type: application/json
{
"WorkflowInstanceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"ActivityId": "review-task",
"Variables": { "approved": true }
}

Success response (200) — empty body

Error responses

  • 400 — {"error": "WorkflowInstanceId is required"} / {"error": "ActivityId is required"}
  • 404 — no workflow instance with that id (ProblemDetails, detail: Workflow instance '<id>' not found.)
  • 409 — the activity has no active entry: unknown activity id, or an activity that already completed (ProblemDetails, detail: No active entry found for activity 'review-task'.). Also returned when the activity is a user task — use the /UserTasks/{id}/complete endpoint instead.

Paginated read endpoints accept page (default 1), pageSize (default 20, max 100), and Sieve sorts / filters query parameters, and return {"items": [...], "totalCount": n, "page": p, "pageSize": s}. An unknown key or version returns an empty page (200), not 404.

VerbPathReturns
GET/DefinitionsDeployed process-definition versions (processDefinitionId, processDefinitionKey, version, deployedAt, activitiesCount, sequenceFlowsCount, isActive). Filterable on ProcessDefinitionKey, Version, IsActive; sortable on ProcessDefinitionKey, Version, DeployedAt.
GET/Definitions/{key}/instancesInstances of every version of key (instanceId, processDefinitionId, isStarted, isCompleted, isCancelled, timestamps). Filterable on IsStarted, IsCompleted, IsCancelled, IsFailed, BusinessId, CreatedAt; businessId=<value> is an exact-match filter. Each row also carries businessId (or null).
GET/Definitions/{key}/{version}/instancesSame, restricted to one version.
GET/custom-tasksCustom-task plugin catalog: one entry per task type with displayName, parameterSchema, and the siloNames hosting it. Not paginated.
GET/custom-tasks/{taskType}One catalog entry; 404 if no plugin registers that task type.
Terminal window
curl -k 'https://localhost:7140/Definitions?sorts=-Version&filters=ProcessDefinitionKey==my-process'
curl -k 'https://localhost:7140/Definitions/my-process/2/instances?filters=IsCompleted==true'
curl -k 'https://localhost:7140/custom-tasks/rest-call'

User-task endpoints expose the human-in-the-loop lifecycle of <bpmn:userTask> activities. The conceptual model (states, who-can-claim, expected outputs) lives in the User Tasks guide — this section is the authoritative wire reference.

User Task operations summary

VerbPathSurfaceAuthSuccess
GET/UserTasksQueryoptional200 OK
GET/UserTasks/{activityInstanceId}Queryoptional200 OK
POST/UserTasks/{activityInstanceId}/claimMutationUserId from body (no auth) or token (JWT)200 OK
POST/UserTasks/{activityInstanceId}/unclaimMutationNONE — see below200 OK
POST/UserTasks/{activityInstanceId}/completeMutationUserId from body (no auth) or token (JWT)200 OK
POST/UserTasks/{activityInstanceId}/failMutationErrorMessage required in body200 OK
POST/UserTasks/{activityInstanceId}/cancelMutationbody optional200 OK

Auth above refers to API-level JWT bearer auth, which is opt-in for the entire API — see Authentication. With auth disabled, the UserId field in claim/complete bodies is trusted as the acting user. With JWT auth enabled, the acting user comes from the token’s Authentication:UserIdClaim claim. The body UserId is then optional, and a value that differs from the token is rejected with 403.

The User Task endpoints emit two distinct error wire shapes depending on which layer rejects the request:

StatusSurfaceWire shape
400 (model-binding)ASP.NET auto via [ApiController] + AddProblemDetails()application/problem+json (RFC 7807)
400 (controller-emitted, e.g. UserId is required)Custom ErrorResponse{"error":"..."}
404 (task not found)Custom ErrorResponse{"error":"..."}
409 (wrong claimer / missing required outputs)Custom ErrorResponse{"error":"..."} (the two cases discriminate only by message text — see the /complete section below)
5xx (unhandled exception)GlobalExceptionHandler ProblemDetailsapplication/problem+json (RFC 7807)

Property casing in the controller-emitted {"error":"..."} shape is camelCase — the Fleans API serializes via the System.Text.Json default policy.

Lists pending user tasks across all running workflow instances, with optional filters and standard pagination/sort/filter query parameters.

Request

GET /UserTasks?assignee=alice&candidateGroup=approvers&page=1&pageSize=20&sorts=&filters=
Query paramTypeDescription
assigneestring?Restrict to tasks assigned to this user (matches Assignee field).
candidateGroupstring?Restrict to tasks where this group is a candidate.
pageint (default 1)1-based page index.
pageSizeint (default 20)Page size.
sortsstring?Sieve-style sort expression (e.g. createdAt, -createdAt).
filtersstring?Sieve-style filter expression.

Success response (200) — paginated envelope with a data: UserTaskResponse[] payload:

{
"data": [
{
"workflowInstanceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"activityInstanceId": "8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8",
"activityId": "review-task",
"assignee": "alice",
"candidateGroups": ["approvers"],
"candidateUsers": [],
"claimedBy": null,
"taskState": "Created",
"createdAt": "2026-05-03T10:30:00+00:00",
"expectedOutputVariables": ["approved"]
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1
}

Curl example

Terminal window
curl -k "https://localhost:7140/UserTasks?assignee=alice&page=1&pageSize=20"

Returns a single user task by its activity-instance id, or 404 if the id is unknown / no longer pending.

Request

GET /UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8

Success response (200) — single UserTaskResponse (same shape as the array element above).

Error response (404)

{ "error": "User task '8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8' not found" }

Curl example

Terminal window
curl -k https://localhost:7140/UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8

POST /UserTasks/{activityInstanceId}/claim

Section titled “POST /UserTasks/{activityInstanceId}/claim”

Claims a pending task for UserId. Subsequent claims by a different user overwrite the claim — Fleans does not enforce first-claim-wins (see the User Tasks guide for the lifecycle table).

Request

POST /UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/claim
Content-Type: application/json
{ "UserId": "alice" }
Body fieldTypeRequiredDescription
UserIdstringyes without auth; optional under JWTActing user. Under JWT the token’s user is used, and a differing value returns 403.

Success response (200) — empty body.

Error responses

  • 400 — {"error": "UserId is required"} — auth disabled and the body is missing or UserId is empty/whitespace.
  • 403 — JWT auth only: {"error": "UserId does not match the authenticated user"} when the body UserId differs from the token, or {"error": "Authenticated user has no user id claim"} when the token lacks the configured claim.
  • 404 — {"error": "User task '<id>' not found"} — no pending task with that activity-instance id.
  • 409 — claim rejected by the domain layer (e.g. caller is not in Assignee / CandidateUsers / CandidateGroups); the body is the underlying InvalidOperationException message.

Curl example

Terminal window
curl -k -X POST https://localhost:7140/UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/claim \
-H "Content-Type: application/json" \
-d '{"UserId":"alice"}'

POST /UserTasks/{activityInstanceId}/unclaim

Section titled “POST /UserTasks/{activityInstanceId}/unclaim”

Releases an existing claim so another user can claim the task. The body is empty.

Request

POST /UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/unclaim
Content-Type: application/json

Success response (200) — empty body. Note: succeeds whether the task was previously claimed or not.

Error response (404)

{ "error": "User task '8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8' not found" }

Curl example

Terminal window
curl -k -X POST https://localhost:7140/UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/unclaim \
-H "Content-Type: application/json"

POST /UserTasks/{activityInstanceId}/complete

Section titled “POST /UserTasks/{activityInstanceId}/complete”

Completes the task on behalf of UserId, merging Variables into the enclosing scope and advancing the token. The caller must be the current claimer, and all variables declared in <fleans:expectedOutputs> must be present.

Request

POST /UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/complete
Content-Type: application/json
{
"UserId": "alice",
"Variables": { "approved": true, "reviewerComment": "looks good" }
}
Body fieldTypeRequiredDescription
UserIdstringyes without auth; optional under JWTActing user — must match claimedBy on the task. Under JWT the token’s user is used, and a differing value returns 403.
Variablesobject?optional unless the task declares expectedOutputVariablesOutput variables merged into the workflow’s enclosing scope. Every entry in expectedOutputVariables must have a value here.

Success response (200) — empty body. The task is removed from the registry; subsequent GET /UserTasks/{id} returns 404.

Error responses

  • 400 — {"error": "UserId is required"} — auth disabled and no UserId in the body.

  • 403 — JWT auth only: the body UserId differs from the token, or the token lacks the configured user-id claim (same bodies as claim).

  • 404 — {"error": "User task '<id>' not found"} — already completed, never existed, or wrong id.

  • 409 — wrong claimer:

    { "error": "Task is claimed by bob, not alice" }

    Distinguishable by the Task is claimed by …, not … prefix.

  • 409 — missing required outputs:

    { "error": "Missing required output variables: approved, reviewerComment" }

    Distinguishable by the Missing required output variables: prefix. The list is the comma-joined set of declared <fleans:expectedOutputs> entries that are absent from the supplied Variables.

Curl example

Terminal window
curl -k -X POST https://localhost:7140/UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/complete \
-H "Content-Type: application/json" \
-d '{"UserId":"alice","Variables":{"approved":true}}'

Fails a pending user task with an error code and message. The engine routes the failure through the standard FailActivity path — if an Error Boundary Event is attached to the task and its error code matches, the workflow continues via that boundary; otherwise the workflow instance enters a top-level error state.

Both operations are idempotent: calling them on a task that is already in a terminal state (Completed, Failed, or Cancelled) returns 200 OK without re-emitting events.

Request body

{
"errorMessage": "User rejected the task",
"errorCode": "400"
}
FieldTypeRequiredDescription
errorMessagestringYesHuman-readable failure reason.
errorCodestringNo (default "500")Error code matched against Error Boundary Events.

Response codes

  • 200 — task failed (or was already terminal — idempotent).
  • 400 — {"error": "ErrorMessage is required"}.
  • 404 — {"error": "User task '<id>' not found"} — task never existed.

Curl example

Terminal window
curl -k -X POST https://localhost:7140/UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/fail \
-H "Content-Type: application/json" \
-d '{"errorMessage":"User rejected the task","errorCode":"400"}'

POST /UserTasks/{activityInstanceId}/cancel

Section titled “POST /UserTasks/{activityInstanceId}/cancel”

Cancels a pending user task. The activity is marked terminal with ActivityCancelled and removed from the task list. No error propagation occurs — the workflow branch simply stops at the cancelled task. The request body is optional.

Request body (optional)

{ "reason": "Operator cancelled" }
FieldTypeRequiredDescription
reasonstring?NoFree-text cancellation reason stored in logs.

Response codes

  • 200 — task cancelled (or was already terminal — idempotent).
  • 404 — {"error": "User task '<id>' not found"} — task never existed.

Curl example

Terminal window
curl -k -X POST https://localhost:7140/UserTasks/8b2e1a7c-9d3f-4e5b-a1c2-d3e4f5a6b7c8/cancel \
-H "Content-Type: application/json" \
-d '{"reason":"Operator cancelled"}'
  • User Tasks guide — conceptual model, state diagram, BPMN authoring (<fleans:expectedOutputs>).
  • Authentication — opt-in JWT bearer auth that gates every API endpoint, including the User Task surface.

GET /Instances/{instanceId}/state returns a per-instance state snapshot including activeActivityIds, completedActivityIds, isStarted, isCompleted, isCancelled, isFailed, businessId, and related fields.

isCompleted means terminal. Tell the outcomes apart with the two flags: isCancelled: true → cancelled, isFailed: true → an unhandled activity failure ended the instance (see Unhandled errors fail the instance); both false → completed successfully. The failing activity is listed in completedActivities with a non-null errorState.

This endpoint is intended for diagnostics and load-test polling, not for high-frequency production use. The response reflects the read-side EF projection, which is eventually consistent with the event stream — callers that need realtime certainty should drive via the grain API directly.

Terminal window
curl -k https://localhost:7140/Instances/<guid>/state

-k (or --insecure) skips dev-cert validation. In production behind a proper TLS cert, drop the flag.

Returns 404 with {"error":"Instance {id} not found"} if the instance ID does not exist in the projection.

Rate limiting: uses the polling policy. See Rate Limiting below for opt-in semantics.

All API endpoints have rate-limiting attributes (workflow-mutation, task-operation, read, admin, polling), but rate limiting is opt-in: it only activates when the RateLimiting configuration section is present. Default appsettings.json has no such section, so the middleware is off by default. When activating rate limiting, populate all five policies together — partially populating the section causes unregistered-policy endpoints to return HTTP 500.

Add this to your appsettings.json (or appsettings.Production.json):

{
"RateLimiting": {
"WorkflowMutation": { "Window": 60, "PermitLimit": 100 },
"TaskOperation": { "Window": 60, "PermitLimit": 100 },
"Read": { "Window": 60, "PermitLimit": 200 },
"Admin": { "Window": 60, "PermitLimit": 50 },
"Polling": { "Window": 1, "PermitLimit": 1000 }
}
}
  • Window — time window in seconds (default: 60)
  • PermitLimit — maximum requests per window per client IP (default: 100)

The rate limiter uses a fixed window algorithm, partitioned by the client’s RemoteIpAddress.

Paths are relative to the API base URL.

PolicyEndpointsDescription
WorkflowMutationPOST /Execution/start, /Execution/message, /Execution/signal, /Execution/evaluate-conditions, /Definitions/deployWorkflow lifecycle write operations (start instance, deliver event, evaluate conditions, deploy BPMN)
TaskOperationPOST /Execution/complete-activity, /UserTasks/{activityInstanceId}/claim, /UserTasks/{activityInstanceId}/unclaim, /UserTasks/{activityInstanceId}/complete, /UserTasks/{activityInstanceId}/fail, /UserTasks/{activityInstanceId}/cancelActivity-completion + user-task operations — see User Tasks guide
ReadGET /Definitions, /Definitions/{key}/instances, /Definitions/{key}/{version}/instances, /UserTasks, /UserTasks/{activityInstanceId}Read-only queries
AdminPOST /Definitions/disable, /Definitions/enableAdmin operations on process definitions
PollingGET /Instances/{instanceId}/stateHigh-frequency state polling

For Docker Compose or container deployments, use __ (double underscore) notation:

Terminal window
RateLimiting__WorkflowMutation__PermitLimit=1000
RateLimiting__WorkflowMutation__Window=1
RateLimiting__Polling__PermitLimit=10000

Important: Either configure all five policies or leave the RateLimiting section absent entirely. A partial configuration will cause HTTP 500 errors on endpoints whose policy is not registered.

The API supports opt-in JWT bearer authentication, disabled by default. See Authentication for configuration, provider walkthroughs, and client examples.