I needed an MBE shipping label to be generated automatically the moment an order is marked completed in an internal order panel (Netlify + Firebase). It looked like a one-afternoon job: a Netlify Function, a SOAP envelope, done.
It turned into three unrelated problems hiding behind the exact same symptom — 403 Forbidden, empty body. Here's how I untangled them.
The setup
MBE (Mail Boxes Etc.) exposes a SOAP web service called e-Link for generating shipping labels programmatically. The plan:
- Order status flips to
completedin the panel - A Netlify Function builds a SOAP request and sends it to MBE
- Response comes back with a tracking number and a base64-encoded PDF label
- Tracking gets saved to Firebase, PDF auto-downloads
Straightforward, in theory. In practice, every test produced the same 403 with an empty body, regardless of what I changed — for three completely different reasons, all wearing the same disguise.
Problem #1 — the invisible 4KB limit on AWS Lambda
First, the deploy itself started failing. The other functions in the project already used the Netlify Functions v2 format (export default async (req) => ...), but my new function had been written in the older Lambda-compatible style (exports.handler).
That distinction matters more than it looks: with the old format, Netlify falls back to the classic AWS Lambda runtime, which caps total environment variable size at 4KB — not per function, but cumulative across everything (Firebase service account key, another integration's private key and certificate, plus my new MBE credentials).
Rewriting the function in v2 ESM syntax should have fixed it. It didn't — because node_bundler was set to esbuild in netlify.toml, which silently recompiles ESM into CommonJS and re-triggers the same Lambda-compatible mode regardless of syntax.
[functions]
# esbuild recompiles ESM → CommonJS and reintroduces
# the classic Lambda 4KB env var limit.
node_bundler = "nft" # was "esbuild"
nft (Node File Trace) is the native bundler for v2 functions and leaves the ESM code untouched. That, plus removing a handful of unused legacy env vars (one ~1KB PEM certificate among them), got the deploy passing.
Problem #2 — 403 everywhere, even from a dedicated server
With the deploy fixed, the function called MBE correctly but always got 403 back. First guess: a classic anti-bot block — plenty of B2B APIs reject requests from known cloud IP ranges (AWS, GCP, Azure) because they expect calls from desktop software, not servers.
To confirm it, I tested the exact same request from four different networks:
| Source | Result |
|---|---|
| Netlify Function (dynamic AWS IP) | 403 |
| Edge proxy worker (different IP range) | 403 |
| Mobile 4G connection, graphical REST client | 403 |
Dedicated VPS, fixed European IP, via curl
|
403 |
Same 403, same session cookie in the response — even from a residential mobile connection that no datacenter-IP filter should ever touch. That ruled out the IP-block theory.
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/2 403
< content-length: 0
< x-frame-options: DENY
< strict-transport-security: max-age=31536000 ; includeSubDomains
< set-cookie: 29db36aa...; HttpOnly; Secure; SameSite=None
A clean TLS 1.3 handshake followed by a 403 with WAF-typical headers and a session cookie is the signature of an application-layer firewall, not a network-level block. The rejection happens after the handshake, on request inspection — which turned out to be the real clue.
The turning point — the docs didn't match the live API
I contacted MBE support directly. Their answer: there's no IP block on their side, and they sent over a real working call example. Comparing it against the structure from the public WSDL I'd been using, the mismatch was significant:
| Field | Public WSDL (broken) | Real working example |
|---|---|---|
| Endpoint | /ws/e-link |
/ws |
| Namespace | onlinembe.it/ws/ |
onlinembe.eu/ws/ |
| Operation | ShipmentCreateRequest |
ShipmentRequest |
| Auth | Credentials in XML body | HTTP Authorization: Basic header |
| Recipient node | RecipientData |
Recipient |
The 403 had never been a geo/IP firewall at all — it was the WAF outright rejecting any request with a namespace, operation, and auth method it didn't recognize, returning the same empty 403 an IP block would. Same symptom on all four networks because the bug was in the payload, not the route.
SOAP + Basic Auth from Node.js, no library
With the right structure, the first test call returned 200 with a tracking number and a base64 PDF. No SOAP client library needed — just fetch and a manually built envelope:
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();
// parse tracking + PDF from <MasterTrackingMBE> and <Stream>
return Response.json({ ok: true, xml });
};
Problem #3 — a second block, this time real
Structure fixed, the call worked perfectly from every manual test environment. But called from the production Netlify Function, the 403 came back — for a third, genuine reason this time: MBE's system does apply an origin check for continuous automated calls at the infrastructure level, independent of a correct payload.
The cleanest fix was moving the SOAP call out of Netlify entirely. A small Node.js proxy on a cheap VPS with a dedicated fixed IP receives the request from the Netlify Function, forwards it to MBE with the right credentials, and returns the response. It runs as an always-on systemd service with automatic restart.
import http from 'http';
import https from 'https';
const PORT = 3001;
const TOKEN = process.env.PROXY_TOKEN; // auth between Netlify and the 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);
The proxy doesn't parse or validate anything — it's a plain TCP/TLS relay that adds the auth header. That makes it reusable: it was extended shortly after to fix an identical problem with a separate payment gateway that also required a fixed public IP and direct HTTPS.
What I'd take away from this
-
Same HTTP code, different root causes. A
403with an empty body can mean a rejected payload, an infrastructure IP block, or both, one after the other. Switching test environments (mobile network, VPS, edge proxy) is the fastest way to tell them apart. - Don't trust the public WSDL blindly. Published technical docs can lag behind the live API. When a "by the book" implementation keeps failing, ask support for a real working example before debugging further.
-
AWS Lambda's 4KB env var limit is cumulative. On Netlify specifically,
esbuildcan silently downgrade a v2 ESM function back into Lambda-compatible mode. Checknode_bundlerinnetlify.tomlbefore chasing anything else. - A VPS proxy is a generic escape hatch. Any time a third-party API demands a fixed IP or rejects serverless traffic, a tiny always-on Node.js relay on a cheap VPS solves it — and it's reusable for the next integration with the same constraint.
Full write-up with the complete diagnostic flow and code is on my site: roversia.it/blog-11-integrazione-soap-mbe-proxy-vps.html
Top comments (1)
This is a great breakdown! I'