Unified.to
All articles

How to Build Multi-Carrier Shipping (Rates, Labels, Tracking) using a Unified Shipping API


April 10, 2026

If you need to support shipping across providers like Shippo, EasyPost, FedEx, UPS, DHL, and USPS, you need a consistent way to create shipments, retrieve rates, generate labels, and track deliveries—without building and maintaining separate integrations for each API.

This guide shows how to implement a full shipping workflow using Unified's Shipping API.

Use case: multi-carrier shipping workflow

Build a shipping flow that:

  • creates shipments from order data
  • retrieves carrier rates
  • selects a service
  • generates a shipping label
  • tracks delivery status

All using a single normalized API.

Core objects

ObjectPurpose
Shipmentsource of truth for addresses, packages, and fulfillment state
Rateavailable pricing and delivery options
Labelpurchased label, tracking number, label URL
Trackingdelivery status and event timeline
Carrieroptional carrier discovery

Step 1: (Optional) List carriers

If your product allows carrier selection upfront:

const carriers = await sdk.shipping.listShippingCarriers({
  connectionId,
  limit: 100,
  offset: 0,
  sort: 'name',
  order: 'asc',
  fields: ['id', 'name', 'code', 'is_active'],
});

If you don't need explicit carrier selection, skip this step.

Step 2: Create a shipment

The shipment object anchors the workflow. It contains:

  • origin and destination addresses
  • package details
  • optional fulfillment configuration (signature, insurance, etc.)
const shipment = await sdk.shipping.createShippingShipment({
  connectionId,
  shippingShipment: {
    order_id: 'order_123',
    from_address: {
      name: 'Warehouse',
      address1: '100 Industrial Rd',
      city: 'Toronto',
      region_code: 'ON',
      postal_code: 'M5V 2T6',
      country_code: 'CA',
      telephone: '+1-416-555-0100',
    },
    to_address: {
      name: 'Jane Doe',
      address1: '200 Main St',
      city: 'Chicago',
      region_code: 'IL',
      postal_code: '60601',
      country_code: 'US',
      telephone: '+1-312-555-0188',
      is_residential: true,
    },
    packages: [
      {
        weight: 2.5,
        weight_unit: 'lb',
        length: 10,
        width: 8,
        height: 4,
        size_unit: 'inch',
        value: 85,
        currency: 'USD',
      },
    ],
    reference_number: 'SO-10001',
  },
  fields: [
    'id',
    'status',
    'carrier_id',
    'service_code',
    'rate_id',
    'label_id',
    'tracking_id',
  ],
});

The shipment can later be updated with:

  • selected rate
  • carrier/service
  • label and tracking references

Step 3: Retrieve shipping rates

Rates can be requested using the shipment_id.

const rateResponse = await sdk.shipping.createShippingRate({
  connectionId,
  shippingRate: {
    shipment_id: shipment.id,
    currency: 'USD',
  },
  fields: ['shipment_id', 'id', 'rates'],
});

Each rate may include:

  • amount, currency
  • estimated_days, delivery_days
  • estimated_delivery_end_at
  • is_guaranteed
  • surcharge breakdowns

Select a rate based on your product logic:

  • lowest cost
  • fastest delivery
  • guaranteed service

Step 4: Apply selected rate to the shipment

Update the shipment with the selected rate.

const selectedRate = rateResponse.rates?.[0];

const updatedShipment = await sdk.shipping.updateShippingShipment({
  connectionId,
  id: shipment.id,
  shippingShipment: {
    carrier_id: selectedRate?.carrier_id,
    rate_id: rateResponse.id,
    service_code: selectedRate?.code,
  },
  fields: ['id', 'carrier_id', 'rate_id', 'service_code'],
});

This keeps the shipment aligned with the chosen fulfillment option.

Step 5: Create the shipping label

Label creation produces:

  • tracking number
  • label URL
  • label cost
  • service metadata
