Marketing Tools

Connecting to Legacy SOAP APIs in 2026 (When REST Isn’t Available)

Let me tell you about a Tuesday afternoon in March 2024. A client needed to pull invoice data from their ERP system. The ERP vendor’s “modern API” documentation pointed to a REST endpoint that hadn’t been updated since 2019 and returned HTTP 500 on every call. Their support team’s response: “Use the SOAP interface. That one works.”

So we used the SOAP interface.

Three weeks, two nervous breakdowns, and one deeply satisfying moment when the first valid response finally came back. The SOAP interface did work. It just required us to manually construct an XML envelope, authenticate via WS-Security UsernameToken with a PasswordDigest and a server-generated nonce, and then figure out how to turn the response — a deeply nested XML document with four different namespace prefixes — into something n8n could actually process downstream.

This is the reality of enterprise software in 2026. REST didn’t kill SOAP. It sidelined it. But SOAP is still running critical infrastructure at thousands of companies: ERP systems, payment processors, healthcare integrations (HL7), government data feeds, insurance backends. If your automation pipeline needs to talk to any of these systems, you’re going to hit a SOAP endpoint eventually.

n8n has no native SOAP node. There’s no drag-and-drop connector that handles the envelope construction, namespace juggling, and authentication quirks for you. You’re building this manually using the HTTP Request node with raw XML bodies. It works. It just requires understanding what’s happening under the hood.

This post covers the three things that will actually block you: constructing the SOAP envelope correctly, converting the XML response into usable JSON, and dealing with authentication methods that were designed before OAuth existed.

Why n8n Has No SOAP Node (And Why That’s Fine)

SOAP is a protocol built on top of HTTP. Under the hood, a SOAP call is just an HTTP POST request with an XML body and a specific Content-Type header. That’s it. The complexity isn’t in the transport layer — it’s in the payload structure and the authentication mechanisms baked into the XML itself.

n8n’s HTTP Request node handles HTTP perfectly. What it doesn’t do is help you construct valid XML or parse the XML that comes back. That’s your job. The HTTP Request node is the vehicle. You’re responsible for what goes inside it.

This actually gives you more control than a dedicated SOAP node would. SOAP services are not standardized in practice. Every vendor implements the spec slightly differently — different namespace prefixes, different authentication header structures, different error response formats. A generic SOAP node would have to make assumptions about all of these. The raw HTTP approach lets you match exactly what the specific service expects, down to the whitespace in your envelope.

Part 1: Constructing the SOAP Envelope

A SOAP envelope has a rigid structure. Get it wrong — even a single misplaced namespace declaration — and the service returns a fault. Most of them don’t tell you why it failed, which makes debugging feel like archaeology.

Here’s what a valid SOAP envelope looks like, stripped to its bones:

<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope 
    xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" 
    xmlns:tem="http://tempuri.org/">
    <soapenv:Header/>
    <soapenv:Body>
        <tem:GetInvoiceById>
            <tem:invoiceId>INV-2024-00847</tem:invoiceId>
        </tem:GetInvoiceById>
    </soapenv:Body>
</soapenv:Envelope>

Three components. The Envelope wraps everything — this is non-negotiable. The Header section carries metadata like authentication tokens (we’ll get to that). The Body contains your actual operation and its parameters.

The namespace declarations on the Envelope element are where things go sideways. xmlns:soapenv defines the SOAP protocol namespace. xmlns:tem (or whatever prefix the service uses) defines the target namespace — the namespace of the web service’s operations. You find this in the WSDL file.

Speaking of which: always start with the WSDL. It’s the service contract. The endpoint URL is typically the WSDL URL minus the ?WSDL suffix. Download it, read through it in a text editor. The <wsdl:types> section defines the XML schema for every request and response. The <wsdl:portType> section lists every operation the service exposes. The <wsdl:binding> section tells you what HTTP method to use and what the SOAPAction header value should be.

That SOAPAction header is one of the most commonly missed pieces. It’s an HTTP header — not part of the XML body — that tells the service which operation you’re invoking. Some services ignore it entirely. Others will reject your request if it’s missing or wrong. The WSDL specifies the value. It looks like this in your n8n HTTP Request node headers:

SOAPAction: "http://tempuri.org/GetInvoiceById"

