← Files Zuora Coding AgentARCHIVED FILE

references/workflow-liquid.md

13.3 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

# Liquid Templating in Zuora Workflows

Every task parameter that accepts a dynamic value is parsed with Liquid. This document lists the scopes available to Liquid expressions, their shape, and the gotchas that most often cause run-time errors. Distilled from `Task#template_parse` (`~/Workspace/workflow/rails/app/models/task.rb:1423-1480`).

## Scopes

Six root scopes are always in context when a task runs:

| Scope              | Purpose                                                                             |
| ------------------ | ----------------------------------------------------------------------------------- |
| `Data`             | The workflow payload — everything upstream tasks have written.                      |
| `Credentials`      | Zuora tenant credentials, gated by auth type.                                       |
| `WorkflowInstance` | The current workflow run (id, timings, status, call_type, …).                       |
| `WorkflowSetup`    | The workflow *definition* (original_workflow): name, description, parameters, etc.  |
| `TaskInstance`     | The currently-executing task (id, name, retry state, timings, …).                   |
| `GlobalConstants`  | Tenant-level key/value store, populated via Workflow Settings → Global Constants.   |

Inside a Liquid `{% for row in Data.Invoice %}` block, the `forloop` scope is also defined (`forloop.index`, `forloop.first`, etc.).

## `Data` — the workflow payload

Tasks write their output under a named key inside `Data`. The conventional key is the object name (`Account`, `Invoice`, `Subscription`) or an explicit `parameters.placement` override.

| Task                          | Writes to                                                |
| ----------------------------- | -------------------------------------------------------- |
| `Query` / `Export` / `Data::Aqua` | `Data.<object>` (array of hashes) unless `parameters.placement` is set, then `Data.<placement>`. |
| `Callout`                     | `Data.Callout` (single object). Multiple callouts chain into `Data.Callout[0]`, `Data.Callout[1]`, … |
| `Logic::Liquid`               | `Data.Liquid` (single object) unless `parameters.placement` overrides. |
| `Iterate`                     | Current row available as `Data.<object>` (singular, a hash) inside the For-Each body. |
| `GraphQuery`                  | `Data.<baseObject>` (array).                              |
| `Create`                      | `Data.<object>[<ResponseFields>]`.                        |
| `Update` / `Delete`           | Updates `Data.<object>` if the record was loaded upstream. |

Access patterns:

```liquid
{{ Data.Account.Name }}
{{ Data.Invoice[0].Id }}
{{ Data.Callout.ResponseBody.subscription_id }}

{% for inv in Data.Invoice %}
  {{ inv.Id }} - {{ inv.Balance | money }}
{% endfor %}
```

### Workflow input parameters

When a workflow is triggered with ordinary input parameters (ondemand form, callout body, sub-workflow call), define each prompt field with `object_name: "Workflow"` and read those values at `Data.Workflow.<key>`. Do not invent object names to group inputs.

```liquid
{{ Data.Workflow.accountNumber }}
{{ Data.Workflow.ExecutionDate }}
```

### Event-trigger payload

Event-triggered workflows get their event payload mapped through `workflow.parameters.event_parameters` into `Data.<object>.<key>`. The server accepts special tokens that are expanded at trigger time:

| Token                    | Meaning                                               |
| ------------------------ | ----------------------------------------------------- |
| `<Event.Category>`       | `data['name']` — the event name (e.g., `InvoicePosted`). |
| `<Event.Date>`           | `data['eventTime']` formatted as `%F` (UTC date).     |
| `<Event.Timestamp>`      | `data['eventTime']`.                                  |
| `<Functions.Today>`      | Current date formatted as `%F`.                       |
| `<Tenant.ID>`            | `data['tenantId']`.                                   |
| `<Tenant.Name>`          | Empty string in the current Rails implementation.     |

Any other angle-bracket token is treated as a notifications merge-field token: Rails strips the brackets plus an optional `Event.` or `DataSource.` prefix, then looks up that literal key in the event payload. Before emitting tokens such as `<Invoice.Id>` or `<BillingRun.Id>`, fetch the event's merge fields from `/notifications/email-templates/info/selections?category=<event-id-or-custom-category>` and pick a published value. A typical `event_parameters` entry for InvoicePosted writes `Data.Invoice.Id`, `Data.Invoice.AccountId`, etc., which downstream tasks then reference as usual.

## `Credentials` — Zuora tenant access

`Credentials.zuora` exposes a drop object whose method return value depends on the authentication type configured for the tenant:

