JumpCloud Workflows Public API and DSL Guide

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
  • 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
  • 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 for
  • condition (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...condition is evaluated against the incoming request data object directly (e.g. userId, not input.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:

  • and instead of &&
  • or instead of ||
  • not instead 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 boolean
  • for.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 the data object; for event workflows, this is the event payload)
  • actions: outputs of prior tasks, by task name
  • Loop 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/or bodyParams)
  • After each page, computes the next-page parameter using pagination.update
  • Stops when pagination.until becomes true
  • Collects results across pages:
    • If extract is present, each page contributes the extracted array
    • If extract is omitted, each page contributes the full response body (as one item per page)

This makes it easy to follow a common pattern:

  1. Collect all items via pagination
  2. Process each item via a for loop

Pagination fields

  • pagination.update.in: where to update (pathParams, queryParams, bodyParams)
  • pagination.update.key: which key to update
  • pagination.update.value: Expr value for the next page
  • pagination.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.bodyParams
  • page.response.status
  • page.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:

  • totalItems
  • pageCount

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

  • until must return a boolean: write explicit comparisons (e.g. ${ len(...) == 0 }), not values that might be null/""/0.
  • extract must return an array: if it returns an object/string/number, the workflow will fail.
  • Make sure update.value changes: if it computes the same value each time (e.g. skip stays at 0), the service will stop with an error.
  • Make sure update.key exists 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 until condition 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 }" }
          }
        }
      }
    ]
  }
}

Note:
  • Nested for loops 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"] }
    }
  }
}

Important:
  • If a task’s if evaluates to false, the engine stops executing the remaining tasks in the current do list.
    • At the top level, this means the workflow run finishes without executing later steps.
    • Inside a for loop 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 switch to branch instead of if.

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

Note:

Rules

  • then targets must be forward-only (no backward jumps).
  • then can also be one of: "continue", "exit", "end".

Email Actions

Workflows support sending emails using two call types:

  • sendEmailsToAddresses: send to explicit email addresses
  • sendEmailsToChannel: 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"
      }
  }
}

TROUBLESHOOT

Troubleshooting

Common validation errors

  • Missing required fields
    • schedule missing or invalid trigger selection
    • do empty (at least one task required)
    • jc_operation.with.operationId missing
    • Email: missing message.subject, message.body, or recipients
  • Expression errors
    • if / when / until must evaluate to a boolean
    • extract must 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 / not instead of && / || / !
  • Loop/pagination limits
    • Iteration cap reached (commonly caused by a missing/incorrect until condition)

Safe patterns

  • Prefer trigger condition for gating event/manual runs.
  • Use switch for 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 }" }
                }
              }
            }
          ]
        }
      }
    ]
   }
}

Back to Top

List IconIn this Article

Still Have Questions?

If you cannot find an answer to your question in our FAQ, you can always contact us.

Submit a Case