Integration guide for 1.3.0

QuotesPBC Web Components - Customer Integration Guide


Which widget do you need?

This guide covers more than one component. Pick by what you are building:

Widget Use it when Section
<ready-zipcode-quote> You want a price for a zip-to-zip move, nothing more Quick Start below
<ready-contact-quote> You want a quote plus an editable delivery address Quick Start below
<quote-order-component> You want the full quote → review → place-order flow (multiple transport options, high-value handling, order submission) Quote/Order Widget at the end

The first two are documented immediately below and remain supported. The Quote/Order widget is the newest and is the only one that can place an order — if you need ordering, skip to its section.

Everything in "Styling & Customization", "Security & Authentication" and "Browser Support" applies to all three.

🚀 Quick Start

Integrate transportation quotes into your website with just one line of code! Our web components are self-contained and require no additional dependencies.

Simple Integration

Add this single script tag to your HTML page — one tag, nothing else:

<script src="https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js"></script>

Environments

Environment Script URL
Dev https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js
Stage https://stage-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js
Prod https://prod-logistics-pbc.awsmanlog10.manheim.com/v/1.3.0/quotespbc-ui-loader.js

Choosing a loader URL

Every release is published in its own folder, and each URL above pins this guide's version, 1.3.0. Two shorter paths follow newer releases for you:

Path Loads Use it when
/v/1.3.0/ Exactly 1.3.0, never anything else Production. Upgrade deliberately, after testing the new version.
/v/M.m/ (the first two numbers of the version) The newest patch release of that minor line You want fixes as soon as they are released, and nothing else.
/v/M/ (the first number of the version) The newest minor or patch release of that major line Non-production pages that should track new features. A major version is never switched for you.

Each path keeps working for as long as its version is supported. The version list at the root of the script host shows every published version and its support status.

And the API host the widgets call, api-config.bffUrl:

Environment BFF URL (api-config.bffUrl)
Dev https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com
Stage https://stage-logistics-pbc-bff.awsmanlog10np.manheim.com
Prod https://prod-logistics-pbc-bff.awsmanlog10.manheim.com

These are two different hosts, deliberately. The script origin serves the widget; the BFF URL is the API the widget calls. Note the -bff in the host name, and that prod alone drops the np (non-production) segment. Give no path or trailing slash — the widget appends its own routes.

Point your non-production environments at dev or stage, never at prod.

What the loader does. It works out its own base URL from its own <script src>, then injects the module and nomodule entry scripts for you, each stamped with a build-time cache-busting version. That is why you get a single tag and never hard-code a /build/… path: when we release, the version changes and browsers pick up the new bundle without you doing anything.

There is no separate CDN — the widget is served by its own deployment, so the host above is both the script origin and the app origin.

Then use the components anywhere on your page:


<!-- Zip-to-zip quote -->
<ready-zipcode-quote
  api-config='{"bffUrl": "https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com", "token": "bearer eyJ..."}'
  quote-request='{ "partnerId": "YOUR_PARTNER_ID", "vehicles": [ … ], "stops": [ … ] }'></ready-zipcode-quote>

<!-- Quote with an editable delivery address -->
<ready-contact-quote
  api-config='{"bffUrl": "https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com", "token": "bearer eyJ..."}'
  quote-request='{ "partnerId": "YOUR_PARTNER_ID", "vehicles": [ … ], "stops": [ … ] }'></ready-contact-quote>

<!-- Full quote -> review -> place-order flow (newest; see its own section below) -->
<quote-order-component
  api-config='{"bffUrl": "https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com", "token": "bearer eyJ..."}'
  quote-request='{ "partnerId": "YOUR_PARTNER_ID", "vehicles": [ … ], "stops": [ … ] }'
  order-source="OVE"
  customer-id="5000001"></quote-order-component>

All three widgets take the same two objects: api-config (where the API is, and your token) and quote-request (the move: vehicles and stops). Your partner ID is part of the move, quote-request.partnerId. The Current API Reference below has their full shape. <quote-order-component> also takes order-source, customer-id and the rest; see its own section for the full attribute list.

Pass each object as a JSON attribute, as above, or set it as a DOM property from JavaScript:

const el = document.getElementById('quote');
el.apiConfig    = { bffUrl: 'https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com', token: 'bearer eyJ...' };
el.quoteRequest = { /* vehicle + stops — same shape as below */ };
el.orderSource  = 'OVE';
el.customerId   = '5000001';

📋 Current API Reference (Object-Based)

Component Properties Overview

All three components take object properties, passed either as JSON attributes or as DOM properties set from JavaScript:

Common Properties

api-config (required) - API configuration object:

{
  bffUrl: string,  // Backend for Frontend API URL
  token: string    // Bearer authentication token
}

quote-request (required) - Quote request object:

