Skip to content

SOAP

Ingest a legacy SOAP service (Siebel, SAP PI) by putting the <soap:Body> in the manifest — a request shape on the REST engine (connector_type stays rest-generic), with QueryPage pagination.

Type rest-generic (shape soap)
Protocol SOAP 1.1 over POST
Target ext.* hybrid
Schedulable cron
Edition Open

Auth, retry, rate limiting and the hybrid JSONB + typed storage are all shared with REST. This page covers what is different: the body template, QueryPage pagination, and the error channel that is not the HTTP status.

Quickstart

Every screen on this page reads a demo service that ships with the repo

The same Python file the REST page uses answers SOAP on POST /<service>/start.swe — eight services over the same 2 400 fictional ACME partners, in XML. Start it in a terminal of its own, and put the credential these manifests need in the hub's registry:

python3 docs/connectors/rest/demo/acme-api.py
lm secret set ACME_BASIC_PW        # demo-pass

The manifests point at http://host.docker.internal:8099, because the hub runs in a container and localhost inside it is that container. What each service imitates, and which ones misbehave on purpose: The demo API.

# 90-soap-querypage.yaml — the Siebel QueryPage shape, on the REST connector.
#
# `connector_type` stays rest-generic. `kind: soap` sends a SOAP envelope by POST; the page
# offset goes inside the body. `kind: soap` and `pagination.kind: soap` go together: one
# without the other is refused at apply.
#
# The body's placeholders: {{page_size}}, {{cursor}} (row offset from 0), {{page}} (page
# number from 1), {{secret:NAME}}, {{env:NAME}}.
#
# Write paths without namespace prefixes (Customer, not ns:Customer). `response_path` is a
# plain dotted path to the repeated element: no [*], no `..`.
#
# XML has no types: `promote` gives each column its type.
#
#   lm secret set ACME_BASIC_PW        # the demo API answers to demo-user / demo-pass
#
# Expect: 2 400 rows in ext.acme_soap_partners, read 500 at a time.

apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: acme-soap
  connector_type: rest-generic
  scopes: [billing]

spec:
  base_url: http://host.docker.internal:8099

  auth:
    kind: basic
    username: demo-user
    password_env: ACME_BASIC_PW

  endpoints:
    - id: partners
      kind: soap
      path: /siebel/start.swe
      soap_action: "document/urn:acme:CustomerQueryPage"
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
          <ListOfCustomer><Customer><Id query="*"/><Name query="*"/></Customer></ListOfCustomer>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 500
        last_page_path: "$.Body.CustomerQueryPage_Output.LastPage"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_partners
        unique_key: "$.Id"
        promote:
          - { name: name,         path: "$.Name",        type: text }
          - { name: city,         path: "$.City",        type: text }
          - { name: credit_limit, path: "$.CreditLimit", type: decimal }
          - { name: updated_at,   path: "$.UpdatedAt",   type: timestamptz, transform: parse_iso8601 }
$ lm validate -f docs/connectors/rest/demo/90-soap-querypage.yaml
✓ 90-soap-querypage.yaml — valid
$ lm apply -f docs/connectors/rest/demo/90-soap-querypage.yaml
Applied connector "acme-soap" — 1 endpoint

Before running anything, send the first request a run would send:

$ lm source rest test acme-soap
ENDPOINT  METHOD  STATUS  ITEMS  VERDICT
partners  POST    200     500    ok

The pre-flight sends the request a run sends — envelope, body template, SOAPAction — for one page. Nothing is written.

$ lm connector run acme-soap
run scheduled: 45b9d0b8-ffb9-4bbf-ab2a-95fe42e03d70
$ lm run list --connector acme-soap
ID        CONNECTOR  ENDPOINT  STATUS     IN    OUT   SKIPPED  DEAD-LETTERS  STARTED           ENDED
45b9d0b8  acme-soap  partners  Completed  2400  2400  0        0             2026-10-19 01:46  2026-10-19 01:46

What landed in ext.acme_soap_partners — the promoted columns, beside the whole payload:

lumnik=> SELECT external_id, name, city, credit_limit, updated_at
lumnik->   FROM ext.acme_soap_partners ORDER BY external_id LIMIT 3;
 external_id |           name            |   city    | credit_limit |       updated_at       
