HomeBlogAbout MeContact

Implementing Apple Pay: A Developer's Step-by-Step Guide

By Wiselin Jaya Jos Kanagamani
June 29, 2026
3 min read

This is the how

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.

Step 1: separate the three concerns

Apple Pay integration on the web breaks into three distinct layers. Build them as three separate components — don’t let them blur together:

  • Client-side – the ApplePaySession JS API: renders the button, opens the payment sheet, fires onvalidatemerchant and onpaymentauthorized.
  • Merchant validation – a server-to-server call to Apple, authenticated with a certificate issued to your merchant ID, proving you’re allowed to process payment on this domain.
  • Payment processing – whatever your actual gateway does with the resulting payment token, completely separate from steps 1 and 2.

Project layout:

Sykasys.Payments.ApplePay/
Crypt/MerchantCertificate.cs # PEM -> X509Certificate2
Services/ApplePayClient.cs # mutual-TLS call to Apple
Models/MerchantSessionRequest.cs
Models/MerchantSessionResponse.cs

Step 2: build the merchant validation endpoint

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.

Step 3: handle the certificate correctly

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);
}

Step 4: make the mutual-TLS call

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")
});
// ...
}

Step 5: instrument before you need it

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.

Step 6: the handshake-failure checklist

In the order we actually hit these:

  1. TLS version not pinned. Force SecurityProtocolType.Tls12 explicitly via ServicePointManager before the call — don’t rely on OS/.NET defaults, especially on older Windows Server images.
  2. Private key not actually usable. Loading a cert doesn’t guarantee the private key is associated correctly for the crypto provider in use — see Step 5’s diagnostics.
  3. Domain association file not served correctly. Apple Pay requires .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.

Step 7: wire the button

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.

Step 8: hand off and stop

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.

Checklist summary

  • Three layers kept separate: client JS, merchant validation, gateway
  • Certificate/key never sent to the browser
  • PEM → PKCS12 reassembly tested with both CNG and legacy CAPI key checks
  • TLS 1.2 pinned explicitly
  • .well-known domain association file serves with correct content type
  • Local development tested via an ngrok tunnel before moving to a shared/test domain
  • Button hidden (not disabled) when Apple Pay unavailable
  • Payment token handoff is gateway-agnostic

Next: Part 3 covers how to actually test this end-to-end, including the failure states that are easy to forget.


Tags

Apple PayOptimizely Commerce.NETPaymentsDeveloper Guide

Share

Previous Article
Testing an Apple Pay Integration: A QA Guide

Quick Links

BlogAbout MeContact MeRSS Feed

Social Media