| Access                         | When available                                             |
| ------------------------------ | ---------------------------------------------------------- |
| `Credentials.zuora.url`        | Always.                                                    |
| `Credentials.zuora.rest_endpoint` | Always. This value is the Zuora REST v1 base URL and already includes the `v1` segment (for example `https://rest.zuora.com/v1/`). Do NOT append `/v1` to it -- that can produce a `/v1/v1/` double-prefix. |
| `Credentials.zuora.username`   | Only for Basic auth tenants. Raises on OAuth tenants; do not use for Zuora API Callout headers. |
| `Credentials.zuora.password`   | Only for Basic auth tenants; do not use for Zuora API Callout headers. |
| `Credentials.zuora.client_id`  | Only for OAuth tenants.                                    |
| `Credentials.zuora.client_secret` | Only for OAuth tenants.                                 |

Canonical Zuora API Callout auth block:

```json
{
  "authorization": { "type": "zuora" },
  "headers": [
    { "key": "Content-Type", "value": "application/json" }
  ]
}
```

For v1 APIs, append only the resource path after `v1/`: `{{ Credentials.zuora.rest_endpoint }}orders`, `{{ Credentials.zuora.rest_endpoint }}subscriptions/{{ Data.Subscription.Id }}`. Do not emit `{{ Credentials.zuora.rest_endpoint }}/v1/orders`, and do not use the fragile `{{ Credentials.zuora.rest_endpoint | replace: "/v1/", "" }}/v1/orders` form. For non-v1 APIs, first normalize the base explicitly, for example `{{ Credentials.zuora.rest_endpoint | split: "/v1" | first }}/oauth/token`.

Do not hard-code tenant URLs or credentials in callouts; for Zuora APIs, always use `Credentials.zuora.rest_endpoint` with `authorization.type = "zuora"`. Do not add `apiAccessKeyId`, `apiSecretAccessKey`, `Authorization`, or bearer-token headers; Rails rejects some plain-text credential headers and the built-in Zuora auth path is what preserves tenant/entity context.

## `WorkflowInstance`, `WorkflowSetup`, `TaskInstance`

These are read-only introspection scopes.

- `WorkflowInstance.id`, `WorkflowInstance.call_type`, `WorkflowInstance.started_at`, `WorkflowInstance.external_track_id`.
- `WorkflowSetup.id`, `WorkflowSetup.name`, `WorkflowSetup.description`, `WorkflowSetup.parameters`.
- `TaskInstance.id`, `TaskInstance.name`, `TaskInstance.attempts`, `TaskInstance.task_setup_id`.

Common usage:

```liquid
Run {{ WorkflowInstance.id }} at {{ WorkflowInstance.started_at }}
For Workflow "{{ WorkflowSetup.name }}"
Processing {{ TaskInstance.name }} (task {{ TaskInstance.id }})
```

These scopes are blanked out when a task is exported/imported between tenants, so relying on them for business logic is fine but their values should not be compared across tenants.

## `GlobalConstants`

Tenant-level configuration stored at the app-instance level.

```liquid
{{ GlobalConstants.SLACK_WEBHOOK_URL }}
{{ GlobalConstants.DUNNING_THRESHOLD_AMOUNT }}
```

Define constants in Workflow Settings → Global Constants; they are loaded on every task render.

## Filters

Zuora registers stock Liquid filters plus the Workflow-specific `Liquid::Filters` module from `~/Workspace/workflow/rails/lib/liquid/filters.rb`. Before writing a `Logic::Liquid` task, check whether a standard Liquid filter or a Workflow-specific filter already expresses the operation. Prefer built-in filters over manual `for` / `if` / `push` loops for simple array selection or grouping. See `workflow-liquid-filters.md` for argument counts, argument types, return values, and examples.

| Filter        | Example                                                | Purpose                                  |
| ------------- | ------------------------------------------------------ | ---------------------------------------- |
| `date`        | `{{ "now" | date: "%Y-%m-%d" }}`                       | Format timestamps.                       |
| `money`       | `{{ Data.Invoice.Balance | money }}`                   | Currency formatting.                     |
| `to_json`     | `{{ Data.Account | to_json }}`                         | Serialize to JSON for logging.           |
| `escape`      | `{{ body | escape }}`                                  | HTML-escape user input.                  |
| `default`     | `{{ Data.Account.Name | default: "Anonymous" }}`       | Fallback value.                          |
| `replace`     | `{{ Data.Account.Name | replace: "-", "_" }}`          | String replacement.                      |
| `split` / `join` | `{{ "a,b,c" | split: "," | join: " - " }}`          | Token manipulation.                      |
| `size`        | `{{ Data.Invoice | size }}`                            | Collection length.                       |