-------------+---------------------------+-----------+--------------+------------------------
 1000        | Hydraulique Lyon 1000     | Lyon      |          500 | 2026-01-01 00:00:00+00
 1001        | Roulements Bruxelles 1001 | Bruxelles |          637 | 2026-01-01 00:01:00+00
 1002        | Fixations Milano 1002     | Milano    |          774 | 2026-01-01 00:02:00+00

The same partners as the REST quickstart, in the same storage layout. XML carries no types: credit_limit and updated_at are typed by their promote entries.

The manifest

Field Where Required Notes
kind: soap endpoint yes Goes with pagination.kind: soap
path endpoint yes The POST target, appended to base_url (e.g. /eai_enu/start.swe)
body endpoint yes The inner <soap:Body> XML — the hub wraps the SOAP 1.1 envelope
soap_action endpoint no Sent as the SOAPAction header (an empty quoted string when absent)
header endpoint no The inner <soap:Header> XML — same substitution as body
pagination.kind: soap endpoint yes Goes with kind: soap
page_size pagination yes Rows per page, injected as {{page_size}} — explicit, no default
response_path pagination yes A plain dotted path to the repeated element
last_page_path / has_more_path pagination no One stop signal — see Where the QueryPage loop stops
target endpoint yes The same hybrid target as REST: table, unique_key, promote
  • The request is always a POST with Content-Type: text/xml; charset=utf-8.
  • SOAP 1.1 envelopes only. A service that requires SOAP 1.2 (application/soap+xml) is not supported.
  • Set page_size to the number of rows the service actually returns per page.

How it reads

  • Write every path without namespace prefixes: Customer, not ns1:Customer.
  • response_path is a plain dotted path to the repeated element. A page holding a single element is still read.
  • A body with neither {{cursor}} nor {{page}} is sent once: there is no next page to ask for.

Nothing is written until the last page is read

The pages are held in the hub's memory until the end. Size page_size × the number of pages accordingly. A cancelled run writes nothing.

Body templating

Five placeholders are resolved on every request — in body: and header: alike:

Placeholder Resolves to
{{page_size}} pagination.page_size, verbatim
{{cursor}} the row offset of the page being fetched
{{page}} the page number of the page being fetched, from 1
{{secret:NAME}} the lm secret registry, then the hub's process environment — see below
{{env:NAME}} the hub's process environment only
  • An unresolved placeholder fails the run before any request is sent. The error names the placeholder, never a value.
  • Any other placeholder ({{watermark}}, a typo) is refused at lm validate and lm apply. The refusal names the placeholder and the key (body or header).

A credential inside the envelope

{{secret:NAME}} reads the lm secret registry first, then the hub's environment, like password_env. {{env:NAME}} reads the hub's environment only.

A session token inside the envelope goes in header::

# 94-soap-session.yaml — the credential that does not travel in a header.
#
# Legacy EAI services often want their session token INSIDE the envelope, in <soap:Header>,
# where no auth provider can put it. That is what the endpoint's own `header:` template is for —
# same substitution as `body:`, same placeholders, same fail-fast.
#
# {{secret:NAME}} reads the `lm secret` registry first and the hub's environment second, like
# password_env below. ({{env:NAME}} would read the hub's environment only.) So:
#
#   lm secret set ACME_SESSION_TOKEN        # demo-session-42
#
# An unresolved placeholder FAILS THE RUN before the request is sent — the template never goes
# on the wire with a literal {{...}} inside it, and the value is never echoed in the error.
#
# Expect (secret set): 2 400 rows.
# Expect (secret absent): a run that fails fast naming the placeholder, NOT a 401 — the refusal
# happens in the hub, before the service is ever asked.

apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: acme-soap-session
  connector_type: rest-generic
  scopes: [billing]

spec:
  base_url: http://host.docker.internal:8099

  # Transport auth is still HTTP Basic — the session token is a SECOND credential, carried in
  # the envelope. Both are needed.
  auth:
    kind: basic
    username: demo-user
    password_env: ACME_BASIC_PW

  endpoints:
    - id: partners
      kind: soap
      path: /session/start.swe
      soap_action: "document/urn:acme:CustomerQueryPage"
      header: |
        <SessionToken>{{secret:ACME_SESSION_TOKEN}}</SessionToken>
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 500
        last_page_path: "$.Body.CustomerQueryPage_Output.LastPage"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_session
        unique_key: "$.Id"
        promote:
          - { name: name, path: "$.Name", type: text }

