Contents
- Shipping Architecture Overview
- Shipping Class System
- Rate Provider Integration (EasyPost)
- Shipping API Endpoint
- Cart & Checkout Integration
- Stripe Shipping Options
- Webhook Shipping Capture
- Rate Caching Strategy
- Portal Product Data Requirements
- Database Schema
- Implementation Build Order
- Verification Checklist
- 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
- US-only shipping — enforced by Stripe
allowed_countries: ['US']and ZIP validation - Live carrier rates — real USPS, UPS, and FedEx prices via EasyPost API
- Per-product shipping class — each product declares how it ships (standard, oversized, freight, free, pickup)
- Freight items block checkout — too large for parcel carriers; customer must contact for a quote
- Cached rates — same cart + ZIP returns cached rates within a configurable TTL (default 30 min)
- Ship-from address stored in DB — not hardcoded; configured per site via
pc_settings
Three Shipping Modes
| Mode | Trigger | Behavior |
|---|---|---|
| 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 |
Data Flow
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.
| Class | Description | Rate Behavior | Checkout |
|---|---|---|---|
| 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_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:
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
- Multi-carrier from one call — USPS, UPS, FedEx, DHL in a single request
- No carrier accounts needed — EasyPost provides negotiated rates out of the box
- Test mode — free test API key returns realistic dummy rates for development
- Simple REST API — one endpoint (
POST /v2/shipments) returns everything - Future-ready — can also purchase labels, track packages, handle returns
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 Key | Purpose |
|---|---|
easypost_test_key | Test API key (starts with EZTK...) — returns dummy rates |
easypost_live_key | Production API key (starts with EZAK...) — returns real rates |
easypost_mode | test or live — controls which key is used |
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
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 Field | Maps To |
|---|---|
rate.carrier | carrier (e.g. "USPS", "UPS", "FedEx") |
rate.service | service (e.g. "Priority", "Ground", "Home Delivery") |
rate.rate | rate_cents (converted: float * 100, rounded) |
rate.delivery_days or rate.est_delivery_days | delivery_days (integer or null) |
rate.id | rate_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
Response Types
Normal Rates Response
Freight Block Response
Free Shipping Response
Validation Chain
The endpoint runs checks in this exact order. Each check can short-circuit the response:
- ZIP validation — must be exactly 5 digits
- Items validation — must be a non-empty array
- Product lookup — each slug must exist and be active
- Freight check — if ANY item is freight, return freight response (no rates)
- Free check — if ALL items are free/pickup, return free response
- Weight validation — shippable items with no weight trigger an error
- Cache lookup — MD5 of sorted slugs+qtys+zip, check TTL
- EasyPost API call — build parcel, create shipment, get rates
- 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.
- Weight: Sum of all (
weight_lbs * qty), converted to ounces (* 16) for EasyPost - Length/Width/Height: Max of each dimension across all items
- Default dimensions: 12" x 12" x 6" if no products have dimensions set
- Dimension format: Products store dimensions as
LxWxHstring (e.g. "24x8.5x2")
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.
UI States
Normal Mode
- ZIP input field + "Get Rates" button appears below the cart summary
- Auto-fetches rates when 5 digits entered (no button click needed)
- Rates displayed as radio-button list: carrier, service, price, delivery days
- Checkout button disabled until a rate is selected
- Summary updates live: subtotal + shipping = total
- Selected rate object sent with checkout POST
Free Mode
- No ZIP input or rate selector shown
- "Free Shipping" shown in summary with green styling
- Checkout button enabled immediately
- Free shipping rate object auto-selected internally
Freight Mode
- No ZIP input or rate selector shown
- "Contact for Quote" shown in summary with red styling
- Freight alert box with phone number and quote link
- Checkout button disabled with tooltip explaining why
Cart Summary Layout
CSS Classes
| Class | Purpose |
|---|---|
.shipping-box | Container for ZIP input and rate list |
.shipping-zip-row | Flexbox row: input + button |
.shipping-rates | UL containing rate radio options |
.shipping-rates li.selected | Highlighted selected rate |
.shipping-rate-service | Carrier + service name (bold) |
.shipping-rate-days | Delivery days estimate (muted) |
.shipping-rate-price | Dollar amount (bold, right-aligned) |
.freight-alert | Red-bordered box with contact info |
.shipping-loading | Centered "Calculating rates..." text |
.shipping-error | Red error text for invalid ZIP or API failures |
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
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.
- Build the same cache key (sorted slugs + qtys + ZIP)
- Look up cached rates within the TTL window
- Match carrier + service + rate_cents against the cache
- If no match found in cache: reject with "rate expired" error
- If no cache entry exists at all: allow (rate was just fetched)
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:
| Column | Value |
|---|---|
subtotal_cents | Sum of all item prices |
shipping_cents | Selected rate amount (0 for free) |
total_cents | subtotal_cents + shipping_cents |
notes | "Shipping: UPS Ground" (carrier + service label) |
Checkout Payload
The frontend sends this to api/cart.php:
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:
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:
Confirmation Email
The order confirmation email includes a three-line total breakdown:
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
Cache Table
| Column | Type | Purpose |
|---|---|---|
cache_key | VARCHAR(64) UNIQUE | MD5 hash of items+zip |
zip | VARCHAR(10) | Destination ZIP (for debugging) |
rates_json | JSON | Full rates array |
created_at | DATETIME | Cache entry timestamp |
TTL & Cleanup
- Default TTL: 30 minutes (configurable via
shipping_cache_minutessetting) - Cache hit: Return rates immediately, skip EasyPost call
- Cache miss: Call EasyPost, store result with
ON DUPLICATE KEY UPDATE - Cleanup: Every API call deletes cache entries older than 24 hours
- Same key, new rates: If a cache entry exists but is stale, the new result overwrites it
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
| Field | Column | Type | Required 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
- Shipping Class: Changed from free-text input to
<select>dropdown with five options: standard, oversized, freight, free, pickup - Weight Warning: Amber indicator appears next to the weight label when weight is empty/zero AND shipping_class requires it (standard or oversized). Text: "Required for shipping"
- Save logic: No changes needed — the existing save handler already persists all three fields
Product JS Generation
The generate-products-js.php script exports two new fields per product for frontend use:
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
Settings Rows
| Key | Default Value | Purpose |
|---|---|---|
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_mode | test | Which key to use |
shipping_cache_minutes | 30 | Cache TTL in minutes |
Existing Columns (Already Present)
Products Table
| Column | Type | Used By |
|---|---|---|
weight_lbs | DECIMAL(8,2) | Parcel weight calculation |
dimensions | VARCHAR(50) | Parcel size (LxWxH inches) |
shipping_class | VARCHAR(50) | Shipping mode detection |
Orders Table
| Column | Type | Used By |
|---|---|---|
shipping_cents | INT | Shipping amount charged |
tracking_number | VARCHAR(100) | Future: carrier tracking number |
tracking_carrier | VARCHAR(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.
| Step | File | Action | Depends 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:
- Ship-from address: ZIP, city, state in settings table
- Contact phone number: In freight alert message (cart.html) and shipping API error messages
- Contact link: Quote request page URL in freight alert
- EasyPost API keys: Each site can have its own or share a single account
- Table prefix:
pc_for Patriot Chassis, different per site - Product categories: Some sites may have more freight items than others
12. Verification Checklist
After implementing shipping on a site, run through every scenario:
Setup Verification
- EasyPost test key is seeded in settings table
easypost_modeis set totest- Ship-from ZIP, city, state are populated
- At least 2-3 products have
weight_lbsset via portal - At least one product has
shipping_class = 'freight' - At least one product has
shipping_class = 'free' products-data.jsregenerated and includesshippingClass/weightLbs
Normal Shipping Flow
- Add standard/oversized products to cart
- Verify checkout button is disabled (no rate selected)
- Enter a ZIP code → rates appear automatically on 5th digit
- Verify multiple carriers shown (USPS, UPS, FedEx)
- Select a rate → summary updates with shipping line and new total
- Checkout button enables after rate selection
- Complete checkout → verify Stripe shows shipping line
- Verify order record has correct
shipping_centsandtotal_cents - Verify confirmation email shows Subtotal / Shipping / Total
Freight Blocking
- Add a freight-class product to cart
- Verify no ZIP input shown
- Verify freight alert with phone number and quote link
- Verify checkout button is disabled
- Mix freight + standard items → still blocked (freight wins)
Free Shipping
- Add only free/pickup products to cart
- Verify no ZIP input shown
- Verify "Free Shipping" in summary (green)
- Verify checkout button is enabled immediately
- Complete checkout → Stripe shows $0 shipping
Edge Cases
- Product with no weight → error message with contact info
- Invalid ZIP (non-numeric, too short) → validation error
- Same ZIP + items within 30 min → cached response (no EasyPost call)
- EasyPost API down → friendly error message with contact info
- Rate tampered in browser → "rate expired" error on checkout
- Tier pricing + shipping → total correctly sums discounted subtotal + shipping
13. Golden Rules
shipping_class = 'freight', the checkout button is disabled and a "Contact for Quote" message is shown. No workarounds, no overrides from the frontend.
shipping_class of standard or oversized must have weight_lbs set. Missing weight triggers a user-friendly error, not a crash.
standard, which requires weight and rate selection. This prevents accidentally enabling free shipping on products that should be charged.
shipping_options entry to Stripe — the chosen rate as a fixed amount. Stripe confirms it, doesn't re-present choices.