Templating
The Jinja template engine — syntax, contexts, contextual escaping, and the SQL parameters model.
Cannectors templates request fields with a Jinja engine (Jinja2 syntax). Every dynamic field — HTTP endpoints, headers, request bodies, SOAP envelopes, SQL queries, cache keys — goes through the same engine, with escaping matched to the field's format applied automatically.
output:
type: httpRequest
endpoint: https://api.example.com/customers/{{ record.customerId }}
method: POST
body: |
{
"email": "{{ record.email | lower }}",
"tier": "{{ record.tier | default('standard') }}"
{% if record.discount %},"discount": "{{ record.discount }}"{% endif %}
}Syntax
| Construct | Example |
|---|---|
| Output tag | {{ record.customer.id }} |
| Array indexing | {{ record.items[0].sku }} |
| Filter | {{ record.email | lower }} |
| Default value | {{ record.tier | default('standard') }} |
| Condition | {% if record.priority == "high" %}URGENT{% endif %} |
| Loop | {% for tag in record.tags %}<t>{{ tag }}</t>{% endfor %} |
| Comment | {# ignored #} |
Common filters: default(value), lower, upper, capitalize, trim,
join(sep), length, replace(old, new). The full set is the standard
Jinja builtin catalog.
Unsupported tags
Two families of Jinja tags are rejected at compile time — cannectors validate reports them with the field path:
| Tag | Why |
|---|---|
{% include %}, {% extends %}, {% import %}, {% from %} | A template pulled from disk would skip contextual escaping, and its path can be a record expression. Keep each template self-contained; external files go through bodyTemplateFile / queryFile, which are escaped normally. |
{% filter %} (block form) | It transforms the value after escaping, which corrupts the output (< becomes <). Use an inline filter instead: {{ value | upper }}. |
Template context
Top-level variables available inside templates:
| Variable | Contents | Available in |
|---|---|---|
record | The current record. | All templated fields. |
meta | The record's _metadata map. | All templated fields. |
state | Persisted incremental state: state.lastTimestamp (RFC3339, epoch on first run), state.lastId (absent until one is persisted). | httpPolling endpoint, headers, queryParams and body; soapPolling body and SOAP headers; database input parameters. |
pagination | Current page values: pagination.page, pagination.offset, pagination.cursor, pagination.limit. | soapPolling body and database input parameters (re-evaluated per page). |
<itemName> | The current item of an enclosing loop, one variable per active loop. | Filters nested in a loop. |
<scopeField> | A field a nested filter wrote into the loop scope without the record. prefix — an http_call response, a sql_call resultKey, a set target. | Filters nested in a loop. |
Inside a loop, record still means the root record — the loop's aliases
sit beside it rather than wrapping it, so {{ line.number }} and the field path
line.number name the same value. The same goes for anything written at scope
level: {{ price }}, not {{ record.price }}. See
fields written at scope level.
An itemName may not take the name of a variable in this table. A scope field
that collides with one is not rejected — it is simply unreachable, since the
context variable always wins.
How values are rendered
Substituted values keep the record's own representation, not Jinja's Python
flavour: 42 renders as 42 (not 42.0), and true renders as true (not
True). This holds on every target, so {{ record.id }} in a URL and the
{id} key placeholder always produce the same text, and a
number or boolean substituted into a JSON body stays valid JSON.
Contextual escaping
Substituted values are escaped for the field they land in — the literal text you write around them is never touched:
| Field | Escaping |
|---|---|
| HTTP / SOAP endpoint | URL percent-encoding (a b&c → a%20b%26c). |
| JSON request body | JSON string escaping — a value containing " or a newline cannot break the payload. |
| SOAP body and headers | XML escaping (<, >, &, " become entities). |
| HTTP headers, cache keys | None (raw text). |
| SQL query | Not applicable — dynamic values go through bound parameters, see below. |
URLs: one encoding for both positions
A space is encoded as %20, not +. The distinction matters because a single
endpoint holds two different positions:
endpoint: https://api.example.com/customers/{{ record.name }}?q={{ record.term }}
# └─ path segment ─┘ └─ query value ─┘In a query string, + and %20 both mean a space. In a path segment + is
a literal plus — so encoding a space as + there asks the server for a customer
whose name really does contain a +. %20 is read as a space in both, so it is
the one encoding that is right wherever you put the placeholder.
Structure is never touched: the ?, & and / you write in the endpoint stay
structural. Only the substituted values are encoded — which is exactly what
stops a value containing & from inventing a new query parameter.
SQL: query + parameters
SQL queries never interpolate values into the text. The query is a Jinja
template that renders to SQL shape — with $1, $2, … placeholders —
and the parameters list supplies the values, bound by the driver:
filters:
- type: sql_call
connectionStringRef: ${REFERENCE_DATABASE_URL}
query: |
SELECT tier, owner FROM customers
WHERE id = $1
{% if record.region %}AND region = $2{% endif %}
parameters:
- record.customer.id # → $1
- record.region # → $2Each parameters entry is an expr expression —
the same language as the condition filter. Values bind natively
typed: an integer stays an integer, a missing field binds as NULL.
Use ?? for explicit fallbacks (pagination.cursor ?? 0) and ?. for
optional navigation.
Write $1, $2, … regardless of the database: Cannectors translates them
per driver (kept for PostgreSQL, ? for MySQL/SQLite). A placeholder
inside a {% if %} branch that isn't taken is neither evaluated nor bound.
cannectors validate checks the pairing statically: a $N with no
matching entry, or a parameter never referenced by the query, is a
validation error. Queries loaded from queryFile are checked too — the
path is resolved from the working directory, as at run time.
An output tag ({{ … }}) inside a SQL query is rejected by
cannectors validate — it would splice a raw, unescaped value into
the SQL. Dynamic values always go through parameters; only control
blocks ({% if %}, {% for %}) may shape the query text.
Missing variables
- HTTP fields (endpoint, headers, body) render a missing variable as
an empty string. Use
default('…')to make fallbacks explicit. - SOAP bodies and SQL query text are strict: a variable that is
missing — or present but
null— fails the record instead of silently sending an incomplete request.default('…')covers both cases. - SQL parameters are permissive: a missing field binds as
NULL.
Not templating: {paramName} keys
Some modules (httpRequest, http_call, SOAP modules) also support a
keys list that injects record fields into the URL path, query string,
or headers via single-brace placeholders:
endpoint: https://api.example.com/projects/{projectId}/tasks
keys:
- field: project.id
paramType: path
paramName: projectIdThis is a separate, structured mechanism — {projectId} is not a Jinja
template. Both can coexist in the same endpoint.