Without the secret, nothing is sent:

$ lm source rest test acme-soap-session
ENDPOINT  METHOD  STATUS  ITEMS  VERDICT
partners  POST    -       0      unresolved secret/env placeholder: ACME_SESSION_TOKEN
Error: 1 of 1 enabled endpoint(s) did not answer with readable data

STATUS -: no request was sent.

Store it, and the next run sends it; the manifest does not change:

lm secret set ACME_SESSION_TOKEN        # demo-session-42

Where the QueryPage loop stops

QueryPage has no total and no next link. The loop follows one signal: the first of these you set.

Set The loop continues
last_page_path only while the flag reads explicitly false
else has_more_path only while the flag reads explicitly true
neither until a page comes back shorter than page_size

A missing, null or unreadable flag stops the loop. "true"/"false" strings are read as booleans.

The quickstart uses last_page_path; here are the other two, plus a single-element last page:

# 91-soap-stop-signals.yaml — the three ways a QueryPage loop ends, side by side.
#
# A SOAP run stops on exactly one signal, chosen by what you declare:
#
#   last_page_path set → continue only while the flag reads "false"
#   else has_more_path → continue only while the flag reads "true"
#   else               → stop on the first page shorter than page_size
#
# A missing or unreadable flag stops the run.
#
# The `tail` endpoint reads 201 rows, 100 per page: its last page holds ONE row. All 201 must
# land — a last page of one row is still read.
#
# Expect: 2 400 (more-records), 2 400 (short-page), 201 (single-element-tail).

apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: acme-soap-stops
  connector_type: rest-generic
  scopes: [billing]

spec:
  base_url: http://host.docker.internal:8099

  auth:
    kind: basic
    username: demo-user
    password_env: ACME_BASIC_PW

  endpoints:
    # ── has_more_path — the SAP PI flavour: "there are more", not "this is the last" ────────
    - id: more-records
      kind: soap
      path: /sap/start.swe
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 500
        has_more_path: "$.Body.CustomerQueryPage_Output.MoreRecords"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_moreflag
        unique_key: "$.Id"
        promote:
          - { name: name, path: "$.Name", type: text }

    # ── no flag at all — the service announces nothing, the short page is the only signal ───
    - id: short-page
      kind: soap
      path: /mute/start.swe
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 700          # 2 400 = 3 x 700 + 300 — the fourth page is the short one
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_shortpage
        unique_key: "$.Id"
        promote:
          - { name: name, path: "$.Name", type: text }

    # ── the single-element last page — all 201 rows must land ────────────────
    - id: single-element-tail
      kind: soap
      path: /tail/start.swe
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 100          # 201 rows = 100 + 100 + ONE
        last_page_path: "$.Body.CustomerQueryPage_Output.LastPage"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_tail
        unique_key: "$.Id"
        promote:
          - { name: name, path: "$.Name", type: text }
$ lm run list --connector acme-soap-stops
ID        CONNECTOR        ENDPOINT             STATUS     IN    OUT   SKIPPED  DEAD-LETTERS  STARTED           ENDED
4ad8f592  acme-soap-stops  single-element-tail  Completed  201   201   0        0             2026-10-19 01:46  2026-10-19 01:46
9ace6ce2  acme-soap-stops  short-page           Completed  2400  2400  0        0             2026-10-19 01:46  2026-10-19 01:46
993bbd1a  acme-soap-stops  more-records         Completed  2400  2400  0        0             2026-10-19 01:46  2026-10-19 01:46

201, not 200: /tail holds 201 rows at a page size of 100, so its last page carries one <Customer>, and it is ingested. On a new SOAP source, compare the row count with the source's.

When the service misbehaves

A Fault fails the run — and is never retried

A <soap:Fault> fails the run, whether it arrives on HTTP 500 (SOAP 1.1) or on HTTP 200 (some gateways):