{
  itemRef: string,
  partnerId: string,
  payerId: string,
  shipperId: string,
  quoteType: string,      // e.g., "Standard", "Scheduled"
  quoteSubType: string,   // e.g., "NewToConsumer", "TradeIn"
  vehicles: [{
    vin: string,
    pickUpId: string,     // References stop refId
    dropOffId: string,    // References stop refId
    isOperable: boolean,  // REQUIRED — send true or false explicitly, never omit (see below)
    isEnclosed: boolean,
    description: {
      year: string,
      make: string,
      model: string,
      subModel: string
    }
  }],
  stops: [{
    refId: string,        // e.g., "s1", "s2"
    zip: string,
    address1: string,
    address2: string,
    city: string,
    state: string,
    country: string       // e.g., "US"
  }]
}

⚠️ isOperable must be sent explicitly — true or false. Do not omit it.

It is a boolean, so an absent value is false: a vehicle with no isOperable is treated as inoperable for both pricing and ordering. Inoperable vehicles need winching and special handling, so they generally price higher — omitting the field can quote your customer a price for a condition they never declared.

This applies consistently across POST /Quote, POST /Quote/multi and POST /Order (US2143547). Before that change the order path treated an absent value as operable while the quote path treated it as inoperable, so an omitting host was priced for one condition and ordered another.

ready-contact-quote Specific

quote-id (optional) - Existing quote ID for retrieval:

quoteId: string  // UUID format

quote-contact has been removed. The widget no longer shows or edits contact details (name, phone, email), and no longer publishes ContactUpdated. Stop passing quote-contact; if a page still sets it, it has no effect and causes no error.

The first quote. Supply a ZIP code on both the pickup and the delivery stop. The ZIP code is what the move is priced on; for the delivery stop it is also where City and State come from — the widget does not send your delivery City and State to the quote service, and replaces them on screen with the ones returned for the ZIP code (so a wrong City or State you supply is corrected, not rejected).

What you supply What the customer sees What you receive on quote_bus
Valid pickup and delivery ZIP codes, and the quote succeeds The price, with the delivery City and State from the quote QuoteCreated
A pickup or delivery ZIP code that is missing or malformed (US: 5 digits or ZIP+4; a missing country counts as US) "Unable to get transportation quote at this time. Please try again later." in place of the price Nothing — no quote is requested, so check your own input first
Valid ZIP codes, but the quote fails (the ZIP cannot be served, the service is down, a timeout) The same message in place of the price QuoteError with the code of the failure

Editing the delivery address. The customer can edit the delivery address from the Transport page. The ZIP code and Address Line 1 are required; City and State are not validated, because the ZIP code decides the price — the widget replaces City and State with the ones the quote service returns for that ZIP code. An edit is saved only when the new quote succeeds:

Outcome of the new quote What the customer sees What you receive on quote_bus
Success The new address and price QuoteCreated with the new quoteId — use it for ordering
The ZIP code cannot be served The edit page stays open, with the error under the ZIP code QuoteError with code quote.delivery_zip_unserviceable or quote.delivery_zip_not_routable. The previous quote is still valid
Service down, timeout or any other error Back on the Transport page with the previous address, and "Unable to get transportation quote at this time. Please try again later." in place of the price QuoteError with code quote.http_<status> (e.g. quote.http_500) or quote.request_failed. Do not place an order with the previous quote — the customer's address change was not applied
Customer cancels (even while the quote is loading) The previous address and price Nothing

Usage Example (Current API)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Transportation Quotes</title>
  <script src="https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js"></script>
</head>
<body>
  <h1>Get Your Quote</h1>
  <ready-contact-quote id="quote-component"></ready-contact-quote>

  <script>
    // Wait for component to be ready
    document.addEventListener('DOMContentLoaded', () => {
      const component = document.getElementById('quote-component');
      
      // Set API configuration
      component.apiConfig = {
        bffUrl: 'https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com',
        token: 'bearer <your-token>'
      };
      
      // Set quote request
      component.quoteRequest = {
        itemRef: 'ref-123',
        partnerId: 'YOUR_PARTNER_ID',
        payerId: 'payer-123',
        shipperId: 'shipper-123',
        quoteType: 'Standard',
        quoteSubType: 'NewToConsumer',
        vehicles: [{
          vin: '1HGBH41JXMN109186',
          pickUpId: 's1',
          dropOffId: 's2',
          isOperable: true,
          isEnclosed: false,
          description: {
            year: '2020',
            make: 'Honda',
            model: 'Accord',
            subModel: 'EX'
          }
        }],
        stops: [
          {
            refId: 's1',
            zip: '90210',
            address1: '',
            address2: '',
            city: 'Beverly Hills',
            state: 'CA',
            country: 'US'
          },
          {
            refId: 's2',
            zip: '10001',
            address1: '',
            address2: '',
            city: 'New York',
            state: 'NY',
            country: 'US'
          }
        ]
      };
    });
  </script>
</body>
</html>

React Example (Current API)

import { useEffect, useRef } from 'react';

