Amazon Buy Shipping Integration

Amazon's Shipping V2 API ("Buy Shipping") integration provides discounted carrier rates with A-to-Z Buy Shipping protections for Amazon marketplace orders, enabling label purchasing and tracking directly from Heatwave.

Overview

When processing Amazon marketplace orders, the system can:

  • Retrieve Amazon's negotiated carrier rates (UPS, USPS, FedEx, Amazon Logistics)
  • Display rates alongside regular carriers for comparison in the shipping workflow
  • Purchase shipping labels via Amazon's API with inline PDF delivery
  • Populate tracking numbers automatically
  • Void/cancel labels if needed
  • Gain A-to-Z Buy Shipping protections against late delivery claims

Architecture

Mirrors the existing Ship with Walmart (SWW) pattern:

┌─────────────────────────────────────────────────────────────────────────┐
│                        Rate Retrieval Flow                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  Delivery needs shipping ──► Is Amazon Order? ──► Fetch Buy Shipping   │
│                                     │              Rates               │
│                                     No                  │              │
│                                     │                   ▼              │
│                                     ▼        Merge with FedEx/UPS rates│
│                              Skip AMZ ───────────────►──┘              │
│                                                        │               │
│                                                        ▼               │
│                                             Display combined rates     │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                        Label Purchase Flow                              │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  User selects AMZ rate ──► purchaseShipment API ──► Inline label PDF   │
│                                                           │            │
│                                                           ▼            │
│                                                  Attach to Shipment    │
│                                                           │            │
│                                                           ▼            │
│                                                  Populate tracking     │
│                                                           │            │
│                                                           ▼            │
│                                          Trigger ship confirm to Amazon│
└─────────────────────────────────────────────────────────────────────────┘

Unlike Walmart SWW (which requires a separate label download step), Amazon returns the label PDF inline during purchaseShipment, simplifying the flow.

Key Components

Services

Service Purpose
Edi::Amazon::ShipWithAmazon Core API client for Shipping V2 endpoints
Shipping::AmazonSeller Shipping carrier class following Shipping::Base pattern
Edi::Amazon::ShippingLabelPurchaser Orchestrates label purchase/void workflow
Edi::MarketplaceLabelPurchaser Base class with factory routing for marketplace labels

API Endpoints Used

Endpoint Method Purpose
/shipping/v2/shipments/rates POST Get eligible shipping rate offerings
/shipping/v2/shipments POST Purchase a shipment (returns label inline)
/shipping/v2/shipments/{id}/documents GET Download label PDF (fallback)
/shipping/v2/shipments/{id}/cancel PUT Cancel/void a purchased shipment
/shipping/v2/tracking GET Get tracking information

Usage

Automatic Rate Retrieval

For Amazon marketplace orders, Buy Shipping rates are automatically included in shipping rate comparison:

# In Delivery#retrieve_shipping_costs
# Amazon order info is automatically detected and passed to WyShipping
if order&.edi_orchestrator_partner&.start_with?('amazon_seller')
  options[:amazon_order_info] = {
    partner: order.edi_orchestrator_partner.to_sym,
    order_id: order.edi_po_number,
    deliver_by_date: order.requested_deliver_by,
    ship_by_date: company.next_valid_ship_date
  }
end

Manual Rate Retrieval

orchestrator = Edi::Amazon::Orchestrator.new(:amazon_seller_central_us)
amz_client = Edi::Amazon::ShipWithAmazon.new(orchestrator)

result = amz_client.get_rates(
  amazon_order_id: '111-2222222-3333333',
  ship_from: {
    name: 'WarmlyYours',
    street1: '590 Telser Rd',
    city: 'Lake Zurich',
    state: 'IL',
    zip: '60047',
    country: 'US',
    phone: '8475407775'
  },
  ship_to: {                            # Required for sandbox; optional for production AMAZON channel
    name: 'John Doe',
    street1: '123 Main St',
    city: 'Chicago',
    state: 'IL',
    zip: '60601',
    country: 'US'
  },
  packages: [{ weight: 5.0, length: 12, width: 10, height: 8 }],
  ship_date: Date.current
)

