The context

A client running a small custom e-commerce site on Firebase and Netlify Functions used a plain payment link, manually generated from the myPOS portal, for checkout. The obvious next step was integrating myPOS's IPC API directly, so the order total would be transmitted automatically to the gateway instead of being copy-pasted into a link. The client forwarded me the credentials issued by their payment provider over chat — not an ideal practice, but a common one when the technical contact is a third party relative to whoever manages the myPOS account.

The initial plan was simple: a Netlify Function receives the total from the frontend, signs the request with the RSA private key, and forwards it to myPOS server-side, returning the payment URL to the browser. Step by step, I discovered that almost none of these assumptions held.

⚠️

Obstacle #1 — the 1024-bit RSA key and OpenSSL 3

The first attempt used Node.js's native crypto module to sign the request with the received PEM private key. The Netlify deploy immediately failed with an unhelpful error:

errore in produzione
Errore tecnico: error:1E08010C:DECODER routines::unsupported

The first suspicion was a format issue: the key was PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) and maybe Node expected PKCS#8. I tried forcing explicit parsing with crypto.createPrivateKey({ key, format: 'pem', type: 'pkcs1' }), with no luck — same error. The real cause was different: the key was a 1024-bit RSA key, and Node.js 18+ relies on OpenSSL 3 as its crypto backend, which deprecated RSA keys under 2048 bits for security reasons. It silently rejects them, in both PKCS#1 and PKCS#8, with a message that sounds like a format problem but actually hides a key-length limit.

With no quick way to have the client regenerate a 2048-bit key, the fix was replacing crypto with node-forge, a pure-JavaScript crypto implementation that doesn't go through OpenSSL and therefore doesn't inherit that restriction:

firma con node-forge invece di crypto nativo
const forge = require('node-forge');

// Node.js crypto rifiuta chiavi RSA < 2048 bit su OpenSSL 3.
// node-forge non passa da OpenSSL: nessuna restrizione di lunghezza.
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
const md = forge.md.sha256.create();
md.update(dataToSign, 'utf8');
const signatureBytes = privateKey.sign(md);
const signature = Buffer.from(signatureBytes, 'binary').toString('base64');

Obstacle #2 — the mutual TLS that wasn't needed

With the signature fixed, the HTTPS call to myPOS failed with a different error but the same root cause:

errore handshake TLS
error:0480006C:PEM routines::no start line

The HTTPS request included cert and key in its options to establish mutual TLS with myPOS — a common pattern in other client-certificate integrations. But here the two authentication mechanisms are redundant: application-level authentication happens entirely through the RSA signature in the request body, verified server-side by the gateway. Connection-level mutual TLS was superfluous and, with a sub-2048-bit key, triggered the same OpenSSL rejection seen earlier, this time in the TLS handshake instead of the application signature. Removing cert and key from the HTTPS request options eliminated the error with no loss of security.

Obstacle #3 — server-to-server instead of a browser redirect

At this point the function signed correctly and got a response from myPOS — but that response was a full HTML page, not a JSON payload with a payment URL:

log Netlify Function
INFO   myPOS IPC response keys: [ '<!DOCTYPE html><html lang', 'subset' ]
INFO   IPCResult: undefined
ERROR  myPOS HTML error code: unknown

The underlying misunderstanding was architectural: IPC isn't a REST API that answers a server-to-server call with JSON, but a redirect flow protocol. The end customer must physically land, with their own browser, on the checkout page hosted by myPOS — because that's where card data collection and any 3D Secure authentication happen, both operations that PCI DSS standards forbid routing through an intermediate server.

The fix moved the responsibility around: the Netlify Function only generates the parameters and signature and returns them to the browser, which uses them to build and auto-submit an HTML form directly to the myPOS endpoint:

