CarrierAPI — Self-hosted multi-carrier shipping platform

One REST API for shipping labels, rates, tracking and batch shipping across multiple carriers — self-hosted, as an alternative to a SaaS tool like SendCloud, with full control over your own carrier contracts.

Multi-carrier shipping
FastAPI / Python
NetSuite & Shopify sync
Self-hosted

Role & scope

An e-commerce company with its own ERP (NetSuite) shipped through a SaaS shipping tool, with the costs and the limited control over carrier contracts that come with it. CarrierAPI is the self-hosted replacement: one API that creates labels, compares rates, tracks shipments and processes batches across multiple carriers, with the same connection points as the previous tool so the switch from NetSuite could be a drop-in replacement rather than a rebuild.

  • An adapter architecture per carrier (PostNL, DHL Express and UPS as the core set; DHL Parcel, FedEx, GLS and SendCloud itself as extra adapters), so adding a new carrier requires no change to the rest of the system.
  • Drop-in replacement for the previous SaaS tool inside NetSuite: identical custom field names, minimal changes to the existing SuiteScript integration.
  • A direct hook into NetSuite Item Fulfillment records, plus synchronisation with Shopify and an Amazon integration alongside the carriers.
  • Two ways to manage carrier credentials: through the UI (database-backed, preferred) or through environment variables (a legacy path for existing deployments).

Impact

  • Shipping costs and carrier contracts back in your own hands instead of being tied to the rates and terms of a SaaS middle layer.
  • Fixed address recognition for a class of addresses that used to be parsed incorrectly (house-number extraction accidentally picked the last number instead of the first one after the street name — wrong for, say, holiday-park addresses with both a house number and a chalet number).
  • Invoice matching with UPS kept working after UPS changed the export format of their invoices twice, by basing column detection on content instead of fixed positions, checked against the total the invoice itself claims.
  • Closed a session leak where deleted users still had access for up to 24 hours, because only the signature and expiry of their session token were checked — not whether the account still existed.

Architecture

Every carrier is an adapter behind a shared interface (creating and cancelling shipments, requesting rates, tracking, available services) — connecting a new carrier does not touch the rest of the system. The backend is FastAPI (Python, async) with PostgreSQL as the primary database and a Redis-backed background worker (arq) for batch label processing, so creating hundreds of labels at once does not block the API. Labels themselves are stored in MinIO (S3-compatible), so storage isn't tied to a single cloud vendor. The frontend is a Next.js/React dashboard for managing shipments, carrier settings and credentials. The whole thing runs self-hosted via Docker Compose behind Traefik, deployed with Coolify.

Model choice

No AI/LLM — this is deliberately a deterministic system: shipping labels, rates and tracking have to be predictable and auditable, not generated. The key choice here was self-hosted versus SaaS: a SaaS shipping tool is quicker to set up, but a self-hosted platform gives you control over carrier contracts, no per-label or per-shipment fee to a middle layer, and the ability to build functionality (such as the direct NetSuite integration) exactly to fit instead of waiting for what a SaaS vendor does or doesn't support.

Cost & latency

  • Self-hosted infrastructure (Docker Compose, PostgreSQL, Redis, MinIO) instead of a recurring per-shipment SaaS fee.
  • Batch label generation runs asynchronously through a Redis queue (arq), so creating hundreds of labels at once keeps the API responsive instead of leaving the requester waiting.
  • Invoice matching verified against real UPS invoices: among them a 1,275-line invoice at €4,855.72 and a 2,275-line export, both processed correctly after the column-detection fix.

Lessons learned

Address parsing for Dutch addresses went wrong for a specific class of addresses (holiday parks with both a house number and a separate chalet number, such as "Hessenweg 83 41") because the parser read the last number in the street line as the house number instead of the first one after the street name. Solved with a shared parser, verified against a real case. UPS invoice import broke twice because UPS changed the export format of their invoices (column count and order shifted); the fixed-column-position approach was replaced by content-based column detection that cross-checks the result against the total the invoice itself claims. Address validation warnings initially blocked shipment creation entirely; that was rolled back to blocking only the automatic label purchase, so a flagged shipment stays visible and retryable instead of stuck. And a session vulnerability: deleted users kept access for up to 24 hours because tokens were only checked on signature and expiry, not on the existence of the account — fixed by verifying on every request that the account still exists.