Skip to main content

Route Package Protection

Route Package Protection lets a shopper pay a small fee at checkout to insure their order against loss, damage, or theft in transit. Route, a third party, sets the price and handles the claims. The storefront offers it on the cart page as a second checkout button.

This page has two halves. How It Works is written for merchandising, product, and WebOps and assumes no knowledge of the codebase. Technical Reference is for engineers working on the feature.


How It Works

What the shopper seesโ€‹

Protection is offered on the cart page (/cart) only. The mini cart drawer never offers it; its only button is "Review Order", which leads to the cart page.

When protection is offered, the Order Summary shows:

  1. A beige row reading "Your order is protected. Exclusions may apply." with an info icon.
  2. A primary Checkout button with a second line underneath, for example "+$3.95 Package Protection". Clicking it checks out with protection added.
  3. A way to check out without it, labeled "Checkout without Package Protection".

Clicking the info icon opens a pop-up with Route's own copy: what is covered, how to file a claim, and links to Route's claims site, privacy policy, and terms. That copy and the legal disclaimer come from Route word for word and should not be edited without Route's sign-off.

The experimentโ€‹

Protection is an A/B/C experiment run through the h2_cart-route-protection gate in GrowthBook.

TreatmentWhat the shopper sees
A (control)The normal single Checkout button. No row, no protection, and Route is never asked for a price.
BThe protection row, the primary button with the price, and "Checkout without Package Protection" as a bordered button.
CSame as B, but "Checkout without Package Protection" is an underlined text link.

The gate defaults to A, so with the experiment off every shopper sees the normal cart.

What is coveredโ€‹

The price is based only on the items Route insures. These are left out of the amount Route prices:

  • Swatches, swatch books, and swatch bundles
  • Rush fees
  • The protection line itself, so leaving and coming back never charges protection on protection

Everything else is covered, fine jewelry included. Fine jewelry is final sale like swatches, but final sale and Route coverage are separate policies, so a fine jewelry cart is offered protection.

For example, a cart with a $129 dress and $8 of swatches shows a $137 subtotal, but protection is priced on $129. A cart made up entirely of swatches has nothing to insure, so it gets the normal Checkout button.

Why the price is slightly higher than Route's quoteโ€‹

Route quotes roughly 3% of the covered amount. Shopify cannot charge an arbitrary amount, so Route gives us a product, Package Protection by Route, with a long list of fixed prices (about 76 of them). The storefront picks the cheapest price at or above Route's quote.

Covered amountRoute's quoteWhat the shopper pays
$129$3.87$3.95
$149$4.47$4.55

The button always shows what the shopper will actually be charged, not Route's raw quote. Design mocks often show the quote, so expect the live number to be a few cents higher.

Orders over the coverage limitโ€‹

There are two limits, and both belong to Route:

  • Route will only insure a covered amount up to $5,000.00.
  • Route's price list tops out at about $140, which covers roughly $4,679.

A cart over either limit cannot be insured. For treatments B and C, the row stays but reads "Package protection isn't available for orders over the coverage limit.", and the shopper sees the normal single Checkout button. "coverage limit" links to our terms of service at /pages/terms-of-service, and the info icon still opens Route's pop-up. The message deliberately does not name a dollar amount, because Route can change the limits at any time without telling us.

If the shopper removes items and drops back under the limit, the two buttons come back.

A decline under $5,000 does not show the notice. If Route ever refuses a cart at or under its limit, the reason cannot be the cart's size, so telling the shopper it is over the limit would be untrue. They get the normal Checkout button with no row instead, and the decline is reported to Sentry (see When something goes wrong).

The protection line in the bagโ€‹

When the shopper chooses protection, a Package Protection by Route line is added to their cart at the moment they click Checkout, and it appears in Shopify checkout and on the order. It never appears in the bag on the storefront, in the same way rush fees do not.

Protection is chosen fresh on every visit to checkout. If a shopper adds protection, goes to checkout, and then comes back to the cart, the line is removed and they choose again. This keeps the price in line with any edits they make to the cart in the meantime, and means declining on a later visit can never leave them still paying for it.