const label = await sdk.shipping.createShippingLabel({
  connectionId,
  shippingLabel: {
    shipment_id: updatedShipment.id,
    rate_id: updatedShipment.rate_id,
    service_code: updatedShipment.service_code,
    label_format: 'PDF_4X6',
  },
  fields: [
    'id',
    'tracking_number',
    'label_url',
    'label_format',
    'status',
    'label_cost',
    'label_cost_currency',
  ],
});

At this point, you can:

  • render or download the label
  • attach tracking to the order
  • mark the shipment ready

Step 6: Retrieve label state (if needed)

If label generation is asynchronous or needs verification:

const latestLabel = await sdk.shipping.getShippingLabel({
  connectionId,
  id: label.id,
  fields: [
    'id',
    'tracking_number',
    'label_url',
    'status',
    'is_voided',
  ],
});

You can also retrieve labels incrementally:

const labels = await sdk.shipping.listShippingLabels({
  connectionId,
  shipment_id: shipment.id,
  updated_gte: lastSyncAt,
  limit: 100,
  offset: 0,
  sort: 'updated_at',
  order: 'asc',
  fields: ['id', 'tracking_number', 'status', 'updated_at'],
});

Step 7: Retrieve tracking data

Tracking provides:

  • current shipment status
  • delivery estimate
  • full event timeline
const tracking = await sdk.shipping.getShippingTracking({
  connectionId,
  id: updatedShipment.tracking_id,
  fields: [
    'id',
    'tracking_number',
    'status',
    'events',
    'estimated_delivery',
    'actual_delivery_at',
    'status_description',
  ],
});

Tracking events include:

  • timestamped status updates
  • carrier-specific event codes
  • location data
  • delivery confirmation

Step 8: Maintain shipment state incrementally

Use list endpoints with pagination and timestamps:

const shipments = await sdk.shipping.listShippingShipments({
  connectionId,
  updated_gte: lastSyncAt,
  limit: 100,
  offset: 0,
  sort: 'updated_at',
  order: 'asc',
  fields: [
    'id',
    'order_id',
    'status',
    'carrier_id',
    'service_code',
    'label_id',
    'tracking_id',
    'updated_at',
  ],
});

This supports:

  • syncing fulfillment state into your system
  • updating order status
  • detecting delivery changes

Step 9: Handle updates with webhooks + polling

Shipping integrations do not provide consistent webhook coverage.

In practice:

  • some integrations support tracking events
  • some support shipment updates
  • others require polling

Recommended approach:

Use webhooks where supported

  • receive real-time updates
  • reduce API calls

Use incremental polling everywhere else

  • use updated_gte
  • paginate with limit + offset
  • sort deterministically

This ensures consistent state across all integrations.

Extended scenarios

The same workflow supports:

International shipping

  • customs object
  • duties and taxes configuration
  • importer/exporter data

Returns

  • is_return
  • original_shipment_id
  • return_reason
  • return labels

Insurance and delivery controls

  • insurance
  • is_signature_required
  • is_adult_signature_required

End-to-end flow

const shipment = await sdk.shipping.createShippingShipment({...});

const rateResponse = await sdk.shipping.createShippingRate({
  shipment_id: shipment.id,
});

const selectedRate = rateResponse.rates?.[0];

await sdk.shipping.updateShippingShipment({
  id: shipment.id,
  shippingShipment: {
    carrier_id: selectedRate?.carrier_id,
    rate_id: rateResponse.id,
    service_code: selectedRate?.code,
  },
});

const label = await sdk.shipping.createShippingLabel({
  shipment_id: shipment.id,
  rate_id: rateResponse.id,
});

const tracking = await sdk.shipping.getShippingTracking({
  id: shipment.tracking_id,
});

Key takeaways

  • Shipping is a multi-step workflow: shipment → rate → label → tracking
  • Rates should be tied to a shipment for traceability
  • Labels provide the bridge to fulfillment and tracking
  • Tracking must be handled as a stateful, event-driven object
  • Pagination and updated_gte enable incremental sync across all integrations
  • Webhooks are partial—polling is required for full coverage

This structure lets you support multiple shipping providers through one implementation, without maintaining separate integration logic for each.

Start your 30-day free trial

Book a demo

All articles