http_call
Per-record HTTP enrichment with caching and merge strategies.
The http_call filter makes an HTTP request per record, then
merges the response back onto the record. Supports path / query /
header keys extracted from record fields, LRU + TTL caching, and three
merge strategies.
Minimal example
filters:
- type: http_call
endpoint: https://profiles.example.com/api/customers/{customerId}
method: GET
keys:
- field: customer_id
paramType: path
paramName: customerId
mergeStrategy: mergeOptions
| property | type | default | description |
|---|---|---|---|
typerequired | "http_call" | — | Module type discriminator. Must be `http_call` for this module. |
id | string | — | Unique identifier within the pipeline. |
name | string | — | Human-readable name. |
description | string | — | — |
enabled | boolean | true | Whether module is active. |
tags | array<string> | — | — |
onError | string | "fail" | Default error action. Case-insensitive; normalized to lowercase by the runtime. |
endpointrequired | string | — | HTTP endpoint URL. Templates are accepted; the final URL is validated at runtime after template resolution. |
method | object | — | — |
headers | map<string, string> | — | — |
queryParams | map<string, string> | — | Query parameters. |
body | string | — | Inline request body. Templates ({{record.field}}) are evaluated at runtime. The body is sent regardless of the HTTP method. |
bodyTemplateFile | string | — | Path to an external template file used as the request body. Mutually exclusive with `body` is not enforced; if both are set, the inline body wins. |
| union (4 variants) | — | — | |
timeoutMs | integer | — | Request timeout in milliseconds. When omitted, each module applies its own runtime default. |
mergeStrategy | string | "merge" | How to merge the call response with the input record. merge: deep-merge the response into the record. replace: overwrite the record's top-level keys with the response ones. append: nest the whole response under resultKey. mergereplaceappend |
resultKey | string | — | Record key receiving the whole response with mergeStrategy: append. Required as soon as mergeStrategy is append, for the three call filters alike; ignored by the merge and replace strategies. |
| object | — | — | |
| array<object> | — | List of key configurations for extracting values from records and using them in requests. | |
| object | — | — | |
dataField | string | — | Field to extract from HTTP response. |
| object | — | — |
Keys
A key extracts a value from the record and uses it in the outgoing
request, in one of three positions:
keys:
- field: customer.id # dot path on the record
paramType: path # path | query | header
paramName: customerIdparamType | Effect |
|---|---|
path | Replaces the {paramName} placeholder in endpoint. |
query | Appended as ?paramName=value to the URL. |
header | Sent as the HTTP header named paramName. |
Each key is one entry — list multiple if you need more than one parameter.
At least one key is required unless the call carries a body or
bodyTemplateFile. Without either, every record would produce the exact
same request, so cannectors validate rejects the config rather than
letting it fail at run time.
Caching
Per-record HTTP calls multiply quickly. The built-in cache (LRU with TTL) deduplicates calls that resolve to the same key.
cache:
enabled: true
maxSize: 1000 # default 1000
ttlSeconds: 600 # default 300
key: "{{ record.customerId }}" # template| Field | Meaning |
|---|---|
enabled | Master switch. |
maxSize | LRU capacity. Older entries are evicted past this. |
ttlSeconds | Per-entry TTL. |
key | Optional template — if omitted, the resolved URL plus the key values is the cache key. |
The default key is what the call actually is: the URL after templates,
queryParams and path keys are resolved, plus the values of any keys
(header keys never reach the URL). So if nothing in the call varies per
record — a fixed endpoint, no keys — every record lands in the same
slot and you'll observe a single HTTP request per pipeline run. To force
a per-record call, make something in the URL vary (a template in the
endpoint or in a queryParams value, a key), or set cache.key to a
per-record template.
The cache outlives a run
A scheduled pipeline keeps its modules — and therefore its cache —
between ticks. An entry lives for ttlSeconds from the moment it was
written, regardless of how many runs happen in between.
That is the point: on a fifteen-second schedule with the default 300 s
TTL, a lookup that used to fire on every tick now fires roughly once
every twenty. But it also means ttlSeconds is your staleness
budget, not a per-run detail. A record enriched at 12:00:00 can be
enriched from cache at 12:04:59 with the same answer, even though four
runs happened in between and the reference data changed at 12:01.
Pick the TTL against how fast the source of truth moves:
| Data | Reasonable TTL |
|---|---|
| Country codes, currency lists, static reference tables | Hours — raise it well past the default |
| Customer or product records | The default 300 s is usually right |
| Prices, stock levels, anything you would call "live" | Low tens of seconds, or enabled: false |
Setting enabled: false is the way to say "always ask" — there is no
per-run flush, and restarting the process is the only other way to
empty the cache.
Merge strategies
What to do with the response, given that it returns alongside an already-shaped record:
| Strategy | Effect |
|---|---|
merge (default) | Deep-merge response fields into the record. Nested objects are merged; response values overwrite conflicts. |
replace | Overlay response fields onto the record. Existing fields not present in the response are preserved. |
append | Store the whole response under resultKey (required). |
mergeStrategy: merge # response fields overlay on the recordmergeStrategy: append # the whole response nested under one key
resultKey: enrichment # → record.enrichmentappend requires resultKey: omitting it fails cannectors validate rather
than silently picking a destination. Outside append, resultKey has no effect
on the merge — but it is still checked: whatever the strategy, it can never
reach into _errors, reserved for the runtime's error markers. That check runs
when the filter is built, so a reserved resultKey passes cannectors validate
and fails on cannectors run.
http_call, soap_call and sql_call share this contract — see
merge contract of the call filters.
Selecting the response with dataField
Most APIs wrap what you want in an envelope. dataField points at the part of the
response that should reach the record — a path, so payload.customer works as
well as a top-level key:
- type: http_call
endpoint: https://catalog.example.com/api/matches
dataField: results # → the "results" value, not the envelope
mergeStrategy: append
resultKey: matchesThree things can be at the end of that path, and each has a defined outcome:
| The field holds | What happens |
|---|---|
| An object | Merged, replaced, or nested according to mergeStrategy. |
| A non-empty list | Nested as-is under resultKey — any length, one element included. Requires mergeStrategy: append. |
| An empty list | A functional answer — "no match". With append the record carries []; with merge or replace there is nothing to fold in, so the record continues untouched. |
A scalar, null, or nothing at all | The record fails, and onError decides whether the pipeline stops, the record is dropped, or it continues annotated. The failure is a validation error: the same call returns the same shape, so it is never retried. |
A list stays a list, and no key is invented on your pipeline's behalf.
dataField is a path expression, not a literal key: data.results walks into
data and reads results. A response whose top-level key genuinely contains a
dot cannot be addressed this way.
This is a behavior change. A list of two or more elements used to be silently
replaced by an empty object: the record went down the pipeline unenriched, with
no error and no onError involvement. Pipelines that relied on a single-element
list being unwrapped into the record now receive a one-element list instead.
Iterating a list response
loop is what consumes the list — the two filters compose, which is why
http_call does not reduce the list itself:
filters:
- type: http_call
endpoint: https://catalog.example.com/api/matches
dataField: results
keys:
- field: sku
paramType: query
paramName: sku
mergeStrategy: append
resultKey: matches
- type: loop
field: matches
itemName: match
filters:
- type: mapping
mappings:
- source: match.name
target: match.labelOnly need one element? Reduce the list explicitly with a script filter, so the pipeline says which element it keeps.
Each record receives its own copy of the response, so nested filters may rewrite the items in place: with caching enabled the response is the object the cache holds, and two records resolving to the same cache entry still enrich independently.
Query parameters
queryParams adds parameters to every call. Unlike keys, which pull a
value out of each record, these are the module-wide defaults — though their
values do accept templates:
- type: http_call
endpoint: https://profiles.example.com/api/customers
queryParams:
include: profile,preferences
tenant: "{{ record.tenantId }}"Precedence, from weakest to strongest: what the endpoint's query string spells
out, then queryParams, then a key with paramType: query. The per-record
value always wins over the default.
Values are percent-encoded exactly once — writing a template here does not double-encode it.
Failures
With onError: log, a failed call does not drop the record: it passes through
annotated under _errors, carrying the status code, the category and whether
the failure is retryable. That is what makes a 400 distinguishable from a
503 downstream, so you can park one and replay the other.
- type: http_call
onError: log
endpoint: https://profiles.example.com/api/customers/{customerId}
errorClassification:
functional: [404] # this API answers 404 for "no match"See Error markers for the marker's fields and how to route on them.
Examples
name: http-call-error-marker-routing
version: 1.0.0
description: Enrich records, mark failures instead of losing them, and route rejects on the marker.
tags:
- http-call
- error-handling
- condition
input:
type: httpPolling
schedule: "*/15 * * * *"
endpoint: https://source.example.com/api/orders
dataField: orders
filters:
# onError: log lets the record continue and annotates it under the reserved
# `_errors` list, instead of dropping it without trace (skip) or stopping the
# whole batch for one bad record (fail).
- type: http_call
endpoint: https://profiles.example.com/api/customers/{customerId}
method: GET
onError: log
keys:
- field: customer.id
paramType: path
paramName: customerId
dataField: profile
mergeStrategy: merge
cache:
enabled: true
maxSize: 10000
ttlSeconds: 900
key: customer.id
retry:
maxAttempts: 3
delayMs: 500
backoffMultiplier: 2
# This API answers 409 while a profile is being re-indexed — a transient
# condition, even though 4xx codes are functional by default. Declaring it
# technical marks the record retryable so the next cycle picks it up.
# The category of a code already on the right side of the line is kept:
# a 404 stays `not_found`, it does not become a flat `validation`.
errorClassification:
technical:
- 409
functional:
- 404
# A functional failure will never succeed on replay: tag it for the dead
# letter path so the next cycle does not pick it up again.
# `_errors != nil` is the guard to write — the key is absent from records that
# never failed, and expr rejects len(nil).
- type: condition
expression: "_errors != nil and _errors[0].retryable == false"
then:
- type: set
target: routing.status
value: REJECTED
else:
- type: set
target: routing.status
value: OK
# Technical failures stay replayable: drop them from this run so the next
# cycle retries them, rather than shipping a half-enriched record downstream.
# They still appear in the end-of-run "Error markers" report: markers are
# counted where they are created, so routing a record out does not erase the
# failure that made you route it.
- type: condition
expression: "_errors != nil and _errors[0].retryable == true"
then:
- type: drop
output:
type: httpRequest
endpoint: https://destination.example.com/api/orders/enriched
method: POST
requestMode: batchname: http-call-get-merge-cache
version: 1.0.0
description: Enrich each record with an HTTP GET response and merge it recursively.
tags:
- http-call
- cache
input:
type: httpPolling
schedule: "*/20 * * * *"
endpoint: https://source.example.com/api/orders
dataField: orders
filters:
- type: http_call
endpoint: https://profiles.example.com/api/customers/{customerId}
method: GET
keys:
- field: customer.id
paramType: path
paramName: customerId
dataField: profile
mergeStrategy: merge
cache:
enabled: true
maxSize: 10000
ttlSeconds: 900
key: customer.id
output:
type: httpRequest
endpoint: https://destination.example.com/api/orders/enriched
method: POST
requestMode: batchname: http-call-query-header-append
version: 1.0.0
description: Use query and header keys for an HTTP enrichment call and append the response.
tags:
- http-call
- append
input:
type: httpPolling
schedule: "*/20 * * * *"
endpoint: https://source.example.com/api/tickets
dataField: tickets
filters:
- type: http_call
endpoint: https://support.example.com/api/sla
method: GET
headers:
X-Tenant: "{{record.tenantId}}"
keys:
- field: priority
paramType: query
paramName: priority
- field: tenantId
paramType: header
paramName: X-Tenant-Id
mergeStrategy: append
resultKey: sla
cache:
enabled: true
maxSize: 1000
ttlSeconds: 300
output:
type: httpRequest
endpoint: https://destination.example.com/api/tickets
method: POST
requestMode: batchname: http-call-post-template-replace
version: 1.0.0
description: Use POST enrichment with a body template and replace overlapping response fields.
tags:
- http-call
- body-template
input:
type: httpPolling
schedule: "0 * * * *"
endpoint: https://source.example.com/api/addresses
dataField: addresses
filters:
- type: http_call
endpoint: https://geo.example.com/api/normalize
method: POST
headers:
X-Request-Source: cannectors
bodyTemplateFile: examples/assets/templates/geocode_request.json
dataField: result
mergeStrategy: replace
cache:
enabled: true
maxSize: 5000
ttlSeconds: 86400
key: address.hash
output:
type: httpRequest
endpoint: https://destination.example.com/api/addresses/normalized
method: POST
requestMode: batchname: call-filters-shared-merge-contract
version: 1.0.0
description: The three call filters share one merge contract - mergeStrategy append nests the whole response under resultKey.
tags:
- http-call
- soap-call
- sql-call
- append
input:
type: httpPolling
schedule: "*/15 * * * *"
endpoint: https://source.example.com/api/orders
dataField: orders
filters:
- type: http_call
endpoint: https://crm.example.com/api/customers
method: GET
keys:
- field: customerId
paramType: query
paramName: id
mergeStrategy: append
resultKey: customer
cache:
enabled: true
maxSize: 1000
ttlSeconds: 300
key: "customer:{{record.customerId}}"
- type: soap_call
endpoint: https://erp.example.com/contracts
soapAction: urn:GetContract
operation: GetContract
body: |
<m:GetContract xmlns:m="urn:contracts">
<m:CustomerId>{{record.customerId}}</m:CustomerId>
</m:GetContract>
dataField: Envelope.Body.GetContractResponse.Contract
mergeStrategy: append
resultKey: contract
cache:
enabled: true
maxSize: 1000
ttlSeconds: 300
key: "contract:{{record.customerId}}"
- type: sql_call
connectionStringRef: ${REFERENCE_DATABASE_URL}
driver: postgres
query: SELECT tier, discount FROM pricing WHERE customer_id = $1
parameters:
- record.customerId
mergeStrategy: append
resultKey: pricing
cache:
enabled: true
maxSize: 1000
ttlSeconds: 300
key: "pricing:{{record.customerId}}"
output:
type: httpRequest
endpoint: https://destination.example.com/api/orders/enriched
method: POST
requestMode: batch
headers:
Content-Type: application/jsonname: http-call-datafield-list-loop
version: 1.0.0
description: Enrich each record with a list-shaped HTTP response, then iterate the list with the loop filter.
tags:
- http-call
- data-field
- loop
input:
type: httpPolling
schedule: "*/10 * * * *"
endpoint: https://source.example.com/api/orders
dataField: orders
filters:
- type: http_call
endpoint: https://catalog.example.com/api/matches
method: GET
dataField: results
keys:
- field: reference
paramType: query
paramName: reference
mergeStrategy: append
resultKey: matches
cache:
enabled: true
maxSize: 1000
ttlSeconds: 300
key: "matches:{{record.reference}}"
- type: loop
field: matches
itemName: match
filters:
- type: mapping
mappings:
- source: match.productCode
target: match.sku
- type: remove
target:
- match.productCode
output:
type: httpRequest
endpoint: https://destination.example.com/api/orders/matched
method: POST
requestMode: batch