Turning it on and testing itโ€‹

  1. On /gates, set h2_cart-route-protection to B or C and save.
  2. Add a product to the cart and open /cart.
  3. Confirm the row and both checkout options appear, and that the price matches the table above for the covered amount.
  4. Click the info icon and check Route's pop-up opens.
  5. Click the primary button and confirm Shopify checkout has a Package Protection by Route line.
  6. Go back to the cart and confirm the line is gone and both options are offered again.
  7. Click "Checkout without Package Protection" and confirm there is no protection line in checkout.

Useful carts to try:

CartExpected
A single dressThe two buttons with a price
Swatches onlyThe normal Checkout button, no row
Covered amount between about $4,700 and $5,000The over the limit notice
Covered amount over $5,000The over the limit notice
Any cart with the gate on AThe normal Checkout button, no row

Analyticsโ€‹

Every event goes to GTM, and from there to Heap. treatment is the shopper's experiment treatment (A, B, or C) and premium is the price shown, or blank when nothing was offered.

EventWhen it firesFields
checkout_protection_viewThe first time a shopper sees a protection offer on the cart pagetreatment, premium
checkout_clickEvery checkout click on the cart page, control includedtreatment, premium, protection
returned_from_checkoutThe shopper comes back to the cart from Shopify checkout using the back buttontreatment, premium, protection
route_info_modal_openedThe info icon is clickedvariant, route_state
route_info_modal_link_clickA link in Route's pop-up is clickedctaText, ctaURL

protection is one of:

  • added: checked out with protection
  • declined: checked out without it
  • not_offered: no offer was shown, which covers control, swatch only carts, carts over the limit, carts Route declined under the limit, and any time Route could not be reached

route_state is offered from the normal row and over_limit from the over the limit notice.

A few things worth knowing when reading the numbers:

  • returned_from_checkout is the clearest signal of someone abandoning checkout. It only fires for the back button. A shopper who reopens the cart by typing the URL or clicking the bag is not counted.
  • Every event waits for cookie consent. A shopper who never accepts tracking sends none of them.
  • Over the limit carts and carts where Route could not be reached both report not_offered under treatment B or C, so they cannot be told apart from checkout_click alone.
  • GrowthBook counts a shopper in the experiment on every cart page view, including empty carts and swatch only carts that can never see an offer. That is a deliberate choice, so segment by cart contents in the analysis rather than expecting the storefront to filter.
  • Carts over the limit still count as being in the experiment. Splitting results by cart size is part of the analysis, not something the storefront filters.

When something goes wrongโ€‹

If Route is down or slow, shoppers see the normal Checkout button. They are never blocked from checking out and never charged for protection they were not offered. The cost is speed: the cart page waits up to about six seconds for Route before giving up.

If the protection product fails to load from Shopify, shoppers also see the normal Checkout button, not the over the limit notice.

If Route declines a cart at or under $5,000, shoppers see the normal Checkout button and a "Route declined a cart under its coverage limit" warning goes to Sentry with Route's quote id. One of these on its own is worth a look. A steady stream of them most likely means Route has changed its limit, and the $5,000 in the code needs updating to match.

Failures are logged to the log drain and Sentry. If the row stops appearing for treatments B and C, the likely causes are Route being unreachable, a missing ROUTE_MERCHANT_ID in the environment, or the Package Protection by Route product being unpublished or out of stock in Shopify.


Technical Reference

File mapโ€‹

PathWhat it does
app/lib/route/quote.server.tsRoute quote API client, attached to the request context as context.routeQuote
app/lib/route/product.server.tsgetRouteProtectionVariants, the premium variant list from Shopify
app/lib/route/protection.server.tsloadRouteProtection, toDisplayState, reconcileRouteProtectionLine, removeStaleRouteProtectionLine
app/lib/route/protection.tsIsomorphic helpers: coveredSubtotal, findPremiumVariant, findRouteProtectionLine, excludeRouteProtectionLines, getProtectionOffer
app/routes/cart.tsxLoader resolves the offer, action applies the shopper's choice
app/components/OrderSummary/Renders the offer or the plain button, and publishes the funnel events
app/components/RouteCheckoutActions/The row, the two buttons, RouteOverLimitNotice, and usePageRestored
app/components/RouteInfoModal/Route's pop-up copy and link tracking
app/hooks/useSendReturnedFromCheckoutAnalytics.tsreturned_from_checkout and the checkout intent marker
app/hooks/useTrackingDeferred.tsRuns an analytics effect once consent allows it

