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.

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.

netlify.toml — bundler corretto per function v2 ESM
[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.

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.

curl -v — connessione TLS riuscita, blocco applicativo dopo l'handshake
* 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.

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

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 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:

JavaScript — chiamata SOAP con Basic Auth (Netlify Function v2)
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 });
};

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.

server.js — proxy Node.js puro (VPS, porta 3001)
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.

When a VPS proxy for legacy SOAP makes sense (and when it doesn't)

When to use it

  • Partner API requiring a fixed IP whitelist: when the provider only accepts connections from a static IP address known in advance, a serverless environment (which changes IP on every invocation) simply doesn't work.
  • Legacy protocols like SOAP with HTTP Basic Auth, where modern serverless libraries default to assuming REST/JSON and need to be forced or replaced with direct HTTP calls.
  • Low-to-moderate call volume: a cheap VPS comfortably handles non-massive loads without needing autoscaling.

When not to use it

  • APIs that natively support OAuth or dynamic tokens: if the provider doesn't require a fixed IP, a Netlify Function or Cloud Function remains cheaper and requires no server maintenance.
  • Very high volumes or spiky traffic: a single VPS with no autoscaling becomes a bottleneck; a serverless architecture with a static IP via a dedicated NAT gateway (more expensive but managed) is better.
  • Team without server management experience: a VPS requires patching, monitoring and maintenance that a serverless function eliminates entirely.

Alternatives

If the fixed IP constraint isn't negotiable but you still want to stay serverless, a dedicated NAT gateway (e.g. on AWS or Google Cloud) provides a static IP in front of serverless functions — at a higher recurring cost than a cheap VPS. For integrating other payment gateways without fixed-IP constraints, see also the myPOS IPC checkout case.

Common mistakes

  • Blindly trusting official documentation for a legacy API: when actual API behavior doesn't match the docs, every assumption needs to be tested empirically, starting from the simplest cases.
  • Underestimating implicit serverless platform limits (like AWS Lambda's payload limit) that aren't always obvious until you hit the error in production.
  • Assuming a 403 always has the same cause: in the same project, multiple independent blocks can coexist (IP whitelist, then a separate authentication issue) that need to be diagnosed one at a time.

What we learned

  1. 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.
  2. 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.
  3. AWS Lambda's 4KB limit is cumulative, not per-function. On Netlify, the esbuild bundler can silently downgrade a v2 ESM function back to the classic Lambda runtime. Always check node_bundler in netlify.toml before suspecting anything else.
  4. 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.
  5. Testing with different tools speeds up diagnosis. Alternating between a graphical REST client and command-line curl calls made it possible to isolate TLS handshake issues, duplicate headers, and HTTP version problems that would otherwise have stayed hidden in a single tool.

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.