Building a Multi-Provider Shipping Integration Without the Pain
Building a multi-provider shipping integration means writing and maintaining separate adapters for each provider's authentication, request format, response structure, and error handling. The standard approach is an adapter pattern with normalized types and parallel execution viaPromise.allSettled, but the ongoing maintenance cost is significant for teams where shipping is not the core product.
What is the core challenge of multi-provider shipping integration?
Every shipping provider has a different API shape: different authentication mechanisms, different request formats, different response structures, and different error codes. You can write adapters for each one, but now you have three adapters to maintain, three sets of tests, and three places where a provider API change can break your integration.
The temptation is to pick one provider and stick with it. The problem is that picking one provider means accepting their carrier coverage, their rate structure, and their reliability as constraints on your product.
How do you normalize shipping rates across different APIs?
The standard approach is to define a normalized internal schema and write an adapter for each provider that maps their response to your schema. Every rate, regardless of provider, gets mapped to the same interface with provider, carrier, service, price, and estimated delivery fields.
interface NormalizedRate {
provider: string;
carrier: string;
service: string;
price_cents: number;
currency: "USD";
estimated_days: number | null;
rate_id: string;
}Each provider adapter takes the raw API response and returns a list of NormalizedRate objects. Your application code only ever sees the normalized shape.
Should you run provider API calls in parallel or sequentially?
Always run provider calls in parallel. Each provider call takes 500ms to 2 seconds. Running three calls sequentially means up to 6 seconds of latency. Running them in parallel withPromise.allSettled keeps total latency equal to the slowest single provider, and a single failure does not reject the entire request.
const [shippoRates, easypostRates, shipengineRates] = await Promise.allSettled([ fetchShippoRates(shipment), fetchEasyPostRates(shipment), fetchShipEngineRates(shipment), ]);
Use Promise.allSettled instead of Promise.all so a single provider failure doesn't reject the entire request. Collect errors separately and return partial results.
How should you handle errors when one provider fails?
Provider APIs fail. Rate limits get hit. Temporary outages happen. Your integration should return whatever rates are available from the providers that succeeded, alongside an errors array describing which providers failed and why. Never let one provider's failure block the response from others.
- Return whatever rates you have, even if some providers failed
- Include an errors array alongside the rates array so callers know what happened
- Categorize errors: AUTH_FAILED means the key is bad, TIMEOUT means try again, PROVIDER_ERROR is on them
- Never let one provider's failure block the response from others
What is the long-term maintenance cost of multiple integrations?
Even with a clean adapter pattern, you are still responsible for keeping three integrations up to date. Provider APIs evolve. New fields get added. Deprecated endpoints get removed. Rate structures change. For teams where shipping is not the core product, this maintenance burden is a real cost that compounds over time.
Can you avoid maintaining multiple integrations yourself?
RateShip is the adapter layer for all three providers. Instead of building and maintaining the fan-out logic, normalization, and error handling yourself, you call one RateShip endpoint and get back normalized rates from every connected provider. Provider API changes are RateShip's problem to handle. Your integration stays the same regardless of what Shippo or EasyPost change on their end.