Request flowโ€‹

GET /cart
-> removeStaleRouteProtectionLine clears any line left from a previous visit
-> loadRouteProtection
gate === 'A' -> null, Route is never called
coveredSubtotal <= 0 -> null, Route is never called
Promise.all([routeQuote.quote, getRouteProtectionVariants])
no_coverage, covered > 5000 -> over_limit
no_coverage, covered <= 5000 -> null, reported to Sentry
quote failed, not paid_by_customer, or premium <= 0 -> null
findPremiumVariant misses -> over_limit if the ladder loaded, else null
otherwise -> offered, with variantId
-> toDisplayState strips variantId before it reaches the client

The loader awaits the quote rather than deferring it. Route answers in about 400ms, and the quote needs cart.get() first, so this adds to cart page TTFB. It is paid deliberately: swapping the checkout button after paint both looks broken and pollutes the experiment. The unhappy path is the real cost, since quote() uses timeout: 3000, maxTries: 2.

The three statesโ€‹

loadRouteProtection returns RouteProtectionState | null, and the cart page receives RouteProtectionDisplayState | null:

StateShapeCart renders
offered{ status, treatment, premium, currencyCode }, plus variantId server-side onlyRouteCheckoutActions
over_limit{ status, treatment }RouteOverLimitNotice above the plain Checkout button
nullThe plain Checkout button only. Covers control, nothing to cover, a failed or zero quote, a decline at or under the limit, and an empty ladder
  • Narrow before touching premium. Components call getProtectionOffer(routeProtection), which returns the offer or null. It takes the display state only and is not generic. The cart action needs the server state's variantId, so it narrows inline with offer?.status === 'offered' ? offer.variantId : undefined.
  • no_coverage is only "over the limit" above ROUTE_COVERAGE_LIMIT. Route answers paid_by_customer up to a covered subtotal of $5,000.00 and no_coverage from $5,000.01, verified against the live API. A no_coverage at or under the constant cannot be about size, so it returns null rather than a notice that would be untrue. Route also answers no_coverage for a $0 cart, which the early coveredSubtotal <= 0 return keeps out, so keep it ahead of the quote.
  • Under-limit declines go to Sentry, not the log drain. captureIssue at warning, tagged api: route/quotes, with a routeQuote context holding Route's quote id, amountCovered, currency, and paymentResponsible. It is an issue because the volume is too low to stand out in the logs, and a warning because the shopper still checks out. The quote id is what Route support will ask for.
  • An empty ladder stays null. getRouteProtectionVariants returns [] on a Storefront error, which also makes findPremiumVariant miss. variants.length > 0 is what separates "too large to insure" from "failed to load".
  • ROUTE_COVERAGE_LIMIT (5000) only picks the notice. It never decides whether to quote or what to charge, so if Route moves its limit the worst case is a missing or extra notice, never a wrong charge. The ladder's ceiling is not hardcoded anywhere; it comes from the variants Shopify returns.
  • Float drift is not a concern at the boundary. amountCovered sums two-decimal amounts, so its error is around 1e-12, far below a cent, and $5,000.01 stays above the constant.

The quote clientโ€‹

createRouteQuoteClient in quote.server.ts POSTs to https://api.route.com/v2/quotes through cachedRequest.

  • merchant_id is Route's only authorization. It is read from ROUTE_MERCHANT_ID with getServerEnv() and never accepted from a caller, so the client must stay server-only.
  • Amounts are two-decimal strings. toRouteAmount does Number(x).toFixed(2).
  • covered_with_discounts is required even when it equals covered. Omitting it returns a 400.
  • Route answers 201 with { id, premium: { amount, currency }, payment_responsible: { type, toggle_state? } }. toggle_state is unused.
  • Failures return null and log through captureLog at error. hasPremium guards the shape, since a 2xx without a premium is still unusable.
  • Caching keys on ['route-quote', currency, amount] at CacheStrategy.fast, and only responses carrying a premium are stored. Two carts with the same covered total share a quote, and therefore Route's per-request id. That is expected.

The premium ladderโ€‹

