Il contesto
Un cliente con un piccolo e-commerce custom su Firebase e Netlify Functions usava, per il checkout, un semplice link di pagamento generato manualmente dal portale myPOS. Il passo successivo era ovvio: integrare direttamente l'API IPC di myPOS, così che il totale dell'ordine venga trasmesso in automatico al gateway invece di essere copiato a mano in un link. Il cliente mi ha girato via chat le credenziali fornite dal suo provider di pagamento — pratica non ideale, ma comune quando l'interlocutore tecnico è terzo rispetto a chi gestisce l'account myPOS.
Il piano iniziale era semplice: una Netlify Function riceve il totale dal frontend, firma la richiesta con la chiave privata RSA e la inoltra a myPOS lato server, restituendo l'URL di pagamento al browser. Ho scoperto passo dopo passo che quasi nessuna di queste assunzioni era corretta.
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.
Nota di sicurezza: qualunque chiave privata o certificato ricevuto via chat va considerato compromesso. Le chiavi vanno inserite solo come variabili d'ambiente sul servizio che le usa (in questo caso Netlify), mai committate nel codice, e — se sono transitate in chiaro su una chat — rigenerate dal portale del provider appena possibile.
Ostacolo #1 — la chiave RSA a 1024 bit e OpenSSL 3
Il primo tentativo usava il modulo crypto nativo di Node.js per firmare la richiesta con la chiave privata PEM ricevuta. Il deploy su Netlify falliva subito con un errore poco parlante:
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
Il primo sospetto era un problema di formato: la chiave era in PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) e forse Node si aspettava PKCS#8. Ho provato a forzare il parsing esplicito con crypto.createPrivateKey({ key, format: 'pem', type: 'pkcs1' }), senza risultato — stesso errore. La causa reale era un'altra: la chiave era una RSA a 1024 bit, e Node.js 18+ si appoggia a OpenSSL 3 come libreria crittografica, che ha deprecato le chiavi RSA sotto i 2048 bit per motivi di sicurezza. Le rifiuta silenziosamente, sia in PKCS#1 sia in PKCS#8, con un messaggio che sembra parlare di formato ma in realtà nasconde un limite di lunghezza.
Senza la possibilità di far rigenerare al cliente una chiave a 2048 bit in tempi brevi, la soluzione è stata sostituire crypto con node-forge, un'implementazione crittografica in puro JavaScript che non passa da OpenSSL e quindi non eredita quella restrizione:
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 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');
Ostacolo #2 — il mutual TLS che non serviva
Risolta la firma, la chiamata HTTPS verso myPOS falliva con un errore diverso ma dalla stessa radice:
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
La richiesta HTTPS includeva cert e key nelle opzioni per stabilire un mutual TLS con myPOS — pattern comune in altre integrazioni con certificati client. Ma qui i due meccanismi di autenticazione sono ridondanti: l'autenticazione applicativa avviene interamente tramite la firma RSA nel corpo della richiesta, verificata dal gateway lato server. Il mutual TLS a livello di connessione era superfluo e, con una chiave sotto i 2048 bit, introduceva lo stesso rifiuto OpenSSL già visto, questa volta nell'handshake TLS invece che nella firma applicativa. Rimuovere cert e key dalle opzioni della richiesta HTTPS ha eliminato l'errore senza alcuna perdita di sicurezza.
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.
Ostacolo #3 — server-to-server invece di redirect dal browser
A questo punto la funzione firmava correttamente e otteneva una risposta da myPOS — ma quella risposta era una pagina HTML completa, non un JSON con un URL di pagamento:
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
Il malinteso di fondo era architetturale: IPC non è un'API REST che risponde con JSON a una chiamata server-to-server, ma un protocollo redirect flow. Il cliente finale deve arrivare fisicamente, con il proprio browser, sulla pagina di checkout ospitata da myPOS — perché lì avvengono la raccolta dei dati carta e l'eventuale autenticazione 3D Secure, entrambe operazioni che per gli standard PCI DSS non possono transitare per un server intermedio.
La correzione ha spostato la responsabilità: la Netlify Function genera solo i parametri e la firma e li restituisce al browser, che li usa per costruire ed autosottomettere un form HTML direttamente verso l'endpoint myPOS:
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(); // il browser del cliente, non il server, arriva su myPOS
}
Pattern generale: quando un gateway di pagamento richiede la raccolta di dati carta o l'autenticazione 3D Secure, aspettati un redirect flow lato browser, non un endpoint REST server-to-server — anche se la documentazione mostra esempi di POST che sembrano suggerire il contrario.
Ostacolo #4 — l'algoritmo di firma con doppio base64
Con il redirect flow in campo, myPOS finalmente mostrava la sua pagina di checkout — ma rifiutava la richiesta con Error Code 3, firma non valida. La documentazione ufficiale descrive l'algoritmo in una riga facile da leggere male:
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:
| Passaggio | Cosa fa |
|---|---|
| 1 | Concatena tutti i valori dei parametri (esclusa la firma) con un trattino -, nell'ordine esatto della richiesta |
| 2 | Codifica il risultato in base64 |
| 3 | Firma la stringa base64 (non i dati originali) con RSA-SHA256 |
| 4 | Codifica la firma binaria risultante in base64 → questo è il valore del parametro Signature |
Il primo tentativo aveva sbagliato sia il separatore (usava | invece di -) sia saltava del tutto il passaggio 2, firmando direttamente la stringa concatenata. Corretto l'algoritmo, l'errore è cambiato ma non è sparito — segno che il problema si era spostato su un dettaglio diverso: la case-sensitivity dei nomi dei parametri. Il payload usava IPCmethod, ma myPOS si aspetta esattamente IPCMethod: un parametro obbligatorio scritto con la lettera sbagliata viene trattato come mancante, non come errato, e produce un più generico Error Code 1.
L'esempio ufficiale in PHP nasconde un'altra trappola speculare: nel campo che rappresenta il numero di wallet, il codice di esempio usa walletnumber tutto minuscolo, in contrasto con lo stile PascalCase di tutti gli altri parametri — copiare lo stile "coerente" degli altri campi porta a un nome sbagliato. Infine, l'SDK PHP ufficiale include sempre un blocco carrello (CartItems, Article_1, Quantity_1, Price_1, Currency_1, Amount_1) e il parametro PaymentMethod, entrambi assenti dagli esempi minimali della documentazione ma di fatto richiesti.
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.
// 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');
Ostacolo #5 — Error Code 25 e il dominio .netlify.app
Firma finalmente valida, restava un ultimo errore: Error Code 25, che secondo la documentazione myPOS significa "store restricted" — lo store non è approvato, oppure uno degli URL della richiesta non è whitelistato.
La configurazione nel portale myPOS elencava correttamente tutti e quattro gli URL richiesti (pagina di checkout, notifica, esito positivo, esito annullato). Il problema non era un URL mancante, ma il dominio stesso: il sito era ospitato su un sottodominio gratuito *.netlify.app, condiviso a livello di dominio radice con migliaia di altri progetti Netlify. I sistemi antifrode dei gateway di pagamento trattano spesso questi domini con più diffidenza di un dominio proprio, ed è probabile che questo — più che un problema di configurazione puntuale — fosse la causa dell'Error Code 25 persistente.
La soluzione è stata migrare il checkout su un sottodominio personalizzato, senza toccare il dominio principale del cliente (già in uso per un altro sito):
- Aggiunto un record CNAME nel DNS del dominio del cliente, puntato al sito Netlify esistente
- Configurato il dominio custom in Netlify → Domain management, lasciando il sottodominio
.netlify.apporiginale attivo in parallelo - Aggiornati tutti gli URL nel portale myPOS (sito, checkout, notifica, esito) sul nuovo dominio
- Atteso il provisioning del certificato SSL Let's Encrypt — myPOS rifiuta silenziosamente URL HTTPS con certificato non ancora attivo, quindi un test troppo anticipato dà l'impressione che il problema persista quando in realtà manca solo il certificato
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.appsubdomain 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 | Significato | Causa in questo caso |
|---|---|---|
| 1 | Parametri obbligatori mancanti | Nome parametro con maiuscola/minuscola errata (IPCmethod invece di IPCMethod) |
| 3 | Firma non valida | Separatore errato e passaggio di base64 mancante nella stringa da firmare |
| 25 | Store limitato / URL non approvati | Dominio *.netlify.app gratuito, risolto con un sottodominio custom |
Cosa mi porto a casa
- Un errore criptico di libreria crittografica va tradotto, non preso alla lettera. "DECODER routines::unsupported" sembra un problema di formato PEM, ma su Node 18+/OpenSSL 3 è quasi sempre un limite di lunghezza chiave (RSA < 2048 bit). node-forge è una via d'uscita quando non si può agire sulla chiave a monte.
- Non tutti i meccanismi di sicurezza in un'integrazione sono necessari contemporaneamente. Firma applicativa nel payload e mutual TLS a livello di connessione sono spesso alternativi, non cumulativi: aggiungerli entrambi può introdurre errori invece di rafforzare la sicurezza.
- Un endpoint che restituisce HTML invece di JSON è un indizio architetturale. Se un gateway di pagamento risponde con una pagina invece di un payload strutturato, probabilmente si aspetta un redirect flow dal browser del cliente finale, non una chiamata server-to-server.
-
Gli esempi ufficiali delle API vanno letti carattere per carattere. Case-sensitivity nei nomi dei parametri e incoerenze di stile tra un campo e l'altro (come
walletnumberminuscolo in mezzo a parametri PascalCase) sono errori silenziosi: il sistema non segnala "nome sbagliato", segnala "parametro mancante". - Un dominio di hosting gratuito può essere trattato con sospetto dai sistemi antifrode. Se un gateway di pagamento rifiuta uno store apparentemente ben configurato, vale la pena testare la migrazione a un dominio proprio prima di continuare a inseguire dettagli di configurazione.
What I'm taking away
- 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.
- 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.
- 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.
-
Official API examples need to be read character by character. Case-sensitivity in parameter names and style inconsistencies between fields (like lowercase
walletnumberamong otherwise PascalCase parameters) are silent failures: the system doesn't report "wrong name," it reports "missing parameter." - 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.
Domande frequenti
Perché myPOS rifiuta chiavi RSA a 1024 bit con errore DECODER unsupported?
Node.js 18+ usa OpenSSL 3 come libreria crittografica sottostante, e OpenSSL 3 ha deprecato le chiavi RSA sotto i 2048 bit per motivi di sicurezza: le rifiuta sia in formato PKCS#1 sia PKCS#8, con un errore poco esplicito che sembra un problema di formato ma in realtà è un limite di lunghezza chiave. La soluzione senza dover rigenerare le chiavi lato provider è usare una libreria in puro JavaScript come node-forge, che non passa da OpenSSL.
Se la firma RSA è nel body della richiesta, perché serve anche il mutual TLS?
Non serve: sono due meccanismi di autenticazione distinti e ridondanti. La firma RSA-SHA256 nel payload autentica il contenuto della richiesta ed è quella verificata dal gateway. Aggiungere anche il certificato client TLS è superfluo e, con una chiave sotto i 2048 bit, introduce lo stesso errore OpenSSL nell'handshake TLS. Rimuoverlo risolve l'errore senza indebolire la sicurezza.
Perché il pagamento deve partire con un form POST dal browser e non da una chiamata server-to-server?
Il protocollo IPC è un redirect flow: il cliente deve arrivare fisicamente sulla pagina di checkout del gateway, dove avvengono raccolta dati carta ed eventuale 3D Secure — operazioni che per PCI DSS non possono transitare per un server intermedio. Una funzione serverless può generare parametri e firma, ma il POST finale deve partire dal browser stesso.
Come si costruisce correttamente la stringa da firmare per IPCPurchase in IPC v1.4?
Si concatenano tutti i valori dei parametri (esclusa la firma) con un trattino, nell'ordine esatto della richiesta. Il risultato va codificato in base64: questo valore, non i dati originali, è ciò che si firma con RSA-SHA256. Il risultato della firma binaria va a sua volta codificato in base64 prima di essere inserito nel parametro Signature.
Perché un dominio gratuito tipo *.netlify.app può causare un Error Code 25 (store restricted)?
L'Error Code 25 segnala che lo store non supera la validazione del gateway, spesso perché gli URL dichiarati non sono considerati affidabili. I sottodomini gratuiti condividono il dominio radice con migliaia di altri progetti e sono trattati con più diffidenza dai sistemi antifrode. Migrare a un sottodominio personalizzato — senza toccare il sito originale — e attendere il provisioning del certificato SSL è la soluzione più affidabile.
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.