Note the double quotes. They’re part of the value. Not wrapping the value — literally included in the string you send. This is a SOAP 1.1 oddity that trips up nearly everyone the first time.

Configuring n8n’s HTTP Request Node for SOAP

Here’s the exact configuration. No guessing required:

Method: POST. Always POST for SOAP calls.

URL: The service endpoint from the WSDL. If your WSDL lives at https://erp.vendor.com/invoicing/service?WSDL, your endpoint is https://erp.vendor.com/invoicing/service.

Headers — add these manually:

Content-Type: text/xml; charset=utf-8
SOAPAction: "http://tempuri.org/GetInvoiceById"

The Content-Type must be text/xml, not application/xml. Some services accept both. Some don’t. text/xml is the safe default for SOAP 1.1.

Body Content Type: Raw/Custom. This is where n8n trips people up. If you select “JSON” as the body type, n8n will try to serialize your input as JSON before sending it. That destroys your XML. Set it to Raw, then paste your XML envelope directly into the body field.

Response Format: Text. Do not set this to “Autodetect” or “JSON.” The response is XML. n8n can’t auto-parse XML into a useful structure. You’ll receive it as a raw string and handle the conversion yourself in the next node.

Here’s how the complete node configuration looks in practice, using n8n expressions to inject dynamic values:

Body (Raw):
<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tem="http://tempuri.org/">
    <soapenv:Header/>
    <soapenv:Body>
        <tem:GetInvoiceById>
            <tem:invoiceId>{{ $json.invoiceId }}</tem:invoiceId>
        </tem:GetInvoiceById>
    </soapenv:Body>
</soapenv:Envelope>

The {{ $json.invoiceId }} pulls the invoice ID from the previous node’s output. Everything else is static XML. Clean separation between the envelope structure (which never changes) and the dynamic payload (which changes every call).

The Namespace Gotcha That Kills Everyone

Here’s the scenario that wasted us two full days on the ERP integration. The WSDL defined multiple schemas:

<wsdl:types>
    <xs:schema targetNamespace="http://vendor.com/invoicing/v2">
        <!-- Invoice operations defined here -->
    </xs:schema>
    <xs:schema targetNamespace="http://vendor.com/shared/types">
        <!-- Common data types: Address, Currency, etc. -->
    </xs:schema>
</wsdl:types>

Two target namespaces. One for operations, one for shared types. Our envelope needed to declare both:

<soapenv:Envelope 
    xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:inv="http://vendor.com/invoicing/v2"
    xmlns:shared="http://vendor.com/shared/types">
    <soapenv:Header/>
    <soapenv:Body>
        <inv:CreateInvoice>
            <inv:invoiceNumber>INV-001</inv:invoiceNumber>
            <inv:billingAddress>
                <shared:street>742 Evergreen Terrace</shared:street>
                <shared:city>Springfield</shared:city>
                <shared:postalCode>62704</shared:postalCode>
            </inv:billingAddress>
        </inv:CreateInvoice>
    </soapenv:Body>
</soapenv:Envelope>

Notice: billingAddress uses the inv: prefix (it’s defined in the invoicing schema), but its child elements — street, city, postalCode — use shared: because they’re defined in the shared types schema. Wrong prefix on any of these and the service returns a generic validation error with zero indication of which element failed.

The only way to get this right is to trace through the WSDL schema definitions carefully. Each <xs:complexType> tells you which namespace its elements belong to. If the WSDL imports types from another schema (<xs:import namespace="...">), follow that import. The namespace prefixes themselves (inv, shared, tem, ns1, ns2) are arbitrary — you choose them. But they must map to the correct namespace URIs, and you must use them consistently on every element.

Part 2: Converting XML Responses to JSON

The SOAP response comes back as XML. Everything downstream in your n8n workflow expects JSON. You need to convert. This sounds trivial. It is not.

Here’s a typical SOAP response body:

<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
    <soap:Body>
        <GetInvoiceByIdResponse xmlns="http://tempuri.org/">
            <Invoice>
                <InvoiceId>INV-2024-00847</InvoiceId>
                <Status>Paid</Status>
                <Amount xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                        xmlns:xsd="http://www.w3.org/2001/XMLSchema">
                    <Value>4200.00</Value>
                    <Currency>EUR</Currency>
                </Amount>
                <LineItems>
                    <LineItem>
                        <Description>Professional Services</Description>
                        <Quantity>40</Quantity>
                        <UnitPrice>85.00</UnitPrice>
                    </LineItem>
                    <LineItem>
                        <Description>Software License</Description>
                        <Quantity>1</Quantity>
                        <UnitPrice>800.00</UnitPrice>
                    </LineItem>
                </LineItems>
            </Invoice>
        </GetInvoiceByIdResponse>
    </soap:Body>
</soap:Envelope>

The n8n XML Node: What It Does and Doesn’t Do

n8n has a built-in XML node (labeled “XML” in the node library, categorized under “Helpers”). It converts XML strings to JSON objects. Connect it after your HTTP Request node, point it at the response body, and it produces a JSON representation of the XML structure.

Simple case, right? Except there are three problems that will bite you.

Problem 1: Namespace prefixes pollute your JSON keys.

When the XML node converts the response above, the output looks like this:

{
  "soap:Envelope": {
    "soap:Body": {
      "GetInvoiceByIdResponse": {
        "Invoice": {
          "InvoiceId": "INV-2024-00847",
          ...
        }
      }
    }
  }
}

The soap:Envelope and soap:Body wrappers are useless to you. They’re SOAP protocol overhead. Every downstream node that reads this data has to navigate through them every single time. You want the actual payload — Invoice — at the root of your JSON object.

Problem 2: Single elements vs. arrays.

Look at the LineItems section. It contains two LineItem elements. The XML node correctly converts this to a JSON array:

"LineItems": {
  "LineItem": [
    { "Description": "Professional Services", ... },
    { "Description": "Software License", ... }
  ]
}

Now imagine an invoice with only one line item. The XML node converts a single LineItem to a plain object, not an array:

"LineItems": {
  "LineItem": {
    "Description": "Professional Services",
    ...
  }
}

Your downstream code breaks. It expects an array. It gets an object. This is the single most common XML-to-JSON conversion bug in production systems. It’s caused by the fundamental structural difference between XML and JSON: XML allows repeated elements with the same tag name to implicitly represent a collection. JSON has no equivalent — you need explicit arrays.

Problem 3: Numeric strings stay as strings.

The Value element contains 4200.00. After conversion:

"Value": "4200.00"

It’s a string. Not a number. If you’re inserting this into a database or comparing it against a threshold, you need to explicitly parse it. The XML node doesn’t infer types.

The Code Node Solution

The cleanest way to handle all three problems is a Code node immediately after the HTTP Request node. Strip the SOAP envelope, enforce array types on known collection elements, and coerce numeric fields. All in one place:

// Input: raw XML string from HTTP Request node (response format: Text)
const xml = $input.first().json.data;  // Adjust path based on your HTTP node output