getRouteProtectionVariants reads the routeins product (CART_ADDON_PRODUCTS.ROUTE_PROTECTION, productType: 'Insurance') with ROUTE_PROTECTION_QUERY at CacheStrategy.slower, keeping only variants that are availableForSale. The query asks for variants(first: 100); the ladder is about 76 rungs today.

findPremiumVariant picks the cheapest variant at or above the quote:

  • It sorts first. Shopify returns variants in catalog order, not price order. Route's own sample code loops unsorted and returns the most expensive rung on a descending list.
  • It rejects a non-positive target. Number('') is 0 and every rung is >= 0, so an empty quote would otherwise match the cheapest variant.

The displayed premium is variant.price.amount, never quote.premium.amount. The number on the button has to be the number that reaches the cart.

Security modelโ€‹

The variant id never reaches the client. toDisplayState strips it, and the checkout form posts only routeProtection=add or remove. The action re-runs loadRouteProtection against the post-mutation cart to resolve the variant itself.

An earlier version sent the id down and accepted it back as routeProtectionVariantId, which let a crafted POST pick the $0.98 rung for a $4,000 cart. The field was removed outright rather than ignored, so there is nothing to forge. A crafted add on an over the limit cart resolves no variant and adds nothing.

Applying the choice at checkoutโ€‹

The write rides on the checkout submission, with no separate request. submitCartForm (OrderSummary/utils.ts) appends routeProtection to the POST that already saves the role attributes and carries redirectTo. The /cart action then calls reconcileRouteProtectionLine right before handling redirectTo, in the same place auditRushFees runs.

Cart stateChoiceAction
No lineaddaddLines
Line on a stale variantaddupdateLines swapping merchandiseId in place
Line already correctaddNothing, returns null
Line presentremoveupdateLines with quantity: REMOVED_LINE_QUANTITY
  • Every write goes through updateLines, never removeLines. Swapping the variant in one call avoids a window where a failure between remove and add leaves the shopper uncovered or charged twice.
  • It is not atomic. The action makes up to four sequential Storefront API calls (attributes, buyer identity, rush fee audit, protection), then may call customerAccount.isLoggedIn() before redirecting. If that throws, the line is on the cart and no redirect happens. The window is narrow and the helper is idempotent, so a retry settles it.
  • A choice made against a stale page can be dropped. If the cart crosses the limit in another tab and the shopper then clicks "Checkout" with protection, the action resolves over_limit, adds nothing, and checks out without protection. That is the safe direction.

Clearing stale linesโ€‹

The /cart loader runs removeStaleRouteProtectionLine before anything else, on every load and revalidation. Protection is only ever chosen at the click, so any line found on a page load is left over from an earlier visit.

  • This is why redirectTo is load-bearing. A protection submit that did not redirect away would have its own line deleted by the revalidation that follows. Keep redirectTo on any submission that adds protection.
  • Success is result.cart && !userErrors?.length && !errors?.length. A partial GraphQL response can carry a cart alongside errors. On failure it logs and returns the cart unchanged.
  • It re-reads after clearing. A cart mutation returns the slimmer CART_MUTATION_FRAGMENT shape, and returning that from the loader renders "Your Bag Is Empty" while the header still counts items. Call cart.get() instead of casting the mutation result.
  • Old checkout URLs can still show the line. Reopening a Shopify checkout URL directly can show a removed line. Starting checkout from the cart buttons always creates a fresh session.

Display rules for the lineโ€‹

  • isHiddenCartLine hides protection and rush fee lines from every line list, and getHiddenLineQty keeps the item count matching what is visible. isRouteProtectionLine takes AnyCartLine, so one predicate covers both the server shape and Hydrogen's optimistic one.
  • Protection is excluded from the mini cart subtotal, but rush fees are not. CartMain computes its subtotal from excludeRouteProtectionLines(lines). The root loader never cleans the cart, so a leftover line can be in the cart the drawer reads. A rush fee is a real charge the shopper committed to, while a leftover protection line is about to be removed.

Back/forward cacheโ€‹

Returning from Shopify checkout with the back button restores /cart from the bfcache with the checkout fetcher frozen mid-redirect (fetcher.state !== 'idle'), which would leave the buttons disabled.

usePageRestored (exported from RouteCheckoutActions) listens for pageshow with persisted. OrderSummary then bumps a key to remount OrderSummaryActions with an idle fetcher and calls revalidate(), which also runs the stale line removal.

