Il contesto
Il pannello ordini interno gestisce le spedizioni dei prodotti POS verso i clienti finali. Fino a questo momento le etichette di spedizione venivano generate manualmente dal portale del corriere. L'obiettivo era automatizzare il flusso: quando un ordine viene marcato come completato, il sistema deve chiamare automaticamente le API MBE (Mail Boxes Etc.) per generare l'etichetta, salvare il tracking su Firebase e scaricare il PDF.
MBE espone un web service SOAP chiamato e-Link. Sulla carta il lavoro sembrava semplice: una Netlify Function che costruisce una busta SOAP, la invia, riceve tracking e PDF in base64. Nella pratica si è trasformato in tre problemi distinti e indipendenti, ognuno mascherato da un sintomo simile — errori 403 e risposte vuote — che hanno richiesto un debug sistematico per essere isolati.
The context
The internal order panel manages POS product shipments to end customers. Until now, shipping labels were generated manually through the carrier's portal. The goal was to automate the flow: when an order is marked completed, the system should automatically call the MBE (Mail Boxes Etc.) API to generate the label, save the tracking number to Firebase, and download the PDF.
MBE exposes a SOAP web service called e-Link. On paper this looked simple: a Netlify Function that builds a SOAP envelope, sends it, and receives tracking plus a base64 PDF back. In practice it turned into three separate, independent problems, each disguised behind a similar symptom — 403 errors and empty responses — that required systematic debugging to isolate.
Problema #1 — il limite invisibile di 4KB su AWS Lambda
La prima versione della Netlify Function falliva in deploy senza un motivo apparente. Le altre function del progetto, già in produzione, usavano tutte il formato Netlify Functions v2 (export default async (req) => ...), mentre la mia era stata scritta nel vecchio formato Lambda compatibile (exports.handler). Con quel formato, Netlify ricade sul runtime AWS Lambda classico, che impone un limite di 4KB sulla dimensione totale di tutte le variabili d'ambiente della funzione — non solo quelle nuove, ma anche quelle già esistenti per le altre integrazioni del progetto (chiave di servizio Firebase, chiave privata e certificato di un altro gateway di pagamento).
Riscrivere la function nel formato v2 ESM ha eliminato il vincolo di runtime, ma il deploy continuava a fallire — il node_bundler impostato su esbuild nel file di configurazione Netlify stava ricompilando il codice ESM in CommonJS, rimettendo la function in modalità Lambda compatibile nonostante la sintassi v2. Il fix è stato cambiare il bundler in nft (Node File Trace), quello nativo per le function v2, che lascia il codice ESM intatto.
Problem #1 — the invisible 4KB limit on AWS Lambda
The first version of the Netlify Function failed to deploy with no obvious reason. The other functions in the project, already in production, all used the Netlify Functions v2 format (export default async (req) => ...), while mine had been written in the old Lambda-compatible format (exports.handler). With that format, Netlify falls back to the classic AWS Lambda runtime, which imposes a 4KB limit on the total size of all environment variables for the function — not just the new ones, but also the existing ones from other integrations in the project (Firebase service account key, private key and certificate for another payment gateway).
Rewriting the function in v2 ESM format removed the runtime constraint, but the deploy kept failing — the node_bundler set to esbuild in the Netlify config file was recompiling the ESM code into CommonJS, putting the function back in Lambda-compatible mode despite the v2 syntax. The fix was switching the bundler to nft (Node File Trace), the native one for v2 functions, which leaves the ESM code untouched.
[build]
functions = "netlify/functions"
[functions]
# esbuild ricompila ESM → CommonJS e reintroduce
# il limite 4KB env var della Lambda classica.
node_bundler = "nft" # era "esbuild"
Anche con il bundler corretto, il totale delle variabili d'ambiente del progetto era già al limite. La soluzione definitiva è stata fare pulizia: alcune variabili di un'integrazione precedente (un certificato PEM da ~1KB tra queste) non erano più usate da nessuna function attiva. Rimuoverle ha liberato margine sufficiente per le nuove credenziali MBE senza dover spostare nulla in un secrets manager esterno.
Problema #2 — 403 ovunque, anche da un VPS dedicato
Con il deploy finalmente passato, la function chiamava correttamente l'endpoint MBE ma riceveva sempre un 403 Forbidden con body vuoto. Il sospetto iniziale è stato un classico blocco anti-bot: molti servizi B2B filtrano le richieste provenienti da intervalli IP noti dei provider cloud (AWS, GCP, Azure), perché il loro sistema è pensato per essere chiamato da software installato su PC fisici, non da server.
Per verificarlo ho testato la stessa identica richiesta da quattro ambienti diversi:
- Netlify Function (IP dinamico AWS) → 403
- Worker su un edge proxy di terze parti (IP diverso da AWS) → 403
- Connessione mobile 4G da rete domestica, via client REST grafico → 403
- VPS dedicato con IP fisso europeo, via
curl→ 403
Stesso codice 403, stesso cookie di sessione nella risposta, su quattro reti completamente diverse — inclusa una connessione mobile residenziale che in teoria non dovrebbe essere filtrata da un WAF anti-datacenter. Questo è stato il primo indizio che la diagnosi iniziale era sbagliata: non era un blocco per categoria di IP.
Problem #2 — 403 everywhere, even from a dedicated VPS
With the deploy finally passing, the function correctly called the MBE endpoint but always received a 403 Forbidden with an empty body. The initial suspicion was a classic anti-bot block: many B2B services filter requests coming from known cloud provider IP ranges (AWS, GCP, Azure), because their system is designed to be called from software installed on physical PCs, not servers.
To verify this I tested the exact same request from four different environments:
- Netlify Function (dynamic AWS IP) → 403
- Worker on a third-party edge proxy (different IP range than AWS) → 403
- Mobile 4G connection from a home network, via a graphical REST client → 403
- Dedicated VPS with a fixed European IP, via
curl→ 403
Same 403 code, same session cookie in the response, across four completely different networks — including a residential mobile connection that in theory shouldn't be filtered by an anti-datacenter WAF. This was the first clue that the initial diagnosis was wrong: it wasn't a block by IP category.
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/2 403
< content-length: 0
< cache-control: no-cache, no-store, max-age=0, must-revalidate
< x-content-type-options: nosniff
< strict-transport-security: max-age=31536000 ; includeSubDomains
< x-frame-options: DENY
< set-cookie: 29db36aa5f07e32c0e7ffa7ce589b780=...; HttpOnly; Secure; SameSite=None
Una connessione TLS 1.3 che si chiude correttamente, seguita da un 403 con header tipici (x-frame-options, strict-transport-security) e un cookie di sessione, è la firma di un Web Application Firewall a livello applicativo, non di un firewall di rete. Il blocco arriva dopo l'handshake, sull'analisi della richiesta — un dettaglio che si sarebbe rivelato decisivo nel passaggio successivo.
La svolta — la documentazione non corrispondeva all'API reale
Dopo aver scartato l'ipotesi del blocco IP, la mossa decisiva è stata contattare direttamente l'assistenza tecnica MBE, che ha risposto: nessun blocco IP esiste sul loro sistema, e ha allegato un esempio reale di chiamata funzionante. Confrontando quell'esempio con la struttura usata fino a quel momento (basata sul WSDL pubblico), le differenze erano sostanziali:
The turning point — the documentation didn't match the real API
After ruling out the IP-block hypothesis, the decisive move was contacting MBE technical support directly, who replied: no IP block exists on their system, and attached a real working call example. Comparing that example with the structure used until then (based on the public WSDL), the differences were substantial:
| Campo | WSDL pubblico (non funzionante) | Esempio reale del supporto |
|---|---|---|
| Endpoint | /ws/e-link |
/ws |
| Namespace | onlinembe.it/ws/ |
onlinembe.eu/ws/ |
| Operazione | ShipmentCreateRequest |
ShipmentRequest |
| Autenticazione | Credenziali nel body XML | Header HTTP Authorization: Basic |
| Destinatario | RecipientData |
Recipient |
Il vero colpevole del 403 non era mai stato un firewall geografico: era il WAF che rifiutava in toto richieste con un namespace, un'operazione e un metodo di autenticazione che la sua configurazione applicativa non riconosceva — bloccandole con la stessa risposta vuota che un blocco IP avrebbe prodotto. Il sintomo era identico in tutti e quattro gli ambienti perché l'errore era nel payload, non nella rete.
The real culprit behind the 403 had never been a geographic firewall: it was the WAF outright rejecting requests with a namespace, operation, and authentication method its application configuration didn't recognize — blocking them with the same empty response an IP block would have produced. The symptom was identical across all four environments because the error was in the payload, not the network.
SOAP con Basic Auth HTTP da Node.js
Con namespace, operazione e header corretti, la prima chiamata di test ha restituito finalmente HTTP 200 con tracking e PDF in base64 dentro la risposta. La Netlify Function costruisce la busta SOAP e imposta l'header Basic Auth manualmente, senza nessuna libreria SOAP esterna:
SOAP with HTTP Basic Auth from Node.js
With the correct namespace, operation and headers, the first test call finally returned HTTP 200 with the tracking number and base64 PDF in the response. The Netlify Function builds the SOAP envelope and sets the Basic Auth header manually, with no external SOAP library:
export default async (req) => {
const { recipient, shipment } = await req.json();
const AUTH = 'Basic ' + Buffer.from(
`${process.env.MBE_USERNAME}:${process.env.MBE_PASSPHRASE}`
).toString('base64');
const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:ns1="http://www.onlinembe.eu/ws/">
<SOAP-ENV:Body>
<ns1:ShipmentRequest>
<RequestContainer>
<System>IT</System>
<Recipient>...</Recipient>
<Shipment>...</Shipment>
</RequestContainer>
</ns1:ShipmentRequest>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>`;
const resp = await fetch('https://api.mbeonline.it/ws', {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset=UTF-8',
'Authorization': AUTH
},
body: soapEnvelope
});
const xml = await resp.text();
// parsing tracking + PDF da <MasterTrackingMBE> e <Stream>
return Response.json({ ok: true, xml });
};
Problema #3 — un secondo blocco, questa volta reale
Risolta la struttura SOAP, la chiamata funzionava perfettamente da postazioni di test (client REST grafico, curl da rete mobile). Ma chiamandola dalla Netlify Function in produzione, il 403 è tornato — stavolta per un motivo distinto e reale: il sistema MBE applica comunque un controllo sulla provenienza della richiesta a livello infrastrutturale per le chiamate automatizzate continuative, indipendentemente dal payload corretto.
A questo punto la soluzione architetturale più solida è stata spostare la chiamata SOAP fuori da Netlify: un piccolo proxy Node.js, in esecuzione su un VPS economico con IP fisso dedicato, riceve la richiesta dalla Netlify Function, la inoltra a MBE con le credenziali corrette, e restituisce la risposta. Il proxy gira come servizio systemd sempre attivo, con riavvio automatico in caso di crash.
Problem #3 — a second block, this time real
With the SOAP structure fixed, the call worked perfectly from test environments (graphical REST client, curl over a mobile connection). But calling it from the production Netlify Function, the 403 came back — this time for a distinct, genuine reason: the MBE system still applies a check on request origin at the infrastructure level for continuous automated calls, regardless of a correct payload.
At this point the most solid architectural solution was to move the SOAP call outside Netlify entirely: a small Node.js proxy, running on a cheap VPS with a dedicated fixed IP, receives the request from the Netlify Function, forwards it to MBE with the correct credentials, and returns the response. The proxy runs as an always-on systemd service, with automatic restart on crash.
import http from 'http';
import https from 'https';
const PORT = 3001;
const TOKEN = process.env.PROXY_TOKEN; // auth tra Netlify e VPS
const BASIC_AUTH = 'Basic ' + Buffer.from(
`${process.env.MBE_USERNAME}:${process.env.MBE_PASSPHRASE}`
).toString('base64');
http.createServer(async (req, res) => {
if (req.headers['x-proxy-token'] !== TOKEN) {
res.writeHead(401); res.end(); return;
}
const chunks = [];
for await (const c of req) chunks.push(c);
const mbeReq = https.request({
hostname: 'api.mbeonline.it', path: '/ws', method: 'POST',
headers: { 'Content-Type': 'text/xml; charset=UTF-8', 'Authorization': BASIC_AUTH }
}, mbeRes => {
const out = [];
mbeRes.on('data', c => out.push(c));
mbeRes.on('end', () => {
res.writeHead(mbeRes.statusCode, { 'Content-Type': 'text/xml' });
res.end(Buffer.concat(out));
});
});
mbeReq.end(Buffer.concat(chunks));
}).listen(PORT);
Il proxy non interpreta né valida il payload — fa solo da relay TCP/TLS verso MBE, aggiungendo l'header di autenticazione. Questo lo rende riutilizzabile per qualunque altra integrazione con lo stesso vincolo di provenienza IP, semplicemente aggiungendo una nuova route. È stato esteso poco dopo per risolvere un problema identico con un altro gateway di pagamento che richiedeva un IP pubblico fisso e HTTPS diretto.
Cosa abbiamo imparato
- Stesso codice HTTP, cause diverse. Un 403 con body vuoto può nascere da un payload SOAP rifiutato dal WAF, da un blocco IP infrastrutturale, o da entrambi in sequenza. Cambiare ambiente di test (rete mobile, VPS, edge proxy) è il modo più rapido per isolare quale dei due sta succedendo.
- Il WSDL pubblico non è sempre la verità. Documentazione tecnica esposta pubblicamente può essere obsoleta rispetto all'API realmente in produzione. Quando una struttura "da manuale" continua a fallire, chiedere all'assistenza un esempio di chiamata funzionante battendo la documentazione ufficiale.
-
Il limite 4KB di AWS Lambda è cumulativo, non per-funzione. Su Netlify, il bundler
esbuildpuò silenziosamente far ricadere una function v2 ESM nel runtime Lambda classico. Verificare semprenode_bundlernelnetlify.tomlprima di sospettare altro. - Un proxy su VPS è una rete di sicurezza generica. Quando un fornitore SaaS richiede un IP pubblico fisso o blocca le richieste serverless, un micro-proxy Node.js su un VPS economico è una soluzione riutilizzabile per qualunque integrazione futura con lo stesso vincolo.
-
Testare con strumenti diversi accelera la diagnosi. Alternare un client REST grafico e chiamate
curlda riga di comando ha permesso di isolare problemi di handshake TLS, header duplicati e versione HTTP che altrimenti sarebbero rimasti nascosti in un solo strumento.
What we learned
- Same HTTP code, different causes. A 403 with an empty body can come from a SOAP payload rejected by the WAF, an infrastructure-level IP block, or both in sequence. Switching test environments (mobile network, VPS, edge proxy) is the fastest way to isolate which one is happening.
- The public WSDL isn't always the truth. Publicly exposed technical documentation can be stale compared to the API actually in production. When a "by the book" structure keeps failing, ask support for a working call example rather than trusting the official docs.
-
AWS Lambda's 4KB limit is cumulative, not per-function. On Netlify, the
esbuildbundler can silently downgrade a v2 ESM function back to the classic Lambda runtime. Always checknode_bundlerinnetlify.tomlbefore suspecting anything else. - A VPS proxy is a generic safety net. When a SaaS provider requires a fixed public IP or blocks serverless requests, a small Node.js proxy on a cheap VPS is a reusable solution for any future integration with the same constraint.
-
Testing with different tools speeds up diagnosis. Alternating between a graphical REST client and command-line
curlcalls made it possible to isolate TLS handshake issues, duplicate headers, and HTTP version problems that would otherwise have stayed hidden in a single tool.
Domande frequenti
Perché il deploy della Netlify Function falliva con un limite di 4KB anche dopo aver rimosso il service account precedente?
Il totale delle variabili d'ambiente del progetto era già al limite anche senza il PEM del service account. La soluzione definitiva è stata fare pulizia: alcune variabili di un'integrazione precedente non più in uso (incluso un certificato PEM da circa 1KB) occupavano spazio inutilmente, e rimuoverle ha liberato margine sufficiente per le nuove credenziali.
Come si distingue un blocco IP anti-bot da un Web Application Firewall a livello applicativo quando si riceve un 403?
Testando la stessa richiesta da reti completamente diverse (cloud, edge proxy, mobile 4G, VPS dedicato): se il 403 persiste ovunque, non è un blocco per categoria di IP. Una connessione TLS che si chiude correttamente seguita da un 403 con header tipici (x-frame-options, strict-transport-security) e un cookie di sessione è la firma di un WAF applicativo, che blocca dopo l'handshake analizzando la richiesta.
Perché una chiamata SOAP costruita seguendo il WSDL pubblico veniva comunque rifiutata dall'API reale?
La documentazione WSDL pubblica non corrispondeva più all'API effettivamente in uso dal provider: endpoint, namespace e nomi delle operazioni erano cambiati. Confrontare la struttura usata con un esempio reale fornito dall'assistenza tecnica ha rivelato le differenze esatte, mostrando che il problema non era mai stato un blocco di rete ma un payload SOAP non valido per la versione corrente dell'API.
Frequently asked questions
Why did the Netlify Function deploy fail with a 4KB limit even after removing the previous service account?
The project's total environment variables were already near the limit even without the service account's PEM. The definitive fix was cleanup: some variables from a previous, no-longer-used integration (including a roughly 1KB PEM certificate) were taking up space unnecessarily, and removing them freed enough margin for the new credentials.
How do you tell an anti-bot IP block apart from an application-level Web Application Firewall when getting a 403?
By testing the same request from completely different networks (cloud, edge proxy, mobile 4G, dedicated VPS): if the 403 persists everywhere, it isn't a block by IP category. A TLS connection that closes correctly followed by a 403 with typical headers (x-frame-options, strict-transport-security) and a session cookie is the signature of an application-level WAF, which blocks after the handshake by analyzing the request.
Why was a SOAP call built following the public WSDL still being rejected by the real API?
The public WSDL documentation no longer matched the API actually in use by the provider: the endpoint, namespace and operation names had changed. Comparing the structure used with a real example provided by technical support revealed the exact differences, showing the problem was never a network block but an invalid SOAP payload for the current API version.