Workflows API
Manage workflows programmatically.
List Workflows
GET /api/workflowsReturns workflows for the authenticated user (session) or organization (API key).
Returns every workflow unless limit is supplied.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | Optional. Filter workflows by project ID |
tagId | string | Optional. Filter workflows by tag ID |
limit | number | Optional. Page size, 1 to 200. Omit for the complete list |
offset | number | Optional. Rows to skip, from 0. Requires limit |
limit must be a whole number from 1 to 200. offset must be a whole number of
0 or more, because offset 0 is the first page. Values outside those ranges,
values that are not plain decimal integers (0x1f4, 1e2, %205), and offset
supplied without limit are each rejected with 400 invalid_input.
A page is never silently shortened. Asking for more than the maximum page size
is rejected rather than trimmed, so a response holding fewer rows than limit
always means the end of the list. Page by requesting successive offsets until
that happens.
The body is a bare JSON array whether or not limit is supplied.
Example
GET /api/workflows?projectId=proj_123&tagId=tag_456Response
[
{
"id": "wm3k8nq7xcz2jv4hpbtd5",
"name": "My Workflow",
"description": "Monitors ETH balance",
"visibility": "private",
"nodes": [],
"edges": [],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
]Get Workflow
GET /api/workflows/{workflowId}Returns a single workflow by ID.
Response
{
"id": "wm3k8nq7xcz2jv4hpbtd5",
"name": "My Workflow",
"description": "Monitors ETH balance",
"visibility": "private",
"nodes": [...],
"edges": [...],
"publicTags": [
{
"id": "tag_1",
"name": "DeFi",
"slug": "defi"
}
],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"isOwner": true
}Public workflows include a publicTags array showing all assigned tags.
Create Workflow
POST /api/workflows/createRequest Body
{
"name": "New Workflow",
"description": "Optional description",
"projectId": "proj_123",
"nodes": [
{
"id": "trigger",
"type": "trigger",
"data": {
"label": "Schedule Trigger",
"config": { "triggerType": "Schedule", "scheduleCron": "*/30 * * * *" }
}
},
{
"id": "supply-aave",
"type": "action",
"data": {
"label": "Supply USDC to Aave",
"config": {
"actionType": "aave-v3/supply",
"network": "8453",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"amount": "100000000",
"onBehalfOf": "0x0000000000000000000000000000000000000000"
}
}
}
],
"edges": [
{
"id": "trigger->supply-aave",
"source": "trigger",
"target": "supply-aave"
}
]
}Note on Node Format: For action nodes, set the outer
typeto"action"and place the plugin slug inconfig.actionType. Thedataobject containslabelandconfig;statusandpositionare optional and auto-assigned by the API if omitted. Trigger nodes use outertype: "trigger"withconfig.triggerTypeset to the Pascal-case trigger name.The server normalizes the request before persisting it, so the saved node will also include an inner
data.typefield set to the kind discriminator ("trigger"or"action"). You do not need to senddata.typeyourself; if you do, it must match the outertype. As a convenience, sending the plugin slug at the outertype(for example"type": "aave-v3/supply") is also accepted, and the server rewrites it intoconfig.actionTypeduring normalization.
name, nodes, and edges are required. description, projectId, tagId, and enabled are optional. projectId assigns the workflow to a project; tagId assigns it to an organization tag for categorization; enabled (boolean) controls whether the workflow is active on creation.
Generic web3 write-contract example (Manual trigger)
Named-protocol actions (aave-v3/supply, etc.) hide most of the plumbing behind protocol-aware config keys. When you want to call an arbitrary contract that isn’t in the plugin catalog, use the generic web3/write-contract action. The trap is that the UI labels (“Function”, “Function Arguments”) don’t line up 1:1 with the API field names, and functionArgs is a JSON-encoded array string, not a raw array. The full config shape:
{
"name": "Release escrow on Sepolia",
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {
"label": "Manual",
"config": { "triggerType": "Manual" }
}
},
{
"id": "step-1",
"type": "action",
"data": {
"label": "Release Escrow",
"config": {
"actionType": "web3/write-contract",
"network": "11155111",
"web3Connection": "default",
"contractAddress": "0x599869cef2e4c52e2c9074caaf8f9fb0cb191776",
"abi": "[{\"type\":\"function\",\"name\":\"release\",\"stateMutability\":\"nonpayable\",\"inputs\":[{\"name\":\"depositId\",\"type\":\"bytes32\"}],\"outputs\":[]}]",
"abiFunction": "release",
"functionArgs": "[\"{{@trigger-1:Manual.depositId}}\"]"
}
}
}
],
"edges": [
{ "id": "e", "source": "trigger-1", "target": "step-1" }
]
}Field-name gotchas the strict validator will reject:
| UI label | API field name | Notes |
|---|---|---|
| Function | abiFunction | Not function, method, or functionName. See the note below on functionName. |
| Function Arguments | functionArgs | A JSON-encoded array string ("[\"0x…\"]"), not a raw array. Templates inside the string are resolved before JSON.parse. |
| Web3 Connection | web3Connection | Sender routing: "default" (org policy), "eoa" (force the Turnkey EOA), or "safe:<safeWalletId>". The signing wallet is your org’s Turnkey wallet, resolved automatically. |
| Contract ABI | abi | JSON-encoded string, not a raw array — same shape convention as functionArgs. |
A warning on functionName and args: the save-time validator accepts them, because workflows persisted before a field rename still carry that shape and have to stay re-savable. The runtime does not translate them. A workflow that uses functionName will therefore save without complaint and then fail at execution with Missing `abiFunction` in the step config. Always send abiFunction and functionArgs.
Trigger data reference from a downstream action uses the stored templating format: {{@<nodeId>:<Label>.<field>}}. Fields on the input object are spread into the trigger’s output, so {"input": {"depositId": "0x…"}} sent to POST /api/workflows/{id}/execute is reachable at {{@trigger-1:Manual.depositId}}. Valid triggerType values are Manual, Schedule, Webhook, Event, Block, and Transfer; a Webhook trigger receives the same treatment on its own POST /api/workflows/{id}/webhook URL.
Response
Returns the created workflow with a default trigger node and an empty action node connected to it.
Update Workflow
PATCH /api/workflows/{workflowId}Request Body
{
"name": "Updated Name",
"description": "Updated description",
"projectId": "proj_123",
"tagId": "tag_456",
"nodes": [...],
"edges": [...],
"visibility": "private"
}The tagId field assigns the workflow to an organization tag for categorization.
Delete Workflow
DELETE /api/workflows/{workflowId}Returns 409 Conflict if the workflow has execution history. Use the force query parameter to cascade delete all runs and logs:
DELETE /api/workflows/{workflowId}?force=trueExecute Workflow
POST /api/workflows/{workflowId}/executeManually trigger a workflow execution. The singular form POST /api/workflow/{workflowId}/execute is also accepted for backward compatibility.
Request Body
{
"input": { "key": "value" }
}The input field is optional. It maps to the workflow’s trigger input and is passed to the first node of the run. Input fields should be nested under input; a body with fields at the top level instead (e.g. {"amount": "1"} rather than {"input": {"amount": "1"}}) is still accepted and now binds correctly, but the response carries the standard Deprecation, Sunset, and Link headers — support for the unnested shape will be removed no earlier than the Sunset date. Every response decided once the body has been read carries them, idempotent replays included. Responses decided before that point do not, because the shape of the body has not been looked at yet: a 404, an authentication or permission failure, a plan or quota block, the concurrency 429, and an unexpected 500 all answer without the headers. In the unnested shape every top-level field binds as input, executionId included; it is only read as an envelope field alongside a nested input — with one exception. A flat body whose only key is executionId is indistinguishable from the envelope shape, so it is read as an envelope field and rejected (see executionId is not yours to set). Send at least one other field, or nest your data under input. A body mixing both shapes, or with input set to something other than an object, returns a 400. In either shape a top-level input field named __proto__ is dropped before the run starts, with no error; fields nested deeper inside your values are left alone. One consequence worth knowing if you send the unnested shape: a flat body cannot carry a field of its own named input, because there is no way to tell it from the envelope. If your input data has a field by that name, wrap the whole object — {"input": <your object>}. This differs deliberately from the webhook trigger route, which takes the entire request body as the input: a webhook carries an external caller’s payload that can’t be asked to nest itself under input, whereas the execute route uses KeeperHub’s own envelope and so can require the nested shape.
Example
curl -X POST https://app.keeperhub.com/api/workflows/wm3k8nq7xcz2jv4hpbtd5/execute \
-H "Authorization: Bearer $KEEPERHUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": {}}'Response
{
"executionId": "k6r4t9yqmn2xwv8jsz0a3",
"status": "running"
}executionId is not yours to set
The run’s id is assigned by KeeperHub and returned to you as executionId.
Sending one is reserved for internal dispatch — the scheduler and the queue
executor, which pre-create the row before routing back through this endpoint.
A request authenticated with an API key or a session is never internal, so a
body carrying the field is refused:
| Status | code | Meaning |
|---|---|---|
| 400 | execution_id_not_allowed | The body carried an executionId. Omit it and read the id from the response. |
This applies to the nested shape, where executionId sits alongside input
and is read as an envelope field. In the unnested shape it is ordinary input
data and binds like any other key — except in the one case noted above, where
it is the body’s only key and the two shapes cannot be told apart.
Webhook Trigger
POST /api/workflows/{workflowId}/webhookTrigger a workflow via webhook. Requires API key authentication.
Duplicate Workflow
POST /api/workflows/{workflowId}/duplicateCreates a copy of an existing workflow.
Download Workflow
GET /api/workflows/{workflowId}/downloadDownload workflow definition as JSON.
Generate Code
GET /api/workflows/{workflowId}/codeGenerate SDK code for the workflow.
Claim Workflow
POST /api/workflows/{workflowId}/claimClaim an anonymous workflow into the authenticated user’s organization. Only the original creator of the anonymous workflow can claim it.
Publish Workflow (Go Live)
PUT /api/workflows/{workflowId}/go-livePublish a workflow to make it publicly visible with metadata and tags.
Request Body
{
"name": "Public Workflow Name",
"publicTagIds": ["tag_1", "tag_2"]
}The name is required. publicTagIds is an array of public tag IDs to associate with the workflow (maximum 5 tags).
List Public Workflows
GET /api/workflows/publicReturns all public workflows with optional filtering.
Query Parameters
| Parameter | Type | Description |
|---|---|---|
featured | boolean | Optional. Filter for featured workflows (?featured=true) |
featuredProtocol | string | Optional. Filter for protocol-featured workflows (e.g., ?featuredProtocol=sky) |
tag | string | Optional. Filter by public tag slug (e.g., “defi”, “nft”) |
Response
[
{
"id": "wm3k8nq7xcz2jv4hpbtd5",
"name": "Public Workflow",
"description": "Description",
"nodes": [...],
"edges": [...],
"publicTags": [
{
"id": "tag_1",
"name": "DeFi",
"slug": "defi"
}
],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
]Update Featured Status (Internal)
POST /api/hub/featuredMark a workflow as featured in the hub. Requires internal service authentication (hub service). Accepts optional category, protocol, and featuredOrder fields alongside the workflowId.
List Action Schemas
GET /api/mcp/schemasReturns the complete registry of available workflow actions, triggers, and templates. Essential for programmatic workflow generation to discover valid action configurations.
Response Structure
{
"version": "1.0.0",
"actions": {
"web3/check-balance": {
"actionType": "web3/check-balance",
"label": "Check Balance",
"category": "web3",
"integration": "web3",
"requiredFields": { "network": "string (chain ID)", "address": "string" },
"optionalFields": {},
"outputFields": { "balance": "..." },
"requiresCredentials": false,
"requiredPlan": null,
"featureEnabled": true
},
"Condition": {
"actionType": "Condition",
"label": "Condition",
"category": "System",
"requiredFields": { "condition": "string (JS expression)" },
"optionalFields": { "conditionConfig": "object (visual builder state)" },
"sourceHandles": ["true", "false"],
"requiredPlan": null,
"featureEnabled": true
}
},
"triggers": {
"Schedule": {
"triggerType": "Schedule",
"label": "Schedule",
"requiredFields": { "scheduleCron": "string" },
"optionalFields": { "scheduleTimezone": "string" }
},
"Manual": { "triggerType": "Manual", "label": "Manual", "requiredFields": {} }
},
"chains": [...],
"platform": { "wallet": {...}, "proxyContracts": {...}, "abiHandling": {...} },
"templateSyntax": { "pattern": "{{@nodeId:Label.field}}" },
"builtinVariables": {
"nodeId": "__system",
"nodeLabel": "System",
"variables": { "unixTimestamp": {...}, "unixTimestampMs": {...}, "isoTimestamp": {...} }
},
"workflowStructure": { "nodeStructure": {...}, "edgeStructure": {...} },
"tips": ["actionType must match exactly", "..."]
}Note on Action Types: The keys in the
actionsobject are the values to use inconfig.actionTypewhen creating workflow nodes.
- Plugin actions use a
{pluginType}/{slug}format (e.g.,"web3/check-balance","aave-v3/supply").- System actions use Pascal-case with spaces between words (e.g.,
"Condition","For Each","HTTP Request"). System actions do not have arequiresCredentialsfield.- Triggers are listed under the
triggerskey (notactions) and their values map toconfig.triggerTypeon trigger nodes.- The endpoint self-documents the correct node and edge shapes under the
workflowStructureandedgeStructurekeys — use these as the source of truth for programmatic workflow generation.
Every action carries a requiredPlan field (the plan an organization must be on to run the
action, or null when it is not plan-gated) and a featureEnabled field (false only when the
gating feature has been rolled back for every plan). Both are the static requirement, never
your organization’s plan, so this endpoint stays anonymous and publicly cacheable. Check them
before building a workflow so a POST /api/workflows/create that needs a paid plan does not
fail after the fact. To see which plan your organization is on and which features that plan
unlocks, GET /api/features returns the org’s feature snapshot (plan, enabled feature ids,
and the full registry); it authenticates with the same Authorization header as every other
agent route.
Every action also carries protocolDirectExecution: when true, pass the action to the MCP
execute_protocol_action tool; when false, use an action-specific sibling tool (such as
execute_transfer or execute_contract_call) or workflow execution.
Get Organization Features
GET /api/featuresReturns the calling organization’s feature snapshot so a client can render per-plan lock states or preflight a workflow build.
Response
{
"plan": "free",
"enabledFeatureIds": [],
"features": [
{
"id": "action.database-query",
"name": "Database Query action",
"description": "Run SQL queries against connected databases inside workflows.",
"category": "workflow-action",
"enabled": true,
"requiredPlan": "pro",
"actionTypes": ["Database Query"]
}
],
"billingEnabled": true
}billingEnabled is false on self-hosted installs where billing is disabled, in which case plan is "enterprise" and every feature is enabled.