Standard Liquid filters (`upcase`, `downcase`, `strip`, `slice`, `truncate`, `first`, `last`, …) all work. Workflow-specific filters most relevant to composition:

- `where` / `where_exp`: select rows without manual `for` + `if` + `push` loops, e.g. `{% assign active = Data.Subscription | where: "Status", "Active" %}` or `{% assign overdue = Data.Invoice | where_exp: "inv", "inv.Balance > 0" %}`.
- `group_by` / `group_by_exp`: bucket rows without constructing grouping hashes by hand.
- `parse_json` / `to_json` / `to_xml`: parse or emit structured payloads without string-concatenating JSON/XML.
- `date_manip`, `date_between`, `date_diff`, `in_time_zone`, `timezone`, `http_date`: date math and timezone formatting.
- `regex`, `money`, `base64_encode` / `base64_decode`, `md5`, `sha1`, `sha2`, `hmac*`: validated text, numeric, encoding, and signing helpers.
- `push` / `pop` / `shift` / `unshift`: array mutation helpers; use them for true array construction, not simple filtering that `where` or `where_exp` can express.

Anti-pattern: `{% assign active = null | array %}{% for sub in Data.Subscription %}{% if sub.Status == "Active" %}{% assign active = active | push: sub %}{% endif %}{% endfor %}`. Preferred: `{% assign active = Data.Subscription | where: "Status", "Active" %}`. Keep manual loops only when transforming rows or building a custom shape; the linter reports obvious manual selection loops as `W184`.

## Strict mode and `strict_variables`

Every task supports a `parameters.strict_variables` toggle (string `"true"` / `"false"`, default `"true"`). When strict:

- Accessing a missing key raises `Liquid::UndefinedVariable`.
- Template parse errors raise `Liquid::SyntaxError`.

When non-strict, missing variables render to empty strings. Always prefer strict mode; disable only for free-form email bodies or preview scenarios.

## Gotchas

1. **Missing braces or typos render empty in non-strict mode.** Always set `strict_variables: "true"` in templates you care about.

2. **`Data.<object>` for a Query result is an array unless `placement` is set and the query returns a single row.** Use `Data.Invoice[0].Id` or iterate with `{% for inv in Data.Invoice %}`.

3. **Inside an `Iterate` body, `Data.<object>` is the single current row.** Outside the iterator, the same key is the full collection. Writing consistent templates means testing both shapes.

4. **`Data.Callout` is scalar for one callout but an array if multiple callouts exist.** Tasks that appear after the second callout must reference `Data.Callout[0]`, `Data.Callout[1]`, etc.

5. **Liquid validation is skipped on import.** `Logic::Case#task_setup_validation` and `If#task_setup_validation` parse their `case_clause` / `if_clause` at save time, but `Task.import` calls `save!(validate: false)`, which bypasses this. The only pre-run check is the client-side linter; Liquid syntax errors surface at first execution, not import.

6. **Do not use Liquid tags in a `Logic::Case.parameters.case_condition` key.** Keys must be sequential `Case_1`, `Case_2`, …, `Case_Else`. Put Liquid in the case *values*, not the keys.

7. **Strict filters** — `Liquid::Template.parse` uses `:error_mode => :strict`, so unknown filter names raise, not silently fail.

8. **Template timeout.** Liquid parse is capped at `LIQUID_TEMPLATE_PARSE_TIMEOUT` (three minutes in production). Heavy nested loops or huge payloads will error with `Evaluation takes more than 3 minutes to finish…`. Prefer filtered queries over client-side iteration.

9. **`{{ "now" | date: "%Y-%m-%d" }}`** is the idiomatic "today" expression. Don't inject Ruby-level times.

## Cross-references

- Task-specific Liquid requirements: `workflow-task-catalog.md` and `workflow-task-templates.json`.
- Where Liquid shows up in each task's parameters: `workflow-examples.md`.
- Machine-readable: `workflow-task-templates.json` entries mark templated params via placeholder text such as `"<<REQUIRED: Liquid expression>>"`.

SHA-256: c5ccd3204064024631adf37cb77fb1d97f12e8331cd60d1d6512e28a3f3770e6