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:
- A beige row reading "Your order is protected. Exclusions may apply." with an info icon.
- A primary Checkout button with a second line underneath, for example "+$3.95 Package Protection". Clicking it checks out with protection added.
- 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.
| Treatment | What the shopper sees |
|---|---|
| A (control) | The normal single Checkout button. No row, no protection, and Route is never asked for a price. |
| B | The protection row, the primary button with the price, and "Checkout without Package Protection" as a bordered button. |
| C | Same 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 amount | Route's quote | What 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โ
- On
/gates, seth2_cart-route-protectionto B or C and save. - Add a product to the cart and open
/cart. - Confirm the row and both checkout options appear, and that the price matches the table above for the covered amount.
- Click the info icon and check Route's pop-up opens.
- Click the primary button and confirm Shopify checkout has a Package Protection by Route line.
- Go back to the cart and confirm the line is gone and both options are offered again.
- Click "Checkout without Package Protection" and confirm there is no protection line in checkout.
Useful carts to try:
| Cart | Expected |
|---|---|
| A single dress | The two buttons with a price |
| Swatches only | The normal Checkout button, no row |
| Covered amount between about $4,700 and $5,000 | The over the limit notice |
| Covered amount over $5,000 | The over the limit notice |
| Any cart with the gate on A | The 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.
| Event | When it fires | Fields |
|---|---|---|
checkout_protection_view | The first time a shopper sees a protection offer on the cart page | treatment, premium |
checkout_click | Every checkout click on the cart page, control included | treatment, premium, protection |
returned_from_checkout | The shopper comes back to the cart from Shopify checkout using the back button | treatment, premium, protection |
route_info_modal_opened | The info icon is clicked | variant, route_state |
route_info_modal_link_click | A link in Route's pop-up is clicked | ctaText, ctaURL |
protection is one of:
added: checked out with protectiondeclined: checked out without itnot_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_checkoutis 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_offeredunder treatment B or C, so they cannot be told apart fromcheckout_clickalone. - 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โ
| Path | What it does |
|---|---|
app/lib/route/quote.server.ts | Route quote API client, attached to the request context as context.routeQuote |
app/lib/route/product.server.ts | getRouteProtectionVariants, the premium variant list from Shopify |
app/lib/route/protection.server.ts | loadRouteProtection, toDisplayState, reconcileRouteProtectionLine, removeStaleRouteProtectionLine |
app/lib/route/protection.ts | Isomorphic helpers: coveredSubtotal, findPremiumVariant, findRouteProtectionLine, excludeRouteProtectionLines, getProtectionOffer |
app/routes/cart.tsx | Loader 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.ts | returned_from_checkout and the checkout intent marker |
app/hooks/useTrackingDeferred.ts | Runs 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:
| State | Shape | Cart renders |
|---|---|---|
offered | { status, treatment, premium, currencyCode }, plus variantId server-side only | RouteCheckoutActions |
over_limit | { status, treatment } | RouteOverLimitNotice above the plain Checkout button |
null | The 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 callgetProtectionOffer(routeProtection), which returns the offer ornull. It takes the display state only and is not generic. The cart action needs the server state'svariantId, so it narrows inline withoffer?.status === 'offered' ? offer.variantId : undefined. no_coverageis only "over the limit" aboveROUTE_COVERAGE_LIMIT. Route answerspaid_by_customerup to a covered subtotal of $5,000.00 andno_coveragefrom $5,000.01, verified against the live API. Ano_coverageat or under the constant cannot be about size, so it returnsnullrather than a notice that would be untrue. Route also answersno_coveragefor a$0cart, which the earlycoveredSubtotal <= 0return keeps out, so keep it ahead of the quote.- Under-limit declines go to Sentry, not the log drain.
captureIssueatwarning, taggedapi: route/quotes, with arouteQuotecontext holding Route's quoteid,amountCovered,currency, andpaymentResponsible. 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 quoteidis what Route support will ask for. - An empty ladder stays
null.getRouteProtectionVariantsreturns[]on a Storefront error, which also makesfindPremiumVariantmiss.variants.length > 0is 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.
amountCoveredsums 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_idis Route's only authorization. It is read fromROUTE_MERCHANT_IDwithgetServerEnv()and never accepted from a caller, so the client must stay server-only.- Amounts are two-decimal strings.
toRouteAmountdoesNumber(x).toFixed(2). covered_with_discountsis required even when it equalscovered. Omitting it returns a 400.- Route answers
201with{ id, premium: { amount, currency }, payment_responsible: { type, toggle_state? } }.toggle_stateis unused. - Failures return
nulland log throughcaptureLogaterror.hasPremiumguards the shape, since a 2xx without a premium is still unusable. - Caching keys on
['route-quote', currency, amount]atCacheStrategy.fast, and only responses carrying a premium are stored. Two carts with the same covered total share a quote, and therefore Route's per-requestid. 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('')is0and 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 state | Choice | Action |
|---|---|---|
| No line | add | addLines |
| Line on a stale variant | add | updateLines swapping merchandiseId in place |
| Line already correct | add | Nothing, returns null |
| Line present | remove | updateLines with quantity: REMOVED_LINE_QUANTITY |
- Every write goes through
updateLines, neverremoveLines. 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
redirectTois load-bearing. A protection submit that did not redirect away would have its own line deleted by the revalidation that follows. KeepredirectToon 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_FRAGMENTshape, and returning that from the loader renders "Your Bag Is Empty" while the header still counts items. Callcart.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โ
isHiddenCartLinehides protection and rush fee lines from every line list, andgetHiddenLineQtykeeps the item count matching what is visible.isRouteProtectionLinetakesAnyCartLine, 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.
CartMaincomputes its subtotal fromexcludeRouteProtectionLines(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, andreturned_from_checkoutshare one payload type (CheckoutProtectionEvent) and one subscriber loop overPROTECTION_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 aprotectionViewedref keeps a cart edit that reprices the offer from counting as a second impression. - Consent gates everything. Hydrogen's
publishis a no-op until consent loads, so mount-time events go throughuseTrackingDeferred, which waits forcanTrack(). Keep once-only guards inside the effect so they are not spent before tracking is on. returned_from_checkoutuses asessionStoragemarker.markCheckoutIntentstores{ 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 throughusePageRestored, or a fresh load whose navigation entry type isback_forward. Storage errors are swallowed, since a blocked private window is not worth failing a checkout over.route_statefollows the rendered row.ProtectionRowis shared by the offer and the over the limit notice, owns the info button and modal, and takesrouteStateas a prop typedRouteProtectionDisplayState['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.subtotalAmountalready contains any protection line. There is no arithmetic left in the component, so don't reintroduce a total without bringing that subtraction back. Buttonforcesuppercase. The decline label is long enough that uppercase overflows the Order Summary column, so it usesnormal-case, which also makeswhitespace-nowrapsafe.- The info modal caps its height against the overlay.
ModalConfigs.ROUTE_INFOusesmax-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 spacescalc()needs.
Environment and toolingโ
ROUTE_MERCHANT_IDmust 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 codegenaborts when the storefront-backend schema is unreachable, which takes the Storefront half down with it. To regenerateROUTE_PROTECTION_QUERYoffline, pointshopify hydrogen codegen --codegen-config-pathat a temporaryCodegenConfigin the repo root holding just thestorefrontproject.
Historyโ
| Ticket | Change |
|---|---|
| SITE-2093 | h2_cart-route-protection gate |
| SITE-2092 | Route quote client on the request context |
| SITE-2094 | Two-button checkout, choice applied in the checkout submission |
| SITE-2240 | Premium moved onto the checkout button |
| SITE-2095 | Stale line removal and mini cart subtotal |
| SITE-2096 | Funnel analytics and returned_from_checkout |
| SITE-2234 | Route info pop-up |
| SITE-2239 | Over the coverage limit state, with the terms of service link |
| SITE-2236 | ROUTE_COVERAGE_LIMIT, plain checkout and a Sentry warning for declines under it |
| SITE-2237 | Confirmed swatch-only carts are ineligible and exposure still fires; no code change |
| SITE-2238 | This doc |