function QuotePage() {
  const componentRef = useRef(null);

  useEffect(() => {
    // Load the script
    const script = document.createElement('script');
    script.src = 'https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js';
    script.onload = () => {
      // Configure component after script loads
      if (componentRef.current) {
        componentRef.current.apiConfig = {
          bffUrl: 'https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com',
          token: 'bearer YOUR_TOKEN'
        };
        
        componentRef.current.quoteRequest = {
          itemRef: 'ref-123',
          partnerId: 'YOUR_PARTNER_ID',
          payerId: 'payer-123',
          shipperId: 'shipper-123',
          quoteType: 'Standard',
          quoteSubType: 'NewToConsumer',
          vehicles: [{
            vin: '1HGBH41JXMN109186',
            pickUpId: 's1',
            dropOffId: 's2',
            isOperable: true,
            isEnclosed: false,
            description: {
              year: '2020',
              make: 'Honda',
              model: 'Accord',
              subModel: 'EX'
            }
          }],
          stops: [
            { refId: 's1', zip: '90210', address1: '', address2: '', 
              city: 'Beverly Hills', state: 'CA', country: 'US' },
            { refId: 's2', zip: '10001', address1: '', address2: '', 
              city: 'New York', state: 'NY', country: 'US' }
          ]
        };
      }
    };
    document.head.appendChild(script);

    return () => {
      if (script.parentNode) {
        document.head.removeChild(script);
      }
    };
  }, []);

  return (
    <div>
      <h1>Get a Quote</h1>
      <ready-contact-quote ref={componentRef}></ready-contact-quote>
    </div>
  );
}

export default QuotePage;

🎨 Styling & Customization

Built-in Design System

Our components come with Interstate Design System built-in, providing:

Custom CSS (Optional)

You can customize colors and spacing with CSS custom properties:

/* Override component colors */
ready-contact-quote {
  --primary-color: #your-brand-color;
  --accent-color: #your-accent-color;
  --text-color: #333333;
  --background-color: #ffffff;
}

/* Adjust component width */
ready-contact-quote,
ready-zipcode-quote {
  max-width: 400px;
  margin: 0 auto;
}

/* quote-order-component is designed for 320-560px */
quote-order-component {
  max-width: 560px;
  margin: 0 auto;
}

Colour custom properties do not apply to <quote-order-component>. It does not expose --primary-color and friends — its interior is fully encapsulated, so sizing the element is the only styling hook. Raise visual changes with the team rather than trying to reach inside.

📱 Responsive Design

Components automatically adapt to different screen sizes:

<quote-order-component> responds to its CONTAINER, not the viewport. It uses CSS container queries and is designed for 320px–560px. That means it adapts correctly inside a narrow sidebar on a wide desktop — but it also means giving it an unbounded container will not look right. Put it in a wrapper with a max-width.

<ready-zipcode-quote> also responds to its own width, not the viewport. Below 800px it stacks the quote under the ZIP entry; from 800px it collapses to a single short strip with the quote beside it. Move that switch with wide-breakpoint (CSS pixels, no unit):

<ready-zipcode-quote wide-breakpoint="1000"></ready-zipcode-quote>

⚠️ Some wide-breakpoint values give a layout the widget was not designed for. It accepts any number, but its design was built and measured around the 800px default:

Change it only when your layout needs it, and check the widget on your own page at container widths either side of the new value before you ship.

🔒 Security & Authentication

API Token

Your token should be a valid Bearer token. The widget sends it exactly as you give it, as the request's Authorization header, so include the bearer prefix:

bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

On all three widgets the token goes inside the api-config object:

el.apiConfig = { bffUrl: 'https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com', token: 'bearer eyJ...' };

An invalid or expired token does not throw — the widget renders its unavailable state and publishes pbc:error on quote_bus. If the widget "doesn't load", check that event first.

📊 Event Handling

Listen for Quote Events

Our components use BroadcastChannel to communicate events. Listen to the quote_bus channel to receive quote events. All three components publish to the same channel.

<quote-order-component> publishes six event types the switch statement below does not cover — pbc:ready, pbc:error, MultiQuoteCreated, OrderSubmitted, OrderConfirmed and OrderFailed. It shares QuoteError, and it does not publish QuoteCreated, QuoteUpdated or ContactUpdated at all.

If you are integrating that widget, read its event section rather than adapting the example below — there are three behaviours documented there that will bite a handler written against this one.

// Create a connection to the quote_bus channel
const channel = new BroadcastChannel('quote_bus');

// Listen for all quote events
channel.onmessage = function (event) {
  console.log('Quote event received:', event.data);

  // Handle different event types
  switch (event.data.eventType) {
    case 'QuoteCreated':
      console.log('Quote created successfully:', event.data.eventBody);
      // Handle successful quote creation (e.g., redirect, show confirmation)
      break;

    case 'QuoteError':
      console.log('Quote error occurred:', event.data.eventBody);
      // Handle error (e.g., show user-friendly message)
      const errors = event.data.eventBody.errors;
      errors.forEach(error => {
        console.error(`Error ${error.code}: ${error.message}`);
        // Display user-friendly error message
        showErrorToUser(error.message);
      });
      break;

    case 'QuoteUpdated':
      console.log('Quote updated:', event.data.eventBody);
      // Handle quote data changes
      break;

    // No component publishes ContactUpdated any more — ready-contact-quote stopped when
    // contact details were removed from it.

    default:
      console.log('Unknown event type:', event.data.eventType);
  }
};