if result.success
  puts "Token: #{result.request_token}"
  result.rates.each do |rate|
    puts "#{rate[:carrier_name]} #{rate[:service_name]}: $#{rate[:total_charge]}"
  end
end

Label Purchase

# After user selects an Amazon Buy Shipping rate
purchaser = Edi::Amazon::ShippingLabelPurchaser.new(shipment)
result = purchaser.purchase_label(selected_rate)

if result.success
  puts "Tracking: #{result.tracking_number}"
  puts "Shipment ID: #{result.label_id}"
  # Label PDF automatically attached to shipment.uploads
end

Void Label

purchaser = Edi::Amazon::ShippingLabelPurchaser.new(shipment)
result = purchaser.void_label

if result[:success]
  puts "Label voided successfully"
  # Tracking, carrier, amz_shipment_id, and amz_metadata cleared from shipment
end

Database Schema

Migration

# db/migrate/20260303200000_add_amazon_buy_shipping_fields_to_shipments.rb
add_column :shipments, :amz_shipment_id, :string   # Amazon's shipment ID (used for cancellation)
add_column :shipments, :amz_metadata, :jsonb        # Typed metadata via jsonb_accessor

# db/migrate/20260307000000_add_amazon_buy_shipping_options.rb
# Seeds ShippingOption records for AMZBS carrier services (UPS, USPS, FedEx PTP rates)

Shipment Model Extensions

# In Shipment model
jsonb_accessor :amz_metadata,
               amz_carrier: :string,        # Actual carrier: UPS, USPS, FedEx
               amz_service_name: :string,    # e.g., "Ground"
               amz_total_charge: :decimal,   # Label cost
               amz_purchased_at: :datetime,  # When label was purchased
               amz_order_id: :string         # Amazon order ID for audit

Carrier Handling

For Amazon Buy Shipping shipments, carrier data is stored in two places:

  • shipment.carrier = "AmazonSeller" — Indicates this is an Amazon Buy Shipping label
  • shipment.amz_carrier = "UPS" / "FedEx" / "USPS" — The actual underlying carrier

This dual storage allows:

  • UI to identify Amazon Buy Shipping shipments and display appropriate badges
  • Tracking to use the actual carrier's tracking system
  • EDI confirms to send the correct carrier info to Amazon

ShippingCost Rate Data

Amazon rates store additional fields in rate_data:

{
  amz_rate_id: 'rate_abc',
  amz_request_token: 'tok_xyz',
  amz_carrier_id: 'UPS',
  amz_carrier_name: 'UPS',
  amz_service_id: 'UPS_GROUND',
  amz_service_name: 'Ground',
  amz_total_charge: 12.50,
  amz_transit_days: 3,
  amz_delivery_window: { start: '...', end: '...' },
  amz_benefits: nil,
  days_in_transit: 3,
  raw: { ... }   # Full Amazon response for debugging
}

Rate Token Lifecycle

Amazon's getRates returns a requestToken that must be passed to purchaseShipment. For AMAZON channel orders, this token has a limited validity window (~10 minutes). The token is stored in rate_data when rates are fetched and flows through ShippingCost → rate selection → label purchase.

If the token expires, rates must be re-fetched before purchasing. The ShippingLabelPurchaser#purchase_label method validates token presence and returns a clear failure_result ("Rate missing request_token — token may have expired, re-fetch rates") so callers know to refresh. Expired tokens surface as user-facing errors on the delivery page, prompting the user to click "Refresh Rates".

UI Integration

Rate Display

Amazon rates are displayed with a distinctive prefix:

Amazon Buy Shipping: UPS Ground - $12.50 (3 days)
Amazon Buy Shipping: USPS Priority Mail - $8.25 (2 days)

