Part 1 (the Solutions Architect overview) covers why this is structured the way it is. Part 3 (the testing guide) covers how to verify it. This post is the how.
Apple Pay’s documentation covers the JS side reasonably well. What it doesn’t cover is how messy the server-side merchant validation gets once you’re running .NET on a commerce platform with an existing payment gateway already wired into the order pipeline. This is a step-by-step writeup of how to build it, where it breaks, and what to check before you ship.
Apple Pay integration on the web breaks into three distinct layers. Build them as three separate components — don’t let them blur together:
ApplePaySession JS API: renders the button, opens the payment sheet, fires onvalidatemerchant and onpaymentauthorized.Project layout:
Sykasys.Payments.ApplePay/Crypt/MerchantCertificate.cs # PEM -> X509Certificate2Services/ApplePayClient.cs # mutual-TLS call to AppleModels/MerchantSessionRequest.csModels/MerchantSessionResponse.cs
A thin Web API controller is the only thing the front-end JS talks to:
[RoutePrefix("api/applepayment")]public class ApplePaymentApiController : ApiController{[Route("createsession")][HttpPost][AllowAnonymous]public async Task<HttpResponseMessage> CreateSession([FromBody] ValidateMerchantSessionModel request){if (!Uri.TryCreate(request.ValidationUrl, UriKind.Absolute, out Uri requestUri))return Request.CreateResponse(HttpStatusCode.BadRequest, "Invalid Request");var payload = new MerchantSessionRequest{DisplayName = appleConfiguration.StoreName,Initiative = "web",InitiativeContext = GetCurrentDomainSite(),MerchantIdentifier = appleConfiguration.MerchantId,};var merchantSession = await _applePayClient.GetMerchantSessionAsync(requestUri, payload);return Request.CreateResponse(HttpStatusCode.OK, merchantSession ?? (object)new {});}}
request.ValidationUrl comes straight from Apple’s onvalidatemerchant event — a one-time URL the browser receives, which you POST your merchant session payload to from your server, never from the browser. This is the part people get wrong first: the validation call requires a client certificate tied to your merchant identity, and that certificate (and its private key) must never reach the browser.
Apple gives you a merchant identity certificate as a .cer/.p12 pair, or a CSR you sign yourself. If you’re storing the cert/key as PEM strings in config (common when secrets come from a vault rather than a .pfx on disk), .NET’s X509Certificate2 constructor won’t take a PEM cert + PEM key directly pre-.NET 5 — reassemble them into a PKCS12 store first:
public X509Certificate2 GetCertificate(){var keyPair = (AsymmetricKeyParameter)new PemReader(new StringReader(config.Key)).ReadObject();var cert = (Org.BouncyCastle.X509.X509Certificate)new PemReader(new StringReader(config.Cert)).ReadObject();var store = new Pkcs12StoreBuilder().SetUseDerEncoding(true).Build();var certEntry = new X509CertificateEntry(cert);store.SetCertificateEntry("", certEntry);store.SetKeyEntry("", new AsymmetricKeyEntry(keyPair), new[] { certEntry });byte[] data;using (var ms = new MemoryStream()){store.Save(ms, Array.Empty<char>(), new SecureRandom());data = ms.ToArray();}return new X509Certificate2(data, (string)null,X509KeyStorageFlags.MachineKeySet |X509KeyStorageFlags.PersistKeySet |X509KeyStorageFlags.Exportable);}
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;var clientHandler = new HttpClientHandler{ClientCertificates = { cert },SslProtocols = SslProtocols.Tls12};using (var client = new HttpClient(clientHandler)){var response = await client.SendAsync(new HttpRequestMessage{RequestUri = requestUri,Method = HttpMethod.Post,Content = new StringContent(JsonConvert.SerializeObject(request), Encoding.UTF8, "application/json")});// ...}
This is the step that saves you the most time, and it’s easy to skip because it looks like premature logging. It isn’t. Once a TLS handshake fails you get a generic exception and nothing from Apple’s side — there’s no further diagnostic surface to lean on. Log every step of the certificate pipeline before the network call:
try { var rsa = cert.GetRSAPrivateKey(); /* CNG */ }catch (Exception ex) { log.Error("GetRSAPrivateKey failed", ex); }try { var rsaLegacy = cert.PrivateKey; /* legacy CAPI */ }catch (Exception ex) { log.Error("Legacy PrivateKey failed", ex); }
If both throw, your PKCS12 reassembly is the problem, not the network.
In the order we actually hit these:
SecurityProtocolType.Tls12 explicitly via ServicePointManager before the call — don’t rely on OS/.NET defaults, especially on older Windows Server images..well-known/apple-developer-merchantid-domain-association to be served with no file extension, and most static file middlewares 404 on extensionless files by default:app.UseStaticFiles(new StaticFileOptions{ServeUnknownFileTypes = true,DefaultContentType = "text/plain",RequestPath = "/.well-known",FileProvider = new PhysicalFileProvider(Path.Combine(env.WebRootPath, ".well-known")),});
ngrok is fine for early local development — tunneling localhost to a public HTTPS URL lets you exercise Apple’s domain association check before you have a real shared environment. But don’t rely on it once you move to shared/test environments or cross-browser QA — Apple’s domain verification can be picky about transient tunnel domains at that stage. Move to a stable test domain (or your QA platform’s domain, e.g. LambdaTest) once you’re past solo local development.
The front-end is the easy part once the server side works. Render the button only after ApplePaySession.canMakePayments() and your own merchant capability checks pass:
<div id="applePay" class="apple-pay input-block-level d-none disabled" lang="en-AU"></div>
#applePay {-webkit-appearance: -apple-pay-button;-apple-pay-button-type: plain;-apple-pay-button-style: black;}
Don’t show the button disabled if Apple Pay isn’t available on the device/browser — don’t show it at all.
Once onpaymentauthorized fires, you have a payment token. Hand off to your existing payment gateway for charging it and completing the order. Don’t special-case Apple Pay through the order pipeline — to your cart/order code it should look like just another payment method producing a token. The only Apple-specific code lives in the validation layer from steps 2–6.
.well-known domain association file serves with correct content typeNext: Part 3 covers how to actually test this end-to-end, including the failure states that are easy to forget.
Quick Links
Legal Stuff