Dr J's Binding Protocol — Document 14

Shipping & Fulfillment

Live carrier rate calculation, shipping class architecture, freight handling, and the complete implementation pattern for checkout-integrated shipping across all DominionPort sites.

← Back to Build Hub

Contents

  1. Shipping Architecture Overview
  2. Shipping Class System
  3. Rate Provider Integration (EasyPost)
  4. Shipping API Endpoint
  5. Cart & Checkout Integration
  6. Stripe Shipping Options
  7. Webhook Shipping Capture
  8. Rate Caching Strategy
  9. Portal Product Data Requirements
  10. Database Schema
  11. Implementation Build Order
  12. Verification Checklist
  13. Golden Rules

1. Shipping Architecture Overview

Every DominionPort e-commerce site ships from a single origin warehouse. Shipping rates are calculated live at cart time using carrier APIs, not estimated or flat-rate. The system handles three shipping modes automatically based on what's in the cart.

Core Principles

Three Shipping Modes

ModeTriggerBehavior
Normal At least one item has class standard or oversized Customer enters ZIP, selects from live carrier rates, rate added to checkout total
Free ALL items are class free or pickup No ZIP needed, "Free Shipping" shown automatically, checkout enabled immediately
Freight ANY item has class freight Checkout button disabled, "Contact for Quote" message with phone number and link shown
Rule: Freight overrides everything. If even one freight item is in the cart, checkout is blocked regardless of what other items are present. The customer must call or submit a quote request.

Data Flow

Cart Page API EasyPost ========== === ======== 1. User adds items to cart 2. JS detects shipping mode (free / freight / normal) [If normal]: 3. User enters ZIP ──────────► POST api/shipping.php 4. Validate ZIP (5-digit US) 5. Look up products from DB 6. Check shipping classes 7. Check cache ──────────────► [cache hit? return] 8. Build parcel dimensions 9. ─────────────────────────► POST /v2/shipments 10. ◄───────────────────────── rates array 11. Cache rates in DB 12. ◄──────────────────────── rates JSON response 13. User selects a rate 14. Checkout click ──────────► POST api/cart.php 15. Verify rate against cache 16. Create order (with shipping_cents) 17. Create Stripe session with shipping_options 18. ◄──────────────────────── redirect to Stripe [After payment]: Stripe webhook ──────────► POST api/webhook.php 19. Extract shipping amount 20. Update order total 21. Send email with shipping line

2. Shipping Class System

Every product must have a shipping_class value. This determines how the cart UI behaves, whether rates are fetched, and whether checkout is allowed. The class is set per product in the Portal's Pricing tab.

ClassDescriptionRate BehaviorCheckout
standard Normal parcel-shippable product Live carrier rates via EasyPost Enabled after rate selection
oversized Large but still parcel-shippable (e.g. long tubes, bulky boxes) Live carrier rates (dimensions matter more) Enabled after rate selection
freight Too large/heavy for parcel carriers (chassis, full builds) No rates — "Contact for Quote" Disabled
free Ships free (promotional, lightweight, digital) $0.00 automatically Enabled immediately
pickup Local pickup only, no shipping $0.00 automatically Enabled immediately
Weight is mandatory for standard and oversized items. If a shippable product has no weight_lbs set, the shipping API returns an error asking the customer to contact for a quote. The Portal shows an amber warning on products missing weight data.

Class Detection Logic (Frontend)

The cart page loads products-data.js which includes shippingClass and weightLbs for each product. The detection runs on every render:

// Determine shipping mode from cart contents function detectShippingMode(items) { var hasFreight = false; var allFreeOrPickup = true; for (var i = 0; i < items.length; i++) { var sc = getShippingClass(items[i].slug); if (sc === 'freight') hasFreight = true; if (sc !== 'free' && sc !== 'pickup') allFreeOrPickup = false; } if (hasFreight) return 'freight'; // freight wins if (allFreeOrPickup) return 'free'; // all free/pickup return 'normal'; // need rates }

Default Class

Products without a shipping_class value default to standard in the frontend detection and in the shipping API. This means any product added to the system will require weight data and rate selection by default — the safe behavior.

3. Rate Provider Integration (EasyPost)

EasyPost provides a single API that returns rates from multiple carriers simultaneously. One API call returns USPS, UPS, and FedEx options. No SDK is needed — direct cURL calls to the REST API.

Why EasyPost

API Keys

Stored in the site's settings table, not in source code or .env files. This allows per-site configuration without deployment changes.

Setting KeyPurpose
easypost_test_keyTest API key (starts with EZTK...) — returns dummy rates
easypost_live_keyProduction API key (starts with EZAK...) — returns real rates
easypost_modetest or live — controls which key is used
Never commit API keys to source. Keys live in the database settings table. On DevHub, use the test key. On production, set the live key via the portal or direct DB update after deployment.

