Wrapping a Legacy Shipping Carrier API in One Endpoint
I built a FastAPI service that turns a two-step, rate-limited carrier API into one internal endpoint — token caching, retry logic, order mapping, and an undocumented limit found the hard way.
An e-commerce backend needed to generate shipping labels automatically instead of an operator re-typing every order into a carrier’s web portal by hand. The carrier’s own API makes that a two-call handshake with a strict rate limit and no idempotency guarantee. I built the service that hides all of it behind one endpoint — and along the way found a validation rule the carrier’s own manual never mentioned.
This is the build log: the pipeline, the mapping from a real order to a compliant shipment, the edge cases international shipping throws at you, and the bug I only found by bisecting the input space by hand.
Context: one button, one order, one label
The starting point was manual and slow: an operator opens the carrier’s portal, retypes the recipient, the weight, the dimensions, picks a shipping product, generates the label, downloads the PDF, comes back to the order and pastes in the tracking code. Every order, every time.
The target was one button in the existing admin panel that does all of that server-side. The carrier’s API is a two-step handshake:
- Authenticate — client ID + secret → bearer token, ~3600s lifetime.
- Create the waybill — token + shipment payload → barcode + label URL.
Constraints that shaped the design:
- 100 calls/minute, carrier-imposed, shared across every shipment the whole business creates that minute.
- The label URL carries a SAS token that expires in ~12h — there’s no “re-download” endpoint, and calling waybill-creation twice for the same order doesn’t regenerate a label, it creates a second real shipment with a new cost.
- The sandbox environment occasionally returned a generic system error on a completely valid payload, which then succeeded unchanged a few minutes later.
None of that is visible to whoever presses the button. From their side: click, wait a few seconds, get a label and a tracking code.
What the service does
A small FastAPI app sits between the admin backend and the carrier, and exposes exactly one contract to the rest of the system:
POST /api/shipping/waybill
{ orderReference, product, packages[], sender, receiver }
→ { success, waybillCode, labelUrl }
Everything the caller shouldn’t have to think about — the token, the retries, the payload shape the carrier actually wants — lives inside.
Token caching. Re-authenticating on every shipment wastes calls against a shared 100/min budget for no benefit. Cache the token in memory, refresh only near expiry, and guard the refresh with a lock so two concurrent requests racing an expired token don’t both re-authenticate:
import time
import asyncio
class TokenCache:
def __init__(self, authenticate, safety_margin_s: int = 60):
self._authenticate = authenticate
self._margin = safety_margin_s
self._token = None
self._expires_at = 0.0
self._lock = asyncio.Lock()
async def get(self) -> str:
async with self._lock:
if self._token is None or time.time() >= self._expires_at:
token, ttl = await self._authenticate()
self._token = token
self._expires_at = time.time() + ttl - self._margin
return self._token
Retrying the flake, not everything. One specific carrier error code was transient in the sandbox — identical payload, fails, then succeeds untouched. Everything else should fail fast instead of masking a real problem behind a retry loop:
RETRIABLE_CODES = {112} # carrier's own "transient system error"
async def call_with_retry(send, backoffs=(2, 5, 15)):
result = await send()
for delay in backoffs:
if result.error_code == 0 or result.error_code not in RETRIABLE_CODES:
return result
await asyncio.sleep(delay)
result = await send()
return result
Classifying errors instead of relaying them. The carrier’s own API returns 200 OK with an error object buried inside — which means every caller has to remember to check a field instead of trusting the status code. I split the carrier’s error codes into “bad input” and “everything else” and mapped each to an HTTP status that actually means something to a caller:
CLIENT_ERROR_CODES = {100, 101, 116, 143, 144} # bad request data → 400
def status_for(error_code: int) -> int:
return 400 if error_code in CLIENT_ERROR_CODES else 502
A malformed postcode is a 400 the caller should fix. A carrier-side outage is a 502 the caller should retry later. Collapsing both into “the carrier said no” would have pushed that judgment call onto every future consumer of the endpoint.
Mapping a real order to a compliant shipment. The service itself is stateless — it takes a ready-made request and doesn’t touch a database. But a second, separate piece maps a real e-commerce order into that request shape: recipient details, computed package weight and dimensions from what was actually purchased, and country handling that turned out to be its own small project. Shipping carriers commonly use their own proprietary country codes rather than plain ISO codes, and some countries fan out into multiple entries for outlying territories with their own postal quirks — the kind of detail that’s invisible until an order to one of those territories fails for a reason that has nothing to do with the order itself. None of that logic is generic enough to be worth showing in full here, but it’s the difference between “the demo works” and “72 real destination countries work.”
Where it breaks: the item that wasn’t there
Before trusting the mapping layer, I ran it against 74 real historical orders — one per distinct destination country — through the sandbox. 53 passed. 21 didn’t. Grouping the failures by error code split them into three unrelated problems:
- 4 were the same transient system error from earlier, on unrelated countries, gone on manual retry — already covered by the retry logic.
- 10 failed with “carrier not available for this destination” — a sandbox-only gap. The same orders, replayed against production, succeeded. Sandbox coverage is simply narrower than production.
- 7 failed with “data not compliant” on the shipment’s item list, on international destinations only — and this one didn’t disappear in production.
Nothing in the carrier’s API manual mentioned a limit on how many line items a shipment can declare. So instead of guessing field by field, I bisected it directly: take a real failing order, halve the item list, see whether the response flips from fail to pass, repeat.
def find_max_items(send_with, all_items):
lo, hi, last_ok = 1, len(all_items), 0
while lo <= hi:
mid = (lo + hi) // 2
if send_with(all_items[:mid]).ok:
last_ok, lo = mid, mid + 1
else:
hi = mid - 1
return last_ok
Four items passed, five failed — every time, on every international destination, and never on a domestic shipment, which doesn’t require a customs declaration at all. An undocumented ceiling of 4 declared items per international shipment, invisible until an order happened to contain a fifth product.
Dropping data to fit under the limit wasn’t an option — an incomplete customs declaration is how a package gets stuck at a border. The fix was aggregation: group the extra items by what customs actually evaluates (tariff code, origin country) and collapse them into one declared line with combined weight and value. In this catalog that collapsed to a single line every time, but the aggregation logic doesn’t assume that — it exists for whatever combination shows up next.
Two destinations stayed broken after the fix, on an unrelated error (“content not compatible”) that no amount of payload permutation moved. A call to the carrier’s own support line confirmed those two territories simply aren’t served by that shipment type — not a bug, a hard limitation. The right fix there was rejecting those destinations explicitly with a clear error before the call ever leaves the building, instead of letting a cryptic carrier error surface two hops downstream.
[!tip] The debugging lesson When a third-party API rejects a valid-looking payload with a vague message and the docs are silent, don’t keep guessing fields one at a time. Bisect the input space. It turns an open-ended guessing game into a five-minute experiment with a definite answer.
The numbers
| Stage | Result |
|---|---|
| Rate limit budget | 100 calls/min (carrier-imposed) |
| Token lifetime / cache margin | ~3600s / 60s safety margin |
| Retry schedule (transient error only) | 2s → 5s → 15s, 3 attempts |
| First batch pass rate (74 orders, sandbox) | 53/74 (71.6%) |
| After triage + fixes | 72/74 (97.3%) |
| Root causes found | 4 sandbox flakiness, 10 sandbox-only coverage gap, 5 real mapping bugs, 2 genuinely unsupported |
| Undocumented limit found | 4 declared items per international shipment |
| Label URL validity | ~12h (expiring token, not persisted by design) |
Verdict
The hard part of integrating a third-party API is never the two calls in the happy-path diagram — it’s the validation the docs forgot to mention, the sandbox that lies about coverage, and the one order type nobody thought to test. Test against a wide, real sample before you trust an integration, and when the API and its own documentation disagree, trust the API and go find out why with an experiment, not a guess. If a bug like this can only be characterized by bisecting real inputs, that’s worth its own writeup — I’ve got one coming on exactly that.
This maps to the Backend pillar — more on wrapping flaky, rate-limited third-party APIs is filed under Backend. Full engagement scope and outcome: Automated Shipping Labels via Poste Delivery Business.
References
Related
A backend service that turns a manual, portal-driven shipping workflow into a one-click label generator — orchestration, retries, and international edge cases included.
MobiShare's Razor Pages, SignalR hub, and MQTT handler all call the app's own Web API over loopback HTTP instead of in-process — forcing a cookie-forwarding handler and an AsyncLocal hack. An accidental distributed monolith, and what it cost.
app.UseAuthentication() was never called and no API controller had [Authorize]. The whole JSON API was open to anyone. Here's how I found it, and what it actually took to fix it without breaking the app.
Get new posts by email
No hype, unsubscribe anytime. · Powered by Buttondown