cannectors

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: merge

Options

propertytypedefaultdescription
typerequired
"http_call"Module type discriminator. Must be `http_call` for this module.
id
stringUnique identifier within the pipeline.
name
stringHuman-readable name.
description
string
enabled
booleantrueWhether module is active.
tags
array<string>
onError
string"fail"Default error action. Case-insensitive; normalized to lowercase by the runtime.
endpointrequired
stringHTTP 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
stringInline request body. Templates ({{record.field}}) are evaluated at runtime. The body is sent regardless of the HTTP method.
bodyTemplateFile
stringPath 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
integerRequest 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
stringRecord 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
stringField 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: customerId
paramTypeEffect
pathReplaces the {paramName} placeholder in endpoint.
queryAppended as ?paramName=value to the URL.
headerSent 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
FieldMeaning
enabledMaster switch.
maxSizeLRU capacity. Older entries are evicted past this.
ttlSecondsPer-entry TTL.
keyOptional 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:

DataReasonable TTL
Country codes, currency lists, static reference tablesHours — raise it well past the default
Customer or product recordsThe 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:

StrategyEffect
merge (default)Deep-merge response fields into the record. Nested objects are merged; response values overwrite conflicts.
replaceOverlay response fields onto the record. Existing fields not present in the response are preserved.
appendStore the whole response under resultKey (required).
mergeStrategy: merge       # response fields overlay on the record
mergeStrategy: append      # the whole response nested under one key
resultKey: enrichment      # → record.enrichment

append 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: matches

Three things can be at the end of that path, and each has a defined outcome:

The field holdsWhat happens
An objectMerged, replaced, or nested according to mergeStrategy.
A non-empty listNested as-is under resultKey — any length, one element included. Requires mergeStrategy: append.
An empty listA 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 allThe 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.label

Only 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

examples/26-http-call-error-marker-routing.yamlview source ↗
26-http-call-error-marker-routing.yaml
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: batch
examples/14-http-call-get-merge-cache.yamlview source ↗
14-http-call-get-merge-cache.yaml
name: 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: batch
examples/15-http-call-query-header-append.yamlview source ↗
15-http-call-query-header-append.yaml
name: 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: batch
examples/16-http-call-post-template-replace.yamlview source ↗
16-http-call-post-template-replace.yaml
name: 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: batch
examples/29-call-filters-shared-merge-contract.yamlview source ↗
29-call-filters-shared-merge-contract.yaml
name: 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/json
examples/30-http-call-datafield-list-loop.yamlview source ↗
30-http-call-datafield-list-loop.yaml
name: 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

Cross-references