Handlers
Handler fields, the payload available to expressions, and how workflow parameters are built.
A handler says what to do with an event. It belongs to one trigger, responds to one event type, and starts one workflow.
What the payload looks like
Every expression on a handler, and the trigger's event type path, is evaluated against the same two roots.
body
The parsed request body, exactly as it arrived.
${body.order.id}
${body.customer.email}
${body.items[0].sku}parameters
Everything addressable about the request other than its body.
| Path | Contents |
|---|---|
parameters.triggerCode | The trigger code from the URL. |
parameters.query | The query string as an object. Absent when there is no query string. |
parameters.path | Trailing path segments, keyed by position as "0", "1". Absent when there are none. |
${parameters.query.source}
${parameters.path.0}For POST /api/trigger/AbC123/orders/42?source=crm:
{
"triggerCode": "AbC123",
"query": { "source": "crm" },
"path": { "0": "orders", "1": "42" }
}Query and path are nested rather than flattened on purpose. A query string is
caller-controlled, so flattening would let ?triggerCode=x shadow the real trigger code.
Handler types
Workflow starts a workflow. This is the type to use.
Orchestration is not implemented. A handler of that type logs its configuration and reports success without running anything, so a trigger wired to one looks healthy in every view and does nothing. Use a workflow handler.
Fields
Name
Yours to choose, but it is the label every failure carries. The outcome log, the monitor and
the "requests that did not land" table all identify a handler by this name, so
Fulfil order beats Handler 2.
Event Type
Which events reach this handler. Covered in full in Event types — read it before setting this, because an empty value means two opposite things depending on whether the trigger classifies.
User Expression
Who the workflow runs as. The expression is a LiquidJS template with ${ } delimiters,
rendered against body and parameters, and the resulting string is resolved to a tenant
user:
- If it contains
@, it is looked up as an email address. - Otherwise it is looked up as a user id.
- If it renders empty, or the literal string
null, the workflow runs with no resolved user.
${body.customer.email} → resolved by email
${body.customer.id} → resolved by id
${body.user.email}If the lookup finds nobody, the workflow still runs; it just has no user context. That is a silent outcome, so if a workflow behaves as though it has no user, check that this expression renders a user who exists in the tenant.
Workflow Parameters
JSON that maps the payload into the workflow's inputs. Values are expressions using ${ }:
{
"orderId": "${body.order.id}",
"orderTotal": "${body.order.total}",
"customerEmail": "${body.customer.email}",
"source": "${parameters.query.source}"
}Workflow parameters are evaluated with the JEXL evaluator, not LiquidJS, which gives them two things the other fields do not have:
- Operators and conditionals:
${body.order.total > 100 ? "review" : "auto"} - Two extra roots,
${context...}and${userContext...}, carrying the tool and user context built from the user expression above.
Do not mix syntaxes in one value. ${ } is JEXL; {{ }} and {% %} switch the evaluator
to LiquidJS. A value containing both is rejected and evaluates to null.
The two expression languages
This is the one piece of the design worth holding in your head, because the delimiters look identical.
| Field | Engine | Notes |
|---|---|---|
| Trigger event type path | LiquidJS | ${ } output delimiters. Missing fields render empty. |
| Handler user expression | LiquidJS | Same. |
| Handler workflow parameters | JEXL | ${ }, plus operators, plus context and userContext. |
Both use ${ }, so a plain field reference such as ${body.order.id} behaves the same in
all three. The difference only shows once you reach for operators, filters or the extra
roots.
Several handlers on one event
If two handlers match the same event type, both run. They are dispatched in sequence and
each gets its own entry in the outcome log, so one failing does not stop the other: the
request is then recorded as partial.
This is the clean way to fan out. A payment event that should both update a record and notify a channel is two handlers on one event type, not one workflow doing both.
What a handler returns
A workflow handler returns as soon as the workflow has been accepted:
{ "state": "started", "workflowId": "wf-fulfil", "runId": "01HX..." }It does not wait for the workflow to finish, and the trigger's HTTP response carries no
workflow result. A handler reported as success means "started cleanly", and a workflow
that then fails is a workflow failure, visible through
Monitoring rather than in the
webhook response.
This matters for callers: most webhook providers retry on a non-2xx response, and because the trigger answers before the work completes, a slow workflow will not cause a retry storm. It also means a 200 is not a promise that the work succeeded.