Skip to main content
By the end of this page you will have authored a workflow, started a run, recorded a human decision, and received the result.
New here? Read the Overview first for the mental model. Customize Behavior is the field reference.

Before you start

All endpoints are under https://api.velt.dev/v2/. Every request needs three headers: Never put apiKey or authToken in the body. They are read from the headers. Wrap your payload in data. Success comes back under result, errors under error:
Export your credentials once so the examples below run as written:

Build your first workflow

You will build the smallest workflow that exercises the whole engine: one human approval. Approving finishes the run. Rejecting routes to a follow-up step.

Step 1: Create the definition

Every human node needs a reject path, so give it an outgoing on: "reject" edge. Approving has no outgoing edge here, so the run completes after the approval. The follow-up node uses the reserved __mock__ agent id so you can run this end to end without registering a real agent. Use a real agentId in production.
A successful create returns a DefinitionView with version: 1 and status: "active". The engine validates the graph at write time. Schema errors, edge-contract errors, cycles, dangling edges, unreachable nodes, and bad quorum settings all fail here rather than at run time. A rejected definition returns INVALID_ARGUMENT. The message is the human-readable rule text, for example every human node must have at least one outgoing edge with on="reject" (a forward reject route or a reject back-edge): manager-approval. Linter failures put everything in error.message too: the text Definition linter failed: followed by a JSON array whose entries each carry a code such as missing-breach-edge. Parse error.message to read those codes, because error.details is not set on a linter failure. Only schema failures populate error.details, with an issues array of Zod { code, path, message } entries. Match on the linter code; the APPROVAL_* names are internal rule identifiers and never appear in a response.
Two rules catch most first-time authors. A human node needs an outgoing on: "reject" edge (APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH). An agent node needs either url or urlPath (APPROVAL_AGENT_NODE_REQUIRES_URL_OR_URLPATH). Those two names are internal rule identifiers. The response carries the rule text, not the name.
Full request shape: Create Definition.

Step 2: Dispatch an execution

Dispatch starts a run against one work item. Write the definition once and reuse it across many dispatches. triggerContext is free-form data your nodes and edge conditions read as execution.input.*. Pass an idempotencyKey so retries never spawn duplicates.
Keep the executionId. It is the handle for everything that follows. deduplicated: true means you replayed an earlier dispatch with the same idempotencyKey and got the original run back. Full request shape: Dispatch Execution.

Step 3: Record a decision

Fetch the execution to find the step waiting on a human:
Look for a step with "status": "waiting" and "nodeType": "human", then grab its stepId. You own the reviewer UI in beta. Render the waiting step to your user, and when they click approve or reject, call recordReviewerDecision:
reviewerId must match a userId declared on the node. The step resolves and the workflow advances when every mandatory reviewer approves, or when any reviewer rejects. Recording the same reviewer’s decision twice is idempotent and returns recorded: false. Full request shape: Record Reviewer Decision.

Step 4: Get the outcome

You have two ways to learn how the run ends. Use both in production: webhooks for liveness, polling for recovery.

A. Webhooks, for real time

Pass webhookUrl and webhookSecret on dispatch. The engine POSTs every externally-visible event to you, signed with HMAC-SHA256:
Each delivery is a POST with a 10s timeout and no redirects. Your receiver sees: Verify the signature against the raw request body bytes. Do not re-serialize the parsed JSON:
Delivery is at-least-once, retried at 2s → 8s → 32s → 2m → 8m before dead-lettering. The same eventId and seq appear on retries, so make your receiver idempotent: dedupe on eventId or (executionId, seq).
webhookUrl must use https. Loopback, private (RFC 1918), and link-local hosts are rejected, as are localhost, metadata.google.internal, metadata, and any *.internal hostname. DNS is re-resolved at delivery time, and redirects are never followed.
To send the same events to one receiver for every run of a definition, set webhookConfig on the definition instead. See Webhook delivery.

B. Event polling, for catch-up

You can read the event stream directly whether or not you use webhooks. Pass the highest seq you have durably stored as sinceSeq to get only what is new:
seq is monotonic per execution. Only external event types are returned, so your stream may have gaps in seq. That is normal. When you see execution.completed or execution.failed, the run is done. Full request shape: Get Execution Events.

Events you will receive

Payload shapes are in the Event reference.

A realistic workflow

Once the basics click, you compose richer graphs. Here an agent drafts, legal and brand review in parallel, and a single publish step fires once both approve:
agent-draft runs first and fans out to both reviewers. Because the group uses joinOnQuorum with quorum: 2, agent-publish runs exactly once after both approve, not once per approver. The reject back-edge from the group is required, not decorative. Every human node needs a reject route, including group members. It also makes the workflow useful: if either reviewer rejects, the work loops back to agent-draft for up to 3 revisions. See Parallel groups.
A group of 3 reviewers with quorum: 2 and onQuorumMet: "cancelOnQuorum":
The two approvers’ downstream paths still fan out. The cancelled reviewer’s edges do not fire.

Start runs without calling dispatch

You do not have to call the dispatch API. Add a triggers[] entry to a definition and the engine starts runs for you:
  • Inbound webhook: an external system POSTs to the engine. GitHub, Vercel, and custom signature presets are built in.
  • Cron schedule: a cron expression starts a run on a cadence.
  • Installed app: connect the Velt GitHub App or Vercel Integration once, then events route to your workflows automatically.

Common errors

The four most common reasons the engine rejects a definition:
  • A human node has no outgoing on: "reject" edge. The message is every human node must have at least one outgoing edge with on="reject" (a forward reject route or a reject back-edge), then a colon and the offending nodeId.
  • An agent node has neither url nor urlPath. The message is agent node requires either a static "url" or a "urlPath".
  • A node sets slaMs but nothing routes on a breach. The linter rejects it with the code missing-breach-edge.
  • when written as JavaScript. It must be a JSON-AST string, not "output.decision == 'approve'".
Linter failures arrive inside error.message as Definition linter failed: plus a JSON array, and every entry carries a code. Parse the message to read them, because error.details stays empty on a linter failure. Match on that code rather than on the surrounding message text. See Linter rules for the full list, and Anti-patterns for the mistakes that trigger them.

Next steps

Customize Behavior

Node config, edge routing, triggers, quorum, SLAs, events, and errors.

REST API Reference

Definitions, Executions, and Steps with full schemas.