New to the Approval Engine? Start with the Overview for the mental model: definitions, nodes, edges, executions, steps, and scoping. Customize Behavior is the patterns and field reference. The REST API Reference is the field-level law: when this page and the API Reference disagree, the API Reference wins.
Prerequisites & authentication
Base URL All endpoints are underhttps://api.velt.dev/v2/.
Required headers on every request
| Header | Description |
|---|---|
x-velt-api-key | Your workspace API key. |
x-velt-auth-token | A short-lived auth token. See Auth Tokens. |
content-type | application/json |
apiKey/authToken in the body; they’re read from the headers.
Request / response envelope. Wrap your payload in data; success comes back under result, errors under error:
Build your first workflow
You’ll build the smallest workflow that exercises the whole engine: one human approval, where approval finishes the run and a rejection routes to a follow-up step.Step 1: Create the definition
Ahuman node must declare what happens on rejection: either an onReject shorthand or membership in a loops[] body. Here we use the onReject.routeToNodeId shorthand to send rejections to a follow-up node, and we gate the success edge with when: decision == 'approve' so it only fires on approval.
The when value is a JSON-AST string: the engine parses it as JSON and evaluates it with a safe walker, never as JavaScript. 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).
DefinitionView with version: 1 and status: "active". Definitions are linted at write time: cycles, dangling edges, unreachable nodes, and quorum misconfiguration all fail before you ever dispatch. If the engine rejects the definition you’ll get INVALID_ARGUMENT with a linter code in the message.
The onReject.routeToNodeId shorthand synthesizes the reject-gated edge for you, so you only need to author the explicit approve-side routing. See Per-human-node onReject shorthand for choosing a rejection strategy. Full request shape: Create Definition.
Step 2: Dispatch an execution
Dispatch starts a run against a work item. You write one definition per workflow type and reuse it across many dispatches.triggerContext is free-form data your nodes and edge expressions can read as execution.input.*. Pass an idempotencyKey so retries never spawn duplicates.
executionId; it’s the handle for everything that follows. (deduplicated: true means this was a replay of an earlier dispatch with the same idempotencyKey, and you got the original execution back.) Full request shape: Dispatch Execution.
Step 3: Find the pending step and record a decision
Fetch the execution to see which step is waiting on a human:"status": "waiting" and "nodeType": "human", and grab its stepId. In v1 you own the reviewer UI: render the pending step to your user, and when they click approve/reject, call recordReviewerDecision:
reviewerId must match a userId declared on the node. When all mandatory reviewers approve (or any reviewer rejects), the step resolves and the workflow advances. Recording the same reviewer’s decision twice is idempotent (recorded: false on replay). Full request shape: Record Reviewer Decision.
For blocking agent steps, record the outcome with Record Agent Resolution instead.
Step 4: Get the outcome
You have two complementary ways to learn how the run ends. A. Webhook push (real-time). PasswebhookUrl + webhookSecret on dispatch and the engine POSTs every externally-visible event to you, signed with HMAC-SHA256:
| Header | What it is |
|---|---|
x-velt-signature | sha256=<hex>. HMAC-SHA256 of the raw request body. |
x-velt-event-id | Stable event ID, unchanged across retries. |
x-velt-attempt | 0-based attempt counter. |
2s → 8s → 32s → 2m → 8m) before dead-lettering. The same eventId and seq appear on retries, so make your receiver idempotent: dedupe by eventId or (executionId, seq). See Webhook retry policy.
B. Events polling (catch-up). Whether or not you use webhooks, you can read the event stream directly. Pass the highest seq you’ve durably stored as sinceSeq to get only what’s new; it’s the recovery path after a missed webhook or an outage:
seq is monotonic per execution. Only externally-visible event types are returned; internal-only events fill seq gaps but are filtered out, so your stream may have non-contiguous seq values. That’s normal. When you see execution.completed (or execution.failed), the run is done. You can also pull the current state of any execution at any time with Get Execution. Full request shape: Get Execution Events.
Recommended production setup: webhooks for liveness, polling as the recovery path.
Putting it together: a realistic workflow
Once the basics click, you compose richer graphs. Here’s an AI-assisted parallel review: an agent drafts, then 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. Agent nodes run automatically (the engine dispatches the agent and resumes the step when it finishes); human nodes wait for recordReviewerDecision as in Step 3. See Parallel groups and quorum policies.
How the events play out
Happy path with joinOnQuorum
Happy path with joinOnQuorum
Marketing copy approval: agent drafts, legal and brand approve in parallel, publish agent ships once both approve.
joinOnQuorum fires one shared downstream step instead of firing it once per approver.Stop-bothering-reviewers with cancelOnQuorum
Stop-bothering-reviewers with cancelOnQuorum
Group with 3 reviewers, The two approvers’ downstream paths still fan out per edge. The cancelled third reviewer’s edges do not fire.
quorum: 2, onQuorumMet: cancelOnQuorum:Going further
Short pointers to the features you’ll reach for next. Each links to the decision-level guidance and the field-level contract.- Conditional routing (
whenexpressions). Gate any edge with awhenJSON-AST predicate overoutput.*,step.*, andexecution.input.*. Operators include equality, comparison, boolean and/or/not, regex, includes, startsWith, endsWith, length, isEmpty. → Edge gating expressions. - Parallel groups & quorum. Declare a
groups[]entry to run reviewers in parallel under one of three policies:waitAll(observability only),cancelOnQuorum(stop bothering siblings once enough approve),joinOnQuorum(run the successor once after quorum). UserequiredNodeIdsfor “these specific people must approve.” →onQuorumMetpolicies · Specific-must-approve quorum. - Rejection handling: shorthand vs. loops. For one reviewer,
onReject.routeToNodeId(route away) oronReject.loopBack(retry up to N times, then escalate) usually suffice. When multiple parallel reviewers must share one retry budget, or a whole stage must rewind as a unit, declare a top-levelloops[]region instead. → Loop regions · When to use a loop vs. theonRejectshorthand. - SLA timers & escalation. Set
slaMson a node to enforce a deadline; on breach the step becomesbreachedand the engine follows your outgoing edges. Gotcha: a node withslaMsmust have an edge that routes onstatus == 'breached', or the definition is rejected (missing-breach-edge). Agent nodes also have a hard runtime ceiling (agentMaxRuntimeMs, default 30 min). → SLA and breach handling. - Agent nodes. An agent node requires
agentIdandurlPath(a dot-path intotriggerContextthat resolves the URL the agent should act on). The step output exposesagentExecutionStatus,agentResultsSummary, and adecision(approvewhen the agent passed). Some configurations post findings that are resolved via Record Agent Resolution. → Agent nodes. - Externally-triggered runs & async callbacks. Beyond dispatching from your backend, external systems can kick off runs or complete long-running steps via the inbound webhook surface. → Inbound webhook handler.
- Versioning & scope. Editing a definition (Update Definition) creates a new version; in-flight executions keep running on their pinned version. Scope a definition to a workspace, organization, or document; the most specific match wins. → Scope.
- Cancellation. Stop a whole run with Cancel Execution, or a single step with Cancel Step. Admins can override a parked step with Resolve Step.
Events you’ll receive
These event types are delivered via webhook and returned from Get Execution Events:| Event | When |
|---|---|
execution.dispatched | Run created; first step(s) scheduled. |
step.awaiting-approval | A human (or blocking-agent) step entered waiting. |
step.completed | A step finished successfully (human resumes include decision). |
step.failed | A step failed after exhausting its retry budget. |
step.breached | A step missed its SLA deadline. |
step.cancelled | A step was cancelled (directly or by a quorum side effect). |
group.quorum-met | A parallel group’s approval threshold was first met. |
execution.completed | All steps terminal, no unhandled failure. |
execution.failed | A blocking step failed/breached with no recovery edge. |
execution.cancelled | The run was cancelled. |
data fields in the Event reference.
Errors & troubleshooting
Errors use the standard envelope with a gRPC-style status code:| Code | Meaning |
|---|---|
INVALID_ARGUMENT | Request failed schema or linter validation. |
UNAUTHENTICATED | Missing/invalid x-velt-auth-token. |
PERMISSION_DENIED | Token valid but lacks the required scope (e.g. admin-only /steps/resolve). |
NOT_FOUND | Unknown executionId, definitionId, or stepId. |
ALREADY_EXISTS | A definition with that definitionId already exists. |
FAILED_PRECONDITION | State/lock violation (e.g. resolving a step that isn’t waiting, deleting a definition with in-flight runs). |
RESOURCE_EXHAUSTED | Rate limited; back off and retry (safe with an idempotencyKey). |
INVALID_ARGUMENT, with a code in the message):
- Human node missing a reject path: add
onReject, or include the node in aloops[]body. missing-breach-edge: a node hasslaMsbut no edge routes onstatus == 'breached'.whenwritten as JavaScript: it must be a JSON-AST string, not"output.decision == 'approve'".- Group quorum >
expectedSteps, or a non-blocking agent placed in a quorum group (it has nodecision, so quorum can never be met).
Limitations in v1
- No visual builder: you author definitions as JSON.
- You host the reviewer UI: render the pending step and call
recordReviewerDecision; a native comment-reply approval UX is not yet a v1 promise. webhooknodes are deferred: the type validates in a definition, but the runtime handler isn’t enabled in v1. (This is distinct from the inboundapprovalwebhookhandler, which is available for external systems to trigger or call back into runs.)- No in-flight definition migration: editing a definition only affects new runs; in-flight ones finish on their pinned version.
Next steps
Customize Behavior
Node configuration, edge expressions, parallel groups, SLAs, linter rules, the event catalog, and the error vocabulary.
REST API Reference
All endpoints organized into Definitions, Executions, and Steps, with full request and response schemas.