// Alternative: Listen for specific event types only
channel.addEventListener('message', function (event) {
  if (event.data.eventType === 'QuoteCreated') {
    // Handle only quote creation events
    handleQuoteCreated(event.data.eventBody);
  }
});

// Don't forget to close the channel when done (optional)
// channel.close();

Example Event Payload

Quote Created Event:

### Example Event Payload

// Example of event.data when a quote is successfully created
{
  "eventType": "QuoteCreated",
  "eventBody": {
    "currency": "USD",
    "discount": 0,
    "discountCurrency": "USD",
    "distance": 11,
    "distanceLabel": "MI",
    "expirationDate": "2025-09-22T18:31:21Z",
    "maxDaysToDeliver": 3,
    "minDaysToDeliver": 2,
    "price": 93,
    "quoteId": "f2d1cce1-e1fe-4105-8070-7c6617f6dc11",
    "quoteRef": "202509001623111",
    "quoteSubType": "",
    "quoteType": "Standard",
    "stops": [
      {
        "distance": 0,
        "address1": "",
        "address2": "",
        "city": "Phoenix",
        "country": "US",
        "refId": "S1",
        "state": "AZ",
        "zip": "85044"
      },
      {
        "distance": 11,
        "distanceLabel": "MI",
        "address1": "",
        "address2": "",
        "city": "Phoenix",
        "country": "US",
        "refId": "S2",
        "state": "AZ",
        "zip": "85040"
      }
    ],
    "vehicles": [
      {
        "currency": "USD",
        "description": "1991/Acura/Legend/L",
        "descriptionDetails": {
          "make": "Acura",
          "model": "Legend",
          "subModel": "L",
          "year": "1991"
        },
        "discount": 0,
        "discountCurrency": "USD",
        "predictedBasePrice": 0,
        "price": 93,
        "priceLineItems": [
          {
            "currency": "USD",
            "description": "FixedRate",
            "price": 93
          }
        ],
        "vehicleSize": "S",
        "weight": "3300",
        "weightUnit": "pounds",
        "dropOffId": "S2",
        "isEnclosed": false,
        "isOperable": true,
        "pickUpId": "S1",
        "vin": "JH4KA7561MC010714"
      }
    ]
  }
}

Quote Error Event:

// Example of event.data when an error occurs during quote processing
{
  "eventType": "QuoteError",
  "eventBody": {
    "errors": [
      {
        "code": "quotes_api.bad_request",
        "message": "An error occurred while calculating distance: Route at index 0: Stop at index 1: No matching postal code found Problem: [N/A] 11111 , ErrorId:fc26f1b3-5226-43b2-8837-269cc14b8c2d"
      }
    ]
  }
}

🐛 Troubleshooting

Common Issues

1. Component not appearing:

2. API errors:

3. Styling issues:

Debug Mode

Enable debug logging by adding to your page:

window.QuotesPBCDebug = true;

Browser Support

Supported Browsers:

Legacy Support: Automatic fallback for older browsers using nomodule polyfills.

📞 Support

Quick Help

If you need immediate assistance:

  1. Check the browser console for error messages
  2. Verify all required properties are provided
  3. Test with this version's example page, published beside the loader at /v/1.3.0/example-usage.html
  4. Contact support with specific error details

🚀 Next Steps

  1. Get Your Credentials: Contact our team for your API token and partner ID — plus orderSource and customerId if you are using <quote-order-component>, which needs both to place an order. That widget also accepts an optional userEmail: the address of the person using your site, which you supply yourself rather than request from us — see Identifying who accepted a warning
  2. Test Integration: Use our sandbox environment for testing
  3. Go Live: Deploy to your production website
  4. Monitor: Use our analytics dashboard to track quote performance

Ready to get started? Contact us at integration@your-company.com



🚚 Quote/Order Widget (quote-order-component)

The newest widget, and the only one that places an order. It renders the whole flow: transport options → quote review → order confirmation.

The script and BFF URLs below are real, and the examples point at dev. Only the bearer token is still a placeholder — that one is per-partner and comes from the Transportation Management team. Everything else is taken from the shipped component contract.

What it does for you

You supply the move and the credentials. These rules stay on our side, so they can change without you shipping anything.

Step 1 — load the script

There is no npm package and no CDN; the widget is served by its own deployment. Use the loader — one tag, and it resolves the bundle paths and cache-busting itself:

<script src="https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js"></script>
Environment Script URL
Dev https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js
Stage https://stage-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/quotespbc-ui-loader.js
Prod https://prod-logistics-pbc.awsmanlog10.manheim.com/v/1.3.0/quotespbc-ui-loader.js

And the API host it calls, api-config.bffUrl:

