DEV Community

Andrea Roversi
Andrea Roversi

Posted on • Originally published at roversia.it

Integrating myPOS IPC into a Netlify Checkout: RSA Signatures, Error Code 25 and the Traps in the Docs

Replacing a plain payment link with a direct myPOS Integrated Payment Checkout (IPC) v1.4 integration looked like an afternoon of work. It turned into an obstacle course: a 1024-bit RSA key rejected by OpenSSL 3, a double-base64 signature algorithm documented ambiguously, and a mysterious Error Code 25 that turned out to be caused by the free hosting domain. Here is every step, error and fix, in order.

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.

Security note: any private key or certificate received over chat should be considered compromised. Keys belong only in environment variables on the service that uses them (Netlify, in this case), never committed to code, and — if they travelled unencrypted through a chat — rotated from the provider's portal as soon as possible.

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 tecnico: error:1E08010C:DECODER routines::unsupported
Enter fullscreen mode Exit fullscreen mode

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:

const forge = require('node-forge');

// Node.js crypto rejects RSA keys < 2048 bit on OpenSSL 3.
// node-forge doesn't go through OpenSSL: no length restriction.
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');
Enter fullscreen mode Exit fullscreen mode

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:

error:0480006C:PEM routines::no start line
Enter fullscreen mode Exit fullscreen mode

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:

INFO   myPOS IPC response keys: [ '<!DOCTYPE html><html lang', 'subset' ]
INFO   IPCResult: undefined
ERROR  myPOS HTML error code: unknown
Enter fullscreen mode Exit fullscreen mode

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:

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(); // the customer's browser, not the server, lands on myPOS
}
Enter fullscreen mode Exit fullscreen mode

General pattern: whenever a payment gateway needs to collect card data or run 3D Secure authentication, expect a browser-side redirect flow, not a server-to-server REST endpoint — even if the docs show POST examples that seem to suggest otherwise.

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:

Step What it does
1 Concatenate all parameter values (excluding the signature) with a dash -, in the exact request order
2 Base64-encode the result
3 Sign the base64 string (not the raw data) with RSA-SHA256
4 Base64-encode the resulting binary signature → this is the Signature parameter value

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.

// Explicit order, not left to Object.values() on an object
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');
Enter fullscreen mode Exit fullscreen mode

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 Meaning Cause in this case
1 Missing required parameters Wrong parameter case (IPCmethod instead of IPCMethod)
3 Invalid signature Wrong separator and missing base64 step in the string to sign
25 Restricted store / unapproved URLs Free *.netlify.app domain, fixed with a custom subdomain

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.

This post was originally published on roversia.it, where I write about the vanilla JS/Firebase/Netlify stack behind my projects.

Top comments (0)