Calling SOAP Services from .NET: dotnet-svcutil, the WCF Client, WS-Security, Certificates and Resilience
You call a SOAP service from .NET through the interface dotnet-svcutil generates from the WSDL, your own binding and a ChannelFactory; resilience belongs at the operation level, not the HTTP level, because SOAP faults arrive with HTTP 500 and WCF calls take no CancellationToken. In Bookstore we send institutional invoices to a fake e-invoice integrator with a UsernameToken and mTLS, and make retries safe with the invoice's ETTN.
Version note: The code in this post was built and run with .NET 10, System.ServiceModel.Http 10.1, dotnet-svcutil 8.0, CoreWCF 1.9 and Polly 8.9.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, sends the invoices of institutional orders to an e-invoice integrator, and the only interface the integrator offers is a SOAP service. Three questions:
First: when the integrator rejects an invoice with a wrong tax number, what is the HTTP status code of the response? What does the standard resilience handler from post 03 do with it?
Second: you add a message inspector to the WCF client and log the outgoing envelope. Does the UsernameToken password show up in the log?
Third: you built the retry around catch (CommunicationException). How many requests go to a server whose certificate you don't trust?
The answers: 500, and the standard handler treats it as a transient failure and retries; the password doesn't show up, because the security header is added after the inspector runs; and three requests, because almost every WCF exception derives from CommunicationException. We see all three below with real output.
Generating a Proxy from WSDL: dotnet-svcutil
The contract of a SOAP service lives in its WSDL document. Just as we generated a client from OpenAPI in post 01, here we generate the client from WSDL. In Visual Studio the UI for this is the WCF Web Service Reference under "Connected Services"; on the command line it's dotnet-svcutil. Instead of a real integrator, the repo has the Bookstore.FakeEInvoice project: a small SOAP service written with CoreWCF that publishes a WSDL.
We install the tool into the repo's tool manifest rather than globally, so everyone uses the same version. We start the fake in one terminal and run the tool in a second one, from the solution root:
dotnet tool install dotnet-svcutil --version 8.0.0
dotnet run --project src/Bookstore.FakeEInvoice
dotnet dotnet-svcutil http://127.0.0.1:52410/EInvoiceService.svc?wsdl --outputDir src/Bookstore.Api/Invoicing/Integrator --namespace "*,Bookstore.Invoicing.Integrator" --outputFile EInvoiceIntegrator.cs --noLogo
Attempting to download metadata from 'http://127.0.0.1:52410/EInvoiceService.svc?wsdl' using WS-Metadata Exchange and HttpGet.
Generating files...
The first attempt had three surprises. The dotnet-svcutil 8.0.0 package only contains net8.0 and net9.0 builds, so on a machine with only .NET 10 installed it fails with "You must install or update .NET". Setting "rollForward": true for this tool in the manifest made it run on .NET 10. Second, when I ran the command inside the project folder, the tool took the project as a reference, tried to build a temporary bootstrapper project targeting net8.0, and stopped with CS0234 because of ASP.NET Core's OpenAPI source generator. Run from the solution root, that step never happened. Third, with --projectFile it added a line to the csproj on its own:
<PackageReference Include="System.ServiceModel.NetTcp" Version="8.*" />
Bookstore uses Central Package Management; versions live in Directory.Packages.props. With this line the build failed with NU1008. The fix is simple: don't let the tool touch the csproj, and add the packages (System.ServiceModel.Http, System.ServiceModel.Primitives) yourself.
The most useful part of the generated file is the interface:
public interface IEInvoiceService
{
System.Threading.Tasks.Task<Bookstore.Invoicing.Integrator.InvoiceReceipt> SendInvoiceAsync(Bookstore.Invoicing.Integrator.InvoiceDocument request);
System.Threading.Tasks.Task<Bookstore.Invoicing.Integrator.InvoiceStatusInfo> GetInvoiceStatusAsync(string uuid);
}
Notice that the methods take no CancellationToken. We'll see what that means when we build resilience below. The folder also has a dotnet-svcutil.params.json; when the WSDL changes, dotnet dotnet-svcutil --update Invoicing/Integrator --projectFile ... reads it and regenerates the code (it added the csproj line again, and I reverted it). We keep the generated code in the repo, like the Kiota client in post 01, so that a WSDL change shows up as a diff in the pull request.
Leaving Generated Code Alone: Port, Adapter and Your Own Binding
The generated client's GetBindingForEndpoint method builds a binding for each endpoint in the WSDL:
System.ServiceModel.BasicHttpBinding result = new System.ServiceModel.BasicHttpBinding();
result.MaxBufferSize = int.MaxValue;
result.ReaderQuotas = System.Xml.XmlDictionaryReaderQuotas.Max;
result.MaxReceivedMessageSize = int.MaxValue;
result.AllowCookies = true;
return result;
The endpoint address is baked into the code too (http://127.0.0.1:52410/...), and the message size limits are wide open. It's tempting to fix this file by hand, but the next regeneration throws all the fixes away. Instead we don't use the generated client class at all; we take only the interface and the data contracts and open the channel ourselves. The anti-corruption layer from the Context Mapping post of the Domain-Driven Design series and from post 01 of this series applies here too: first Bookstore's own language.
namespace Bookstore.Invoicing;
// Port: Bookstore's own language for issuing an e-invoice.
public interface IInvoiceIntegrator
{
Task<InvoiceSubmission> SubmitAsync(Invoice invoice, CancellationToken cancellationToken);
}
The binding and the endpoint now come from the Invoicing:EInvoice section in appsettings:
public static Binding CreateBinding(EInvoiceIntegratorOptions settings)
{
var binding = settings.Security switch
{
EInvoiceSecurity.None => new BasicHttpBinding(BasicHttpSecurityMode.None),
EInvoiceSecurity.UserName => new BasicHttpBinding(BasicHttpSecurityMode.TransportWithMessageCredential)
{
Security = { Message = { ClientCredentialType = BasicHttpMessageCredentialType.UserName } }
},
EInvoiceSecurity.Certificate => new BasicHttpBinding(BasicHttpSecurityMode.Transport)
{
Security = { Transport = { ClientCredentialType = HttpClientCredentialType.Certificate } }
},
_ => throw new ArgumentOutOfRangeException(nameof(settings))
};
binding.OpenTimeout = TimeSpan.FromSeconds(5);
binding.SendTimeout = settings.SendTimeout;
// The generated code allows int.MaxValue; 1 MB is plenty for a receipt.
binding.MaxReceivedMessageSize = 1024 * 1024;
return binding;
}
A ChannelFactory<IEInvoiceServiceChannel> is created once per application with this binding (EInvoiceChannelFactory, a singleton). Building the factory is expensive, opening channels from it is cheap; every call uses a new channel.
Real integrators usually want the invoice as a UBL-TR document, the Turkish Revenue Administration's invoice XML format based on the UBL standard, often compressed and base64-encoded. Our fake integrator skips that detail and carries the fields directly in the data contract. But it keeps one rule exactly: every invoice has an ETTN, a UUID chosen by the sender that identifies the invoice, and the same ETTN arriving twice is rejected. That rule pays off in the resilience section.
Authentication: UsernameToken, Client Certificates and What Isn't Supported
With integrators you'll most often see two methods: a WS-Security UsernameToken over HTTPS, where the user name and password travel in the SOAP header, or mutual TLS with a client certificate. Bookstore uses the first by default:
case EInvoiceSecurity.UserName:
_factory.Credentials.UserName.UserName = settings.UserName;
_factory.Credentials.UserName.Password = settings.Password;
break;
case EInvoiceSecurity.Certificate:
_factory.Credentials.ClientCertificate.Certificate =
X509CertificateLoader.LoadPkcs12FromFile(settings.ClientCertificatePath!, password: null);
break;
If you start the fake integrator with --show-wire, it prints the header of the incoming request as it arrived (we masked the password):
<s:Header><o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"><u:Timestamp u:Id="_0"><u:Created>2026-11-15T08:20:52.308Z</u:Created><u:Expires>2026-11-15T08:25:52.308Z</u:Expires></u:Timestamp><o:UsernameToken u:Id="uuid-f1925873-5960-49db-94fd-120ffb430fd4-1"><o:Username>bookstore-test</o:Username><o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">***</o:Password></o:UsernameToken></o:Security></s:Header>
PasswordText: the password travels as plain text, and the only thing protecting it is TLS. That's why TransportWithMessageCredential doesn't work without HTTPS. The password itself belongs in a secret store in production, not in appsettings.json; we covered that in the Configuration and Secret Management series. With a wrong password the response was a MessageSecurityException with the integrator's fault message inside: "Unknown user name or bad password."
On the client certificate side, choosing Certificate mode and the fake's /mtls endpoint got the same invoice accepted with envelope number ENV-4001. Loading certificates and validating chains in detail is in the X.509 and PKI post of the Cryptography for Developers series.
The WCF client on .NET is not all of WCF from .NET Framework. I tried which bindings it accepts with samples/Bookstore.SoapLab; opening a channel is enough, nothing goes over the network:
WSHttpBinding(Transport): channel created
WSHttpBinding(TransportWithMessageCredential): channel created
WSHttpBinding(Message): PlatformNotSupportedException: Operation is not supported on this platform.
BasicHttpBinding(Message): PlatformNotSupportedException: The value 'Message' is not supported in this context for the binding security property 'securityMode'.
So message security mode, which signs and encrypts the message itself, isn't available; you get TLS at the transport level and a UsernameToken on top. If the integrator's WSDL requires message security, that's something to discuss when the contract is agreed; code won't solve it.
Logging Messages and Translating Faults into Your Own Error Model
When something goes wrong with the integrator, the first thing support asks for is the outgoing and incoming envelope. In WCF that belongs in an IClientMessageInspector. Tax numbers are personal data, so we mask them:
public object? BeforeSendRequest(ref Message request, IClientChannel channel)
{
// A Message can be read once: log a buffered copy and send a fresh one.
var buffer = request.CreateBufferedCopy(int.MaxValue);
request = buffer.CreateMessage();
logger.LogInformation("SOAP request {Action}: {Envelope}", request.Headers.Action, Mask(Write(buffer)));
return null;
}
On the first try the log showed ... stream ... instead of the body: Message.ToString() doesn't print a body that is read as a stream. Writing the envelope to an XmlWriter with WriteMessage showed all of it:
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Header>
<Action s:mustUnderstand="1" xmlns="http://schemas.microsoft.com/ws/2005/05/addressing/none">http://einvoice.example.com/integrator/v1/IEInvoiceService/SendInvoice</Action>
</s:Header>
<s:Body>
<SendInvoice xmlns="http://einvoice.example.com/integrator/v1">
<request xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Uuid>ef3d1794-8a47-4be3-9f44-81fcbc2ec9ed</Uuid>
<InvoiceNumber>BKS2026000000001</InvoiceNumber>
<IssueDate>2026-11-15T00:00:00Z</IssueDate>
<SenderTaxNumber>***</SenderTaxNumber>
<ReceiverTaxNumber>***</ReceiverTaxNumber>
<ReceiverName>School 0042</ReceiverName>
<PayableAmount>4131.00</PayableAmount>
Here is the answer to the second question: this request went out over HTTPS with a UsernameToken, but the log has no o:Security header. The inspector runs at the top of the channel stack; the security header is added by the security layer further down. So the password doesn't land in the inspector's log, but it's better to guarantee that with the mask than to rely on the settings: the binding may change one day.
The integrator's answer for the institutional order with a wrong tax number (1004):
<s:Fault>
<faultcode>s:INVALID_TAX_NUMBER</faultcode>
<faultstring>Receiver tax number must be 10 or 11 digits.</faultstring>
<detail>
<IntegratorFault xmlns="http://einvoice.example.com/integrator/v1" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
<Code>INVALID_TAX_NUMBER</Code>
<Message>Receiver tax number must be 10 or 11 digits.</Message>
</IntegratorFault>
</detail>
</s:Fault>
This envelope arrives with HTTP 500; in SOAP 1.1 the status code of a fault is always 500. That's the answer to the first question: a retry that looks at the HTTP status mistakes a business rejection for a server failure. The adapter turns the fault into an InvoiceIntegratorException, and the API returns 422:
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"The e-invoice was rejected by the integrator.","status":422,"integratorCode":"INVALID_TAX_NUMBER","traceId":"00-5f6cc1b947e8adb0edd23b3049f2ea27-19f8a898bd22fb0c-02"}
The integrator's code (integratorCode) stays in the response for support; the SOAP details and the stack trace stay in our logs only.
Channel Lifetime: Close, Abort and Cancellation
The oldest WCF trap is closing a channel with using: on a faulted channel Close throws and hides the original error. The rule: Close on success, Abort on failure, and never reuse a faulted channel.
private async ValueTask<T> CallAsync<T>(Func<IEInvoiceServiceChannel, Task<T>> operation, CancellationToken token)
{
var channel = channels.CreateChannel();
// WCF operations take no CancellationToken: aborting the channel is the only way to stop a call.
using var registration = token.Register(channel.Abort);
try
{
var result = await operation(channel);
await Task.Factory.FromAsync(channel.BeginClose, channel.EndClose, null);
return result;
}
catch (CommunicationException) when (token.IsCancellationRequested)
{
channel.Abort();
throw new OperationCanceledException(token);
}
catch
{
// A faulted channel cannot be closed or reused, only aborted.
channel.Abort();
throw;
}
}
The token.Register(channel.Abort) line matters. In post 04 we saw that Polly's timeout is optimistic and can't stop code that ignores the token. Since the WCF call takes no token, Polly's timeout alone stops nothing; aborting the channel when the token is cancelled really closes the HTTP request in flight. The duration of a single call is set by the binding's SendTimeout.
I tried this with the fake integrator in --mode Slow (answer after 5 seconds) and the API started with a 6-second SendTimeout. When curl --max-time 1 gave up on the request after 1 second, the HttpContext.RequestAborted token was cancelled, the channel was aborted, and the call ended without waiting for the 5-second answer:
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'A task was canceled.', Handled: 'False', Attempt: '0', Execution Time: 922.5451ms
Resilience pipeline executed. Source: 'einvoice/(null)', Operation Key: '', Result: 'A task was canceled.', Execution Time: 937.2477ms
Resilience: Retrying at the Operation Level
The WCF client manages its own HTTP layer; it doesn't plug into IHttpClientFactory or AddResilienceHandler. Even if it did, we wouldn't want that: a retry at the HTTP level would resend every fault that comes back with 500. We build the pipeline with AddResiliencePipeline, as in post 04, and put it around the operation:
builder.Services.AddResiliencePipeline(SoapInvoiceIntegrator.PipelineName, pipeline => pipeline
.AddTimeout(TimeSpan.FromSeconds(30))
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<Exception>(SoapInvoiceIntegrator.IsTransient),
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(500)
}));
The order inside the predicate that decides what is transient matters. FaultException, MessageSecurityException, SecurityNegotiationException, ServerTooBusyException: all of them derive from CommunicationException. In my first version the last line was at the top, and three requests went to the server whose certificate I didn't trust; the last attempt in the log ended with Attempt: '2' and an UntrustedRoot error. That was the answer to the third question. The corrected version:
// Retry only what can succeed a moment later. FaultException derives from CommunicationException, so order matters.
public static bool IsTransient(Exception exception) => exception switch
{
FaultException => false,
// Wrong password, untrusted or expired certificate: the next attempt fails the same way.
MessageSecurityException or SecurityAccessDeniedException or SecurityNegotiationException => false,
ServerTooBusyException => false,
TimeoutException or CommunicationException => true,
_ => false
};
ServerTooBusyException is what WCF throws for a 503. With the fake integrator started with --mode Maintenance (503 plus Retry-After: 600) one request went out and the API returned 503; there's no point in going back to a ten-minute maintenance window within seconds.
So is sending an invoice idempotent, and is a retry safe? The counterpart of the Idempotency-Key from post 06 is the ETTN. InvoiceStore fixes the UUID and the invoice number before the first attempt; every retry, even the user calling the API again, sends the same UUID. When the integrator sees the same UUID a second time it returns a DUPLICATE_UUID fault; the adapter doesn't treat that as an error, it asks for the status. I started the fake with --mode Slow: it stores the invoice at once but answers after 5 seconds. SendTimeout is 2 seconds:
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'The request channel timed out attempting to send after 00:00:01.9965394. Increase the timeout value passed to the call to Request or increase the SendTimeout value on the Binding. The time allotted to this operation may have been a portion of a longer timeout.', Handled: 'True', Attempt: '0', Execution Time: 2066.674ms
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'Invoice 1618f4e0-a58c-4824-95c1-97f1b7082c18 was already received.', Handled: 'False', Attempt: '1', Execution Time: 41.1307ms
Execution attempt. Source: 'einvoice/(null)/Retry', Operation Key: '', Result: 'Bookstore.Invoicing.Integrator.InvoiceStatusInfo', Handled: 'False', Attempt: '0', Execution Time: 8.4471ms
Invoice 1618f4e0-a58c-4824-95c1-97f1b7082c18 was already received as ENV-4001
The API's response and the time curl measured:
{"orderId":1003,"uuid":"1618f4e0-a58c-4824-95c1-97f1b7082c18","number":"BKS2026000000001","envelopeId":"ENV-4001","status":"QUEUED"}
2.847307s
The first attempt timed out, but the invoice had reached the integrator. The second attempt got DUPLICATE_UUID (Handled: 'False', so it wasn't retried), the status query returned the envelope number, and the request finished in 2.8 seconds. The integrator has a single invoice. A real integrator will name and code its "same UUID" answer differently; find it in its documentation and handle it in the adapter. You can also move issuing out of the request into a background job and leave the retrying there; the reasoning is in the retry and idempotency post of the Background Jobs series.
Testing SOAP with WireMock.Net
We can write the same tests as in post 05 for SOAP. To WireMock.Net, SOAP is just a POST: we match on the path and the SOAPAction header and return a canned envelope. I copied the envelopes from the fake integrator's real answers:
// SOAP 1.1: a fault always comes with HTTP 500.
private static IResponseBuilder Fault(string code, string message) => Soap(
$"""<s:Fault><faultcode>s:{code}</faultcode><faultstring>{message}</faultstring><detail><IntegratorFault xmlns="{Ns}"><Code>{code}</Code><Message>{message}</Message></IntegratorFault></detail></s:Fault>""",
500);
[Fact]
public async Task Timeout_then_duplicate_uuid_is_treated_as_success()
{
// First answer is later than the 1 s send timeout; the integrator has already stored the invoice.
_integrator.Given(Operation("SendInvoice")).InScenario("send").WillSetStateTo("stored")
.RespondWith(Soap(Receipt).WithDelay(TimeSpan.FromSeconds(3)));
_integrator.Given(Operation("SendInvoice")).InScenario("send").WhenStateIs("stored")
.RespondWith(Fault("DUPLICATE_UUID", "Invoice was already received."));
_integrator.Given(Operation("GetInvoiceStatus")).RespondWith(Soap(
$"""<GetInvoiceStatusResponse xmlns="{Ns}"><GetInvoiceStatusResult><Uuid>u</Uuid><EnvelopeId>ENV-9001</EnvelopeId><Status>QUEUED</Status></GetInvoiceStatusResult></GetInvoiceStatusResponse>"""));
await using var api = CreateApi();
using var response = await api.CreateClient().PostAsync("/api/orders/1003/invoice", null, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var invoice = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
Assert.Equal("ENV-9001", invoice.GetProperty("envelopeId").GetString());
// WireMock logs the delayed request only after its late answer has been written.
await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken);
Assert.Equal(2, Requests("SendInvoice"));
Assert.Equal(1, Requests("GetInvoiceStatus"));
}
The final Task.Delay comes from a real run: on the first run the test went red with Expected: 2, Actual: 1. WireMock adds the request with the delayed answer to its log only after the answer has been written. The same file has two more tests, one checking that the business fault becomes a 422 with a single request, and one that a 503 isn't retried:
Test run summary: Passed!
total: 17
failed: 0
succeeded: 17
skipped: 0
duration: 17s 095ms
On the server side, if you need to move an old WCF service to .NET, the place to go is CoreWCF: the service side of WCF ported to .NET. Our fake integrator is written with it, and it produces its WSDL and faults the same way a real WCF service does.
In Production
Give every call a time budget, defined at two levels: the binding's SendTimeout limits a single attempt, Polly's timeout limits the total. The second only works if the channel is tied to the token and aborted.
Don't turn message logging on by default. Envelopes are big and carry personal data; turn LogMessages on only when support needs it, and only for a short while. Review the list of masked fields against the integrator's schema.
Don't switch certificate validation off. For the fake's self-signed certificate, Bookstore pins one certificate in development (PinnedCertificateValidator); in production the machine's trust store and the real CA chain apply. Watch the expiry dates of your client certificate and of the integrator's server certificate; as the table in post 01 said, retrying against an expired certificate doesn't help.
Common Pitfalls
Editing the generated proxy by hand. Addresses, timeouts and limits come back with the next --update. Keep the settings in your own binding.
Closing a channel or client with using. On a faulted channel Close throws and hides the real error. Close on success, Abort on failure.
Keeping a faulted channel or client in a field and reusing it. After a failure that object is useless; what you keep per application is the ChannelFactory, not the channel.
Retrying on catch (CommunicationException). It also retries faults, credential errors and certificate errors.
Putting an HTTP-level retry on a SOAP client. Because faults arrive with 500, business rejections get resent too.
A Small Experiment
★☆☆ Add NetHttpBinding and BasicHttpsBinding to samples/Bookstore.SoapLab. Which of them can open a channel?
★★☆ Start the fake integrator with --mode Slow and raise Invoicing:EInvoice:SendTimeout to 6 seconds. How many requests go out, and how long does the response take? Then lower the total timeout in Polly to 3 seconds; what does the API return when the channel is aborted?
★★★ Move IssueAsync into a background job: the endpoint stores the invoice as Pending and returns 202, the job retries later with the same UUID on Transient errors, and opens a task for the back office on Rejected errors.
In this post we saw where SOAP behaves differently from plain HTTP: errors arrive with 500, the client has its own HTTP layer, and cancellation happens through abort rather than a token. In the next and final post of the series we look at an integration with no HTTP at all: exchanging files over SFTP and FTP, watching folders, never processing the same file twice, and reconciling against a bank statement.