Environment BFF URL (api-config.bffUrl)
Dev https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com
Stage https://stage-logistics-pbc-bff.awsmanlog10np.manheim.com
Prod https://prod-logistics-pbc-bff.awsmanlog10.manheim.com

These are two different hosts, deliberately. The script origin serves the widget; the BFF URL is the API the widget calls. Note the -bff in the host name, and that prod alone drops the np (non-production) segment. Give no path or trailing slash — the widget appends its own routes.

Loading the entry scripts directly instead (not recommended)

<script type="module" src="https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/build/quotespbc-ui.esm.js"></script>
<script nomodule       src="https://dev-logistics-pbc.awsmanlog10np.manheim.com/v/1.3.0/build/quotespbc-ui.js"></script>

This works, but you take on two things the loader handles for you: the /build/… paths become part of your page, and you get no cache-busting parameter — so a browser can hold an older bundle after we release.

Step 2 — place the element

Eight attributes: four objects, three strings, and one presence flag.

Attribute Property Required Purpose
api-config apiConfig Yes { bffUrl, token }
quote-request quoteRequest Yes The move: vehicle(s) + stops
order-source orderSource Yes to order Your channel identifier, e.g. OVE
customer-id customerId Yes to order Your customer identifier
user-email userEmail No Email of the person using your site
contact contact Conditional Contact used for the order
quote-contact quoteContact No Alternate contact input
contact-required contactRequired No Makes all three contact fields mandatory

api-config and quote-request have the same shape as the other widgets — see Current API Reference above. order-source, customer-id, contact and user-email are new to this widget.

Two rows need a word more than the table gives them:

Objects can be passed either as JSON string attributes or as DOM properties.

<quote-order-component id="quote"></quote-order-component>

<script type="module">
  const el = document.getElementById('quote');

  el.apiConfig   = { bffUrl: 'https://dev-logistics-pbc-bff.awsmanlog10np.manheim.com', token: 'bearer eyJ...' };
  el.orderSource = 'OVE';
  el.customerId  = '5000001';
  el.userEmail   = 'shipper@example.com';

  el.quoteRequest = {
    itemRef: '', partnerId: 'OVE', payerId: '', shipperId: '',
    vehicles: [{
      vin: 'JH4KA7561MC010714',
      isOperable: true,
      isEnclosed: false,
      pickUpId: 'S1',
      dropOffId: 'S2',
      description: { year: '', make: '', model: '', subModel: '' },
      // sizeOverride: 'OS'   // "OS" = oversize; suppresses Expedited
    }],
    stops: [
      { refId: 'S1', zip: '85044', address1: '', address2: '', city: '', state: '', country: 'US' },
      { refId: 'S2', zip: '85040', address1: '4615 E Elwood St', address2: '#400',
        city: 'Phoenix', state: 'AZ', country: 'US' },
    ],
  };

  el.contact = { name: 'Jane Roe', phone: '480-558-3200', email: 'jane@example.com' };
</script>

Minimum to render: a token, a bffUrl, at least two stops and at least one vehicle. Anything less and the widget shows its unavailable state.

Stops match by refId, not position — pickUpId and dropOffId must each name a refId present in stops.

Vehicle size — the one field that changes what is offered

sizeOverride accepts "S" | "M" | "L" | "XL" | "OS". OS means oversize, and Expedited is then never offered. Any other value — or omitting the field — is treated as not oversized. An unrecognised value is tolerated rather than rejected, so a typo like "os" would silently offer Expedited.

High value is not something you declare

It is determined upstream and returned on the quote response as isHighValueVehicle. The widget applies the enclosed rule and the open-transport acknowledgement on its own. You do not send a flag for it.

Contact

contact:      { name?: string, phone: string, email: string }
quoteContact: { name:  string, phone: string, email: string }

Both carry a contact; contact wins field by field where both supply a value.

Contact is optional for you to supply:

Deciding whether contact is mandatory — contact-required

You choose, per embed:

<!-- contact name, phone and email are ALL mandatory -->
<quote-order-component contact-required></quote-order-component>

<!-- omit the attribute: all three are optional -->
<quote-order-component></quote-order-component>

It is presence-based, like required on an <input> — the bare attribute means yes, and leaving it off means no. ="true" and ="false" also work if you are setting it programmatically.

contact-required set attribute absent
Contact name mandatory optional
Phone mandatory optional
Email mandatory optional
Review Quote waits for them yes no
A value you do supply is format-checked yes yes

That last row is the important one: optional relaxes presence, never format. An empty phone is fine when the attribute is absent; a malformed one never is, in either mode.

If you intend contact to be mandatory, set the attribute. We cannot distinguish "the host wants optional" from "the host forgot", so a missing attribute is read as optional.

Phone format: send it however you hold it. 480-558-3200, 4805583200, (480) 558-3200 and +1 480-558-3200 are all accepted and display identically as 480-558-3200. A leading 1 is treated as the country code only on an 11-digit value.