# 92-soap-fault.yaml — the run that must fail.
#
# A SOAP service says "no" with a <soap:Fault>, sent with HTTP 500 or with HTTP 200. Both fail
# the run, naming the faultstring. A Fault is the service's answer: it is never retried.
# Compare 93, the same status with a retry.
#
# Expect: BOTH endpoints Failed, 0 rows written, the run message naming the faultstring.

apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: acme-soap-fault
  connector_type: rest-generic
  scopes: [billing]

spec:
  base_url: http://host.docker.internal:8099

  auth:
    kind: basic
    username: demo-user
    password_env: ACME_BASIC_PW

  endpoints:
    - id: fault-on-500
      kind: soap
      path: /fault/start.swe
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 100
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_fault500
        unique_key: "$.Id"
        promote: []

    - id: fault-on-200
      kind: soap
      path: /fault200/start.swe
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 100
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_fault200
        unique_key: "$.Id"
        promote: []
$ lm run list --connector acme-soap-fault
ID        CONNECTOR        ENDPOINT      STATUS  IN  OUT  SKIPPED  DEAD-LETTERS  STARTED           ENDED
0de9fa56  acme-soap-fault  fault-on-200  Failed  0   0    0        0             2026-10-19 01:46  2026-10-19 01:46
8d179cc0  acme-soap-fault  fault-on-500  Failed  0   0    0        0             2026-10-19 01:46  2026-10-19 01:46

$ lm run get 8d179cc0
ID        CONNECTOR        ENDPOINT      STATUS  IN  OUT  SKIPPED  DEAD-LETTERS  STARTED           ENDED
8d179cc0  acme-soap-fault  fault-on-500  Failed  0   0    0        0             2026-10-19 01:46  2026-10-19 01:46
✖ run failed: SOAP Fault: SBL-DAT-00500: no such Business Component 'Customer'
  • The message carries the service's faultstring verbatim.
  • Nothing is ingested or dead-lettered. Neither endpoint's ext.* table is created.
  • A Fault is never retried: the demo API logs one request per endpoint.
  • lm source rest test shows the same: its VERDICT reads SOAP Fault: …. A 500 without a Fault (an HTML page) reads HTTP 500.

The same 500, without a Fault, is retried

# 93-soap-retry.yaml — the other error channel, on the same status code.
#
# The /flaky service answers 500 twice per page window and then serves the page. The body of
# those 500s is XML, but it carries NO <soap:Fault> — it is an infrastructure hiccup, not an
# answer. The shared REST retry policy applies unchanged: 5 attempts, transient on exactly
# 429, 500, 502, 503, 504, 408.
#
# Read this file against 92. Same server, same 500, opposite outcome — and the body is the only
# thing that differs. That is the whole design: the status says "something went wrong", the
# body says whose fault it is.
#
# ── Before running ────────────────────────────────────────────────────────────────────────
#   curl http://127.0.0.1:8099/admin/reset
#
# The failure counters are per page window and they persist. Without a reset, a second run finds
# the failures already burnt in, retries nothing, and goes green for the wrong reason.
#
# Expect: Completed with 2 400 rows, and visibly slower than 91's short-page endpoint over the
# same data — three requests per window instead of one, plus the backoff between them.

apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: acme-soap-retry
  connector_type: rest-generic
  scopes: [billing]

spec:
  base_url: http://host.docker.internal:8099

  auth:
    kind: basic
    username: demo-user
    password_env: ACME_BASIC_PW

  endpoints:
    - id: flaky
      kind: soap
      path: /flaky/start.swe
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 800         # 3 windows x 3 requests — enough retries to see, few to wait for
        last_page_path: "$.Body.CustomerQueryPage_Output.LastPage"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_flaky
        unique_key: "$.Id"
        promote:
          - { name: name, path: "$.Name", type: text }

The /flaky service answers 500 without a Fault twice per page before serving it. The REST retry policy applies unchanged.

$ lm run list --connector acme-soap-retry
ID        CONNECTOR        ENDPOINT  STATUS     IN    OUT   SKIPPED  DEAD-LETTERS  STARTED           ENDED
520b32f6  acme-soap-retry  flaky     Completed  2400  2400  0        0             2026-10-19 01:46  2026-10-19 01:46

The demo API's log shows three requests per page, with a growing pause between them:

19/Oct/2026 01:46:32  "POST /flaky/start.swe HTTP/1.1" 500 -
19/Oct/2026 01:46:33  "POST /flaky/start.swe HTTP/1.1" 500 -
19/Oct/2026 01:46:34  "POST /flaky/start.swe HTTP/1.1" 200 -
19/Oct/2026 01:46:34  "POST /flaky/start.swe HTTP/1.1" 500 -
19/Oct/2026 01:46:35  "POST /flaky/start.swe HTTP/1.1" 500 -
19/Oct/2026 01:46:37  "POST /flaky/start.swe HTTP/1.1" 200 -

Same status code as the Fault above, opposite handling: a 500 with a Fault fails at once, a 500 without one is retried.

Reset the counters before re-running

curl http://127.0.0.1:8099/admin/reset. Otherwise a second run finds the counters spent and retries nothing.

A refusal that reads I/O error, tried five times

A CoreWCF service on Kestrel can lose its Fault over HTTP/2. The run then fails without the Fault. It is retried like a network failure, 5 attempts in all:

$ lm source rest test scenario-wcf-probe
ENDPOINT     METHOD  STATUS  ITEMS  VERDICT
open-orders  POST    -       0      I/O error (IOException)

Ask for HTTP/1.1 in the connector. The same answer then arrives whole:

spec:
  base_url: https://wcf-orders:18443
  http_version: "1.1"        # "1.1" or "2"; absent, the hub negotiates
  • The Fault is read, the run fails with its faultstring, and it is not retried.
  • Quote the value. http_version: 1.1 is a number to YAML, and lm validate refuses it.
  • It is one setting for the connector, not for one endpoint.
  • Successful calls do not show the problem: it appears on the first refusal, a rotated password for example.

What lm apply refuses

  • kind: soap without pagination.kind: soap, or the reverse.
  • A response_path with [*] or ..: write a plain dotted path.
  • A missing page_size.

Each refusal names the key at fault, e.g. spec.endpoints[0].pagination.page_size.

Incremental — the mark rides the URL, not the body

incremental: { mode: watermark } works on a SOAP endpoint:

  • The stored mark goes out on every request of the run, under the query parameter param names.
  • The new mark is the highest watermark_path over the rows read.
  • As on REST, the run asks from a little before the mark: a change during a run.
# 95-soap-watermark.yaml — incremental over SOAP.
#
# `incremental: watermark` sends the last mark on the first request of a run, under the
# parameter named by `incremental.param`. On SOAP, that parameter goes in the URL's query
# string, not in the body:
#
#   POST /siebel/start.swe?updated_since=2026-01-02T15:59:00Z
#
# The body template has no {{watermark}} placeholder: `lm apply` refuses one. A service that
# wants the filter inside the body cannot be read incrementally today.
#
#   lm connector run acme-soap-incremental      # run 1 — the full 2 400
#   lm connector run acme-soap-incremental      # run 2 — only what changed
#   lm endpoint reset-cursor acme-soap-incremental/partners
#
# Expect: run 1 = 2 400 in / 2 400 out. Run 2 = only the rows changed since run 1 (a little
# before, so nothing changed during run 1 is missed). They replace their earlier version: the
# table stays at 2 400.

apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: acme-soap-incremental
  connector_type: rest-generic
  scopes: [billing]

spec:
  base_url: http://host.docker.internal:8099

  auth:
    kind: basic
    username: demo-user
    password_env: ACME_BASIC_PW

  endpoints:
    - id: partners
      kind: soap
      path: /siebel/start.swe
      soap_action: "document/urn:acme:CustomerQueryPage"
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
        </CustomerQueryPage_Input>
      incremental:
        mode: watermark
        param: updated_since          # a query parameter on the POST — not a body element
        watermark_path: "$.UpdatedAt"
      pagination:
        kind: soap
        page_size: 500
        last_page_path: "$.Body.CustomerQueryPage_Output.LastPage"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.acme_soap_incremental
        unique_key: "$.Id"
        promote:
          - { name: name,       path: "$.Name",      type: text }
          - { name: updated_at, path: "$.UpdatedAt", type: timestamptz, transform: parse_iso8601 }

Run 1 reads the 2 400 rows. Run 2 reads only the rows changed since the mark, and upserts them on their key: the table stays at 2 400 rows.

Know this before you promise a customer an incremental SOAP feed

