Ensemble Docs

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.

PathContents
parameters.triggerCodeThe trigger code from the URL.
parameters.queryThe query string as an object. Absent when there is no query string.
parameters.pathTrailing 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.

FieldEngineNotes
Trigger event type pathLiquidJS${ } output delimiters. Missing fields render empty.
Handler user expressionLiquidJSSame.
Handler workflow parametersJEXL${ }, 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.

On this page