autosubmit form dal browser
function redirectToMyPOS(params) {
  const form = document.createElement('form');
  form.method = 'POST';
  form.action = 'https://www.mypos.com/vmp/checkout/';

  Object.entries(params).forEach(([key, value]) => {
    const input = document.createElement('input');
    input.type = 'hidden';
    input.name = key;
    input.value = value;
    form.appendChild(input);
  });

  document.body.appendChild(form);
  form.submit(); // il browser del cliente, non il server, arriva su myPOS
}
💡

Obstacle #4 — the double base64 signature algorithm

With the redirect flow in place, myPOS finally showed its checkout page — but rejected the request with Error Code 3, invalid signature. The official docs describe the algorithm in a line that's easy to misread:

1
2
3
4

The first attempt got both the separator wrong (using | instead of -) and skipped step 2 entirely, signing the concatenated string directly. Once the algorithm was fixed, the error changed but didn't disappear — a sign the problem had shifted to a different detail: the case-sensitivity of parameter names. The payload used IPCmethod, but myPOS expects exactly IPCMethod: a required parameter with the wrong letter case is treated as missing, not as wrong, producing the more generic Error Code 1.

The official PHP example hides another mirror-image trap: for the field representing the wallet number, the sample code uses all-lowercase walletnumber, breaking with the PascalCase style of every other parameter — copying the "consistent" style of the other fields leads to the wrong name. Finally, the official PHP SDK always includes a cart block (CartItems, Article_1, Quantity_1, Price_1, Currency_1, Amount_1) and the PaymentMethod parameter, both absent from the documentation's minimal examples but required in practice.

generazione firma — versione corretta
// Ordine esplicito, non affidato a Object.values() su un oggetto
const orderedValues = [
  params.IPCMethod, params.IPCVersion, params.IPCLanguage,
  params.SID, params.walletnumber, params.Amount, params.Currency,
  params.OrderID, params.URL_OK, params.URL_Cancel, params.URL_Notify,
  params.CardTokenRequest, params.KeyIndex, params.PaymentMethod,
  params.CartItems, params.Article_1, params.Quantity_1,
  params.Price_1, params.Currency_1, params.Amount_1
];

const joined   = orderedValues.join('-');
const b64input = Buffer.from(joined).toString('base64');

const md = forge.md.sha256.create();
md.update(b64input, 'utf8');
params.Signature = Buffer.from(privateKey.sign(md), 'binary').toString('base64');

Obstacle #5 — Error Code 25 and the .netlify.app domain

With a finally valid signature, one last error remained: Error Code 25, which per myPOS documentation means "store restricted" — the store isn't approved, or one of the request URLs isn't whitelisted.

The myPOS portal configuration correctly listed all four required URLs (checkout page, notify, success, cancel). The problem wasn't a missing URL, but the domain itself: the site was hosted on a free *.netlify.app subdomain, sharing its root domain with thousands of other Netlify projects. Payment gateway fraud systems often treat such domains with more suspicion than a dedicated domain, and this — rather than a specific misconfiguration — was likely the cause of the persistent Error Code 25.

The fix was migrating the checkout to a custom subdomain, without touching the client's main domain (already serving a different site):

  • Added a CNAME record in the client's domain DNS, pointing to the existing Netlify site
  • Configured the custom domain in Netlify → Domain management, keeping the original .netlify.app subdomain active in parallel
  • Updated all URLs in the myPOS portal (site, checkout, notify, outcome) to the new domain
  • Waited for Let's Encrypt SSL certificate provisioning — myPOS silently rejects HTTPS URLs whose certificate isn't active yet, so testing too early gives the impression the problem persists when really only the certificate is missing
Error Code
1
3
25