API Call Structure

POST https://api.easypost.com/v2/shipments Auth: Basic {api_key}: Content-Type: application/json { "shipment": { "to_address": {
"zip": "90210",
"country": "US"
}, "from_address": {
"zip": "83852", // from settings table
"city": "Ponderay",
"state": "ID",
"country": "US"
}, "parcel": {
"length": 24, // inches — largest item dimension
"width": 12,
"height": 6,
"weight": 160 // ounces (weight_lbs * 16)
} } }

Response Parsing

EasyPost returns a rates array on the shipment object. Each rate contains carrier, service, price, and estimated delivery days. The shipping API filters duplicates, removes $0 rates, and sorts by price ascending.

Response FieldMaps To
rate.carriercarrier (e.g. "USPS", "UPS", "FedEx")
rate.serviceservice (e.g. "Priority", "Ground", "Home Delivery")
rate.raterate_cents (converted: float * 100, rounded)
rate.delivery_days or rate.est_delivery_daysdelivery_days (integer or null)
rate.idrate_id (EasyPost rate ID, used if purchasing labels later)

4. Shipping API Endpoint

Each site has a shipping endpoint at api/shipping.php. It accepts a ZIP code and cart items, runs through the validation chain, and returns either carrier rates, a freight block, a free shipping response, or an error.

Endpoint

POST /api/shipping.php Content-Type: application/json // Request { "zip": "90210", "items": [ { "slug": "product-slug", "qty": 1 }, { "slug": "another-product", "qty": 2 } ] }

Response Types

Normal Rates Response

{ "rates": [ { "carrier": "USPS", "service": "Priority Mail", "rate_cents": 1250, "delivery_days": 3, "rate_id": "rate_..." }, { "carrier": "UPS", "service": "Ground", "rate_cents": 1875, "delivery_days": 5, "rate_id": "rate_..." }, { "carrier": "FedEx", "service": "Home Delivery", "rate_cents": 2230, "delivery_days": 4, "rate_id": "rate_..." } ], "cached": false }

Freight Block Response

{ "freight": true, "message": "One or more items in your cart requires freight shipping. Please contact us at (509) 675-7083 or submit a quote request for shipping pricing." }

Free Shipping Response

{ "free": true, "rates": [ { "carrier": "Free", "service": "Free Shipping", "rate_cents": 0, "delivery_days": null, "rate_id": "free" } ] }

Validation Chain

The endpoint runs checks in this exact order. Each check can short-circuit the response:

  1. ZIP validation — must be exactly 5 digits
  2. Items validation — must be a non-empty array
  3. Product lookup — each slug must exist and be active
  4. Freight check — if ANY item is freight, return freight response (no rates)
  5. Free check — if ALL items are free/pickup, return free response
  6. Weight validation — shippable items with no weight trigger an error
  7. Cache lookup — MD5 of sorted slugs+qtys+zip, check TTL
  8. EasyPost API call — build parcel, create shipment, get rates
  9. Cache storage — store result, clean old entries (>24 hours)

Parcel Dimension Logic

For multi-item orders, the API uses the largest dimensions from any single item and sums all weights. This is a simple heuristic that works well for most cases — items are usually packed into one box using the largest product's box size.