We never silently correct a bad number. If you send something that cannot be a NANP number — say eleven digits that do not start with 1 — it is shown to the shipper exactly as it arrived, unformatted, with "Invalid US phone number" beside it. We do not truncate it into something that looks valid.

Phone validation is NANP-structural: ten digits, area code and exchange each starting 2–9, neither being an N11 code (911, 411…). An optional leading 1 is accepted. Note 555-123-4567 fails — the exchange 123 starts with 1.

Contact is never sent on a quote request — only on the order.

Identifying who accepted a warning — user-email

Two points in the flow ask the shipper to accept something before they can continue:

When either happens, we record it on the order so dispatch and the carrier can see what was agreed. user-email is how that record names a person:

<quote-order-component user-email="shipper@example.com"></quote-order-component>
el.userEmail = 'shipper@example.com';

Send the email of whoever is operating your site — the person clicking the tick-box. That may not be your customer of record, so do not simply pass the account holder's address if someone else is placing the order on their behalf.

It is optional, and omitting it is completely benign. The widget loads, quotes and places orders exactly as it does without it, and the record is still written — it simply names no one:

High Value Vehicle Open Transport Override accepted at 2026-09-16T14:31:48Z.

Note that variant reads passively, because with no user there is no subject to lead with. It is the one case with a different word order, so do not write anything that assumes <email> accepted <type> at <time> always holds.

We would still encourage you to send it. A record nobody can be tied to is of limited use in a dispute, which is most of why the record is kept. But that is advice, not a requirement: nothing is gated on it, nothing validates it, and no error is produced if you leave it out.

Supply it whether or not the shipper will hit a warning. You cannot know in advance: whether a vehicle is high-value is decided upstream and returned on the quote, and a condition change is the shipper's to make.

We do not validate or verify it. A malformed address is recorded exactly as you sent it, and we have no way to confirm the value belongs to the person at the keyboard — it is your assertion, and it is recorded as such. It is not derived from your bearer token, which identifies your application rather than a user.

Where the value ends up:

If neither warning was accepted, nothing is recorded and the value is unused.

Step 3 — listen on quote_bus

Same BroadcastChannel as the other widgets (see Event Handling above), with additional event types for the order flow.

const channel = new BroadcastChannel('quote_bus');

channel.onmessage = ({ data }) => {
  switch (data.eventType) {
    case 'pbc:ready':         /* widget mounted and configured */ break;
    case 'pbc:error':         /* { reason: 'unavailable' } */ break;
    case 'MultiQuoteCreated': /* MultiQuoteResponse */ break;
    case 'QuoteError':        /* { errors: [{ code, message }] } — why quoting failed */ break;
    case 'OrderSubmitted':    /* { order } — fires per ATTEMPT */ break;
    case 'OrderConfirmed':    /* OrderResponse — { orderId?, confirmationNumber?, status, quoteId?, itemRef?, shipperId?, vehicle? } */ break;
    case 'OrderFailed':       /* { status, error, orderId?, confirmationNumber?, quoteId?, itemRef?, shipperId?, vehicle?, order } — may arrive with NO OrderSubmitted */ break;
  }
};
eventType eventBody When
pbc:ready (none) Loaded and configured. Also fires again after each re-quote
pbc:error { reason: 'unavailable' } Config/token rejected, or no option could be priced
MultiQuoteCreated MultiQuoteResponse Quotes returned
QuoteError { errors: [{ code, message }] } Quote request failed — carries the reason
OrderSubmitted { order } Each submission attempt
OrderConfirmed OrderResponse Order placed
OrderFailed { status, error, orderId?, confirmationNumber?, quoteId?, itemRef?, shipperId?, vehicle?, order } Order rejected, or could not be sent

Four things to know before you write the handler

1. OrderSubmitted fires once per attempt, not per order. Submission is not idempotent, so a shipper retrying after a failure produces a second event for the same order. Deduplicate on quoteId if you record attempts.

2. OrderFailed can arrive with NO preceding OrderSubmitted. Two cases reject the order before anything is sent — an invalid contact, and a widget that has lost its api-config. If your handler marks an order "pending" on OrderSubmitted and resolves it on OrderConfirmed/OrderFailed, it will receive a resolution for something it never marked pending. Key your state off the order itself, not off having seen OrderSubmitted.

3. pbc:error tells you the widget is unavailable; QuoteError tells you why. They are different signals and both can fire for one failure. Listen for QuoteError if you want to distinguish a bad postal code from an expired token from an outage.

4. Match an outcome to your request with its correlation fields. quote_bus is shared by the whole page, so every tab or modal hears every OrderConfirmed and OrderFailed. Both carry quoteId, itemRef, shipperId and vehicle at the top level, copied from the order that was submitted — the same values OrderSubmitted carries in order. Compare them with what you supplied and ignore events that aren't yours. Values are echoed exactly: a field you left out of quote-request is absent, and one you sent as '' comes back as ''. itemRef, shipperId and vehicle.vin are your own values, so they are the ones to match on. quoteId is the quote the shipper picked, which you supplied nothing for; it is useful only if you captured it from that widget's OrderSubmitted.