When direct myPOS IPC integration makes sense (and when it doesn't)

When to use it

  • Server-to-server checkout where full control over the payment flow is needed, without depending on a redirect entirely managed by the user's browser.
  • Competitive myPOS fees for the expected transaction volume, justifying the technical investment of a direct integration over an aggregator.
  • Team capable of handling RSA cryptography and request signing, a non-trivial technical requirement of this type of IPC integration.

When not to use it

  • Low volumes or idea-validation phase: a payment aggregator with a ready SDK (Stripe, PayPal) drastically reduces integration time, at the cost of slightly higher fees.
  • Team without prior experience with cryptographic signing or mutual TLS: the debugging time for issues like those described in this article (RSA key, double base64) can outweigh the fee savings.
  • Need to quickly support multiple payment providers: a direct integration for each gateway multiplies maintenance work compared to a common abstraction layer.

Alternatives

For an integration with different infrastructure security requirements (IP whitelist instead of RSA signing), see also the VPS proxy for legacy SOAP API case — a similar problem pattern (documentation not matching real behavior) solved with a different infrastructure approach.

Common mistakes

  • Underestimating OpenSSL 3 compatibility with legacy 1024-bit RSA keys: modern OpenSSL versions may reject or handle differently keys that older providers still require.
  • Implementing mutual TLS when it's not actually required by the provider's documentation, adding unnecessary complexity to the setup.
  • Assuming the signing algorithm is standard without checking details like a double layer of base64 encoding, which some providers require without documenting it clearly.

What I'm taking away

  1. A cryptic crypto-library error needs translating, not taking literally. "DECODER routines::unsupported" sounds like a PEM format issue, but on Node 18+/OpenSSL 3 it's almost always a key-length limit (RSA < 2048 bit). node-forge is a workaround when the key itself can't be changed upstream.
  2. Not every security mechanism in an integration is needed at the same time. An application-level signature in the payload and connection-level mutual TLS are often alternatives, not additive: using both can introduce errors instead of strengthening security.
  3. An endpoint returning HTML instead of JSON is an architectural clue. If a payment gateway responds with a page instead of a structured payload, it probably expects a browser-side redirect flow from the end customer, not a server-to-server call.
  4. Official API examples need to be read character by character. Case-sensitivity in parameter names and style inconsistencies between fields (like lowercase walletnumber among otherwise PascalCase parameters) are silent failures: the system doesn't report "wrong name," it reports "missing parameter."
  5. A free hosting domain can be treated with suspicion by fraud systems. If a payment gateway rejects an apparently well-configured store, it's worth testing a migration to a dedicated domain before continuing to chase configuration details.

Frequently asked questions

Why does myPOS reject 1024-bit RSA keys with a DECODER unsupported error?

Node.js 18+ uses OpenSSL 3 as its crypto backend, and OpenSSL 3 deprecated RSA keys under 2048 bits for security reasons: it rejects them in both PKCS#1 and PKCS#8, with an unclear error that sounds like a format issue but is actually a key-length limit. Without regenerating the key upstream, the fix is a pure-JavaScript library like node-forge, which doesn't go through OpenSSL.

If the RSA signature is in the request body, why would mutual TLS also be needed?

It isn't: they're two separate, redundant authentication mechanisms. The RSA-SHA256 signature in the payload authenticates the request content and is what the gateway verifies. Adding a TLS client certificate too is superfluous and, with a sub-2048-bit key, triggers the same OpenSSL error in the TLS handshake. Removing it fixes the error without weakening security.

Why does the payment have to start with a browser form POST instead of a server-to-server call?

The IPC protocol is a redirect flow: the customer must physically land on the gateway's checkout page, where card data collection and any 3D Secure happen — operations PCI DSS forbids routing through an intermediate server. A serverless function can generate the parameters and signature, but the final POST must originate from the browser itself.

How do you correctly build the string to sign for IPCPurchase in IPC v1.4?

Concatenate all parameter values (excluding the signature) with a dash, in the exact request order. Base64-encode the result: this value, not the raw data, is what gets signed with RSA-SHA256. The resulting binary signature is itself base64-encoded before being placed in the Signature parameter.

Why can a free domain like *.netlify.app cause an Error Code 25 (store restricted)?

Error Code 25 signals the store fails gateway validation, often because the declared URLs aren't considered trustworthy. Free subdomains share a root domain with thousands of other projects and are treated with more suspicion by fraud systems. Migrating to a custom subdomain — without touching the original site — and waiting for SSL certificate provisioning is the most reliable fix.