// Use n8n's built-in XML parser via the helper
// First, let's strip namespaces and parse manually for full control
function stripNamespaces(xmlString) {
  // Remove namespace prefixes like soap:, ns1:, tns:, xsi:, xsd:
  // but preserve the actual element names
  return xmlString
    .replace(/xmlns[^"]*"[^"]*"/g, '')     // Remove xmlns declarations
    .replace(/<\/?[a-zA-Z0-9]+:/g, (match) => {
      // Replace <soap: with <, </soap: with </
      return match.includes('/') ? '</' : '<';
    });
}

function xmlToJson(xmlString) {
  // After stripping namespaces, use a simple recursive parser
  // n8n's Code node runs in Node.js — we can use the xml2js-style approach
  
  const stripped = stripNamespaces(xmlString);
  
  // Parse using DOMParser (available in n8n's Code node environment)
  const parser = new DOMParser();
  const doc = parser.parseFromString(stripped, 'text/xml');
  
  function nodeToJson(node) {
    const result = {};
    
    for (const child of node.children) {
      const value = child.children.length === 0 
        ? child.textContent.trim()   // Leaf node — extract text
        : nodeToJson(child);          // Branch node — recurse
      
      if (result[child.tagName] !== undefined) {
        // Duplicate key — convert to array
        if (!Array.isArray(result[child.tagName])) {
          result[child.tagName] = [result[child.tagName]];
        }
        result[child.tagName].push(value);
      } else {
        result[child.tagName] = value;
      }
    }
    
    return result;
  }
  
  // Start from the Body element, skipping Envelope and Header
  const body = doc.querySelector('Body');
  return body ? nodeToJson(body) : nodeToJson(doc.documentElement);
}

// Parse the XML
let invoice = xmlToJson(xml);

// Navigate to the actual payload (skip the operation response wrapper)
// Structure: Body > GetInvoiceByIdResponse > Invoice
const responseKey = Object.keys(invoice)[0]; // "GetInvoiceByIdResponse"
invoice = invoice[responseKey].Invoice;

// ENFORCE ARRAY TYPES on known collection fields
// This is the critical step — do this for every field that CAN contain multiple elements
const arrayFields = ['LineItem', 'TaxEntry', 'PaymentTerm'];
function enforceArrays(obj, fields) {
  if (!obj || typeof obj !== 'object') return obj;
  
  for (const key of Object.keys(obj)) {
    if (fields.includes(key) && !Array.isArray(obj[key])) {
      obj[key] = [obj[key]];  // Wrap single item in array
    }
    if (typeof obj[key] === 'object') {
      enforceArrays(obj[key], fields);  // Recurse into nested objects
    }
  }
  return obj;
}

invoice = enforceArrays(invoice, arrayFields);

// COERCE NUMERIC FIELDS
const numericFields = ['Value', 'Quantity', 'UnitPrice', 'TaxRate'];
function coerceNumbers(obj, fields) {
  if (!obj || typeof obj !== 'object') return obj;
  
  for (const key of Object.keys(obj)) {
    if (fields.includes(key) && typeof obj[key] === 'string') {
      const num = parseFloat(obj[key]);
      if (!isNaN(num)) obj[key] = num;
    }
    if (typeof obj[key] === 'object') {
      coerceNumbers(obj[key], fields);
    }
  }
  return obj;
}

invoice = coerceNumbers(invoice, numericFields);

// Output — clean JSON, no namespace pollution, correct types
return invoice;

The output after this Code node:

{
  "InvoiceId": "INV-2024-00847",
  "Status": "Paid",
  "Amount": {
    "Value": 4200.00,
    "Currency": "EUR"
  },
  "LineItems": {
    "LineItem": [
      {
        "Description": "Professional Services",
        "Quantity": 40,
        "UnitPrice": 85.00
      },
      {
        "Description": "Software License",
        "Quantity": 1,
        "UnitPrice": 800.00
      }
    ]
  }
}

Clean. Typed. Array-safe. Ready for any downstream node without special handling.

The arrayFields and numericFields arrays are your configuration layer. When you integrate a new SOAP service, you update these lists based on the WSDL schema. Every <xs:maxOccurs="unbounded"> in the schema means that element needs to be in arrayFields. Every <xs:decimal> or <xs:integer> type means numeric coercion.

SOAP Faults: Don’t Forget the Error Path

SOAP services don’t return HTTP 500 when something goes wrong. They return HTTP 200 with a Fault element in the body:

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
    <soap:Body>
        <soap:Fault>
            <faultcode>soap:Client</faultcode>
            <faultstring>Invoice not found: INV-2024-00847</faultstring>
            <detail>
                <ErrorCode>INVOICE_NOT_FOUND</ErrorCode>
            </detail>
        </soap:Fault>
    </soap:Body>
</soap:Envelope>

HTTP status 200. Your n8n workflow sees “success.” It passes the XML to your Code node. Your parser tries to find GetInvoiceByIdResponse and gets nothing. Silent failure.

You must check for <soap:Fault> before processing the response. Add this at the top of your Code node:

if (xml.includes('<soap:Fault>') || xml.includes('<Fault>')) {
  // Extract fault details
  const faultString = xml.match(/<faultstring>(.*?)<\/faultstring>/)?.[1];
  const faultCode = xml.match(/<faultcode>(.*?)<\/faultcode>/)?.[1];
  
  throw new Error(`SOAP Fault [${faultCode}]: ${faultString}`);
}

This surfaces the error properly in n8n’s execution log instead of letting it disappear into a downstream processing failure three nodes later.

Part 3: Dealing with Antiquated Authentication

Modern APIs authenticate via OAuth 2.0, API keys in headers, or Bearer tokens. Clean. Stateless. Well-documented.

SOAP services from 2005 were built before any of that existed. Their authentication methods are baked into the XML envelope itself, not the HTTP headers. This means n8n’s built-in authentication options (Basic Auth, Bearer Token, API Key) are often useless. You have to construct the authentication payload manually and embed it in your SOAP Header.

There are three authentication patterns you’ll encounter. Listed in order of how often we see them in production, not in order of how much they make sense.

Pattern 1: WS-Security UsernameToken (The Most Common)

WS-Security is a specification that defines how to embed security tokens inside SOAP headers. The UsernameToken profile is the simplest variant: you include a username and password directly in the XML.

Basic version — some services accept this:

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" 
                  xmlns:tem="http://tempuri.org/">
    <soapenv:Header>
        <wsse:Security soapenv:mustUnderstand="1" 
            xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
            <wsse:UsernameToken>
                <wsse:Username>api_user_847</wsse:Username>
                <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">
                    s3cur3P@ssw0rd
                </wsse:Password>
            </wsse:UsernameToken>
        </wsse:Security>
    </soapenv:Header>
    <soapenv:Body>
        <!-- Your operation here -->
    </soapenv:Body>
</soapenv:Envelope>

The mustUnderstand="1" attribute on the Security element tells the server: “If you can’t process this security header, reject the message.” Without it, some servers silently ignore the authentication and process the request as unauthenticated — which either fails with a permission error or, worse, returns data you shouldn’t have access to.

The Password Type attribute specifies how the password is encoded. #PasswordText means plaintext (obviously only safe over TLS). The other option is #PasswordDigest:

<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">
    BASE64(SHA1(Nonce + Created + Password))
</wsse:Password>
<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd#Base64Binary">
    RANDOM_BASE64_STRING
</wsse:Nonce>
<wsu:Created xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
    2026-02-01T14:30:00Z
</wsu:Created>

PasswordDigest is significantly more secure. The password never travels over the wire. Instead, you hash it together with a nonce (random value) and a timestamp. The server has your password on file, generates the same hash using the nonce and timestamp you sent, and compares. If they match, you’re authenticated.

Here’s how to generate PasswordDigest in an n8n Code node:

const crypto = require('crypto');

const password = 'your_password_here';  // From n8n credentials or environment variable
const nonce = crypto.randomBytes(16).toString('base64');
const created = new Date().toISOString().replace(/(\.\d{3})\d*Z/, '$1Z'); // ISO 8601 with milliseconds

// PasswordDigest = Base64(SHA-1(Nonce + Created + Password))
// CRITICAL: Nonce must be decoded from Base64 before concatenation
const nonceBuffer = Buffer.from(nonce, 'base64');
const createdBuffer = Buffer.from(created, 'utf8');
const passwordBuffer = Buffer.from(password, 'utf8');

const combined = Buffer.concat([nonceBuffer, createdBuffer, passwordBuffer]);
const digest = crypto.createHash('sha1').update(combined).digest('base64');

// Build the complete envelope with PasswordDigest authentication
const envelope = `<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tem="http://tempuri.org/">
    <soapenv:Header>
        <wsse:Security soapenv:mustUnderstand="1" 
            xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
            xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
            <wsse:UsernameToken wsu:Id="UsernameToken-${Date.now()}">
                <wsse:Username>api_user_847</wsse:Username>
                <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">${digest}</wsse:Password>
                <wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd#Base64Binary">${nonce}</wsse:Nonce>
                <wsu:Created>${created}</wsu:Created>
            </wsse:UsernameToken>
        </wsse:Security>
    </soapenv:Header>
    <soapenv:Body>
        <tem:GetInvoiceById>
            <tem:invoiceId>${$json.invoiceId}</tem:invoiceId>
        </tem:GetInvoiceById>
    </soapenv:Body>
</soapenv:Envelope>`;

// Pass the envelope to the next node (HTTP Request)
return [{ json: { envelope: envelope } }];

The nonce must be regenerated for every single request. If you reuse a nonce, the server will reject it as a replay attack. The Created timestamp must be within the server’s acceptable time window — usually ±5 minutes. If your n8n instance’s system clock drifts, PasswordDigest authentication will start failing mysteriously. We’ve seen this happen. Check your NTP configuration.

Pattern 2: HTTP Basic Auth Layered on Top of SOAP

Some services use HTTP Basic Auth for transport-level security and skip WS-Security entirely. This is the easiest pattern to handle in n8n because the HTTP Request node has built-in Basic Auth support.

But here’s the catch: some services require both. HTTP Basic Auth on the transport layer and a WS-Security header in the SOAP envelope. The HTTP Basic Auth authenticates the connection. The WS-Security header authenticates the specific operation.

When you see this combination in a WSDL, it usually means the service was secured by two different teams at different times and nobody consolidated the authentication strategy. Welcome to enterprise software.

In n8n: enable Basic Auth on the HTTP Request node (username and password in the node’s auth settings) and include the WS-Security header in your XML envelope. Both are required. Missing either one returns a 401 or a SOAP Fault.

Pattern 3: Pre-Authentication Token Exchange

The most painful pattern. The service doesn’t accept credentials directly in your operation calls. Instead, you first call a separate authentication endpoint that returns a session token or a ticket. You then include that token in subsequent SOAP calls.

The flow:

Step 1: POST to /auth/login with credentials → receive SessionToken
Step 2: POST to /invoicing/service with SessionToken in SOAP Header → receive data

Both steps are SOAP calls. Step 1 produces a token. Step 2 consumes it.

In n8n, this means two HTTP Request nodes in sequence. The first one calls the login endpoint, extracts the token from the XML response using a Code node, and passes it forward. The second node constructs the operation envelope with the token embedded in the header.

The token inclusion varies by service. Some want it as a custom SOAP header element:

<soapenv:Header>
    <auth:SessionToken xmlns:auth="http://vendor.com/auth/v1">
        eyJhbGciOiJIUzI1NiIs...
    </auth:SessionToken>
</soapenv:Header>

Others want it as a standard WS-Security BinarySecurityToken:

<soapenv:Header>
    <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
        <wsse:BinarySecurityToken 
            ValueType="http://vendor.com/auth/SessionToken"
            EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd#Base64Binary">
            eyJhbGciOiJIUzI1NiIs...
        </wsse:BinarySecurityToken>
    </wsse:Security>
</soapenv:Header>

Check the WSDL’s security policy section. If it references <sp:SupportingTokens> or <sp:SignedSupportingTokens>, the service expects a token in the Security header. The ValueType attribute tells you what kind of token.

Token expiration is another gotcha. Most session tokens expire after 15-60 minutes. If your n8n workflow runs on a schedule (every hour, say), the token might be stale by the time the second call fires. Build a Code node that checks token age before each operation call and re-authenticates if the token is older than 80% of its expected lifetime. Don’t wait for the service to reject a stale token — that adds a full round-trip of latency and error handling to every failed call.

Putting It All Together: The Full n8n Workflow

Here’s the node sequence for a complete SOAP integration:

[Trigger] → [Code: Build Auth + Envelope] → [HTTP Request: SOAP Call] → [Code: Parse XML → JSON] → [Code: Enforce Arrays + Coerce Types] → [downstream nodes]

Five nodes. The middle three are where the actual work happens. The Trigger can be a schedule, a webhook, or another workflow’s output. The downstream nodes do whatever you need: insert into a database, push to another API, send a notification.

We consolidated the two Code nodes (parse + enforce arrays) into one in our production setup. Fewer nodes means fewer failure points and easier debugging when something goes wrong. The combined node handles namespace stripping, envelope navigation, fault detection, array enforcement, and type coercion in a single pass.

The Mistakes We Made So You Don’t Have To

Mistake 1: Using n8n’s autodetect response format. n8n saw text/xml in the response Content-Type and tried to be clever. It returned a partially parsed object with namespace prefixes as keys and lost half the nested structure. Setting response format to Text and handling parsing ourselves fixed it immediately.

Mistake 2: Not checking for SOAP Faults. Our first integration silently failed for three days because a SOAP Fault (invoice ID format change on the vendor’s side) came back as HTTP 200. We were inserting empty records into our database. The fault string contained the exact error. We just weren’t looking at it.

Mistake 3: Reusing the nonce in PasswordDigest auth. We cached the nonce for performance. The service rejected every call after the first one. Took us four hours to figure out that nonces must be unique per request. The WS-Security spec says this explicitly. We hadn’t read it carefully enough.

Mistake 4: Hardcoding namespace prefixes. We used soap: for the envelope namespace. The service expected soapenv:. Both are valid prefixes for the same namespace URI. The service was broken — it was pattern-matching on the prefix string instead of the namespace URI. We changed our prefix. It worked. This is technically a bug in the service, not in our code. But in the real world, you work around broken services, you don’t fix them.

Mistake 5: Ignoring the SOAPAction header. First call returned a 405 Method Not Allowed. Not because we used the wrong HTTP method — we used POST, which was correct. The service was rejecting requests without a valid SOAPAction header and returning a misleading error code. Adding the header from the WSDL fixed it.

When to Wrap SOAP in a Proxy vs. Calling It Directly

If you’re calling one SOAP service occasionally — monthly batch, manual trigger, low-volume sync — call it directly from n8n using the pattern above. It works. It’s maintainable. It’s transparent.

If you’re calling SOAP services frequently (hourly or more), from multiple workflows, or if the authentication overhead is significant (token exchange on every call), consider building a thin REST proxy. A small Node.js or Python service that accepts REST requests, translates them to SOAP calls, handles authentication and token caching, and returns JSON responses.

The proxy absorbs the ugly parts: namespace handling, envelope construction, auth token lifecycle. Your n8n workflows call clean REST endpoints. The proxy is one service to maintain, one place to update when the SOAP service changes its behavior.

We built this for the ERP integration mentioned at the start. The proxy handles authentication, caches session tokens, constructs envelopes, and returns clean JSON. Our n8n workflows don’t know SOAP exists. They call POST /api/invoices/:id and get back a JSON invoice object.

The proxy added two days of development time. It saved us from rebuilding the SOAP integration logic every time we added a new operation. We’ve since added six more operations to the same ERP service. Each one took four hours instead of four days.

The Reality Check

SOAP isn’t coming back. No one is building new SOAP services in 2026. But the existing ones aren’t going anywhere either. They’re running payroll systems, processing insurance claims, feeding government reporting platforms. They’ll be running for another decade, minimum.

Your automation stack will eventually need to talk to one of them. When it does, you won’t find a polished n8n node that handles everything. You’ll have the HTTP Request node, a Code node, and a WSDL file.

Read the WSDL carefully. Construct the envelope precisely. Strip the namespaces on the way back. Handle the auth quirks without complaint.

It’s unglamorous work. But it connects your modern automation stack to systems that power real business operations. That connection is worth the effort.


Post curated and edited by the Triumphoid team

Elizabeth Sramek

Elizabeth Sramek is an independent advisor on search visibility and demand architecture for B2B companies operating in high-competition markets. Based in Prague and working globally, she specializes in designing search presence for AI-mediated discovery and building category visibility that survives algorithmic shifts.

Share
Published by
Elizabeth Sramek

Recent Posts

OCR Automation: Extracting Text from Images in Gmail Attachments

Most OCR automations fail because they OCR everything. Logos, signatures, random screenshots, someone’s cat. The…

3 days ago

Removing Emojis and Special Characters in Python: Cleaning Dirty Data

We pulled 84,000 contact records from a client's CRM last month to feed into their…

4 days ago

Triumphoid is Flying to San Francisco — Meet Us at Workflow 2026

The Triumphoid team is heading to Workflow 2026 on March 5, 2026 in San Francisco.…

6 days ago

Pausing Workflows via Slack Buttons: The “Manager Approval” Loop

Most automation workflows are fire-and-forget. An event happens, a sequence of steps executes, data moves…

1 week ago

Elizabeth Sramek from Triumphoid is Heading to Madrid — WordCamp Madrid 2026

Elizabeth Sramek from our team will be at WordCamp Madrid on March 6-7, 2026. Two…

1 week ago

30 B2B Marketing Automation Platforms In 2026: The Technical Breakdown No One’s Publishing

I just spent six weeks reverse-engineering the API architectures of thirty B2B marketing automation platforms.…

2 weeks ago