Back to Journal
logisticsen

Multi-carrier shipping behind one dashboard

Six carriers, six webhook shapes, one dashboard, and the one feature we would remove if we could start over.

Switching a webshop from MPL to DPD or GLS takes weeks. Running all six carriers at once (MPL, GLS, Foxpost, DPD, DHL, UPS) is not a longer project, it is a different product. We built multi-carrier dashboards for six partners on the Netorigo platform over the past year, and here is what we learned.

The adapter is the work

The dashboard itself is not much code. The real work happens in the adapter layer, which is different for every carrier, and every carrier believes their format will be the standard. Our CarrierAdapter interface asks for four operations:

interface CarrierAdapter {
  bookShipment(intent: ShipmentIntent): Promise<CarrierShipmentRef>;
  getStatus(ref: CarrierShipmentRef): Promise<CarrierStatus>;
  generateLabel(ref: CarrierShipmentRef): Promise<LabelArtifact>;
  parseWebhook(raw: unknown): CarrierEvent | null;
}

The six adapters land at different sizes: GLS is 340 lines, Foxpost is 180 (because of the parcel-locker code lookup), UPS is 720 lines (their OAuth 2.0 flow now requires a hardware security key, a six-month-old change). DPD is 290 lines, but we are already on the second version because DPD changed their webhook payload schema in November 2025 without warning.

The webhook-shape mess

The single biggest thing that nearly broke us: webhook format inconsistency. MPL sends XML (UTF-8 with a BOM). DPD sends JSON in camelCase. Foxpost sends JSON in snake_case. GLS sends JSON in PascalCase. DHL sends JSON in camelCase wrapped inside an event_data envelope. UPS sends XML inside a SOAP envelope. This is not just labour, it is a bug magnet. Our parseWebhook in each adapter runs its own zod schema validation, and parse errors land in a webhook_parse_errors table for periodic review.

The timing accuracy of the standard webhook events (shipment.delivered, shipment.failed_delivery, shipment.in_transit) also varies. DHL fires the event the moment the driver presses the proof-of-delivery button on the handheld. MPL batches the entire day's deliveries into one large post around 22:00 local time, which means the deliveredAt field can lag the real event by up to eight hours.

Idempotency keys per carrier

The biggest gotcha: not every carrier sends idempotency keys. DHL and UPS do (X-Idempotency-Key). GLS does not. Foxpost deliberately fires the delivered event twice to make sure the partner receives it. Our solution is a carrier_event_hash dedup table that stores the SHA-256 of every webhook payload, and we reject any duplicate hash within 24 hours. Since January 2026 this has stopped 1.4 million duplicate events from reaching downstream consumers.

The retry-policy trap

The bookShipment operation is the most critical because it reserves capacity at the carrier. Our first retry policy was naive exponential backoff (1s, 2s, 4s, 8s), and that produced double bookings at GLS: the booking succeeded but the response was lost in a transient network error, and the next attempt created a second booking. Today every booking call carries an idempotency key (X-Idempotency-Key: order_42_attempt_2) and we perform server-side dedup ourselves when the carrier does not.

The one feature we would remove

The dashboard shows two things: shipment status per carrier, and performance metrics (average delivery time, failure rate, shipping cost per carrier). The performance metrics are pretty. Nobody looks at them. 90% of partners use the status list for daily work, and if the metrics were gone tomorrow they would not be missed. If we started today, we would not build them, and that would have saved us roughly 320 lines of controller code, 480 lines of service code, and a third-party charting library dependency.