Rate limiting: The shipping endpoint allows 20 requests per minute per IP (more generous than checkout's 10/min) since customers may try multiple ZIP codes. Adjust in the rate_limit() call.

5. Cart & Checkout Integration

The cart page (cart.html) detects shipping mode on render, shows the appropriate UI, and gates the checkout button until a shipping rate is selected (for normal mode) or blocks it entirely (for freight mode).

Required Script

The cart page must load products-data.js before the cart rendering script. This file provides the PRODUCTS array with shippingClass and weightLbs per product.

<!-- Load order matters --> <script src="js/products-data.js"></script> <script src="js/cart.js"></script>

UI States

Normal Mode

Free Mode

Freight Mode

Cart Summary Layout

/* Summary box always shows these rows: */ Subtotal: $459.00 Shipping: $18.75 (UPS Ground) /* or "Free Shipping" or "Contact for Quote" */ Total: $477.75 /* If tier pricing active, also shows: */ Retail: $599.00 Savings: -$140.00 Subtotal: $459.00 Shipping: $18.75 Total: $477.75

CSS Classes

ClassPurpose
.shipping-boxContainer for ZIP input and rate list
.shipping-zip-rowFlexbox row: input + button
.shipping-ratesUL containing rate radio options
.shipping-rates li.selectedHighlighted selected rate
.shipping-rate-serviceCarrier + service name (bold)
.shipping-rate-daysDelivery days estimate (muted)
.shipping-rate-priceDollar amount (bold, right-aligned)
.freight-alertRed-bordered box with contact info
.shipping-loadingCentered "Calculating rates..." text
.shipping-errorRed error text for invalid ZIP or API failures
Mobile-responsive: The shipping box, freight alert, and rate list all have max-width:100% on mobile (<768px), matching the cart summary and checkout form behavior.

6. Stripe Shipping Options

When the checkout POST includes a shipping_rate object, the cart API adds it to the Stripe Checkout Session as a shipping_options entry. Stripe displays the shipping line in its hosted checkout page and includes it in the payment total.

How It Passes to Stripe

// Added to Stripe session data when shipping_rate is provided 'shipping_options' => [[ 'shipping_rate_data' => [ 'type' => 'fixed_amount', 'fixed_amount' => [ 'amount' => $shippingCents, 'currency' => 'usd', ], 'display_name' => "$carrier $service", 'delivery_estimate' => [ 'minimum' => ['unit' => 'business_day', 'value' => $days - 1], 'maximum' => ['unit' => 'business_day', 'value' => $days], ], ], ]]

Rate Verification

Before creating the Stripe session, the cart API verifies the submitted shipping rate against the cached rates. This prevents price tampering — a customer can't modify the rate in the browser and submit a lower price.

Single shipping option. We send exactly one shipping_options entry to Stripe — the rate the customer already selected. Stripe's checkout page shows it as a confirmed line item, not a selector. The selection already happened on our cart page.

Order Record

The order is created with shipping data before the Stripe session:

ColumnValue
subtotal_centsSum of all item prices
shipping_centsSelected rate amount (0 for free)
total_centssubtotal_cents + shipping_cents
notes"Shipping: UPS Ground" (carrier + service label)

Checkout Payload

The frontend sends this to api/cart.php:

{ "items": [{ "slug": "...", "qty": 1 }], "email": "customer@example.com", "shipping_rate": { "carrier": "UPS", "service": "Ground", "rate_cents": 1875, "rate_id": "rate_abc123", "delivery_days": 5 }, "shipping_zip": "90210" }

7. Webhook Shipping Capture

After Stripe processes payment, the checkout.session.completed webhook fires. The webhook handler extracts the shipping amount from Stripe and updates the order record to ensure the final totals match exactly what Stripe charged.

Shipping Extraction

Stripe provides shipping amounts in two possible locations (API version dependent). The webhook checks both:

// Primary location (newer API versions) $session['total_details']['amount_shipping'] // Fallback location $session['shipping_cost']['amount_total'] // Carrier info from our metadata $session['metadata']['shipping_carrier']

Order Update Logic

The webhook uses COALESCE to handle both cases: when Stripe provides a shipping amount and when the order already has one from the cart creation step. The Stripe amount takes priority if non-zero:

UPDATE orders SET shipping_cents = COALESCE(NULLIF(stripe_amount, 0), shipping_cents), total_cents = subtotal_cents + COALESCE(NULLIF(stripe_amount, 0), shipping_cents, 0)

Confirmation Email

The order confirmation email includes a three-line total breakdown:

Subtotal: $459.00 Shipping: $18.75 (UPS Ground) Order Total: $477.75 // If shipping is $0: Shipping: Free

The shipping carrier label comes from the shipping_carrier metadata stored on the Stripe session. Both the customer confirmation and the admin notification emails include this breakdown.

8. Rate Caching Strategy

Hitting EasyPost on every cart interaction is wasteful and slow. The shipping API caches rate responses in the database with a configurable TTL. Same cart contents + same ZIP = cached response.

Cache Key Generation

// 1. Collect shippable items (skip free/pickup) // 2. Build "slug:qty" strings // 3. Sort alphabetically (order-independent) // 4. Join with "|" separator // 5. Append "|zip" // 6. MD5 hash the result cacheKey = MD5("air-shock-kit:1|bolt-pack:2|90210") = "a3f2e8c9..." (64-char hex)

Cache Table

ColumnTypePurpose
cache_keyVARCHAR(64) UNIQUEMD5 hash of items+zip
zipVARCHAR(10)Destination ZIP (for debugging)
rates_jsonJSONFull rates array
created_atDATETIMECache entry timestamp

TTL & Cleanup

Cart verification uses the same cache. When the customer proceeds to checkout, the cart API builds the same cache key and verifies the submitted rate exists in the cached rates. This prevents price tampering without needing a separate verification call to EasyPost.

9. Portal Product Data Requirements

Shipping depends on three product fields managed through the Portal's Pricing tab. All three already exist in the products table schema — no migration needed for the product table itself.

Required Fields

FieldColumnTypeRequired When
Shipping Class shipping_class ENUM-like (select dropdown) Always (defaults to standard if empty)
Weight (lbs) weight_lbs DECIMAL When shipping_class is standard or oversized
Dimensions (LxWxH) dimensions VARCHAR (e.g. "24x8.5x2") Recommended for oversized; optional for standard

Portal UI Changes

Product JS Generation

The generate-products-js.php script exports two new fields per product for frontend use:

// Added to each product object in products-data.js if ($p['shipping_class']) $item['shippingClass'] = $p['shipping_class']; if ($p['weight_lbs']) $item['weightLbs'] = floatval($p['weight_lbs']);

These fields only appear in the output when they have values. Products without shipping data won't have these keys, and the frontend defaults to standard for missing shippingClass.

10. Database Schema

The shipping system uses one new table and several settings rows. The product and order tables already have the needed columns — no schema changes required for those.

New Table: Shipping Cache

CREATE TABLE {prefix}shipping_cache ( id INT AUTO_INCREMENT PRIMARY KEY, cache_key VARCHAR(64) NOT NULL UNIQUE, -- MD5 of items+zip zip VARCHAR(10) NOT NULL, rates_json JSON NOT NULL, -- array of rate objects created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_created (created_at) );

Settings Rows

KeyDefault ValuePurpose
shipping_origin_zip(per site)Ship-from ZIP code
shipping_origin_city(per site)Ship-from city
shipping_origin_state(per site)Ship-from state abbreviation
easypost_test_key(empty)EasyPost test API key
easypost_live_key(empty)EasyPost production API key
easypost_modetestWhich key to use
shipping_cache_minutes30Cache TTL in minutes

Existing Columns (Already Present)

Products Table

ColumnTypeUsed By
weight_lbsDECIMAL(8,2)Parcel weight calculation
dimensionsVARCHAR(50)Parcel size (LxWxH inches)
shipping_classVARCHAR(50)Shipping mode detection

Orders Table

ColumnTypeUsed By
shipping_centsINTShipping amount charged
tracking_numberVARCHAR(100)Future: carrier tracking number
tracking_carrierVARCHAR(50)Future: carrier name

11. Implementation Build Order

When adding the shipping system to a new site, follow this order. Steps marked with the same number can run in parallel.

StepFileActionDepends On
1 Database Create shipping_cache table, seed settings rows
2a api/shipping.php Create new endpoint (EasyPost integration + caching) Step 1
2b api/generate-products-js.php Add shippingClass + weightLbs to product output
3 cart.html ZIP input, rate fetching, rate selection, freight blocking Steps 2a + 2b
4 api/cart.php Accept shipping rate, pass to Stripe, update order Step 2a
5 api/webhook.php Capture shipping from Stripe, update email template Step 4
6 Portal product editor shipping_class dropdown + weight warning — (independent)

Per-Site Customization Points

When implementing for a new site, these values change per site:

12. Verification Checklist

After implementing shipping on a site, run through every scenario:

Setup Verification

Normal Shipping Flow

Freight Blocking

Free Shipping

Edge Cases

13. Golden Rules

Ship-from address lives in the database, not in code. Each site has its own origin ZIP, city, and state stored in the settings table. Never hardcode warehouse addresses.
Freight items block checkout entirely. If any item in the cart has shipping_class = 'freight', the checkout button is disabled and a "Contact for Quote" message is shown. No workarounds, no overrides from the frontend.
Weight is mandatory for shippable products. Any product with shipping_class of standard or oversized must have weight_lbs set. Missing weight triggers a user-friendly error, not a crash.
Verify rates server-side before checkout. The rate the customer selected is verified against the shipping cache. Never trust the frontend-submitted price without checking it against a server-side source.
Cache rates, not sessions. The cache key is cart contents + ZIP, not user sessions. Two different customers with the same cart and ZIP get the same cached rates. Cache TTL is configurable per site.
EasyPost keys in settings table, not .env. API keys are stored in the database settings table so they can be managed per-site through the portal. Never commit keys to source code.
Confirmation emails show the full breakdown. Every order email — both customer and admin — must show Subtotal, Shipping (with carrier name), and Total as separate lines. No more "calculated at checkout."
Default to safe behavior. Products without a shipping class default to standard, which requires weight and rate selection. This prevents accidentally enabling free shipping on products that should be charged.
One shipping option to Stripe, not a list. The customer selects their rate on our cart page. We send exactly one shipping_options entry to Stripe — the chosen rate as a fixed amount. Stripe confirms it, doesn't re-present choices.
Contact info is always available. Every error state — freight blocking, missing weight, API failure, no rates available — includes the phone number and a link to the quote/contact page. Never leave the customer stranded.