Helper Methods

<%# Badge for Amazon Buy Shipping %>
<%= amz_bs_badge %> <%# Renders: <span class="badge bg-warning text-dark">Amazon Buy Shipping</span> %>

<%# Check if rate is from Amazon Buy Shipping %>
<% if is_amz_bs_rate?(shipping_cost) %>
  <!-- Show Amazon-specific info -->
<% end %>

<%# Combined rate display with badge %>
<%= format_shipping_rate_with_marketplace_badge(shipping_cost) %>

Configuration

Orchestrator Settings

Buy Shipping is controlled via settings in Edi::Amazon::Orchestrator:

amazon_seller_central_us: {
  # ... other settings
  buy_shipping_enabled: true,
  buy_shipping_business_id: 'AmazonShipping_US',
  transporter_profile: :amazon_sc_seller_api
}

amazon_seller_central_ca: {
  # ... other settings
  buy_shipping_enabled: true,
  buy_shipping_business_id: 'AmazonShipping_US',   # CA routes through US endpoint
  transporter_profile: :amazon_sc_seller_api
}

To disable Buy Shipping for a partner, set buy_shipping_enabled: false.

Business ID Mapping

Amazon Shipping V2 requires an x-amzn-shipping-business-id header per marketplace region:

Partner Business ID
amazon_seller_central_us AmazonShipping_US
amazon_seller_central_ca AmazonShipping_US
amazon_seller_central_uk AmazonShipping_UK
amazon_seller_central_fr AmazonShipping_FR
amazon_seller_central_de AmazonShipping_FR
amazon_seller_central_es AmazonShipping_ES
amazon_seller_central_it AmazonShipping_IT

Credentials

Uses existing SP-API credentials configured in credentials.yml. No separate credentials needed — Buy Shipping uses the same LWA + SigV4 auth chain as orders, catalog, and feeds:

amazon_sc_seller_api:
  client_id: '...'
  client_secret: '...'
  api_host: sellingpartnerapi-na.amazon.com
  refresh_token: '...'

Seller Central Setup

Completed prerequisites:

  1. Carrier Terms & Conditions accepted in Seller Central > Settings > Shipping Settings
  2. SP-API IAM roles verified (same roles used for Orders/Feeds/Catalog)

EDI Ship Confirmation

When labels are purchased via Amazon Buy Shipping:

Carrier Mapping

Edi::Amazon::ConfirmMessageProcessor handles carrier info for ship confirm messages:

CARRIER_TO_CARRIER_CODE_MAP_HASH = {
  'FedEx': 'FedEx',
  'UPS': 'UPS',
  'USPS': 'USPS',
  'AmazonSeller': 'Amazon'   # Fallback if amz_carrier not set
}

Effective Carrier Resolution

For Amazon Buy Shipping shipments (shipment.carrier == 'AmazonSeller'), the processor resolves the actual carrier from metadata:

effective_carrier = if shipment.carrier == 'AmazonSeller' && shipment.amz_carrier.present?
                      shipment.amz_carrier   # e.g., "UPS", "FedEx"
                    else
                      shipment.carrier       # Falls back to "AmazonSeller"
                    end

This ensures Amazon receives the correct carrier code (e.g., "UPS") rather than "AmazonSeller".

Early Label Purchase

Extended from the Walmart SWW early label flow. All early label methods in Order branch
on edi_orchestrator_partner.start_with?('amazon_seller') to use the correct API, message
format, and shipper class.

