This guide covers the public Workflows API and supported DSL constructs. It is designed to help you successfully author, manage, and scale your custom automations.
Key Concepts
- Workflow: a saved definition (name, status, DSL).
- Workflow run: an execution instance created when a workflow is triggered.
- Trigger: what starts a run (Directory Insights event, manual/external request, or time-based schedule).
- Connectors: configurable connectors and connector driven actions in order to perform HTTP calls on external endpoints.
Authentication
All endpoints require an API key:
- Header:
x-api-key: <YOUR_API_KEY> - Content type: application/json for requests with bodies
Execution Role ID
When you create a workflow, you must provide execution_role_id. This binds a JumpCloud role to the workflow’s execution identity and determines which JumpCloud API operations the workflow is permitted to call. Choose a role that grants only the permissions your workflow needs (least privilege).
API Overview
Base URL
Your base URL depends on your environment/region. Examples below use:
{{base_url}}(replace with your Workflows API base)
Workflows (definitions)
- List workflows:
GET {{base_url}}/api/v2/workflows- Query params commonly supported:
limit, skip, filter
- Query params commonly supported:
- Create workflow:
POST {{base_url}}/api/v2/workflows - Get workflow by id:
GET {{base_url}}/api/v2/workflows/{id} - Update workflow:
PUT {{base_url}}/api/v2/workflows/{id} - Delete workflow:
DELETE {{base_url}}/api/v2/workflows/{id}
Workflow Runs (executions)
- Trigger workflow run (manual):
POST {{base_url}}/api/v2/workflows/{id}/runs- Only meaningful for workflows with an external trigger.
- List workflow runs:
GET {{base_url}}/api/v2/workflows/runs- Common filter:
workflow_id
- Common filter:
- Get workflow run by id:
GET {{base_url}}/api/v2/workflows/runs/{runId}
Response Body(examples)
- Create workflow (typical 201 response body):
{
"id": "5f3a2b1c9d8e7f6a5b4c3d2e",
"name": "Example workflow",
"description": "Example for API + DSL",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"trigger_type": "external",
"dsl": { "schedule": { "on": { "one": { "with": { "source": "external" } } } }, "do": [] },
"created_at": "2026-05-05T03:00:00Z",
"updated_at": "2026-05-05T03:00:00Z"
}
- Trigger external workflow (typical 202 response body):
{
"id": "run_8b9c4d2e1f0a3b5c7d9e",
"workflowId": "5f3a2b1c9d8e7f6a5b4c3d2e",
"status": "running",
"startedAt": "2026-05-05T03:05:00Z"
}
- List runs (typical 200 response body):
{
"totalCount": 15,
"results": [
{
"id": "run_8b9c4d2e1f0a3b5c7d9e",
"workflowId": "5f3a2b1c9d8e7f6a5b4c3d2e",
"status": "completed",
"name": "Example workflow",
"startedAt": "2026-05-05T03:05:00Z",
"completedAt": "2026-05-05T03:05:05Z",
"error": ""
}
]
}
Example: Create a Workflow
curl --request POST \
--url "{{base_url}}/api/v2/workflows" \
--header "x-api-key: {{api_key}}" \
--header "Content-Type: application/json" \
--data '{
"name": "Example workflow",
"description": "Example for API + DSL",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": { "on": { "one": { "with": { "source": "external" } } } },
"do": [
{ "getUsers": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 1 } } } }
]
}
}'
Workflow DSL
Workflow structure
A workflow DSL is JSON with:
- schedule: how the workflow triggers
- do: an ordered list of tasks (actions/control-flow)
- Optional input.schema: only for external/manual workflows (documents + validates the expected input body)
{
"input": {
"schema": {
"format": "json",
"document": { "type": "object", "properties": { "userId": { "type": "string" } } }
}
},
"schedule": { "on": { "one": { "with": { "source": "external" } } } },
"do": [
{ "stepName": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1 } } }
]
}
Triggers
You must choose exactly one trigger style:
- Event-based (
schedule.on...source = "jc_events") - Manual/external (
schedule.on...source = "external") - Scheduled (
schedule.frequency = "once" | "hourly" | "daily" | "weekly" | "monthly")
Trigger: Directory Insights Event (jc_events)
{
"name": "DI event trigger example",
"description": "Triggers on a Directory Insights event and runs a JumpCloud API operation.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": {
"on": {
"one": {
"with": {
"source": "jc_events",
"type": "user_suspended",
"condition": "changes[0].field == \"state\" and changes[0].from == \"ACTIVATED\" and changes[0].to == \"SUSPENDED\""
}
}
}
},
"do": [
{ "systemUsersList": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 1 } } } }
]
}
}
type: event type to listen forcondition(optional): an Expr boolean expression evaluated against the raw event payload (for example:changes,resource, etc.). It is not evaluated against the ${input...} workflow context.
Trigger: Manual/External (external)
External trigger workflows can be started via the API POST /api/v2/workflows/{id}/runs.
{
"name": "External trigger example",
"description": "Manual trigger workflow with input schema validation.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"input": {
"schema": {
"format": "json",
"document": {
"type": "object",
"required": ["userId"],
"properties": {
"userId": { "type": "string", "description": "A JumpCloud user ID" },
"sendEmail": { "type": "boolean" }
}
}
}
},
"schedule": {
"on": {
"one": {
"with": {
"source": "external",
"condition": "userId != \"\""
}
}
}
},
"do": [
{
"getUser": {
"call": "jc_operation",
"with": {
"operationId": "systemusers_get",
"version": 1,
"pathParams": { "id": "${ input.userId }" }
}
}
}
]
}
}
- Important:
schedule...conditionis evaluated against the incoming request data object directly (e.g.userId, notinput.userId). - Important: within tasks,
${ input }is the workflow run input object (for external triggers, this is the data object you send to/runs).
Example: Trigger an External Workflow
curl --request POST \
--url "{{base_url}}/api/v2/workflows/{{workflow_id}}/runs" \
--header "x-api-key: {{api_key}}" \
--header "Content-Type: application/json" \
--data '{
"data": {
"userId": "673dd658ac4a0658c780f9ff",
"sendEmail": true
}
}'
Trigger: Scheduled (time-based)
Scheduled triggers use a structured schedule object (no cron strings).
Run once:
{
"name": "Scheduled trigger (once) example",
"description": "Runs once at a specific time.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": {
"frequency": "once",
"start_date": "2026-06-01",
"start_time": "09:30",
"timezone": "Etc/UTC"
},
"do": [
{ "heartbeat": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 1 } } } }
]
}
}
Daily:
{
"name": "Scheduled trigger (daily) example",
"description": "Runs daily at a specific time.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": {
"frequency": "daily",
"interval": 1,
"time": "07:00",
"timezone": "Etc/UTC"
},
"do": [
{ "dailyAudit": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 1 } } } }
]
}
}
Expressions (Expr language)
Workflows use the Expr language for dynamic values and conditions.
Expression Syntax in this DSL
Expressions are evaluated using the Expr language.
You can write expressions with or without the ${ ... } wrapper (both are accepted). For example, these are equivalent:
userId != ""${ userId != "" }
When you need to embed an expression inside a larger string (for example: "updated ${ input.resource.id }"), use ${ ... } placeholders.
Boolean Operator Compatibility (recommended)
For the most compatible DSL (and to avoid parser errors), use:
andinstead of&&orinstead of||notinstead of!
Example:
{
"if": "${ actions.fetchUser.body.active == true and actions.fetchUser.body.email != \"\" }"
}
Expressions are used in:
- Task
if: must evaluate to a boolean switch[*].when: must evaluate to a booleanfor.in: must evaluate to an iterable collection/array- Most dynamic fields in
with(e.g.pathParams,queryParams,bodyParams) - Pagination expressions:
pagination.update.value, pagination.until, extract
Available contexts inside ${ ... }
input: trigger input available to the workflow run (for external workflows, this is thedataobject; for event workflows, this is the event payload)actions: outputs of prior tasks, by task nameLoop iterator variable: the for.each name (e.g. user, system)page: only inside pagination expressions (see below)
Actions and Control Flows
jc_operation (single call)
jc_operation invokes a JumpCloud API operation by operationId.
{
"getUser": {
"call": "jc_operation",
"with": {
"operationId": "systemusers_get",
"version": 1,
"pathParams": { "id": "${ input.resource.id }" }
}
}
}
Typical with fields:
operationId(required)version(optional; commonly 1 or 2)pathParams,queryParams,bodyParams(optional)
Pagination
Pagination (jc_operation.with.pagination)
Pagination is enabled by adding a pagination object inside the with block.
{
"listAllUsers": {
"call": "jc_operation",
"with": {
"operationId": "systemusers_list",
"version": 1,
"queryParams": { "limit": 10, "skip": 0 },
"pagination": {
"update": {
"in": "queryParams",
"key": "skip",
"value": "${ page.request.queryParams.skip + 10 }"
},
"until": "${ len(page.response.body.results) == 0 }"
},
"extract": "${ page.response.body.results }"
}
}
}
How Pagination Works (conceptually)
When pagination is present, the service repeatedly calls the same jc_operation across pages:
- Starts with your initial request parameters (
queryParams,pathParams, and/orbodyParams) - After each page, computes the next-page parameter using
pagination.update - Stops when
pagination.untilbecomes true - Collects results across pages:
- If
extractis present, each page contributes the extracted array - If
extractis omitted, each page contributes the full response body (as one item per page)
- If
This makes it easy to follow a common pattern:
- Collect all items via pagination
- Process each item via a
forloop
Pagination fields
pagination.update.in: where to update (pathParams, queryParams, bodyParams)pagination.update.key: which key to updatepagination.update.value: Expr value for the next pagepagination.until: Expr stop condition (must return boolean)extract(optional): Expr that returns an array for each page
Common Pagination Patterns
Offset-based (limit/skip):
{
"queryParams": { "limit": 100, "skip": 0 },
"pagination": {
"update": { "in": "queryParams", "key": "skip", "value": "${ page.request.queryParams.skip + 100 }" },
"until": "${ len(page.response.body.results) == 0 }"
},
"extract": "${ page.response.body.results }"
}
Cursor-based (cursor/nextCursor):
{
"queryParams": { "limit": 100, "cursor": "" },
"pagination": {
"update": { "in": "queryParams", "key": "cursor", "value": "${ page.response.body.nextCursor }" },
"until": "${ page.response.body.nextCursor == \"\" }"
},
"extract": "${ page.response.body.results }"
}
Page-number-based (page increments):
{
"queryParams": { "page": 1 },
"pagination": {
"update": { "in": "queryParams", "key": "page", "value": "${ page.request.queryParams.page + 1 }" },
"until": "${ page.response.body.totalPages == page.request.queryParams.page }"
},
"extract": "${ page.response.body.results }"
}
page context (pagination expressions only)
Inside pagination.update.value, pagination.until, and extract, you can reference:
page.request.queryParams,page.request.pathParams,page.request.bodyParamspage.response.statuspage.response.body
What Pagination Returns
Pagination produces an iterable collection that you can use directly in a for loop via ${ actions.<paginationStepName> }.
In addition to being iterable, the collected result may also expose summary fields like:
totalItemspageCount
These can be useful for reporting (for example, “processed ${ actions.allUsers.totalItems } users”).
When to omit extract (per-page processing)
If you omit extract, pagination collects the full response body for each page. This is useful when you want to process or inspect pages as whole units (for example, for debugging, or when each page response is not a simple results[] list).
{
"listUsersPages": {
"call": "jc_operation",
"with": {
"operationId": "systemusers_list",
"version": 1,
"queryParams": { "limit": 100, "skip": 0 },
"pagination": {
"update": { "in": "queryParams", "key": "skip", "value": "${ page.request.queryParams.skip + 100 }" },
"until": "${ len(page.response.body.results) == 0 }"
}
}
}
}
In this mode, ${ actions.listUsersPages } is a collection of page bodies (one item per page).
Common pagination pitfalls
untilmust return a boolean: write explicit comparisons (e.g.${ len(...) == 0 }), not values that might be null/""/0.extractmust return an array: if it returns an object/string/number, the workflow will fail.
- Make sure
update.valuechanges: if it computes the same value each time (e.g. skip stays at 0), the service will stop with an error. - Make sure
update.keyexists in the initial request: include the key you plan to update (e.g. skip, cursor, page) in the appropriate parameter group (queryParams, pathParams, or bodyParams) on the first request. - Watch iteration limits: pagination consumes from the same iteration budget as loops; a missing/incorrect
untilcondition can exhaust the cap.
for loop
Use for to iterate an array/collection and run a do block for each item.
{
"addUsersToGroup": {
"for": {
"each": "user",
"in": "${ actions.listAllUsers }"
},
"do": [
{
"addUser": {
"call": "jc_operation",
"with": {
"operationId": "graph_userGroupMembersPost",
"version": 2,
"pathParams": { "group_id": "REPLACE_WITH_GROUP_ID" },
"bodyParams": { "op": "add", "type": "user", "id": "${ user.id }" }
}
}
}
]
}
}
- Nested
forloops are not supported. - The loop body (
do) must contain at least one task.
if guard (task-level)
Tasks can include an if expression.
{
"maybeNotify": {
"if": "${ input.sendEmail == true }",
"call": "sendEmailsToAddresses",
"with": {
"message": {
"subject": "Welcome",
"body": "Hello **${ input.userId }**"
},
"recipients": { "to_addresses": ["user@example.com"] }
}
}
}
- If a task’s
ifevaluates to false, the engine stops executing the remaining tasks in the currentdolist.- At the top level, this means the workflow run finishes without executing later steps.
- Inside a
forloop body, this means the rest of that iteration’s body is skipped, and the loop continues with the next item.
- If you want “skip only this one step but continue later steps”, use
switchto branch instead ofif.
switch branching
Use switch to choose a forward target task based on when expressions.
{
"routeByUserType": {
"switch": [
{ "isContractor": { "when": "${ input.attributes.userType == \"contractor\" }", "then": "addToContractorGroup" } },
{ "isEmployee": { "when": "${ input.attributes.userType == \"employee\" }", "then": "addToEmployeeGroup" } },
{ "default": { "then": "defaultAction" } }
]
}
}
Targets must be tasks defined after the switch in the same do list.
Multi-step branches with then
For multi-step branches, chain tasks using then and converge to a shared task.
{
"do": [
{ "route": { "switch": [
{ "caseA": { "when": "${ input.type == \"A\" }", "then": "a1" } },
{ "default": { "then": "b1" } }
] } },
{ "a1": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 1 } }, "then": "a2" } },
{ "a2": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 2 } }, "then": "common" } },
{ "b1": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 3 } }, "then": "common" } },
{ "common": { "call": "jc_operation", "with": { "operationId": "systemusers_list", "version": 1, "queryParams": { "limit": 99 } } } }
]
}
Rules
thentargets must be forward-only (no backward jumps).thencan also be one of: "continue", "exit", "end".
Email Actions
Workflows support sending emails using two call types:
sendEmailsToAddresses: send to explicit email addressessendEmailsToChannel: send to notification channels (by channel object IDs)
Both accept a with payload shaped like:
{
"message": { "subject": "Subject", "body": "Markdown body" },
"recipients": { "to_addresses": ["user@example.com"] }
}
For channel delivery, use channel_object_ids instead:
{
"message": { "subject": "Subject", "body": "Markdown body" },
"recipients": { "channel_object_ids": ["CHANNEL_OBJECT_ID"] }
}
Email to recipients (sendEmailsToAddresses)
{
"sendWelcomeEmail": {
"call": "sendEmailsToAddresses",
"with": {
"message": {
"subject": "Welcome to JumpCloud",
"body": "# Welcome, ${ input.resource.username }\n\nYour account is ready.\n"
},
"recipients": {
"to_addresses": ["user@example.com"]
}
}
}
}
Email to channel (sendEmailsToChannel)
{
"sendToChannel": {
"call": "sendEmailsToChannel",
"with": {
"message": {
"subject": "Workflow notification",
"body": "User `${ input.userId }` was processed."
},
"recipients": {
"channel_object_ids": ["REPLACE_WITH_CHANNEL_OBJECT_ID"]
}
}
}
}
Connector
Workflows support calling third-party integrations configured in JumpCloud Connectors using one call type:
- connector_operation: execute an HTTP request against a configured connector
The with payload is shaped like:
{
"id": "CONNECTOR_OBJECT_ID",
"httpMethod": "GET",
"endpointPath": "/api/v1/resource",
"headers": {
"Accept": "application/json"
},
"body": {}
}
Connector GET (connector_operation)
{
"callConnector": {
"call": "connector_operation",
"with": {
"id": "6a0319815fef7800019182a4",
"endpointPath": "/api/v1/resource"
}
}
}
Connector POST with body (connector_operation)
{
"callConnector": {
"call": "connector_operation",
"with": {
"id": "6a0319815fef7800019182a4",
"httpMethod": "POST",
"endpointPath": "/api/now/table/incident",
"headers": {
"Accept": "application/json"
},
"body": {
"short_description": "hello from workflows"
}
}
}
Troubleshooting
Common validation errors
- Missing required fields
schedulemissing or invalid trigger selectiondoempty (at least one task required)jc_operation.with.operationIdmissing- Email:
missing message.subject,message.body,orrecipients
- Expression errors
if / when / untilmust evaluate to a booleanextractmust evaluate to an array- References to unknown fields (for example
actions.someStep.body.missingField) will fail evaluation - If you see an error like invalid runtime expression format, rewrite logical operators using jq-style keywords: use
and / or / notinstead of&& / || / !
- Loop/pagination limits
- Iteration cap reached (commonly caused by a missing/incorrect
untilcondition)
- Iteration cap reached (commonly caused by a missing/incorrect
Safe patterns
- Prefer trigger
conditionfor gating event/manual runs. - Use
switchfor branching that must continue afterward. - Use pagination + extract + for for large collections rather than trying to process a giant single response.
Complete Examples (end-to-end DSL)
These examples use real-world patterns and are presented with placeholder IDs.
- Example: System update event → update system display name
- Use case: when a system is updated, set a field on the system using the system’s id from the event.
{
"name": "System update → set display name",
"description": "On system_update, update the system display name.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": {
"on": {
"one": {
"with": {
"type": "system_update"
}
}
}
},
"do": [
{
"setDisplayName": {
"call": "jc_operation",
"with": {
"operationId": "systems_put",
"version": 1,
"pathParams": {
"id": "${ input.resource.id }"
},
"bodyParams": {
"displayName": "updated"
}
}
}
}
]
}
}
- Example: User suspended event → remove user from all groups (
for-loop)- Use case: on user suspension, list group memberships, and remove the user from each group.
{
"name": "User suspended → remove from all groups",
"description": "On user_suspended, remove the user from every group they belong to.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": {
"on": {
"one": {
"with": {
"source": "jc_events",
"type": "user_suspended",
"condition": "changes[0].field == \"state\" and changes[0].from == \"ACTIVATED\" and changes[0].to == \"SUSPENDED\""
}
}
}
},
"do": [
{
"listUserGroups": {
"call": "jc_operation",
"with": {
"operationId": "graph_userMemberOf",
"version": 2,
"pathParams": {
"user_id": "${ input.resource.id }"
}
}
}
},
{
"removeFromAllGroups": {
"for": {
"each": "group",
"in": "${ actions.listUserGroups.body }"
},
"do": [
{
"removeUserFromGroup": {
"call": "jc_operation",
"with": {
"operationId": "graph_userGroupMembersPost",
"version": 2,
"pathParams": {
"group_id": "${ group.id }"
},
"bodyParams": {
"op": "remove",
"type": "user",
"id": "${ input.resource.id }"
}
}
}
}
]
}
}
]
}
}
- Example: External trigger → paginate users → process each user
- Use case: manually trigger a workflow that pages through users and performs an operation per user.
{
"name": "External → paginate users → process each user",
"description": "Manually triggered workflow that paginates users and processes each user.",
"status": "active",
"execution_role_id": "REPLACE_WITH_ROLE_ID",
"dsl": {
"schedule": {
"on": {
"one": {
"with": {
"source": "external"
}
}
}
},
"do": [
{
"allUsers": {
"call": "jc_operation",
"with": {
"operationId": "systemusers_list",
"version": 1,
"queryParams": { "limit": 10, "skip": 0 },
"pagination": {
"update": { "in": "queryParams", "key": "skip", "value": "${ page.request.queryParams.skip + 10 }" },
"until": "${ len(page.response.body.results) == 0 }"
},
"extract": "${ page.response.body.results }"
}
}
},
{
"processEachUser": {
"for": {
"each": "user",
"in": "${ actions.allUsers }"
},
"do": [
{
"getUser": {
"call": "jc_operation",
"with": {
"operationId": "systemusers_get",
"version": 1,
"pathParams": { "id": "${ user.id }" }
}
}
}
]
}
}
]
}
}