Playwright's Chromium disables the bfcache, so this path only reproduces in a normal browser. Dispatching a synthetic pageshow with persisted: true verifies the wiring.

Analytics implementationโ€‹

Events are defined in app/components/GoogleTagManager/enums.ts and forwarded by subscribers in googleTagManager.component.tsx. A custom event without a subscriber never reaches the dataLayer.

  • The funnel is a table. checkout_protection_view, checkout_click, and returned_from_checkout share one payload type (CheckoutProtectionEvent) and one subscriber loop over PROTECTION_FUNNEL. A new funnel step is a new row.
  • The view is published from OrderSummary, not the buttons. The buttons remount on every back navigation, and a protectionViewed ref keeps a cart edit that reprices the offer from counting as a second impression.
  • Consent gates everything. Hydrogen's publish is a no-op until consent loads, so mount-time events go through useTrackingDeferred, which waits for canTrack(). Keep once-only guards inside the effect so they are not spent before tracking is on.
  • returned_from_checkout uses a sessionStorage marker. markCheckoutIntent stores { protection, treatment, premium } on the checkout click. On the way back, the marker is taken (read and deleted) either way, so a completed checkout can never fire on a later visit. A back navigation is detected two ways: a bfcache restore through usePageRestored, or a fresh load whose navigation entry type is back_forward. Storage errors are swallowed, since a blocked private window is not worth failing a checkout over.
  • route_state follows the rendered row. ProtectionRow is shared by the offer and the over the limit notice, owns the info button and modal, and takes routeState as a prop typed RouteProtectionDisplayState['status'].

Phantom add to cartโ€‹

Hydrogen's CartAnalytics diffs the root cart against its previous copy. The protection line is added server-side right before the external redirect, so the client first sees it on a bfcache return and publishes a product_added_to_cart nobody clicked.

The GTM add_to_cart subscriber returns early on isHiddenCartLine, so neither protection nor rush fee lines reach the dataLayer or klaviyo_cart from there. Hydrogen's built-in Shopify analytics still receives the event. Nothing subscribes to product_removed_from_cart, so the loader's removal is silent.

A plain reload does not reproduce it, since there is no previous cart to diff. Reproduce by adding the line with a direct POST /cart.data carrying routeProtection=add, then dispatching pageshow with persisted: true.

UI notesโ€‹

  • The button shows the premium, never an order total. An earlier version put subtotal plus premium on it, which double-charged the premium on a return visit, because Shopify's cost.subtotalAmount already contains any protection line. There is no arithmetic left in the component, so don't reintroduce a total without bringing that subtraction back.
  • Button forces uppercase. The decline label is long enough that uppercase overflows the Order Summary column, so it uses normal-case, which also makes whitespace-nowrap safe.
  • The info modal caps its height against the overlay. ModalConfigs.ROUTE_INFO uses max-h-[calc(var(--visual-viewport-height,90dvh)_-_1.5rem)], so the close button stays reachable on short mobile screens. The underscores are how Tailwind encodes the spaces calc() needs.

Environment and toolingโ€‹

  • ROUTE_MERCHANT_ID must be set in every environment. Without it every quote fails, logs, and the feature quietly falls back to the plain Checkout button.
  • Codegen needs the storefront-backend server. npm run codegen aborts when the storefront-backend schema is unreachable, which takes the Storefront half down with it. To regenerate ROUTE_PROTECTION_QUERY offline, point shopify hydrogen codegen --codegen-config-path at a temporary CodegenConfig in the repo root holding just the storefront project.

Historyโ€‹

TicketChange
SITE-2093h2_cart-route-protection gate
SITE-2092Route quote client on the request context
SITE-2094Two-button checkout, choice applied in the checkout submission
SITE-2240Premium moved onto the checkout button
SITE-2095Stale line removal and mini cart subtotal
SITE-2096Funnel analytics and returned_from_checkout
SITE-2234Route info pop-up
SITE-2239Over the coverage limit state, with the terms of service link
SITE-2236ROUTE_COVERAGE_LIMIT, plain checkout and a Sentry warning for declines under it
SITE-2237Confirmed swatch-only carts are ineligible and exposure still fires; no code change
SITE-2238This doc