Flow

  1. User checks "Purchase label early" on the shipping page
  2. Order proceeds through normal CR hold / payment flow
  3. When order reaches awaiting_deliveries, purchase_early_label_if_requested fires
  4. build_early_label_shipper creates Shipping::AmazonSeller (or Shipping::WalmartSeller)
  5. Rates are fetched, matched to the delivery's selected shipping cost by amz_carrier_id + amz_service_id
  6. Label is purchased via Shipping::AmazonSeller#create_label; PDF stored inline
  7. fire_early_label_edi_confirm sends an Amazon-format ship confirm (packageDetail)
  8. Warehouse picks/packs; at complete_picked, early_label_shipments_match? checks for dimension/weight changes
  9. If mismatch detected, void_early_label! cancels via ShipWithAmazon#cancel_shipment and resets the flag
  10. At ship-label time, ShippingLabelPurchaser#use_early_purchased_label reuses the label (if no mismatch) or a fresh label is purchased

Key Methods

  • Order#marketplace_early_label_eligible? — checks both Walmart and Amazon eligibility
  • Order#amazon_buy_shipping_eligible? — checks EDI order + amazon_seller partner + buy_shipping_enabled?
  • Order#build_early_label_shipper — returns Shipping::AmazonSeller or Shipping::WalmartSeller
  • Order#match_early_label_rate — matches by amz_carrier_id + amz_service_id (Amazon) or sww_carrier_id + sww_service_type (Walmart)
  • Order#fire_early_label_edi_confirm_amazon — builds Amazon packageDetail format confirm and sends immediately
  • Order#void_early_label! — routes to ShipWithAmazon#cancel_shipment for Amazon orders
  • Edi::Amazon::ShippingLabelPurchaser#use_early_purchased_label — transfers early label from order to shipment

Warehouse Change Handling

If the warehouse changes packaging after an early label was purchased:

  • Mismatch detection compares current shipment dimensions/weights against the early label snapshot (>20% or >2 inches / >1 lb threshold)
  • If mismatch, the early label is voided via Amazon's cancel API
  • A new label is purchased through the normal ship-label flow
  • A new ship confirm is sent with the updated tracking number
  • Amazon allows multiple confirmShipment calls; the latest tracking supersedes the earlier one

Voiding

Via WyShipping

WyShipping.void_amazon_buy_shipping_delivery handles delivery-level voiding:

  1. Finds shipments with amz_shipment_id (or non-empty amz_metadata)
  2. Creates Edi::Amazon::ShippingLabelPurchaser for each shipment
  3. Calls void_label which invokes ShipWithAmazon#cancel_shipment
  4. Clears tracking, carrier, amz_shipment_id, and amz_metadata from shipment
  5. Returns aggregate result with voided/failed counts

Graceful Handling

If amz_shipment_id is blank (label metadata missing), void_label clears metadata silently and returns success — avoids blocking the void flow for orphaned records.

Error Handling

All API calls return structured Data.define result objects:

result = amz_client.get_rates(options)
if result.success
  # Use result.rates, result.request_token
else
  Rails.logger.error("AMZ-BS Error: #{result.error}")
end

Exceptions within API calls are caught and returned as failed results (never raised to callers):

# get_rates, purchase_shipment, cancel_shipment, get_tracking all follow this pattern:
rescue StandardError => e
  RatesResult.new(success: false, error: e.message)
end

Carrier Codes

config/initializers/carrier_codes.rb includes the AmazonSeller entry:

{
  internal:    'AmazonSeller',
  scac:        nil,
  shipengine:  nil,
  gem:         nil,
  shipsurance: nil,
  type:        :parcel,
  active:      true,
  notes:       'Marketplace carrier via Amazon Buy Shipping API; underlying carrier in amz_metadata'
}

Troubleshooting

No Amazon Rates Displayed

  1. Verify the order is from Amazon marketplace (order.edi_orchestrator_partner.start_with?('amazon_seller'))
  2. Check that order.edi_po_number is present
  3. Verify buy_shipping_enabled: true in orchestrator config
  4. Check Rails logs for [AMZ-BS] prefixed errors
  5. Confirm carrier T&C is accepted in Seller Central

"shipTo address" Error in Sandbox

The Amazon sandbox does not resolve shipTo from AMAZON channel order IDs. The integration always includes shipTo explicitly (populated from Shipping::Base destination address fields), which works in both sandbox and production.