A business rejection from the order service passes its whole response through, so an OrderFailed may also carry orderId and confirmationNumber. Treat both as optional on either event — don't write a type guard that rejects an OrderFailed that has them.

error.code values you may see on OrderFailed

Code Meaning Was anything sent?
invalid_contact Phone or email failed validation No
not_configured Widget lost its api-config before submitting No
service_unavailable The order service could not be reached Yes, outcome unknown
(from the BFF) Business rejection — code and message come from the service Yes, rejected

service_unavailable is the only one where the order may have reached the service, so treat it as indeterminate rather than as a clean failure.

Privacy note. OrderSubmitted and OrderFailed carry the full order, which includes the contact name, phone and email — deliberately, since you own the contact and need it to fulfil the order. Once you set user-email, that address rides along too. Treat those events as containing personal data when deciding what to log.

Response shapes

MultiQuoteResponse {
  quotes: {                          // always contains all three keys
    open?: TransportPriceOption,
    enclosed?: TransportPriceOption,
    expedited?: TransportPriceOption,
  },
  isHighValueVehicle: boolean,
  status: 'Successful' | 'Partial' | 'Failed',
}

TransportPriceOption {
  price: number | null,              // null when this option could not be priced
  currency: string | null,
  minDaysToDeliver: number | null,
  maxDaysToDeliver: number | null,
  expirationDate: string | null,     // UTC
  quoteId: string | null,
  error: { code, message } | null,
}

OrderResponse {
  orderId?: string,
  confirmationNumber?: string,
  status: 'Unknown' | 'Confirmed' | 'Failed',
  error?: { code, message },
  // Correlation fields, echoed from the submitted order:
  quoteId?: string,
  itemRef?: string,
  shipperId?: string,
  vehicle?: { vin?, isOperable, description?: { year?, make?, model?, subModel? } },
}

A Partial status is normal, not an error — one option can price while another fails. The widget handles the display; you only need status if you record the outcome.

Framework notes

React

import { useEffect, useRef } from 'react';

export function QuotePage({ apiConfig, quoteRequest, contact, userEmail }) {
  const ref = useRef(null);

  // Object props MUST be assigned as DOM properties. React sets unknown JSX props as
  // attributes, so an object would arrive as the string "[object Object]".
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.apiConfig = apiConfig;
    el.quoteRequest = quoteRequest;
    el.contact = contact;
    el.orderSource = 'OVE';
    el.customerId = '5000001';
    el.userEmail = userEmail;   // optional
  }, [apiConfig, quoteRequest, contact, userEmail]);

  useEffect(() => {
    const channel = new BroadcastChannel('quote_bus');
    channel.onmessage = ({ data }) => {
      if (data.eventType === 'OrderConfirmed') {
        console.log('Confirmation:', data.eventBody.confirmationNumber);
      }
    };
    return () => channel.close();
  }, []);

  return <quote-order-component ref={ref} />;
}

Angular

@ViewChild('quote') quote!: ElementRef;

ngAfterViewInit() {
  const el = this.quote.nativeElement;
  el.apiConfig = this.apiConfig;
  el.quoteRequest = this.quoteRequest;
  el.orderSource = 'OVE';
  el.customerId = '5000001';
  el.userEmail = this.userEmail;
}

Add CUSTOM_ELEMENTS_SCHEMA to your module so Angular accepts the unknown element.

Sizing

Responsive between 320px and 560px, using CSS container queries — it responds to the width of your container, not the browser window. Give it a sized wrapper:

.quote-widget-container { max-width: 560px; }

Do not try to restyle the interior; Shadow DOM makes that unreliable by design. Raise visual changes with the team.

Troubleshooting

What you see Why
"This transportation quote is currently unavailable. Please try again later." The token in api-config is missing, expired or invalid, or quote-request is missing the move details: at least two stops and one vehicle. Fix your configuration; the shipper cannot resolve this by calling.
"Unable to retrieve transportation pricing at this time." with a Contact Ready Logistics link No transport option could be priced for this move, or the pricing service failed. The configuration is accepted; the shipper can try again or call.
A message under a field (ZIP code, phone, email) That input is invalid. The shipper corrects it and continues; nothing is sent until it is valid.

Nothing renders. Check the console for a failed script load, then confirm pbc:ready fired. If you got pbc:error with reason: 'unavailable', the widget loaded but rejected its configuration — usually a missing or expired token.

Unavailable state instead of the widget. api-config missing/malformed or its token invalid, or quote-request failed validation — it needs at least two stops and one vehicle.

Object props arriving empty. In a framework you are probably setting an attribute rather than a property. See the React note above.

A phone number you consider valid is rejected. Validation is NANP-structural: area code and exchange must start 2–9 and neither may be an N11 code.

Quotes return but an option has no price. Expected — check quotes.<key>.error. A Partial status means some options priced and others did not. Expedited is also withheld entirely for an OS vehicle.

Before you start — what to request

  1. A bearer token, and how to refresh it
  2. Your partnerId, orderSource and customerId values
  3. Which environment your testing should point at