The mark goes in the URL query string, not in the body: there is no {{watermark}} placeholder. The request reads POST /siebel/start.swe?updated_since=….

A service that expects the filter inside the body ignores it. Then filter in the body with a literal and leave incremental at full.

Connector-level headers

spec.headers[] applies to every SOAP request, as for REST. Do not redeclare Content-Type or SOAPAction: the engine sets them.

How to…

Point it at your own service

The TUI's :new picker hands you this manifest for soap — a Siebel EAI endpoint:

# Siebel CustomerQueryPage over SOAP, exposed as a canonical hub table.
# kind: soap is a request-shape on the HTTP engine (connector_type stays rest-generic).
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
  name: siebel-customers
  connector_type: rest-generic
  scopes: [crm, customers, siebel]
spec:
  base_url: https://siebel.example.com
  auth:
    kind: basic
    username: siebel_svc          # literal username (the basic block reads `username`, not an env)
    password_env: SIEBEL_PWD      # env var name holding the password
  endpoints:
    - id: customers
      kind: soap
      path: /eai_enu/start.swe
      soap_action: "document/urn:crmod:...:CustomerQueryPage"
      body: |
        <CustomerQueryPage_Input>
          <PageSize>{{page_size}}</PageSize>
          <StartRowNum>{{cursor}}</StartRowNum>
          <ListOfCustomer><Customer><Id query="*"/><Name query="*"/></Customer></ListOfCustomer>
        </CustomerQueryPage_Input>
      pagination:
        kind: soap
        page_size: 100
        last_page_path: "$.Body.CustomerQueryPage_Output.LastPage"
        response_path: "$.Body.CustomerQueryPage_Output.ListOfCustomer.Customer"
      target:
        kind: hybrid
        table: ext.siebel_customers
        unique_key: "$.Id"
        promote:
          - { name: name, path: "$.Name", type: text }

Change base_url, the path your EAI listener publishes, the operation names inside body: and the response_path. Then:

lm apply -f siebel-customers.yaml
lm source rest test siebel-customers

Read a custom service that pages by number

Custom business services often take a page number in the body and say on the page whether it is the last one, in an attribute. Put {{page}} where the number goes; an attribute reads like an element:

      body: |
        <cus:getListOpportunity_Input xmlns:cus="http://siebel.com/CustomUI">
          <getOpportunity>
            <Opportunity>
              <refExtPersonne>40012345</refExtPersonne>
              <page>{{page}}</page>
            </Opportunity>
          </getOpportunity>
        </cus:getListOpportunity_Input>
      pagination:
        kind: soap
        page_size: 3            # the service's own page size: no placeholder sends it
        last_page_path: "$.Body.getListOpportunity_Output.getOpportunity.lastpage"
        response_path: "$.Body.getListOpportunity_Output.getOpportunity.Opportunity"

The pages asked are 1, 2, 3… until lastpage reads true.

Read a service that does not page

A service that answers everything in one call (the actions of one case, say) needs no paging placeholder. Leave {{cursor}} and {{page}} out of the body and header: the endpoint sends one request per run, whatever the answer holds. pagination.kind: soap, page_size and response_path are still required.

Read a second collection from the same service

Add a second endpoint with its own body:, response_path and table. The ledger records one run per endpoint, as the stop-signals connector shows.

Troubleshooting

Symptom Cause Gesture
Run fails instantly with unresolved secret/env placeholder secret: — the name is neither in lm secret nor in the hub's environment; env: — it is not in the hub's environment lm secret set NAME for secret:; the hub's environment for env:
Run fails on HTTP 500 with no retry a <soap:Fault> is never retried read the faultstring in lm run get; fix the request or the permission it needs
I/O error (IOException) on a service that should answer a Fault, five POSTs per run in its log the service resets its HTTP/2 stream after the answer set http_version: "1.1" in the connector
Run failed on pagination.response_path reads nothing on the first page response_path does not match the answer write the path without namespace prefixes; lm source rest test shows the same message as its verdict
A run reads one row fewer than the source holds an intermediate segment of response_path repeats in the answer the last page holding a single element is where the loss shows; compare the row count with the source's
Pagination stops after one page the flag is missing, null or unreadable, which stops the loop check last_page_path / has_more_path against the real answer, or set neither and rely on the short page

See also