Label Creation Failed

  1. Check the rate has valid amz_request_token and amz_rate_id (tokens expire in ~10 minutes)
  2. Verify addresses are valid and complete
  3. Check package dimensions are within carrier limits
  4. Review [AMZ-BS] log entries for API error details

Tracking Not Resolving

For AmazonSeller shipments, tracking uses shipment.amz_carrier (not shipment.carrier). If amz_carrier is blank, WyShipping.track_shipment returns an error. Verify amz_metadata was populated during label purchase.

Differences from Ship with Walmart

Aspect Amazon Buy Shipping Ship with Walmart
Label delivery Inline in purchase response (Base64 PDF) Separate download step required
Cancellation key amz_shipment_id Carrier + tracking number
Channel type AMAZON (order address resolved server-side) N/A (address always required)
Business ID header x-amzn-shipping-business-id per region N/A
Auth LWA + SigV4 (shared with all SP-API) Walmart OAuth (separate)
Metadata column amz_metadata JSONB sww_metadata JSONB
Service code prefix AMZBS_ SWW_
Badge color Warning (yellow) Primary (blue)

Test Coverage

123 tests / 315 assertions across 5 test files:

Test File Coverage
test/services/edi/amazon/ship_with_amazon_test.rb API client: payload builders, mocked HTTP flows for all 5 endpoints, result structs, error handling
test/services/shipping/amazon_seller_test.rb Carrier class: validation, find_rates/create_label/void_label with stubbed client, rate estimate building
test/services/edi/amazon/shipping_label_purchaser_test.rb Label purchaser: factory routing, purchase/void lifecycle, early label reuse, validation
test/services/edi/amazon/buy_shipping_integration_test.rb Integration: WyShipping routing, Order eligibility, ConfirmMessageProcessor mapping, carrier codes
test/services/edi/amazon/early_label_test.rb Early label: Amazon shipper construction, rate matching, inline PDF storage, Amazon-format EDI confirm, void routing, mismatch detection

Run tests with: PARALLEL_WORKERS=1 mise exec -- bundle exec rails test test/services/edi/amazon/ship_with_amazon_test.rb test/services/shipping/amazon_seller_test.rb test/services/edi/amazon/shipping_label_purchaser_test.rb test/services/edi/amazon/buy_shipping_integration_test.rb test/services/edi/amazon/early_label_test.rb

Key Files

File Purpose
app/services/edi/amazon/ship_with_amazon.rb API client wrapping Shipping V2 endpoints
app/services/shipping/amazon_seller.rb Carrier class for WyShipping rate comparison
app/services/edi/amazon/shipping_label_purchaser.rb Label purchase/void lifecycle
app/services/edi/marketplace_label_purchaser.rb Base class with factory routing
app/services/edi/amazon/orchestrator.rb Partner config with buy_shipping_enabled flag
app/services/wy_shipping.rb Carrier routing, void flow, track flow
app/services/edi/amazon/confirm_message_processor.rb Ship confirmation with carrier mapping
app/models/shipment.rb amz_metadata JSONB accessors
app/models/order.rb amazon_buy_shipping_eligible?, early label methods
app/models/delivery.rb amazon_order_info in rate retrieval, intentional selection
app/helpers/deliveries_helper.rb amz_bs_badge, is_amz_bs_rate? helpers
config/initializers/carrier_codes.rb AmazonSeller carrier entry
db/migrate/20260303200000_add_amazon_buy_shipping_fields_to_shipments.rb Schema migration

Related Documentation

  • doc/features/SHIP_WITH_WALMART.md — Ship with Walmart integration (analogous pattern)
  • doc/features/WALMART_EARLY_LABEL_PURCHASE.md — Early label purchase flow (shared with Amazon)
  • .agents/skills/webhooks/SKILL.md — Webhook handling patterns

API Reference