What you do not need to ask us for: the script and BFF URLs are in the tables above. userEmail comes from your own session rather than from us — but work out where you will read the signed-in user's address from before you start.

Component reference (generated)

Generated from the components' source code when this version was built. Messages on the quote_bus BroadcastChannel are not DOM events: see the event sections of this guide.

<ready-zipcode-quote>

Properties of <ready-zipcode-quote>
PropertyAttributeTypeDefaultRequiredDescription
apiConfigapi-configbffConfigs | undefined—No—
quoteRequestquote-requestquoteRequest | undefined—No—
vdpModevdp-modebooleanfalseNo

Renders for embedding in a vehicle detail page whose parent application owns the ZIP code (F306320). The ZIP field and the GO button are hidden, and the quote is generated from the ZIP carried on quote-request and refreshed whenever that ZIP changes.

Defaults to false — the standalone ZIP-entry experience — so existing consumers are unaffected.

wideBreakpointwide-breakpointnumber800No

Width, in CSS pixels, from which the card switches from the stacked layout (ZIP entry above the quote) to a single short strip (quote beside it). Measured against this element's own content box, never the viewport, so one page can embed a narrow and a wide instance side by side.

Defaults to 800. The strip's type is sized so the whole quote fits on one line from roughly 780px; a smaller value is accepted, but below that the delivery text wraps and the strip grows taller. A value that is not a number falls back to the default.

No DOM events.

<ready-contact-quote>

The OVE transportation quote (F318368).

An address edit only takes effect on a successful quote. The quote is requested with a copy of the edited stop; the saved address and quote are left exactly as they were until a successful response replaces both. So:

Properties of <ready-contact-quote>
PropertyAttributeTypeDefaultRequiredDescription
apiConfigapi-configbffConfigs | undefined—No—
quoteIdquote-idstring | undefined—No—
quoteRequestquote-requestquoteRequest | undefined—No—

No DOM events.

<quote-order-component>

Public PBC widget for the Quote/Order experience (F255314). Owns the screen FSM and drives the internal sub-components; communicates with the host via the quote_bus BroadcastChannel. US1: options selection + requote.

Properties of <quote-order-component>
PropertyAttributeTypeDefaultRequiredDescription
apiConfigapi-configbffConfigs | undefined—No—
contactcontactOrderContact | undefined—No—
contactRequiredcontact-requiredbooleanfalseNo

Does the host require a contact? Public attribute — contact-required.

true → contact name, phone and email are all mandatory. Each shows a required message on load while blank, and Review Quote stays disabled until all three are filled and phone/email are well-formed. false → all three are optional. No required messages, and Review Quote does not wait for them. A value the shipper does type is still format-checked: optional relaxes presence, never format.

Presence-based, like every native boolean attribute — <quote-order-component contact-required> opts in; omitting it means optional. That is the same contract as required on an <input> or disabled on a <button>, which is why absence meaning false is the expected reading rather than a trap: nobody considers an <input> without required ambiguous. Product decision 2026-08-22.

All four spellings work, because Stencil coerces the attribute value: omitted → false (the default) contact-required → true (bare — the idiomatic form) contact-required="true" → true contact-required="false" → false

That last row is a Stencil convenience, not standard HTML. Stencil's parsePropertyValue special-cases the literal string "false"; plain HTML ignores a boolean attribute's value entirely, so disabled="false" still disables. Worth knowing if a host tests that spelling against a non-Stencil component and sees the opposite.

The residual risk is narrow but real: a host that intends required mode and simply forgets the attribute gets optional mode, and the widget cannot tell the difference. Host-facing documentation and one integration test are the mitigation.

customerIdcustomer-idstring | undefined—No—
orderSourceorder-sourcestring | undefined—No—
quoteContactquote-contactcontact | undefined—No—
quoteRequestquote-requestquoteRequest | undefined—No—
userEmailuser-emailstring | undefined—No

Acting user who accepted the on-screen disclosures. Public attribute — user-email.

OPTIONAL, with a documented default (US2097771 / FR-002): omit it and the widget loads, quotes and orders exactly as today, and the acceptance note is still written — just without attribution (FR-010). That default is what satisfies UI-4, which requires new attributes to be optional. Absence is a normal path, so do not add a presence check, a validation rule, or a submit gate for it.

Team decision 2026-09-22. This reversed twice during specification; the "required" reading made the release MAJOR, needed a UI-4 override, and broke 31 existing tests. See specs/US2097771-order-acknowledgments-caveat/research.md R9 before changing it.

Host-asserted, never verified. NOT derived from the bearer token — that is a client-credentials token identifying the calling application, carrying no user claim (UI-8). FR-003 therefore forbids presenting or relying on this as a verified identity, and the note must not assert the person's relationship to the customer.

Privacy: the value enters the DOM, so session replay captures it, in addition to being visible to dispatch and carrier staff via the order note. Both reviewed and accepted by the business (spec Assumption 2).

No DOM events.